fix(tui): stabilize compaction completion (#36435)

This commit is contained in:
Kit Langton
2026-07-11 13:06:14 -04:00
committed by GitHub
parent 64f995095d
commit b3f7c2d319
9 changed files with 124 additions and 96 deletions
+13 -14
View File
@@ -57,7 +57,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 +280,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,
} }
}) })
+2 -6
View File
@@ -669,16 +669,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, {
@@ -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,
}
}
+13
View File
@@ -1064,6 +1064,9 @@ 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 +1081,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 +1108,9 @@ 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 +1128,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()
} }
+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)
})
}) })