diff --git a/src/features/chat/components/context-ring.tsx b/src/features/chat/components/context-ring.tsx new file mode 100644 index 00000000..ee38d3d0 --- /dev/null +++ b/src/features/chat/components/context-ring.tsx @@ -0,0 +1,103 @@ +/** + * Compact context-window meter — a donut whose arc is the fraction of the + * model's context window the session has consumed. + * + * Replaces the speedometer glyph that used to sit beside the context numbers: + * the icon now *carries* the value it previously only labelled, so the fill + * level is readable at a glance without parsing "45.2k / 200.0k". + * + * Geometry is fixed (12px box, no text inside) and the arc animates purely via + * `stroke-dashoffset`, which is not a layout property — so a value landing + * mid-run repaints without reflowing the row it sits in. + */ +import type { ContextUsage } from "@/types/agent"; + +/** Rendered box, in px. The 20×20 viewBox scales into it. */ +const RING_PX = 12; +/** Radius inside the 20×20 viewBox — leaves room for the 4-wide stroke. */ +const RING_R = 7; +const RING_C = 2 * Math.PI * RING_R; +/** A 4/20 stroke on a 12px box is ~2.4 device px of ring: heavy enough that + * the arc reads as a fill rather than a hairline at this size. */ +const RING_STROKE = 4; + +/** Fraction of the window used, or `null` when the agent reports no limit. */ +export function contextFraction(ctx: ContextUsage): number | null { + if (ctx.size <= 0) return null; + return Math.min(1, Math.max(0, ctx.used / ctx.size)); +} + +/** + * Screen-reader label / tooltip for a context reading: used tokens, total + * window, percentage, and estimated cost. Agents that don't advertise a + * context limit say so instead of implying a proportion we don't have. + */ +export function contextLabel(ctx: ContextUsage): string { + const frac = contextFraction(ctx); + const cost = ctx.cost > 0 ? ` · est. $${ctx.cost.toFixed(4)}` : ""; + if (frac === null) { + return `Context: ${ctx.used.toLocaleString()} tokens used — this agent does not report a context limit${cost}`; + } + return `Context: ${ctx.used.toLocaleString()} of ${ctx.size.toLocaleString()} tokens used (${Math.round( + frac * 100, + )}%)${cost}`; +} + +export function ContextRing({ ctx }: { ctx: ContextUsage }) { + const frac = contextFraction(ctx); + // The point of the ring is seeing the wall before you hit it, so the arc + // warms as the window fills. Below the thresholds it stays monochrome, in + // keeping with the rest of the palette. + const stroke = + frac === null + ? "var(--text-tertiary)" + : frac >= 0.9 + ? "var(--status-error)" + : frac >= 0.75 + ? "var(--status-warning)" + : "var(--text-secondary)"; + + return ( + + {/* Track. With no reported limit it's the whole glyph, and it goes + dashed — a solid empty ring is what 0%-of-a-known-window looks like, + and "we don't know the capacity" must not read as "nothing used". */} + + {frac !== null && frac > 0 && ( + + )} + + ); +} diff --git a/src/features/chat/components/message-input.tsx b/src/features/chat/components/message-input.tsx index c9f1dd3a..4aaf6704 100644 --- a/src/features/chat/components/message-input.tsx +++ b/src/features/chat/components/message-input.tsx @@ -19,6 +19,7 @@ import { agents } from "../lib/agents-api"; import { CLAUDE_PERMISSION_MODE_LABEL, AGENT_LABEL, agentTypeFromPluginId, type SwitchableAgent } from "@/types/agent"; import { AgentMark } from "@/components/agent-mark"; import { ProviderModelPills } from "./provider-model-pills"; +import { ContextRing, contextLabel } from "./context-ring"; import { loadCerseiEffort, loadCerseiCompress } from "../lib/cersei-model-pref"; import { loadCachedAcpModels } from "../lib/acp-models-cache"; // `ChatInput` pulls in CodeMirror (~870 KB) via `cm-mention-extension`. @@ -279,6 +280,37 @@ function CerseiUsagePill({ tabId }: { tabId: string }) { ); } +/** Live context-window meter for the ACP agents (Claude Code / Codex), which + * stream a cumulative `context_usage` gauge instead of the native agent's + * per-turn token deltas. The ring is fixed-size and the count is tabular, so + * the pill repaints on every gauge delta mid-run without nudging the row. + * Hidden until the first gauge lands; agents that report no window size get + * the empty track plus a bare token count (see `contextLabel`). */ +function ContextUsagePill({ tabId }: { tabId: string }) { + const ctx = useChatStore((s) => s.sessions[tabId]?.contextUsage); + if (!ctx || (ctx.used <= 0 && ctx.size <= 0)) return null; + const label = contextLabel(ctx); + return ( + + + {fmtCtxTokens(ctx.used)} + {ctx.size > 0 && ` / ${fmtCtxTokens(ctx.size)}`} + + ); +} + +/** Compact token count for the context pill: 1.2k / 42.1k / 1.0M. */ +function fmtCtxTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return `${n}`; +} + /** * Composer permission-mode picker for non-Claude ACP agents (Codex). Unlike * Claude's fixed 4-mode cycling pill, the modes here are agent-advertised @@ -1533,6 +1565,9 @@ export function MessageInput({ {agentType === "cersei" && } {agentType === "cersei" && } {agentType === "cersei" && } + {/* ACP agents have no per-turn token split but do stream a + context gauge — same slot, same shape, live during a run. */} + {agentType !== "cersei" && }
diff --git a/src/features/chat/components/turn-summary-card.tsx b/src/features/chat/components/turn-summary-card.tsx index 2dc6b75d..0809507a 100644 --- a/src/features/chat/components/turn-summary-card.tsx +++ b/src/features/chat/components/turn-summary-card.tsx @@ -6,7 +6,6 @@ import { Workflow, Loader2, Sparkles, - Gauge, Clock, GitCompare, Code, @@ -20,6 +19,7 @@ import { openFile } from "@/lib/open-file"; import { openGitDiff } from "@/features/git/lib/git-diff-api"; import { useGitStore, type GitFileStatus } from "@/features/git/stores/git-store"; import type { ChatMessage, TurnFile } from "@/types/agent"; +import { ContextRing, contextLabel } from "./context-ring"; /** Compact token count: 1.2k / 42.1k / 1.0M. */ function fmtTokens(n: number): string { @@ -309,13 +309,16 @@ export const TurnSummaryCard = memo(function TurnSummaryCard({ (`--border-subtle`); it bleeds left (`-ml-8`) to meet that rail. */}
{ctx && (ctx.used > 0 || ctx.size > 0) ? ( + // `role="img"` + the full label: the ring and the abbreviated + // "45.2k / 200.0k · $0.12" are one reading, and announcing the + // spelled-out label once beats reading the parts separately. 0 ? ` · est. $${ctx.cost.toFixed(4)}` : "" - }`} + role="img" + aria-label={contextLabel(ctx)} + title={contextLabel(ctx)} + className="mr-auto flex items-center gap-1.5 text-[10px] text-[var(--text-tertiary)] tabular-nums select-none" > - + {fmtTokens(ctx.used)} {ctx.size > 0 && ` / ${fmtTokens(ctx.size)}`} {ctx.cost > 0 && ` · $${ctx.cost.toFixed(ctx.cost < 1 ? 4 : 2)}`} diff --git a/src/types/agent.ts b/src/types/agent.ts index 3b32bbfd..962b0dee 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -2,6 +2,17 @@ import type { SessionModeInfo } from "./agents"; export type AgentType = "claude-code" | "codex" | "cersei" | "custom"; +/** + * ACP context-window reading: tokens consumed, the window's total capacity, + * and the run's estimated cost. `size` is 0 when the agent doesn't advertise a + * limit — consumers must treat the proportion as unknown rather than as zero. + */ +export interface ContextUsage { + used: number; + size: number; + cost: number; +} + /** Switchable (Atlas-shipped) agents — excludes the catch-all "custom". */ export type SwitchableAgent = "claude-code" | "codex" | "cersei"; @@ -176,7 +187,7 @@ export interface ChatSession { /** Latest ACP context-window gauge (Claude Code / Codex) from `context_usage` * deltas — `used`/`size` tokens + cost. Snapshotted onto the trailing * assistant message at turn end (ACP agents have no per-turn in/out split). */ - contextUsage?: { used: number; size: number; cost: number }; + contextUsage?: ContextUsage; /** True while the native agent is compacting its context window. */ compacting?: boolean; /** Reasoning-effort level for the native agent ("" / low / medium / high / @@ -265,7 +276,7 @@ export interface ChatMessage { * assistant message at turn end. These agents can't report a per-turn * input/output split, so the card shows this `used`/`size` context gauge in * the same slot the native agent uses for `usage`. */ - contextUsage?: { used: number; size: number; cost: number }; + contextUsage?: ContextUsage; /** Adaptive per-turn footer, frozen onto the trailing assistant message at * turn_finished (mirrors `usage` — never set mid-stream). Drives the * TurnSummaryCard's files-read/modified accordion + action buttons. */