diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index eef7ff3144..652f8d012f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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, } }) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index b3cfb760b8..03d109b2a1 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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, { diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index 9a6be91812..f41fb23787 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -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 ( Context - {state().tokens.toLocaleString()} tokens - {state().percent ?? 0}% used + Not measured}> + {(value) => ( + <> + {value().tokens.toLocaleString()} tokens + + {value().percent}% used + + + )} + {money.format(cost())} spent ) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 13c200101a..55ef1459ba 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1325,23 +1325,18 @@ function SessionSkillMessage(props: { message: Extract - status?: "running" - text?: string -}) { +function CompactionMessage(props: { message: Extract }) { 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 ( - + @@ -1349,24 +1344,21 @@ function CompactionMessage(props: { - - - Compaction - + - + ) { { id: message.id, created: message.time.created, - input: message.status === "running", }, ] : [], @@ -183,7 +182,9 @@ export function createSessionRows(sessionID: Accessor) { } 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) { 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}` }) diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index dcd00e106f..2d32055e4f 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -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, } }) diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 41f583b1c6..60dddbd1ea 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -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, 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 } => - 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, + models: ReadonlyArray | 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, + } +} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6f2d047517..364f71e6de 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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() } diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 38ec243770..cd534a40e8 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -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) + }) })