mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1803aaf857 | |||
| 00ab94c44f |
@@ -1003,6 +1003,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -378,7 +378,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
get: (input) => runtime.session.get(input.sessionID),
|
||||
prompt: runtime.session.prompt,
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
|
||||
@@ -70,8 +70,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
catalog: {
|
||||
provider: {
|
||||
list: (input) => run(host.catalog.provider.list(input)),
|
||||
get: (input) =>
|
||||
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
|
||||
get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
|
||||
},
|
||||
model: {
|
||||
list: (input) => run(host.catalog.model.list(input)),
|
||||
@@ -97,9 +96,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
connect: {
|
||||
key: (input) =>
|
||||
run(
|
||||
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
|
||||
),
|
||||
run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })),
|
||||
oauth: (input) =>
|
||||
run(
|
||||
host.integration.connect.oauth({
|
||||
@@ -211,13 +208,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
resume: input.resume ?? undefined,
|
||||
}),
|
||||
),
|
||||
rename: (input) =>
|
||||
run(
|
||||
host.session.rename({
|
||||
...input,
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
}),
|
||||
),
|
||||
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
SessionV2.Interface,
|
||||
"get" | "create" | "messages" | "prompt" | "command" | "rename" | "resume" | "interrupt" | "synthetic"
|
||||
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
@@ -51,7 +51,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
|
||||
@@ -82,7 +82,6 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
get: () => Effect.die("unused session.get"),
|
||||
prompt: () => Effect.die("unused session.prompt"),
|
||||
command: () => Effect.die("unused session.command"),
|
||||
rename: () => Effect.die("unused session.rename"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
@@ -44,37 +43,6 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards session rename", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const renamed: { sessionID: SessionV2.ID; title: string }[] = []
|
||||
const host = yield* PluginHost.make(plugin).pipe(
|
||||
Effect.provideService(
|
||||
PluginRuntime.Service,
|
||||
PluginRuntime.Service.of({
|
||||
...runtime,
|
||||
session: {
|
||||
...runtime.session,
|
||||
rename: (input) =>
|
||||
Effect.sync(() => {
|
||||
renamed.push(input)
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-session-rename",
|
||||
setup: (ctx) => ctx.session.rename({ sessionID: "ses_plugin_rename", title: "Updated scope" }),
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
expect(renamed).toEqual([{ sessionID: SessionV2.ID.make("ses_plugin_rename"), title: "Updated scope" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads a promise plugin and registers a transform hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
||||
Vendored
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `interrupt`, and `hook` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `interrupt`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
|
||||
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "rename" | "interrupt">
|
||||
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt">
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
|
||||
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "rename" | "interrupt">
|
||||
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt">
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogIntegration } from "../dialog-integration"
|
||||
@@ -57,7 +58,7 @@ import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
import { contextUsage } from "../../util/session"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -280,21 +281,20 @@ export function Prompt(props: PromptProps) {
|
||||
if (!props.sessionID) return
|
||||
const session = data.session.get(props.sessionID)
|
||||
if (!session) return
|
||||
const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID)
|
||||
if (!last) return
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = data.location.model
|
||||
.list(session.location)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = data.session.cost(props.sessionID)
|
||||
const formattedCost = cost > 0 ? money.format(cost) : undefined
|
||||
const context = contextUsage(
|
||||
data.session.message.list(props.sessionID),
|
||||
data.location.model.list(session.location),
|
||||
session.revert?.messageID,
|
||||
)
|
||||
return {
|
||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||
cost: cost > 0 ? money.format(cost) : undefined,
|
||||
context: context
|
||||
? context.percent === undefined
|
||||
? Locale.number(context.tokens)
|
||||
: `${Locale.number(context.tokens)} (${context.percent}%)`
|
||||
: undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1147,9 +1147,18 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
data.session.input.optimistic(sessionID, {
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: inputText,
|
||||
agents: store.prompt.agents,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const error = await sdk.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
@@ -1159,6 +1168,7 @@ export function Prompt(props: PromptProps) {
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
data.session.input.rollback(sessionID, messageID)
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionMessageUser,
|
||||
Shell,
|
||||
SkillInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
@@ -105,6 +106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const optimisticInput = new Map<string, Set<string>>()
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
let connected = false
|
||||
|
||||
@@ -214,6 +216,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
messageIndex.delete(sessionID)
|
||||
optimisticInput.delete(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
@@ -327,32 +330,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
case "session.input.admitted": {
|
||||
const inputs = optimisticInput.get(event.data.sessionID)
|
||||
if (inputs?.delete(event.data.inputID) && inputs.size === 0) optimisticInput.delete(event.data.sessionID)
|
||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||
setStore("session", "input", event.data.sessionID, [
|
||||
...(store.session.input[event.data.sessionID] ?? []),
|
||||
event.data.inputID,
|
||||
])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(
|
||||
draft,
|
||||
index,
|
||||
const item =
|
||||
event.data.input.type === "user"
|
||||
? {
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
type: "user" as const,
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
}
|
||||
: {
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
type: "synthetic" as const,
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
@@ -669,16 +675,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
if (current?.type === "compaction") {
|
||||
draft[position] = {
|
||||
id: current.id,
|
||||
type: "compaction",
|
||||
Object.assign(current, {
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
metadata: current.metadata,
|
||||
time: current.time,
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
@@ -818,6 +820,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.status[sessionID] ?? "idle"
|
||||
},
|
||||
input: {
|
||||
optimistic(sessionID: string, item: SessionMessageUser) {
|
||||
const inputs = optimisticInput.get(sessionID) ?? new Set<string>()
|
||||
inputs.add(item.id)
|
||||
optimisticInput.set(sessionID, inputs)
|
||||
if (!store.session.input[sessionID]?.includes(item.id))
|
||||
setStore("session", "input", sessionID, [...(store.session.input[sessionID] ?? []), item.id])
|
||||
message.update(sessionID, (draft, index) => message.append(draft, index, item))
|
||||
},
|
||||
rollback(sessionID: string, inputID: string) {
|
||||
const inputs = optimisticInput.get(sessionID)
|
||||
if (!inputs?.delete(inputID)) return
|
||||
if (inputs.size === 0) optimisticInput.delete(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.input[sessionID] = (draft.input[sessionID] ?? []).filter((id) => id !== inputID)
|
||||
const messages = draft.message[sessionID]
|
||||
const position = messageIndex.get(sessionID)?.get(inputID)
|
||||
if (!messages || position === undefined) return
|
||||
messages.splice(position, 1)
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
|
||||
}),
|
||||
)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useData } from "../../context/data"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
import { contextUsage } from "../../util/session"
|
||||
|
||||
const id = "internal:sidebar-context"
|
||||
|
||||
@@ -18,33 +18,23 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const session = createMemo(() => data.session.get(props.session_id))
|
||||
const cost = createMemo(() => data.session.cost(props.session_id))
|
||||
|
||||
const state = createMemo(() => {
|
||||
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
|
||||
if (!last) {
|
||||
return {
|
||||
tokens: 0,
|
||||
percent: null,
|
||||
}
|
||||
}
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = data.location
|
||||
.model.list(session()?.location)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
return {
|
||||
tokens,
|
||||
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme().text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
|
||||
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme().textMuted}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme().textMuted}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1325,23 +1325,18 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
|
||||
)
|
||||
}
|
||||
|
||||
function CompactionMessage(props: {
|
||||
message?: Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
status?: "running"
|
||||
text?: string
|
||||
}) {
|
||||
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
|
||||
const ctx = use()
|
||||
const kv = useKV()
|
||||
const { theme, syntax } = useTheme()
|
||||
const status = () => props.message?.status ?? props.status
|
||||
const text = () =>
|
||||
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "")
|
||||
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
|
||||
const border = color
|
||||
const status = () => props.message.status
|
||||
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
|
||||
const content = createMemo(() => text().trim())
|
||||
const color = () => (status() === "failed" ? theme.error : theme.textMuted)
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" alignItems="center">
|
||||
<box border={["top"]} borderColor={border()} flexGrow={1} />
|
||||
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
@@ -1349,24 +1344,21 @@ function CompactionMessage(props: {
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={status() === "completed"}>
|
||||
<text fg={color()}>✓</text>
|
||||
</Match>
|
||||
<Match when={status() === "failed"}>
|
||||
<text fg={color()}>✗</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={color()}>Compaction</text>
|
||||
</box>
|
||||
<box border={["top"]} borderColor={border()} flexGrow={1} />
|
||||
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
||||
</box>
|
||||
<Show when={text().trim()}>
|
||||
<Show when={content()}>
|
||||
<box paddingTop={1} paddingLeft={3}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={status() === "running"}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={text().trim()}
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
|
||||
@@ -88,7 +88,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: message.status === "running",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -183,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.input.admitted", input),
|
||||
data.on("session.compaction.started", message),
|
||||
data.on("session.compaction.started", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
|
||||
}),
|
||||
data.on("session.instructions.updated", message),
|
||||
data.on("session.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
@@ -192,9 +193,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
data.on("session.shell.started", message),
|
||||
data.on("session.agent.selected", message),
|
||||
data.on("session.model.selected", message),
|
||||
data.on("session.compaction.ended", (event) => {
|
||||
if (event.data.reason !== "manual") message(event)
|
||||
}),
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||
|
||||
@@ -6,7 +6,12 @@ import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
import { contextUsage } from "../../util/session"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
@@ -23,26 +28,21 @@ export function SubagentFooter() {
|
||||
const usage = createMemo(() => {
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
|
||||
if (!last) return
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = data.location
|
||||
.model.list(current.location)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = data.session.cost(route.sessionID)
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
const cost = current.cost
|
||||
const formattedCost = cost > 0 ? money.format(cost) : undefined
|
||||
const context = contextUsage(
|
||||
data.session.message.list(route.sessionID),
|
||||
data.location.model.list(current.location),
|
||||
current.revert?.messageID,
|
||||
)
|
||||
|
||||
return {
|
||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||
cost: cost > 0 ? money.format(cost) : undefined,
|
||||
context: context
|
||||
? context.percent === undefined
|
||||
? Locale.number(context.tokens)
|
||||
: `${Locale.number(context.tokens)} (${context.percent}%)`
|
||||
: undefined,
|
||||
cost: formattedCost,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function isDefaultTitle(title: string) {
|
||||
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
@@ -7,8 +7,29 @@ export function isDefaultTitle(title: string) {
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
const end = boundaryIndex === -1 ? messages.length : boundaryIndex
|
||||
const compactionIndex = messages.findLastIndex(
|
||||
(message, index) => message.type === "compaction" && message.status === "completed" && index < end,
|
||||
)
|
||||
return messages.findLast(
|
||||
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
|
||||
message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end,
|
||||
)
|
||||
}
|
||||
|
||||
export function contextUsage(
|
||||
messages: ReadonlyArray<SessionMessageInfo>,
|
||||
models: ReadonlyArray<ModelInfo> | undefined,
|
||||
boundary?: string,
|
||||
) {
|
||||
const last = lastAssistantWithUsage(messages, boundary)
|
||||
if (!last) return
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
const model = models?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
return {
|
||||
tokens,
|
||||
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1064,7 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
||||
})
|
||||
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_ended",
|
||||
created: 3,
|
||||
@@ -1078,6 +1079,9 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
expect(manualRows.filter((row) => row.type === "message")).toEqual([
|
||||
{ type: "message", messageID: "message-compaction" },
|
||||
])
|
||||
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
|
||||
compactionRow,
|
||||
)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
@@ -1102,6 +1106,7 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
||||
})
|
||||
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
@@ -1119,6 +1124,10 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
status: "completed",
|
||||
summary: "Live summary",
|
||||
})
|
||||
expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
|
||||
autoCompactionRow,
|
||||
)
|
||||
expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -2046,6 +2055,14 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
await mounted
|
||||
const received: string[] = []
|
||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||
sync.session.input.optimistic(sessionID, {
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: "optimistic",
|
||||
time: { created: -1 },
|
||||
})
|
||||
expect(sync.session.message.get(sessionID, messageID)).toMatchObject({ text: "optimistic", time: { created: -1 } })
|
||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
created: 0,
|
||||
@@ -2057,11 +2074,13 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
input: { type: "user", data: { text: "hello" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.list(sessionID)?.length === 1)
|
||||
await wait(() => sync.session.message.get(sessionID, messageID)?.time.created === 0)
|
||||
const admitted = sync.session.message.list(sessionID)?.[0]
|
||||
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
|
||||
expect(admitted?.metadata).toBeUndefined()
|
||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||
sync.session.input.rollback(sessionID, messageID)
|
||||
expect(sync.session.message.ids(sessionID)).toEqual([messageID])
|
||||
|
||||
await sync.session.message.refresh(sessionID)
|
||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
||||
@@ -2096,6 +2115,66 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test("rolls back unconfirmed optimistic prompts and accepts later admission", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_user_rollback"
|
||||
const calls = createFetch(undefined, events)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
sync.session.input.optimistic(sessionID, {
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: "optimistic",
|
||||
time: { created: -1 },
|
||||
})
|
||||
sync.session.input.rollback(sessionID, messageID)
|
||||
expect(sync.session.message.ids(sessionID)).toEqual([])
|
||||
expect(sync.session.input.list(sessionID)).toEqual([])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_after_rollback",
|
||||
created: 1,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: messageID,
|
||||
input: { type: "user", data: { text: "accepted" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.ids(sessionID).length === 1)
|
||||
expect(sync.session.message.get(sessionID, messageID)).toMatchObject({ text: "accepted", time: { created: 1 } })
|
||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("projects live instruction updates with their message ID", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
@@ -2167,8 +2246,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
|
||||
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
||||
const calls = createFetch((url) => {
|
||||
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (match && match[1] !== "active")
|
||||
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
@@ -2237,10 +2315,7 @@ test("indexes arbitrarily deep nesting under a single root", async () => {
|
||||
})
|
||||
|
||||
test("totals family cost for roots and keeps subagent cost scoped", async () => {
|
||||
const { data, app } = await mountData(
|
||||
{ grandchild: "child", child: "root" },
|
||||
{ root: 1, child: 2, grandchild: 3 },
|
||||
)
|
||||
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
|
||||
try {
|
||||
await data.session.refresh("grandchild")
|
||||
await data.session.refresh("child")
|
||||
|
||||
@@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0 },
|
||||
})
|
||||
|
||||
describe("util.session", () => {
|
||||
test("recognizes generated parent and child titles", () => {
|
||||
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
@@ -10,15 +20,6 @@ describe("util.session", () => {
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0 },
|
||||
})
|
||||
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
||||
@@ -26,4 +27,22 @@ describe("util.session", () => {
|
||||
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
||||
})
|
||||
|
||||
test("resets usage at completed compaction until the next assistant reports it", () => {
|
||||
const compaction: SessionMessageInfo = {
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 0 },
|
||||
}
|
||||
const messages = [assistant("msg_before", 30), compaction]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)).toBeUndefined()
|
||||
|
||||
messages.push(assistant("msg_after", 5))
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user