diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 7df802f5..4224bff7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -46,6 +46,11 @@ const SETTINGS = { "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "zai:glm-5.2": "GLM-5.2 · Z AI", }, + // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the + // composer usage chip's context-fill meter. + model_context_windows: { + "anthropic:claude-opus-4-8": 200_000, + }, }; const PERSONAS = { @@ -704,7 +709,18 @@ export async function mockApi(page: import("@playwright/test").Page) { send("assistant_delta", { text: msg.text }); // Echo the model the message carried — pins the model-per-message contract (the // composer's visible model must ride on every user_message; 2026-07-04 fix). - send("assistant_message", { text: `Echo: ${msg.text} [model=${msg.model || "none"}]` }); + // `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed + // counts per turn so the usage-chip specs can assert exact accumulation. + send("assistant_message", { + text: `Echo: ${msg.text} [model=${msg.model || "none"}]`, + usage: { + model: msg.model || "anthropic:claude-opus-4-8", + input: 1_000, + output: 200, + cache_read: 8_000, + cache_write: 800, + }, + }); send("turn_done"); } else if (msg.type === "approval") { if (pendingTool === "run_shell") { diff --git a/surfaces/gui/e2e/usage-chip.spec.ts b/surfaces/gui/e2e/usage-chip.spec.ts new file mode 100644 index 00000000..58a18a10 --- /dev/null +++ b/surfaces/gui/e2e/usage-chip.spec.ts @@ -0,0 +1,60 @@ +// Token-usage chip (OPE-42): after a turn reports usage, a quiet meter+count chip appears +// in the composer's bottom row; clicking it opens the per-model breakdown popover with the +// context-window fill. The fake agent attaches fixed usage to every echo turn +// (input 1k / output 200 / cache_read 8k / cache_write 800 — 10k per turn), and the +// settings fixture maps the default model to a 200k context window. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +test("usage chip appears after a turn and opens the breakdown popover", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + // Fresh session: no usage yet — the chip is hidden entirely. + await expect(page.getByTestId("usage-chip")).toHaveCount(0); + + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello"); + await box.press("Enter"); + await expect(page.getByText("Echo: hello", { exact: false }).first()).toBeVisible({ + timeout: 10_000, + }); + + // Chip shows the session total (1k + 200 + 8k + 800 = 10k). + const chip = page.getByTestId("usage-chip"); + await expect(chip).toContainText("10k"); + + // Popover: context fill (9.8k prompt-side of 200k = 5%) + per-model breakdown. + await chip.click(); + const pop = page.getByTestId("usage-popover"); + await expect(pop).toBeVisible(); + await expect(pop).toContainText("Context window"); + await expect(pop).toContainText("9.8k of 200k · 5%"); + await expect(pop).toContainText("Claude Opus 4.8 · Anthropic"); + await expect(pop).toContainText("Input"); + await expect(pop).toContainText("Cache read"); + await expect(pop).toContainText("10k tokens"); + + // Second turn accumulates (totals double), and the scrim click closes the popover. + await page.mouse.click(10, 10); + await expect(pop).toHaveCount(0); + await box.fill("again"); + await box.press("Enter"); + await expect(page.getByText("Echo: again", { exact: false }).first()).toBeVisible({ + timeout: 10_000, + }); + await expect(chip).toContainText("20k"); +}); + +test("usage resets on a new session", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello"); + await box.press("Enter"); + await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 }); + + // "+ New session" wipes the transcript — and the usage accumulation with it. + await page.getByRole("button", { name: /New session/ }).first().click(); + await expect(page.getByTestId("usage-chip")).toHaveCount(0); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 603d1e8e..6c261944 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -28,10 +28,19 @@ import { type SurfaceVisibility, type WorkspaceCommandTrust, } from "./api"; -import type { ApprovalDecision, Attachment, Item, SessionInfo, TodoItem, WsEvent } from "./types"; +import type { + ApprovalDecision, + Attachment, + Item, + SessionInfo, + SessionUsage, + TodoItem, + WsEvent, +} from "./types"; import { isProjectScoped } from "./personaScope"; import { baseName } from "./paths"; import { itemsFromMessages } from "./itemsFromMessages"; +import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage"; import { streamMode } from "./streamGate"; import { InboxItemCard } from "./components/InboxItemCard"; import { isTauri, platformOS, startWindowDrag } from "./tauri"; @@ -153,6 +162,12 @@ export function App() { const [model, setModel] = useState("gpt-5.6-sol"); const [models, setModels] = useState([]); const [modelLabels, setModelLabels] = useState>({}); + // {full model id → context window in tokens} from the curated matrix (verified only); + // drives the composer usage chip's context-fill meter. + const [modelContextWindows, setModelContextWindows] = useState>({}); + // Per-session token usage (OPE-42): rebuilt from the transcript on session load, + // accumulated live from assistant_message events, reset with the transcript. + const [usage, setUsage] = useState(emptyUsage()); const [surfaces, setSurfaces] = useState({ cowork: true, chat: false, code: false }); const [mode, setMode] = useState("interactive"); const [connected, setConnected] = useState(false); @@ -392,9 +407,12 @@ export function App() { setBranch(null); } try { - setItems(itemsFromMessages(await getSessionMessages(last.session_id))); + const messages = await getSessionMessages(last.session_id); + setItems(itemsFromMessages(messages)); + setUsage(usageFromMessages(messages)); } catch { setItems([]); + setUsage(emptyUsage()); } setSessionId(last.session_id); setShowGate(false); @@ -483,6 +501,7 @@ export function App() { .then((s) => { setModels(s.models || []); setModelLabels(s.model_labels || {}); + setModelContextWindows(s.model_context_windows || {}); setModelReady(s.model_ready); if (s.surfaces) setSurfaces(s.surfaces); }) @@ -598,6 +617,7 @@ export function App() { setReasoningStream(reasoningRef.current + (d.text || "")); break; case "assistant_message": { + if (d.usage) setUsage((u) => addTurnUsage(u, d.usage)); // The event's reasoning is authoritative (covers background-delivered turns); // the local buffer is the fallback for older servers. const reasoning = d.reasoning || reasoningRef.current; @@ -878,6 +898,7 @@ export function App() { const target = forAgent || agent; setSurface("session"); // return to the conversation view if we were on a sub-view setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setRunning(false); @@ -942,8 +963,10 @@ export function App() { try { const messages = await getSessionMessages(id); setItems(itemsFromMessages(messages)); + setUsage(usageFromMessages(messages)); } catch { setItems([]); + setUsage(emptyUsage()); } }; const switchAgent = async (name: string) => { @@ -956,6 +979,7 @@ export function App() { setAgent(name); setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setRunning(false); @@ -984,9 +1008,12 @@ export function App() { else setShowGate(true); setSessionId(target.sessionId); try { - setItems(itemsFromMessages(await getSessionMessages(target.sessionId))); + const messages = await getSessionMessages(target.sessionId); + setItems(itemsFromMessages(messages)); + setUsage(usageFromMessages(messages)); } catch { setItems([]); + setUsage(emptyUsage()); } return; } @@ -1010,6 +1037,7 @@ export function App() { setShowGate(false); setGateCreate(false); setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setSessionId(newId()); @@ -1022,6 +1050,7 @@ export function App() { const target = forAgent || agent; setSurface("session"); setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setRunning(false); @@ -1046,6 +1075,7 @@ export function App() { // Archiving the open chat: leave it and start fresh (it moves to the Archived section). if (archived && id === sessionId) { setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setRunning(false); @@ -1058,6 +1088,7 @@ export function App() { refreshSessions(); if (id === sessionId) { setItems([]); + setUsage(emptyUsage()); setStreaming(""); setTodo([]); setRunning(false); @@ -1535,6 +1566,8 @@ export function App() { onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined} prefill={composerPrefill} resetKey={sessionId} + usage={usage} + contextWindow={modelContextWindows[model]} placeholder={ agent === "code" ? "Ask the coder to build, fix, or explain… (drop or paste files)" diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index dcb54ac7..80a5dcef 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -141,6 +141,9 @@ export interface ConversationMessage { tool_calls?: any[]; tool_call_id?: string; source?: MessageSource; + // Token counts for the round-trip that produced an assistant message + // ({model, input, output, cache_read, cache_write}); absent on older servers. + usage?: import("./types").TurnUsage; [key: string]: any; } @@ -691,6 +694,9 @@ export interface ModelSettings { sessions_peek?: number; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; + // {full id → context window in tokens}, verified matrix entries only — drives the + // composer's context-fill meter (absent id → the meter hides). Optional for older backends. + model_context_windows?: Record; // Token savings (PDF attachments): fallback for models without native PDF support, // and attach-time thresholds. Optional so the GUI is robust to an older backend. pdf_fallback?: "text" | "images"; diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 852e4d6f..7862a37f 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -1,7 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import type { Attachment } from "../types"; +import type { Attachment, SessionUsage } from "../types"; import { isPdfFile, readFile } from "../attach"; import { getSettings, inspectPdf } from "../api"; +import { formatTokens, totalTokens } from "../usage"; import { Dropdown, type Option } from "./Dropdown"; import { Icon } from "./Icon"; import { Toggle } from "./Toggle"; @@ -77,6 +78,12 @@ interface Props { resetKey?: string; // Surface-specific hint shown in the empty textarea. placeholder?: string; + // Per-session token usage (OPE-42) — absent/empty hides the usage chip entirely + // (older servers, backends that don't report usage, fresh sessions). + usage?: SessionUsage; + // Context-window size (tokens) of the ACTIVE model, from the curated matrix; + // undefined hides the fill meter (unverified/custom models) but keeps the counts. + contextWindow?: number; } export function Composer(props: Props) { @@ -462,6 +469,18 @@ export function Composer(props: Props) { + {/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server + reports usage. Fill = context-window occupancy (bounded), count = session + consumption (unbounded, so never a fill). */} + {!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && ( + + )} + {/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22: mid-session switching shipped, so the picker stays actionable; the topbar subtitle still states the current model). */} @@ -546,6 +565,121 @@ export function Composer(props: Props) { ); } +// Token-usage chip + popover (OPE-42). Trigger: a tiny context-fill meter (only when the +// active model's window is known) + the session's total token count. Click → per-model +// breakdown. Tokens only, never dollars (true cost is unknowable client-side — discounted +// pricing, per-provider cache billing). +function UsageChip({ + usage, + contextWindow, + model, + modelLabels, +}: { + usage: SessionUsage; + contextWindow?: number; + model: string; + modelLabels?: Record; +}) { + const [open, setOpen] = useState(false); + const total = totalTokens(usage); + const pct = contextWindow + ? Math.min(100, Math.round((usage.context / contextWindow) * 100)) + : null; + const labelFor = (id: string) => + id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id); + const stat = (label: string, value: number) => ( + + {label} + {formatTokens(value)} + + ); + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ {contextWindow ? ( +
+
+ Context window +
+
+
+
+
+ {formatTokens(usage.context)} of {formatTokens(contextWindow)} · {pct}% +
+
+ ) : usage.context > 0 ? ( +
+ In context now: {formatTokens(usage.context)} tokens +
+ ) : null} +
+ This session +
+
+ {Object.entries(usage.byModel).map(([id, t]) => ( +
+
+ {labelFor(id)} +
+
+ {stat("Input", t.input)} + {stat("Output", t.output)} + {t.cache_read > 0 && stat("Cache read", t.cache_read)} + {t.cache_write > 0 && stat("Cache write", t.cache_write)} +
+
+ ))} +
+
+ Total + {formatTokens(total)} tokens +
+ {model && !modelLabels?.[model] && contextWindow === undefined && ( +
+ Context meter unavailable for custom models. +
+ )} +
+ + )} +
+ ); +} + // The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with // the current one marked, plus — when the session supports it — the "Send approvals to Inbox" // toggle at the bottom (the old standalone InboxControl, folded in). diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index e0104802..f86b3ce8 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -38,6 +38,25 @@ export interface TodoItem { status: "pending" | "in_progress" | "done"; } +// Per-round-trip token counts, as attached by the server to assistant messages and +// the assistant_message event (`{model, input, output, cache_read, cache_write}`). +// Absent on older servers and on backends that don't report usage. +export interface TurnUsage { + model?: string | null; + input: number; + output: number; + cache_read: number; + cache_write: number; +} + +// Per-session accumulation, keyed by model id (multiple models when the user +// switched mid-session). `context` = the latest round-trip's prompt-side total — +// what currently occupies the active model's context window. +export interface SessionUsage { + byModel: Record; + context: number; +} + export interface SessionInfo { session_id: string; title?: string; diff --git a/surfaces/gui/src/usage.test.ts b/surfaces/gui/src/usage.test.ts new file mode 100644 index 00000000..82e85322 --- /dev/null +++ b/surfaces/gui/src/usage.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { addTurnUsage, emptyUsage, formatTokens, totalTokens, usageFromMessages } from "./usage"; + +const turn = (over: Record = {}) => ({ + model: "anthropic:claude-fable-5", + input: 100, + output: 20, + cache_read: 50, + cache_write: 10, + ...over, +}); + +describe("addTurnUsage", () => { + it("accumulates per model and tracks the latest prompt-side total as context", () => { + let u = addTurnUsage(emptyUsage(), turn()); + u = addTurnUsage(u, turn({ input: 40, output: 5, cache_read: 200, cache_write: 0 })); + const m = u.byModel["anthropic:claude-fable-5"]; + expect(m).toEqual({ + model: "anthropic:claude-fable-5", + input: 140, + output: 25, + cache_read: 250, + cache_write: 10, + }); + // context = LAST turn's input + cache_read + cache_write, not a sum. + expect(u.context).toBe(240); + }); + + it("keys separate models separately (mid-session switch)", () => { + let u = addTurnUsage(emptyUsage(), turn()); + u = addTurnUsage(u, turn({ model: "gpt-5.5", input: 7 })); + expect(Object.keys(u.byModel).sort()).toEqual(["anthropic:claude-fable-5", "gpt-5.5"]); + }); + + it("ignores malformed payloads and clamps negatives", () => { + expect(addTurnUsage(emptyUsage(), null)).toEqual(emptyUsage()); + expect(addTurnUsage(emptyUsage(), "x")).toEqual(emptyUsage()); + const u = addTurnUsage(emptyUsage(), turn({ input: -5, output: "9" })); + expect(u.byModel["anthropic:claude-fable-5"].input).toBe(0); + expect(u.byModel["anthropic:claude-fable-5"].output).toBe(9); + }); + + it("buckets a missing model id under 'unknown'", () => { + const u = addTurnUsage(emptyUsage(), turn({ model: undefined })); + expect(u.byModel["unknown"].input).toBe(100); + }); +}); + +describe("usageFromMessages", () => { + it("folds assistant usage sidecars and ignores everything else", () => { + const u = usageFromMessages([ + { role: "user", content: "hi" }, + { role: "assistant", content: "a", usage: turn() as any }, + { role: "tool", content: "r", usage: turn() as any }, // wrong role — ignored + { role: "assistant", content: "b" }, // no sidecar (older server) — ignored + { role: "assistant", content: "c", usage: turn({ input: 300 }) as any }, + ]); + expect(u.byModel["anthropic:claude-fable-5"].input).toBe(400); + expect(u.context).toBe(360); + }); +}); + +describe("totalTokens / formatTokens", () => { + it("totals across models and directions", () => { + let u = addTurnUsage(emptyUsage(), turn()); + u = addTurnUsage(u, turn({ model: "gpt-5.5" })); + expect(totalTokens(u)).toBe(360); + }); + + it("formats humane numbers", () => { + expect(formatTokens(0)).toBe("0"); + expect(formatTokens(980)).toBe("980"); + expect(formatTokens(12_400)).toBe("12.4k"); + expect(formatTokens(982_000)).toBe("982k"); + expect(formatTokens(1_240_000)).toBe("1.24M"); + expect(formatTokens(NaN)).toBe("0"); + }); +}); diff --git a/surfaces/gui/src/usage.ts b/surfaces/gui/src/usage.ts new file mode 100644 index 00000000..135a3fa1 --- /dev/null +++ b/surfaces/gui/src/usage.ts @@ -0,0 +1,75 @@ +// Per-session token-usage accumulation (OPE-42). Pure functions so the reducer is +// unit-testable without the app. The server attaches a `usage` sidecar +// ({model, input, output, cache_read, cache_write}) to assistant messages and to the +// assistant_message event; older servers and non-reporting backends send none, and +// everything here no-ops gracefully in that case. + +import type { ConversationMessage } from "./api"; +import type { SessionUsage, TurnUsage } from "./types"; + +export function emptyUsage(): SessionUsage { + return { byModel: {}, context: 0 }; +} + +const num = (v: any): number => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : 0; +}; + +/** Fold one turn's usage sidecar into the session accumulation. */ +export function addTurnUsage(prev: SessionUsage, raw: any): SessionUsage { + if (!raw || typeof raw !== "object") return prev; + const turn: TurnUsage = { + model: typeof raw.model === "string" && raw.model ? raw.model : null, + input: num(raw.input), + output: num(raw.output), + cache_read: num(raw.cache_read), + cache_write: num(raw.cache_write), + }; + const key = turn.model || "unknown"; + const cur = prev.byModel[key]; + return { + byModel: { + ...prev.byModel, + [key]: { + model: turn.model, + input: (cur?.input || 0) + turn.input, + output: (cur?.output || 0) + turn.output, + cache_read: (cur?.cache_read || 0) + turn.cache_read, + cache_write: (cur?.cache_write || 0) + turn.cache_write, + }, + }, + // Prompt-side total of the LATEST round-trip = what currently sits in the + // context window (not a sum — each request resends the whole history). + context: turn.input + turn.cache_read + turn.cache_write, + }; +} + +/** Rebuild the accumulation from a replayed transcript (session load/switch). */ +export function usageFromMessages(messages: ConversationMessage[]): SessionUsage { + let acc = emptyUsage(); + for (const m of messages || []) { + if (m.role === "assistant" && m.usage) acc = addTurnUsage(acc, m.usage); + } + return acc; +} + +/** All tokens consumed this session, across models and directions (chip headline). */ +export function totalTokens(u: SessionUsage): number { + return Object.values(u.byModel).reduce( + (sum, t) => sum + t.input + t.output + t.cache_read + t.cache_write, + 0, + ); +} + +/** 980 → "980", 12_400 → "12.4k", 982_000 → "982k", 1_240_000 → "1.24M". */ +export function formatTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0"; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) { + const k = n / 1000; + return (k < 100 ? k.toFixed(1).replace(/\.0$/, "") : String(Math.round(k))) + "k"; + } + const m = n / 1_000_000; + return (m < 100 ? m.toFixed(2).replace(/\.?0+$/, "") : String(Math.round(m))) + "M"; +}