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 (
+
+ );
+}
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 (
+
+