feat(desktop): show session tokens and cost on the session view (#53785)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1 2026-10-14 01:14:04 +02:00 • committed by GitHub
parent 46e4695b96
commit 7b33bcea4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 264 additions and 78 deletions

View file

@ -145,7 +145,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
const contextButton = page.getByRole("button", { name: "Toggle session context" })
const openFile = panel.getByRole("button", { name: "Open file" })
const tab = (name: string) => panel.getByRole("tab", { name, exact: true })
await contextButton.click()
@ -311,7 +311,7 @@ test("context closes the side region only when its button opened it", async ({ p
})
const panel = page.locator("#review-panel")
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const contextButton = page.getByRole("button", { name: "View context usage" })
const contextButton = page.getByRole("button", { name: "Toggle session context" })
const context = panel.getByRole("tab", { name: "Context", exact: true })
await contextButton.click()
@ -335,6 +335,83 @@ test("context closes the side region only when its button opened it", async ({ p
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
})
test("context hides free-model cost but keeps paid-model zero cost", async ({ page }) => {
const cases = [
{ id: "ses_context_free", title: "Free model", model: "free", cost: 0, usage: false, text: "0" },
{ id: "ses_context_paid", title: "Paid model", model: "paid", cost: 0, usage: false, text: "0·$0.00" },
{ id: "ses_context_used", title: "Paid usage", model: "paid", cost: 1.25, usage: true, text: "2k·$1.25" },
{ id: "ses_context_free_used", title: "Free usage", model: "free", cost: 0, usage: true, text: "2k" },
{ id: "ses_context_mixed", title: "Previously paid", model: "free", cost: 1.25, usage: true, text: "2k·$1.25" },
{ id: "ses_context_unknown", title: "Unknown pricing", model: "missing", cost: 0, usage: false, text: "0·$0.00" },
{ id: "ses_context_output", title: "Output pricing", model: "output", cost: 0, usage: false, text: "0·$0.00" },
// The root's model is free, but its subagent spent on a paid model: the family total shows.
{ id: "ses_context_family", title: "Paid subagent", model: "free", cost: 0, usage: true, text: "2k·$1.25" },
// A completed compaction resets the context until the next reply reports usage, as in the TUI.
{ id: "ses_context_compacted", title: "Compacted", model: "paid", cost: 1.25, usage: true, text: "0·$1.25" },
]
await openSession(page, {
name: "ContextPricing",
provider: provider(
{ id: "free", name: "Free", cost: { input: 0, output: 0 } },
{ id: "paid", name: "Paid", cost: { input: 1, output: 3 } },
{ id: "output", name: "Output", cost: { input: 0, output: 1 } },
),
sessions: [
...cases.map((item) => ({
id: item.id,
title: item.title,
model: { id: item.model, providerID: "opencode" },
cost: item.cost,
})),
{
id: "ses_context_family_child",
parentID: "ses_context_family",
title: "Subagent run",
model: { id: "paid", providerID: "opencode" },
cost: 1.25,
},
],
pageMessages: (id) => ({
items: cases.flatMap((item): SessionMessageInfo[] =>
item.id === id && item.usage
? [
{
id: `msg_${item.id}`,
type: "assistant",
agent: "build",
model: { id: item.model, providerID: "opencode" },
tokens: { input: 1500, output: 500, reasoning: 0, cache: { read: 0, write: 0 } },
content: [{ type: "text", text: "Done." }],
time: { created: 1, completed: 2 },
},
...(item.id === "ses_context_compacted"
? [
{
id: `msg_${item.id}_compaction`,
type: "compaction" as const,
status: "completed" as const,
reason: "manual" as const,
summary: "Current state",
recent: "",
time: { created: 3 },
},
]
: []),
]
: [],
),
}),
})
const button = page.getByRole("button", { name: "Toggle session context", exact: true })
for (const item of cases) {
await page.locator("[data-titlebar-tab-slot]", { hasText: item.title }).click()
await expectSessionTitle(page, item.title)
await expect(button).toHaveText(item.text)
}
})
test("file tree expands Windows paths and scrolls long names in both directions", async ({ page }) => {
const directory = "C:/OpenCode/OpenFileExpand"
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
@ -864,7 +941,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
await page.getByRole("option", { name: "Branch changes" }).click()
await page.getByRole("button", { name: "beta.ts" }).click()
await selectedFile("beta.ts")
await page.getByRole("button", { name: "View context usage" }).click()
await page.getByRole("button", { name: "Toggle session context" }).click()
await selectedTab("Context")
await switchSession("Gamma review state")

View file

@ -29,6 +29,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionListInput,
SessionInboxInfo,
SessionInboxCompaction,
ShellInfo,
@ -204,6 +205,8 @@ function createSync() {
}
}
const CHILD_PAGE = 200
export function createData(config: CreateDataInput) {
const api = config.api
let disposed = false
@ -499,6 +502,16 @@ export function createData(config: CreateDataInput) {
return created
}
// Every direct subagent, not only the server's default first page, so a root's family cost
// counts all of them. Pages stay newest first, the order the family index has always used.
async function listChildren(input: SessionListInput): Promise<SessionInfo[]> {
const page = await api().session.list(input)
if (page.data.length < CHILD_PAGE || !page.cursor.next) return page.data
return [...page.data, ...(await listChildren({ cursor: page.cursor.next, limit: CHILD_PAGE }))]
}
// Walk parentID upward through loaded session info to the family root. When a
// parent's info is missing, that missing ID is the furthest-known ancestor and
// is returned so orphan subtrees group under it until the parent arrives. A
@ -1619,11 +1632,7 @@ export function createData(config: CreateDataInput) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
api().session.get({ sessionID }),
options?.children
? api()
.session.list({ parentID: sessionID, order: "desc" })
.then((response) => response.data)
: [],
options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [],
])
const sessions = [info, ...children]
batch(() => {

View file

@ -1,3 +1,4 @@
export * from "./data"
export * from "./connection"
export * from "./pty"
export * from "./usage"

View file

@ -0,0 +1,22 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "../promise"
/**
* The assistant step whose tokens fill the context window now: the latest one after the last completed
* compaction and before the revert boundary. Every client shows context usage from this step, so the TUI
* and the desktop app agree on it.
*/
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 && index > compactionIndex && index < end,
)
}

View file

@ -52,6 +52,47 @@ test("uses the configured initial window and retains normal cursor page sizes",
}
})
test("counts every subagent page in a root session's cost", async () => {
const child = (index: number): SessionInfo => ({
...session(0),
id: `ses_child_${index}`,
parentID: "ses_root",
cost: 1,
})
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
requests.push(`${url.pathname}?${url.searchParams}`)
if (url.pathname === "/api/session/ses_root") return Response.json({ data: { ...session(0), id: "ses_root", cost: 0.5 } })
if (url.searchParams.get("cursor") === "page_2")
return Response.json({ data: Array.from({ length: 50 }, (_, index) => child(200 + index)), cursor: { next: "page_3" } })
return Response.json({ data: Array.from({ length: 200 }, (_, index) => child(index)), cursor: { next: "page_2" } })
},
})
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }),
dispose,
}))
try {
await setup.data.session.sync("ses_root", { children: true })
expect(setup.data.session.family("ses_root")).toHaveLength(251)
expect(setup.data.session.cost("ses_root")).toBe(250.5)
expect(requests.toSorted()).toEqual([
"/api/session/ses_root?",
"/api/session?limit=200&cursor=page_2",
"/api/session?limit=200&order=desc&parentID=ses_root",
])
} finally {
setup.dispose()
}
})
test("keeps the previous model in a live variant notice until the projected message arrives", async () => {
const projected = Promise.withResolvers<void>()
const previous = { providerID: "provider", id: "model", variant: "high" }

View file

@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "../src/promise"
import { lastAssistantWithUsage } from "../src/solid/usage"
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("lastAssistantWithUsage", () => {
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
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)
})
})

View file

@ -5,6 +5,7 @@ export default {
"usage.usage": "Context",
"usage.cost": "Cost",
"usage.view": "View context usage",
"usage.toggle": "Toggle session context",
"systemPrompt.title": "System Prompt",
"rawMessages.title": "Raw messages",
"export.session": "Export session",

View file

@ -1,8 +1,9 @@
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import { IconButton } from "@opencode/ui/icon-button"
import { Button } from "@opencode/ui/button"
import { Tooltip } from "@opencode/ui/tooltip"
import { useI18n } from "@opencode/ui/context/i18n"
import { lastAssistantWithUsage } from "@opencode/client/solid"
import { useExtension, type MountedSession } from "../sdk"
import { catalogModel, syncCatalog } from "./catalog"
@ -39,10 +40,15 @@ export function SessionContextUsage(props: {
}),
)
const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
const info = createMemo(() =>
props.session.id ? props.session.server.data.session.get(props.session.id) : undefined,
)
if (message?.type !== "assistant" || !message.tokens) return
// Same step as the TUI: after the last completed compaction and before an undo boundary.
const context = createMemo(() => {
const message = lastAssistantWithUsage(messages(), info()?.revert?.messageID)
if (!message) return
const model = catalogModel(props.session, message.model)?.model
const total =
@ -58,11 +64,28 @@ export function SessionContextUsage(props: {
}
})
// A root session's cost includes its subagent sessions, as in the TUI.
const cost = createMemo(() =>
usd().format(props.session.id ? props.session.server.data.session.cost(props.session.id) : 0),
const tokens = createMemo(() =>
new Intl.NumberFormat(i18n.locale(), { notation: "compact", maximumFractionDigits: 0 })
.format(context()?.total ?? 0)
.toLocaleLowerCase(i18n.locale()),
)
// A root session's cost includes its subagent sessions, as in the TUI.
const spent = createMemo(() => (props.session.id ? props.session.server.data.session.cost(props.session.id) : 0))
const cost = createMemo(() => usd().format(spent()))
// Spend anywhere in the family keeps the cost visible, even when this session's model is free.
const showCost = createMemo(() => {
if (spent() > 0) return true
const ref = info()?.model ?? messages().findLast((item) => item.type === "assistant")?.model
const model = ref ? catalogModel(props.session, ref)?.model : undefined
if (!model) return true
return model.cost.some((cost) => cost.input > 0 || cost.output > 0 || cost.cache.read > 0 || cost.cache.write > 0)
})
const openContext = () => {
if (!props.session.id) return
layout.toggle(`${ctx.id}:main`, props.session)
@ -86,13 +109,15 @@ export function SessionContextUsage(props: {
const compactCircle = () => (
<div class="flex items-center justify-center">
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} />
<ProgressCircle appearance="compact" size={16} percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => (
<div class="flex w-[120px] flex-col gap-2">
<ContextTooltipRow name={ctx.t("usage.cost")} value={cost()} />
<Show when={showCost()}>
<ContextTooltipRow name={ctx.t("usage.cost")} value={cost()} />
</Show>
<ContextTooltipRow name={ctx.t("usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow name={ctx.t("usage.tokens")} value={context()?.total.toLocaleString(i18n.locale()) ?? "0"} />
</div>
@ -100,18 +125,33 @@ export function SessionContextUsage(props: {
return (
<Show when={props.session.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Tooltip
value={variant() === "indicator" ? tooltipValue() : ctx.t("usage.toggle")}
placement={props.placement ?? "top"}
>
<Show
when={variant() === "indicator"}
fallback={
<IconButton
<Button
type="button"
variant="ghost-muted"
size="large"
icon={compactCircle()}
class="group shrink-0"
style={{ padding: "0 6px", color: "var(--v2-text-text-faint)" }}
onClick={openContext}
aria-label={ctx.t("usage.view")}
/>
aria-expanded={layout.state(`${ctx.id}:main`, props.session) === "visible"}
aria-label={ctx.t("usage.toggle")}
>
<span class="flex items-center gap-2 whitespace-nowrap group-active:text-v2-text-text-muted">
{compactCircle()}
<span>{tokens()}</span>
<Show when={showCost()}>
<span aria-hidden="true" class="flex w-1.5 shrink-0 items-center justify-center">
·
</span>
<span>{cost()}</span>
</Show>
</span>
</Button>
}
>
{circle()}

View file

@ -11,6 +11,7 @@ import { useI18n } from "@opencode/ui/context/i18n"
import { File } from "@opencode/session-ui/file"
import { Markdown } from "@opencode/session-ui/markdown"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { lastAssistantWithUsage } from "@opencode/client/solid"
import { createKeyed, useExtension, type MountedSession } from "../sdk"
import { catalogModel, syncCatalog } from "./catalog"
import { fetchSessionExport, sessionExportFilename } from "./export"
@ -108,10 +109,11 @@ export default function SessionContextTab(props: { session: MountedSession }) {
}),
)
// Same step as the TUI and the header button: after the last completed compaction and before an undo boundary.
const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
const message = lastAssistantWithUsage(messages(), info()?.revert?.messageID)
if (message?.type !== "assistant" || !message.tokens) return
if (!message) return
const entry = catalogModel(props.session, message.model)
const total =

View file

@ -1,4 +1,5 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode/client"
import type { ModelInfo, SessionMessageInfo } from "@opencode/client"
import { lastAssistantWithUsage } from "@opencode/client/solid"
import { Locale } from "./locale"
type SessionNode = {
@ -42,19 +43,6 @@ export function sessionFamily<T extends SessionNode>(sessions: readonly T[], ses
return walk(root(current).id, [])
}
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 && index > compactionIndex && index < end,
)
}
export function contextUsage(
messages: ReadonlyArray<SessionMessageInfo>,
models: ReadonlyArray<ModelInfo> | undefined,

View file

@ -1,16 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client"
import { lastAssistantWithUsage, sessionFamily } 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 },
})
import { sessionFamily } from "../../src/util/session"
describe("util.session", () => {
test("flattens nested subagents from any session in the family", () => {
@ -33,31 +22,4 @@ describe("util.session", () => {
{ session: sessions[6], prefix: "└─ " },
])
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
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)
})
})