Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/features/chat/components/context-ring.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
width={RING_PX}
height={RING_PX}
viewBox="0 0 20 20"
className="shrink-0"
// The reading is announced by the labelled wrapper this sits inside;
// the SVG itself is decoration.
aria-hidden
focusable="false"
>
{/* 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". */}
<circle
cx="10"
cy="10"
r={RING_R}
fill="none"
stroke="var(--border-strong)"
strokeWidth={RING_STROKE}
strokeDasharray={frac === null ? "2.6 2.2" : undefined}
/>
{frac !== null && frac > 0 && (
<circle
cx="10"
cy="10"
r={RING_R}
fill="none"
stroke={stroke}
strokeWidth={RING_STROKE}
// Butt caps: round ones would pad both ends of the arc and overstate
// small readings, and the criterion here is proportional accuracy.
strokeLinecap="butt"
strokeDasharray={RING_C}
strokeDashoffset={RING_C * (1 - frac)}
// Start the fill at 12 o'clock rather than 3.
transform="rotate(-90 10 10)"
style={{ transition: "stroke-dashoffset 300ms ease-out" }}
/>
)}
</svg>
);
}
35 changes: 35 additions & 0 deletions src/features/chat/components/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 (
<span
role="img"
aria-label={label}
title={label}
className="flex items-center gap-1.5 px-2 h-6.5 rounded-full border border-[var(--border-default)] bg-[var(--bg-elevated)] text-[10px] leading-none font-medium text-[var(--text-tertiary)] select-none tabular-nums"
>
<ContextRing ctx={ctx} />
{fmtCtxTokens(ctx.used)}
{ctx.size > 0 && ` / ${fmtCtxTokens(ctx.size)}`}
</span>
);
}

/** 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
Expand Down Expand Up @@ -1533,6 +1565,9 @@ export function MessageInput({
{agentType === "cersei" && <EffortPill tabId={tabId} />}
{agentType === "cersei" && <CerseiMemoryPill />}
{agentType === "cersei" && <CerseiUsagePill tabId={tabId} />}
{/* ACP agents have no per-turn token split but do stream a
context gauge — same slot, same shape, live during a run. */}
{agentType !== "cersei" && <ContextUsagePill tabId={tabId} />}
</div>

<div className="flex items-center">
Expand Down
15 changes: 9 additions & 6 deletions src/features/chat/components/turn-summary-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Workflow,
Loader2,
Sparkles,
Gauge,
Clock,
GitCompare,
Code,
Expand All @@ -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 {
Expand Down Expand Up @@ -309,13 +309,16 @@ export const TurnSummaryCard = memo(function TurnSummaryCard({
(`--border-subtle`); it bleeds left (`-ml-8`) to meet that rail. */}
<div className="-ml-8 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-[var(--border-subtle)] pt-3 pl-8">
{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.
<span
className="mr-auto flex items-center gap-1 text-[10px] text-[var(--text-tertiary)] tabular-nums select-none"
title={`${ctx.used.toLocaleString()} of ${ctx.size.toLocaleString()} context tokens used${
ctx.cost > 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"
>
<Gauge size={10} className="text-[var(--text-tertiary)]" />
<ContextRing ctx={ctx} />
{fmtTokens(ctx.used)}
{ctx.size > 0 && ` / ${fmtTokens(ctx.size)}`}
{ctx.cost > 0 && ` · $${ctx.cost.toFixed(ctx.cost < 1 ? 4 : 2)}`}
Expand Down
15 changes: 13 additions & 2 deletions src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 /
Expand Down Expand Up @@ -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. */
Expand Down
Loading