fix(tui): stabilize compaction completion (#36435)
This commit is contained in:
parent
fe09a2e9b7
commit
00ab94c44f
9 changed files with 124 additions and 96 deletions
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue