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