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

This commit is contained in:
Kit Langton 2026-07-11 13:06:14 -04:00 committed by GitHub
commit 00ab94c44f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 124 additions and 96 deletions

View file

@ -57,7 +57,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 +280,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,
}
})

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 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, {

View file

@ -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>
)

View file

@ -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}

View file

@ -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}` })

View file

@ -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,
}
})

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) {
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,
}
}

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")
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 +1081,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 +1108,9 @@ 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 +1128,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()
}

View file

@ -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)
})
})