Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 1803aaf857 fix(tui): render submitted prompts optimistically 2026-07-11 13:25:10 -04:00
Kit Langton 00ab94c44f fix(tui): stabilize compaction completion (#36435) 2026-07-11 13:06:14 -04:00
19 changed files with 250 additions and 162 deletions
+1
View File
@@ -1003,6 +1003,7 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
-1
View File
@@ -378,7 +378,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
command: runtime.session.command, command: runtime.session.command,
rename: runtime.session.rename,
interrupt: (input) => runtime.session.interrupt(input.sessionID), interrupt: (input) => runtime.session.interrupt(input.sessionID),
}, },
} satisfies Plugin.Context } satisfies Plugin.Context
+2 -12
View File
@@ -70,8 +70,7 @@ export function fromPromise(plugin: Plugin) {
catalog: { catalog: {
provider: { provider: {
list: (input) => run(host.catalog.provider.list(input)), list: (input) => run(host.catalog.provider.list(input)),
get: (input) => get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
}, },
model: { model: {
list: (input) => run(host.catalog.model.list(input)), list: (input) => run(host.catalog.model.list(input)),
@@ -97,9 +96,7 @@ export function fromPromise(plugin: Plugin) {
), ),
connect: { connect: {
key: (input) => key: (input) =>
run( run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })),
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
),
oauth: (input) => oauth: (input) =>
run( run(
host.integration.connect.oauth({ host.integration.connect.oauth({
@@ -211,13 +208,6 @@ export function fromPromise(plugin: Plugin) {
resume: input.resume ?? undefined, 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) })), interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
}, },
} }
+1 -2
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface { export interface Interface {
readonly session: Pick< readonly session: Pick<
SessionV2.Interface, 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 job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: { readonly location: {
@@ -51,7 +51,6 @@ export const layerWithCell = (cell: Cell) =>
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)), prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(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)), resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
-1
View File
@@ -82,7 +82,6 @@ export function host(overrides: Overrides = {}): PluginContext {
get: () => Effect.die("unused session.get"), get: () => Effect.die("unused session.get"),
prompt: () => Effect.die("unused session.prompt"), prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"), command: () => Effect.die("unused session.command"),
rename: () => Effect.die("unused session.rename"),
interrupt: () => Effect.die("unused session.interrupt"), interrupt: () => Effect.die("unused session.interrupt"),
}, },
} }
-32
View File
@@ -4,7 +4,6 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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", () => it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
+1 -1
View File
@@ -177,7 +177,7 @@ and plugin options.
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution | | `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs | | `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` | | `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.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` | | `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` | | `ctx.aisdk` | `hook` |
+1 -1
View File
@@ -1,3 +1,3 @@
import type { SessionApi } from "@opencode-ai/client/effect/api" 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 -1
View File
@@ -1,3 +1,3 @@
import type { SessionApi } from "@opencode-ai/client/promise/api" 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">
+1
View File
@@ -55,6 +55,7 @@
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
+24 -14
View File
@@ -40,6 +40,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error" import { errorMessage } from "../../util/error"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { createColors, createFrames } from "../../ui/spinner" import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration" import { DialogIntegration } from "../dialog-integration"
@@ -57,7 +58,7 @@ import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment" import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { lastAssistantWithUsage } from "../../util/session" import { contextUsage } from "../../util/session"
registerOpencodeSpinner() registerOpencodeSpinner()
@@ -280,21 +281,20 @@ export function Prompt(props: PromptProps) {
if (!props.sessionID) return if (!props.sessionID) return
const session = data.session.get(props.sessionID) const session = data.session.get(props.sessionID)
if (!session) return 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 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 { return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), context: context
cost: cost > 0 ? money.format(cost) : undefined, ? 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 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 const error = await sdk.api.session
.prompt({ .prompt({
sessionID, sessionID,
id: messageID,
text: inputText, text: inputText,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
@@ -1159,6 +1168,7 @@ export function Prompt(props: PromptProps) {
(error) => error, (error) => error,
) )
if (error) { if (error) {
data.session.input.rollback(sessionID, messageID)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false return false
} }
+40 -14
View File
@@ -21,6 +21,7 @@ import type {
SessionMessageAssistantText, SessionMessageAssistantText,
SessionMessageAssistantTool, SessionMessageAssistantTool,
SessionInfo, SessionInfo,
SessionMessageUser,
Shell, Shell,
SkillInfo, SkillInfo,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
@@ -105,6 +106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(), directory: process.cwd(),
}) })
const messageIndex = new Map<string, Map<string, number>>() const messageIndex = new Map<string, Map<string, number>>()
const optimisticInput = new Map<string, Set<string>>()
let bootstrapping: Promise<void> | undefined let bootstrapping: Promise<void> | undefined
let connected = false let connected = false
@@ -214,6 +216,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) { function removeSession(sessionID: string) {
messageIndex.delete(sessionID) messageIndex.delete(sessionID)
optimisticInput.delete(sessionID)
setStore( setStore(
"session", "session",
produce((draft) => { produce((draft) => {
@@ -327,32 +330,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
break 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)) if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore("session", "input", event.data.sessionID, [ setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []), ...(store.session.input[event.data.sessionID] ?? []),
event.data.inputID, event.data.inputID,
]) ])
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append( const item =
draft,
index,
event.data.input.type === "user" event.data.input.type === "user"
? { ? {
id: event.data.inputID, id: event.data.inputID,
type: "user", type: "user" as const,
...event.data.input.data, ...event.data.input.data,
time: { created: event.created }, time: { created: event.created },
} }
: { : {
id: event.data.inputID, id: event.data.inputID,
type: "synthetic", type: "synthetic" as const,
...event.data.input.data, ...event.data.input.data,
time: { created: event.created }, time: { created: event.created },
}, }
) const position = index.get(event.data.inputID)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
}) })
break break
}
case "session.instructions.updated": case "session.instructions.updated":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(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 position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position] const current = draft[position]
if (current?.type === "compaction") { if (current?.type === "compaction") {
draft[position] = { Object.assign(current, {
id: current.id,
type: "compaction",
status: "completed", status: "completed",
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
metadata: current.metadata, })
time: current.time,
}
return return
} }
message.append(draft, index, { message.append(draft, index, {
@@ -818,6 +820,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.status[sessionID] ?? "idle" return store.session.status[sessionID] ?? "idle"
}, },
input: { 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) { list(sessionID: string) {
return store.session.input[sessionID] ?? [] return store.session.input[sessionID] ?? []
}, },
@@ -1,8 +1,8 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins" import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js" import { createMemo, Show } from "solid-js"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { lastAssistantWithUsage } from "../../util/session" import { contextUsage } from "../../util/session"
const id = "internal:sidebar-context" 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 session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => data.session.cost(props.session_id)) const cost = createMemo(() => data.session.cost(props.session_id))
const state = createMemo(() => { const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
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,
}
})
return ( return (
<box> <box>
<text fg={theme().text}> <text fg={theme().text}>
<b>Context</b> <b>Context</b>
</text> </text>
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text> <Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</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> <text fg={theme().textMuted}>{money.format(cost())} spent</text>
</box> </box>
) )
+10 -18
View File
@@ -1325,23 +1325,18 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
) )
} }
function CompactionMessage(props: { function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
message?: Extract<SessionMessageInfo, { type: "compaction" }>
status?: "running"
text?: string
}) {
const ctx = use() const ctx = use()
const kv = useKV() const kv = useKV()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status const status = () => props.message.status
const text = () => const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "") const content = createMemo(() => text().trim())
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted) const color = () => (status() === "failed" ? theme.error : theme.textMuted)
const border = color
return ( return (
<box> <box>
<box flexDirection="row" alignItems="center"> <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}> <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch> <Switch>
<Match when={status() === "running"}> <Match when={status() === "running"}>
@@ -1349,24 +1344,21 @@ function CompactionMessage(props: {
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} /> <spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show> </Show>
</Match> </Match>
<Match when={status() === "completed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "failed"}> <Match when={status() === "failed"}>
<text fg={color()}></text> <text fg={color()}></text>
</Match> </Match>
</Switch> </Switch>
<text fg={color()}>Compaction</text> <text fg={color()}>Compaction</text>
</box> </box>
<box border={["top"]} borderColor={border()} flexGrow={1} /> <box border={["top"]} borderColor={color()} flexGrow={1} />
</box> </box>
<Show when={text().trim()}> <Show when={content()}>
<box paddingTop={1} paddingLeft={3}> <box paddingTop={1} paddingLeft={3}>
<markdown <markdown
syntaxStyle={syntax()} syntaxStyle={syntax()}
streaming={status() === "running"} streaming={true}
internalBlockMode="top-level" internalBlockMode="top-level"
content={text().trim()} content={content()}
tableOptions={{ style: "grid" }} tableOptions={{ style: "grid" }}
conceal={ctx.conceal()} conceal={ctx.conceal()}
fg={theme.markdownText} fg={theme.markdownText}
+3 -5
View File
@@ -88,7 +88,6 @@ export function createSessionRows(sessionID: Accessor<string>) {
{ {
id: message.id, id: message.id,
created: message.time.created, created: message.time.created,
input: message.status === "running",
}, },
] ]
: [], : [],
@@ -183,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
} }
const subscriptions = [ const subscriptions = [
data.on("session.input.admitted", input), 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.instructions.updated", message),
data.on("session.synthetic", (event) => { data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim()) 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.shell.started", message),
data.on("session.agent.selected", message), data.on("session.agent.selected", message),
data.on("session.model.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) => { data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID()) if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) 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 { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" 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() { export function SubagentFooter() {
const route = useRouteData("session") const route = useRouteData("session")
@@ -23,26 +28,21 @@ export function SubagentFooter() {
const usage = createMemo(() => { const usage = createMemo(() => {
const current = session() const current = session()
if (!current) return if (!current) return
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID) const cost = current.cost
if (!last) return const formattedCost = cost > 0 ? money.format(cost) : undefined
const tokens = const context = contextUsage(
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write data.session.message.list(route.sessionID),
if (tokens <= 0) return data.location.model.list(current.location),
current.revert?.messageID,
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",
})
return { return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), context: context
cost: cost > 0 ? money.format(cost) : undefined, ? context.percent === undefined
? Locale.number(context.tokens)
: `${Locale.number(context.tokens)} (${context.percent}%)`
: undefined,
cost: formattedCost,
} }
}) })
+23 -2
View File
@@ -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) { 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) 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) { export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined 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( return messages.findLast(
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } => (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,
}
}
+82 -7
View File
@@ -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") const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" 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, { emitEvent(events, {
id: "evt_manual_compaction_ended", id: "evt_manual_compaction_ended",
created: 3, 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([ expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" }, { type: "message", messageID: "message-compaction" },
]) ])
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
compactionRow,
)
emitEvent(events, { emitEvent(events, {
id: "evt_compaction_started", 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") const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" 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, { emitEvent(events, {
id: "evt_compaction_ended", id: "evt_compaction_ended",
@@ -1119,6 +1124,10 @@ test("tracks session status from active sessions and execution events", async ()
status: "completed", status: "completed",
summary: "Live summary", 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 { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
@@ -2046,6 +2055,14 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
await mounted await mounted
const received: string[] = [] const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name)) 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, { emitEvent(events, {
id: "evt_admitted_1", id: "evt_admitted_1",
created: 0, 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" }, 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] const admitted = sync.session.message.list(sessionID)?.[0]
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
expect(admitted?.metadata).toBeUndefined() expect(admitted?.metadata).toBeUndefined()
expect(sync.session.input.list(sessionID)).toEqual([messageID]) 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) await sync.session.message.refresh(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() 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 () => { test("projects live instruction updates with their message ID", async () => {
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) 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> = {}) { async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => { const calls = createFetch((url) => {
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active") if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
}) })
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let ready!: () => void 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 () => { test("totals family cost for roots and keeps subagent cost scoped", async () => {
const { data, app } = await mountData( const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
{ grandchild: "child", child: "root" },
{ root: 1, child: 2, grandchild: 3 },
)
try { try {
await data.session.refresh("grandchild") await data.session.refresh("grandchild")
await data.session.refresh("child") await data.session.refresh("child")
+28 -9
View File
@@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2" import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session" 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", () => { describe("util.session", () => {
test("recognizes generated parent and child titles", () => { test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue() 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", () => { 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)] const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
@@ -26,4 +27,22 @@ describe("util.session", () => {
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined() expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) 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)
})
}) })