mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 07:40:18 +00:00
Show per-session token usage in the composer
Quiet chip (context-fill meter + session total) opening a per-model input/output/cache breakdown popover; accumulation from live events, rebuilt from persisted sidecars on load; unit + e2e coverage.
This commit is contained in:
@@ -46,6 +46,11 @@ const SETTINGS = {
|
|||||||
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
|
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
|
||||||
"zai:glm-5.2": "GLM-5.2 · Z AI",
|
"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 = {
|
const PERSONAS = {
|
||||||
@@ -704,7 +709,18 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
send("assistant_delta", { text: msg.text });
|
send("assistant_delta", { text: msg.text });
|
||||||
// Echo the model the message carried — pins the model-per-message contract (the
|
// 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).
|
// 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");
|
send("turn_done");
|
||||||
} else if (msg.type === "approval") {
|
} else if (msg.type === "approval") {
|
||||||
if (pendingTool === "run_shell") {
|
if (pendingTool === "run_shell") {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -28,10 +28,19 @@ import {
|
|||||||
type SurfaceVisibility,
|
type SurfaceVisibility,
|
||||||
type WorkspaceCommandTrust,
|
type WorkspaceCommandTrust,
|
||||||
} from "./api";
|
} 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 { isProjectScoped } from "./personaScope";
|
||||||
import { baseName } from "./paths";
|
import { baseName } from "./paths";
|
||||||
import { itemsFromMessages } from "./itemsFromMessages";
|
import { itemsFromMessages } from "./itemsFromMessages";
|
||||||
|
import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage";
|
||||||
import { streamMode } from "./streamGate";
|
import { streamMode } from "./streamGate";
|
||||||
import { InboxItemCard } from "./components/InboxItemCard";
|
import { InboxItemCard } from "./components/InboxItemCard";
|
||||||
import { isTauri, platformOS, startWindowDrag } from "./tauri";
|
import { isTauri, platformOS, startWindowDrag } from "./tauri";
|
||||||
@@ -153,6 +162,12 @@ export function App() {
|
|||||||
const [model, setModel] = useState("gpt-5.6-sol");
|
const [model, setModel] = useState("gpt-5.6-sol");
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
const [modelLabels, setModelLabels] = useState<Record<string, string>>({});
|
const [modelLabels, setModelLabels] = useState<Record<string, string>>({});
|
||||||
|
// {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<Record<string, number>>({});
|
||||||
|
// 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<SessionUsage>(emptyUsage());
|
||||||
const [surfaces, setSurfaces] = useState<SurfaceVisibility>({ cowork: true, chat: false, code: false });
|
const [surfaces, setSurfaces] = useState<SurfaceVisibility>({ cowork: true, chat: false, code: false });
|
||||||
const [mode, setMode] = useState("interactive");
|
const [mode, setMode] = useState("interactive");
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
@@ -392,9 +407,12 @@ export function App() {
|
|||||||
setBranch(null);
|
setBranch(null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setItems(itemsFromMessages(await getSessionMessages(last.session_id)));
|
const messages = await getSessionMessages(last.session_id);
|
||||||
|
setItems(itemsFromMessages(messages));
|
||||||
|
setUsage(usageFromMessages(messages));
|
||||||
} catch {
|
} catch {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
}
|
}
|
||||||
setSessionId(last.session_id);
|
setSessionId(last.session_id);
|
||||||
setShowGate(false);
|
setShowGate(false);
|
||||||
@@ -483,6 +501,7 @@ export function App() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setModels(s.models || []);
|
setModels(s.models || []);
|
||||||
setModelLabels(s.model_labels || {});
|
setModelLabels(s.model_labels || {});
|
||||||
|
setModelContextWindows(s.model_context_windows || {});
|
||||||
setModelReady(s.model_ready);
|
setModelReady(s.model_ready);
|
||||||
if (s.surfaces) setSurfaces(s.surfaces);
|
if (s.surfaces) setSurfaces(s.surfaces);
|
||||||
})
|
})
|
||||||
@@ -598,6 +617,7 @@ export function App() {
|
|||||||
setReasoningStream(reasoningRef.current + (d.text || ""));
|
setReasoningStream(reasoningRef.current + (d.text || ""));
|
||||||
break;
|
break;
|
||||||
case "assistant_message": {
|
case "assistant_message": {
|
||||||
|
if (d.usage) setUsage((u) => addTurnUsage(u, d.usage));
|
||||||
// The event's reasoning is authoritative (covers background-delivered turns);
|
// The event's reasoning is authoritative (covers background-delivered turns);
|
||||||
// the local buffer is the fallback for older servers.
|
// the local buffer is the fallback for older servers.
|
||||||
const reasoning = d.reasoning || reasoningRef.current;
|
const reasoning = d.reasoning || reasoningRef.current;
|
||||||
@@ -878,6 +898,7 @@ export function App() {
|
|||||||
const target = forAgent || agent;
|
const target = forAgent || agent;
|
||||||
setSurface("session"); // return to the conversation view if we were on a sub-view
|
setSurface("session"); // return to the conversation view if we were on a sub-view
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -942,8 +963,10 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const messages = await getSessionMessages(id);
|
const messages = await getSessionMessages(id);
|
||||||
setItems(itemsFromMessages(messages));
|
setItems(itemsFromMessages(messages));
|
||||||
|
setUsage(usageFromMessages(messages));
|
||||||
} catch {
|
} catch {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const switchAgent = async (name: string) => {
|
const switchAgent = async (name: string) => {
|
||||||
@@ -956,6 +979,7 @@ export function App() {
|
|||||||
|
|
||||||
setAgent(name);
|
setAgent(name);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -984,9 +1008,12 @@ export function App() {
|
|||||||
else setShowGate(true);
|
else setShowGate(true);
|
||||||
setSessionId(target.sessionId);
|
setSessionId(target.sessionId);
|
||||||
try {
|
try {
|
||||||
setItems(itemsFromMessages(await getSessionMessages(target.sessionId)));
|
const messages = await getSessionMessages(target.sessionId);
|
||||||
|
setItems(itemsFromMessages(messages));
|
||||||
|
setUsage(usageFromMessages(messages));
|
||||||
} catch {
|
} catch {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1037,7 @@ export function App() {
|
|||||||
setShowGate(false);
|
setShowGate(false);
|
||||||
setGateCreate(false);
|
setGateCreate(false);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setSessionId(newId());
|
setSessionId(newId());
|
||||||
@@ -1022,6 +1050,7 @@ export function App() {
|
|||||||
const target = forAgent || agent;
|
const target = forAgent || agent;
|
||||||
setSurface("session");
|
setSurface("session");
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -1046,6 +1075,7 @@ export function App() {
|
|||||||
// Archiving the open chat: leave it and start fresh (it moves to the Archived section).
|
// Archiving the open chat: leave it and start fresh (it moves to the Archived section).
|
||||||
if (archived && id === sessionId) {
|
if (archived && id === sessionId) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -1058,6 +1088,7 @@ export function App() {
|
|||||||
refreshSessions();
|
refreshSessions();
|
||||||
if (id === sessionId) {
|
if (id === sessionId) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setUsage(emptyUsage());
|
||||||
setStreaming("");
|
setStreaming("");
|
||||||
setTodo([]);
|
setTodo([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
@@ -1535,6 +1566,8 @@ export function App() {
|
|||||||
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
onUnattendedChange={agent !== "chat" ? toggleUnattended : undefined}
|
||||||
prefill={composerPrefill}
|
prefill={composerPrefill}
|
||||||
resetKey={sessionId}
|
resetKey={sessionId}
|
||||||
|
usage={usage}
|
||||||
|
contextWindow={modelContextWindows[model]}
|
||||||
placeholder={
|
placeholder={
|
||||||
agent === "code"
|
agent === "code"
|
||||||
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ export interface ConversationMessage {
|
|||||||
tool_calls?: any[];
|
tool_calls?: any[];
|
||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
source?: MessageSource;
|
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;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +694,9 @@ export interface ModelSettings {
|
|||||||
sessions_peek?: number;
|
sessions_peek?: number;
|
||||||
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
||||||
model_labels?: Record<string, string>;
|
model_labels?: Record<string, string>;
|
||||||
|
// {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<string, number>;
|
||||||
// Token savings (PDF attachments): fallback for models without native PDF support,
|
// 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.
|
// and attach-time thresholds. Optional so the GUI is robust to an older backend.
|
||||||
pdf_fallback?: "text" | "images";
|
pdf_fallback?: "text" | "images";
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
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 { isPdfFile, readFile } from "../attach";
|
||||||
import { getSettings, inspectPdf } from "../api";
|
import { getSettings, inspectPdf } from "../api";
|
||||||
|
import { formatTokens, totalTokens } from "../usage";
|
||||||
import { Dropdown, type Option } from "./Dropdown";
|
import { Dropdown, type Option } from "./Dropdown";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Toggle } from "./Toggle";
|
import { Toggle } from "./Toggle";
|
||||||
@@ -77,6 +78,12 @@ interface Props {
|
|||||||
resetKey?: string;
|
resetKey?: string;
|
||||||
// Surface-specific hint shown in the empty textarea.
|
// Surface-specific hint shown in the empty textarea.
|
||||||
placeholder?: string;
|
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) {
|
export function Composer(props: Props) {
|
||||||
@@ -462,6 +469,18 @@ export function Composer(props: Props) {
|
|||||||
|
|
||||||
<span className="ml-auto" />
|
<span className="ml-auto" />
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<UsageChip
|
||||||
|
usage={props.usage}
|
||||||
|
contextWindow={props.contextWindow}
|
||||||
|
model={props.model}
|
||||||
|
modelLabels={props.modelLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
|
{/* 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
|
mid-session switching shipped, so the picker stays actionable; the topbar
|
||||||
subtitle still states the current model). */}
|
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<string, string>;
|
||||||
|
}) {
|
||||||
|
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) => (
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-faint">{label}</span>
|
||||||
|
<span className="text-ink tabular-nums">{formatTokens(value)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11.5px] text-muted hover:text-ink hover:bg-paper shrink-0"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Token usage"
|
||||||
|
title={
|
||||||
|
pct !== null
|
||||||
|
? `Token usage — ${pct}% of the context window used`
|
||||||
|
: "Token usage this session"
|
||||||
|
}
|
||||||
|
data-testid="usage-chip"
|
||||||
|
>
|
||||||
|
{pct !== null && (
|
||||||
|
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${Math.max(pct, 4)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="absolute z-40 bottom-full mb-1 right-0 w-[280px] rounded-xl border border-line bg-panel shadow-2xl p-3"
|
||||||
|
role="menu"
|
||||||
|
data-testid="usage-popover"
|
||||||
|
>
|
||||||
|
{contextWindow ? (
|
||||||
|
<div className="mb-2.5">
|
||||||
|
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
|
||||||
|
Context window
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-line overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11.5px] text-muted tabular-nums">
|
||||||
|
{formatTokens(usage.context)} of {formatTokens(contextWindow)} · {pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : usage.context > 0 ? (
|
||||||
|
<div className="mb-2.5 text-[11.5px] text-muted tabular-nums">
|
||||||
|
In context now: {formatTokens(usage.context)} tokens
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold mb-1">
|
||||||
|
This session
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{Object.entries(usage.byModel).map(([id, t]) => (
|
||||||
|
<div key={id}>
|
||||||
|
<div className="text-[12px] text-ink font-medium truncate" title={id}>
|
||||||
|
{labelFor(id)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 text-[11.5px] leading-snug">
|
||||||
|
{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)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-line flex items-baseline justify-between text-[11.5px]">
|
||||||
|
<span className="text-faint">Total</span>
|
||||||
|
<span className="text-ink tabular-nums">{formatTokens(total)} tokens</span>
|
||||||
|
</div>
|
||||||
|
{model && !modelLabels?.[model] && contextWindow === undefined && (
|
||||||
|
<div className="mt-1 text-[10.5px] text-faint leading-snug">
|
||||||
|
Context meter unavailable for custom models.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with
|
// 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"
|
// 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).
|
// toggle at the bottom (the old standalone InboxControl, folded in).
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ export interface TodoItem {
|
|||||||
status: "pending" | "in_progress" | "done";
|
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<string, TurnUsage>;
|
||||||
|
context: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { addTurnUsage, emptyUsage, formatTokens, totalTokens, usageFromMessages } from "./usage";
|
||||||
|
|
||||||
|
const turn = (over: Record<string, unknown> = {}) => ({
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user