From a6d74f1532f0c7424879c0e55539d5c6c26a1359 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 16:19:17 -0700 Subject: [PATCH 1/2] feat(claude): show a context window breakdown in the composer meter Hovering the context meter in a Claude thread now shows what fills the window: system prompt, system tools, MCP tools, messages, skills, memory files, and custom agents, each with token counts and share of the window. Rows below the list expand to individual MCP tools, memory files, skills, and agents. The data comes from the Claude Agent SDK's getContextUsage, read on demand when the popover opens. Codex app-server reports only aggregate token counts, so the breakdown stays Claude only; the adapter method is optional for that reason. Co-Authored-By: Claude Fable 5.1 --- ...ProviderSessionStartup.integration.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 103 ++++++++++++-- .../src/provider/Layers/ClaudeAdapter.ts | 118 ++++++++++++++++ .../provider/Layers/ProviderService.test.ts | 56 ++++++++ .../src/provider/Layers/ProviderService.ts | 30 ++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 7 + .../src/provider/Services/ProviderService.ts | 10 ++ .../serverRuntimeStartup.reconcile.test.ts | 1 + apps/server/src/ws.ts | 15 ++ apps/web/src/components/chat/ChatComposer.tsx | 26 +++- .../components/chat/ContextUsageBreakdown.tsx | 132 ++++++++++++++++++ .../components/chat/ContextWindowMeter.tsx | 75 +++++++++- .../web/src/lib/contextUsageBreakdown.test.ts | 68 +++++++++ apps/web/src/lib/contextUsageBreakdown.ts | 111 +++++++++++++++ docs/user/providers-claude.md | 14 ++ .../src/state/threadCommands.ts | 4 + packages/contracts/src/provider.ts | 60 +++++++- packages/contracts/src/rpc.ts | 11 ++ 23 files changed, 829 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/chat/ContextUsageBreakdown.tsx create mode 100644 apps/web/src/lib/contextUsageBreakdown.test.ts create mode 100644 apps/web/src/lib/contextUsageBreakdown.ts diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 86a00323c049..662ba36987fc 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -121,6 +121,7 @@ const startupDependencies = Layer.mergeAll( getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), + getContextUsage: () => Effect.die("unused"), streamEvents: Stream.empty, }), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..421a2f20e5d1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -106,6 +106,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, + [WS_METHODS.providerGetContextUsage]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 9e141600713f..92778bf15180 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -138,6 +138,7 @@ function createProviderServiceHarness( }), rollbackConversation, uploadFeedback: () => unsupported(), + getContextUsage: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..b135742fbe69 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -391,6 +391,7 @@ describe("ProviderCommandReactor", () => { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + getContextUsage: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..ab3ec169f13f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -142,6 +142,7 @@ function createProviderServiceHarness() { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + getContextUsage: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub).pipe( Stream.flatMap(({ events, enqueued }) => diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3b3f427d0cbb..51a4d19a1ecd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -8,6 +8,7 @@ import type { Options as ClaudeQueryOptions, PermissionMode, PermissionResult, + SDKControlGetContextUsageResponse, SDKMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -118,6 +119,10 @@ class FakeClaudeQuery implements AsyncIterable { this.setMaxThinkingTokensCalls.push(maxThinkingTokens); }; + getContextUsage = async (): Promise => { + throw new Error("getContextUsage not stubbed"); + }; + readonly close = (): void => { this.closeCalls += 1; if (this.closeError !== undefined) { @@ -2926,16 +2931,14 @@ describe("ClaudeAdapterLive", () => { it.effect("completes with result usage without querying current context usage", () => { const harness = makeHarness(); let getContextUsageCalls = 0; - Object.assign(harness.query, { - getContextUsage: async () => { - getContextUsageCalls += 1; - return { - totalTokens: 999, - maxTokens: 200000, - isAutoCompactEnabled: true, - }; - }, - }); + harness.query.getContextUsage = async () => { + getContextUsageCalls += 1; + return { + totalTokens: 999, + maxTokens: 200000, + isAutoCompactEnabled: true, + } as unknown as SDKControlGetContextUsageResponse; + }; return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( @@ -3238,6 +3241,86 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("reads the context breakdown on demand through the SDK", () => { + const harness = makeHarness(); + let getContextUsageCalls = 0; + harness.query.getContextUsage = async () => { + getContextUsageCalls += 1; + return { + categories: [ + { name: "System tools", tokens: 19_200, color: "inactive" }, + { name: "Messages", tokens: 12_000.4, color: "purple" }, + { name: "Free space", tokens: 168_800, color: "inactive" }, + { name: "MCP tools (deferred)", tokens: 42_400, color: "inactive", isDeferred: true }, + ], + totalTokens: 31_200, + maxTokens: 200_000, + rawMaxTokens: 200_000, + percentage: 15.6, + gridRows: [], + model: "claude-fable-5-1", + memoryFiles: [{ path: "/Users/me/repo/CLAUDE.md", type: "Project", tokens: 3_100 }], + mcpTools: [ + { name: "mcp__t3-code__preview_click", serverName: "t3-code", tokens: 400 }, + { name: "mcp__xcodebuildmcp__build_run_sim", serverName: "xcodebuildmcp", tokens: 900 }, + ], + agents: [{ agentType: "sol", source: "user", tokens: 87 }], + systemTools: [{ name: "Bash", tokens: 5_000 }], + isAutoCompactEnabled: true, + apiUsage: null, + } as SDKControlGetContextUsageResponse; + }; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + // Nothing queried the breakdown yet: it is on-demand only. + assert.equal(getContextUsageCalls, 0); + assert.isDefined(adapter.getContextUsage); + const usage = yield* adapter.getContextUsage!(THREAD_ID); + + assert.equal(getContextUsageCalls, 1); + assert.equal(usage.model, "claude-fable-5-1"); + assert.equal(usage.totalTokens, 31_200); + assert.equal(usage.maxTokens, 200_000); + assert.deepEqual(usage.categories, [ + { name: "System tools", tokens: 19_200, deferred: false }, + { name: "Messages", tokens: 12_000, deferred: false }, + { name: "Free space", tokens: 168_800, deferred: false }, + { name: "MCP tools (deferred)", tokens: 42_400, deferred: true }, + ]); + assert.deepEqual( + usage.groups.map((group) => [group.label, group.tokens, group.items.length]), + [ + ["System tools", 5_000, 1], + ["MCP tools", 1_300, 2], + ["Memory files", 3_100, 1], + ["Custom agents", 87, 1], + ], + ); + // Items inside a group sort by size so the biggest offender is first, + // and MCP names drop their `mcp____` prefix. + assert.deepEqual(usage.groups[1]?.items[0], { + name: "build_run_sim", + tokens: 900, + detail: "xcodebuildmcp", + }); + // Memory file paths shrink to their last two segments. + assert.deepEqual(usage.groups[2]?.items[0], { + name: "repo/CLAUDE.md", + tokens: 3_100, + detail: "Project", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves compacted usage when completion follows an older assistant frame", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index e46024fa4078..cc498aa95f1e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -13,6 +13,7 @@ import { type PermissionMode, type PermissionResult, type PermissionUpdate, + type SDKControlGetContextUsageResponse, type SDKMessage, type SDKRateLimitInfo, type SDKResultMessage, @@ -31,6 +32,7 @@ import { type ClaudeSettings, EventId, type ProviderApprovalDecision, + type ProviderContextUsage, ProviderDriverKind, ProviderInstanceId, type ModelSelection, @@ -339,9 +341,111 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; + readonly getContextUsage: () => Promise; readonly close: () => void; } +/** + * Translate the SDK's `/context` report into the wire contract. The SDK's + * category list is ordered by size and already includes free space and any + * deferred slices; the detail groups mirror the expandable rows Claude Code + * shows under that list. + */ +export function toProviderContextUsage( + response: SDKControlGetContextUsageResponse, +): ProviderContextUsage { + const categories = response.categories + .filter((category) => category.name.trim().length > 0 && category.tokens >= 0) + .map((category) => ({ + name: category.name, + tokens: Math.round(category.tokens), + deferred: category.isDeferred === true, + })); + + const groups: Array = []; + const pushGroup = ( + label: string, + items: ReadonlyArray<{ name: string; tokens: number; detail?: string }>, + ) => { + const kept = items + .filter((item) => item.name.trim().length > 0 && item.tokens >= 0) + .map((item) => ({ + name: item.name, + tokens: Math.round(item.tokens), + ...(item.detail && item.detail.trim().length > 0 ? { detail: item.detail } : {}), + })) + .sort((left, right) => right.tokens - left.tokens); + if (kept.length === 0) { + return; + } + groups.push({ + label, + tokens: kept.reduce((sum, item) => sum + item.tokens, 0), + items: kept, + }); + }; + + pushGroup( + "System prompt", + (response.systemPromptSections ?? []).map((section) => ({ + name: section.name, + tokens: section.tokens, + })), + ); + pushGroup( + "System tools", + (response.systemTools ?? []).map((tool) => ({ name: tool.name, tokens: tool.tokens })), + ); + // MCP tool names carry an `mcp____` prefix; the server already + // appears as the detail column, so only the bare tool name is shown. + pushGroup( + "MCP tools", + response.mcpTools.map((tool) => ({ + name: tool.name.replace(/^mcp__[^_]+(?:_[^_]+)*__/, "") || tool.name, + tokens: tool.tokens, + detail: tool.serverName, + })), + ); + // Memory files arrive as absolute paths; the last two segments identify + // them (`t3code/AGENTS.md`) without the home-directory prefix that would + // otherwise get truncated away in a narrow popover. + pushGroup( + "Memory files", + response.memoryFiles.map((file) => ({ + name: file.path.split(/[\\/]/).filter(Boolean).slice(-2).join("/") || file.path, + tokens: file.tokens, + detail: file.type, + })), + ); + pushGroup( + "Skills", + (response.skills?.skillFrontmatter ?? []).map((skill) => ({ + name: skill.name, + tokens: skill.tokens, + detail: skill.source, + })), + ); + pushGroup( + "Custom agents", + response.agents.map((agent) => ({ + name: agent.agentType, + tokens: agent.tokens, + detail: agent.source, + })), + ); + // `messageBreakdown` is left out on purpose: it totals every API call in + // the session, so it exceeds the resident "Messages" slice and would read + // as a contradiction next to it. + + return { + model: response.model, + totalTokens: Math.max(0, Math.round(response.totalTokens)), + maxTokens: Math.max(1, Math.round(response.maxTokens)), + categories, + groups, + }; +} + export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -5085,6 +5189,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const listSessions: ClaudeAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + // On-demand only. The SDK's token-count fallback can issue a model request, + // so this must never run automatically after turns (see #8610). + const getContextUsage: NonNullable = Effect.fn( + "getContextUsage", + )(function* (threadId) { + const context = yield* requireSession(threadId); + const response = yield* Effect.tryPromise({ + try: () => context.query.getContextUsage(), + catch: (cause) => toRequestError(threadId, "context/usage", cause), + }); + return toProviderContextUsage(response); + }); + const hasSession: ClaudeAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const context = sessions.get(threadId); @@ -5135,6 +5252,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( stopSession, listSessions, hasSession, + getContextUsage, stopAll, get streamEvents() { return Stream.fromQueue(runtimeEventQueue); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 4d17aabaa5f5..5a72229c0843 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,7 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + ProviderContextUsage, ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, } from "@t3tools/contracts"; @@ -263,6 +264,17 @@ function makeFakeCodexAdapter( Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), ); + const getContextUsage = vi.fn( + (threadId: ThreadId): Effect.Effect => + Effect.succeed({ + model: `model-${threadId}`, + totalTokens: 1_000, + maxTokens: 200_000, + categories: [], + groups: [], + }), + ); + const stopAll = vi.fn((): Effect.Effect => Effect.sync(() => { sessions.clear(); @@ -294,6 +306,7 @@ function makeFakeCodexAdapter( readThread, rollbackThread, ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), + ...(provider === CLAUDE_AGENT_DRIVER ? { getContextUsage } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -331,6 +344,7 @@ function makeFakeCodexAdapter( readThread, rollbackThread, uploadFeedback, + getContextUsage, stopAll, }; } @@ -2060,6 +2074,48 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("reads context usage through the Claude adapter, resuming a stopped session", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-context-usage-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + cwd: fixtureCwd("context-usage-project"), + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + routing.claude.getContextUsage.mockClear(); + + const usage = yield* provider.getContextUsage({ threadId }); + + assert.strictEqual(usage.model, `model-${threadId}`); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.claude.getContextUsage.mock.calls, [[threadId]]); + routing.claude.startSession.mockClear(); + }), + ); + + it.effect("rejects context usage for providers without a breakdown", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-context-usage-codex"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.getContextUsage({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not report context usage"); + }), + ); + it.effect("appends attachment file paths to the turn input text", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2b2719faabd3..abe67f856cc9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -21,6 +21,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderContextUsageInput, ProviderUploadFeedbackInput, ThreadId, TurnId, @@ -1992,6 +1993,34 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const getContextUsage: ProviderServiceMethod<"getContextUsage"> = Effect.fn("getContextUsage")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.getContextUsage", + schema: ProviderContextUsageInput, + payload: rawInput, + }); + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.getContextUsage", + allowRecovery: true, + }); + const getContextUsage = routed.adapter.getContextUsage; + if (getContextUsage === undefined) { + return yield* toValidationError( + "ProviderService.getContextUsage", + `Provider '${routed.adapter.provider}' does not report context usage.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "get-context-usage", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + return yield* getContextUsage(input.threadId); + }, + ); + const runStopAll = Effect.fn("runStopAll")(function* () { const continueAfterRestart = yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.continueThreadsAfterServerUpdate), @@ -2085,6 +2114,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( assertConversationRollbackSupported, rollbackConversation, uploadFeedback, + getContextUsage, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 4998f710c29b..2d5087f5f005 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -187,6 +187,7 @@ describe("ProviderSessionReaper", () => { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + getContextUsage: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..7ec35c487eb3 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -10,6 +10,7 @@ import type { ApprovalRequestId, ProviderApprovalDecision, + ProviderContextUsage, ProviderDriverKind, ProviderUserInputAnswers, ProviderRuntimeEvent, @@ -146,6 +147,12 @@ export interface ProviderAdapterShape { input: ProviderUploadFeedbackInput, ) => Effect.Effect; + /** + * Read the provider's own breakdown of what fills a thread's context window. + * Only adapters whose runtime reports per-category usage implement this. + */ + readonly getContextUsage?: (threadId: ThreadId) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index c189e2916ff1..812e0ca95ce6 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -12,6 +12,8 @@ * @module ProviderService */ import type { + ProviderContextUsage, + ProviderContextUsageInput, ProviderInterruptTurnInput, ProviderInstanceId, ProviderRespondToRequestInput, @@ -128,6 +130,14 @@ export interface ProviderServiceShape { input: ProviderUploadFeedbackInput, ) => Effect.Effect; + /** + * Read the provider's breakdown of a thread's context window. Resumes the + * provider session when it is not currently running. + */ + readonly getContextUsage: ( + input: ProviderContextUsageInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index e46c2a8ca010..60a48efc580a 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -69,6 +69,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), + getContextUsage: () => Effect.die("unused"), streamEvents: Stream.empty, }) satisfies ProviderService.ProviderService["Service"]; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46396bf1e4a7..c2ad1cab0306 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -51,6 +51,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderContextUsageError, ProviderUploadFeedbackError, ProviderSetupError, RelayClientInstallFailedError, @@ -1835,6 +1836,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.providerGetContextUsage]: (input) => + observeRpcEffect( + WS_METHODS.providerGetContextUsage, + providerService.getContextUsage(input).pipe( + Effect.mapError( + (cause) => + new ProviderContextUsageError({ + threadId: input.threadId, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0a54a2a55abb..6a1aee6a30f1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -189,7 +189,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter } from "./ContextWindowMeter"; +import { ContextWindowMeter, type ContextUsageLoader } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, @@ -824,6 +824,7 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "../../state/server"; +import { threadEnvironment } from "../../state/threads"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; @@ -1068,6 +1069,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onInterrupt: () => void; onImplementPlanInNewThread: () => void; onCompactContext?: (() => void) | undefined; + loadContextBreakdown?: ContextUsageLoader | undefined; compactDisabled: boolean; compactDisabledReason: string | null; }) { @@ -1080,6 +1082,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onCompact={props.onCompactContext} compactDisabled={props.compactDisabled} compactDisabledReason={props.compactDisabledReason} + loadBreakdown={props.loadContextBreakdown} /> ) : null} (() => { + if (selectedProvider !== "claudeAgent" || !isServerThread || !activeThreadId) { + return undefined; + } + const threadId = activeThreadId; + return async () => { + const result = await getThreadContextUsage({ environmentId, input: { threadId } }); + return result._tag === "Success" + ? { ok: true, usage: result.value } + : { ok: false, message: "Context breakdown is unavailable right now." }; + }; + }, [activeThreadId, environmentId, getThreadContextUsage, isServerThread, selectedProvider]); + const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -5657,6 +5678,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } compactDisabledReason={resolvedCompactDisabledReason} {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} + loadContextBreakdown={loadContextBreakdown} /> diff --git a/apps/web/src/components/chat/ContextUsageBreakdown.tsx b/apps/web/src/components/chat/ContextUsageBreakdown.tsx new file mode 100644 index 000000000000..2fdd89db6df9 --- /dev/null +++ b/apps/web/src/components/chat/ContextUsageBreakdown.tsx @@ -0,0 +1,132 @@ +import type { ProviderContextUsage } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; +import { useState } from "react"; + +import { + buildContextUsageBreakdownView, + formatBreakdownPercentage, + formatBreakdownTokens, + type ContextUsageRow, +} from "~/lib/contextUsageBreakdown"; +import { cn } from "~/lib/utils"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; + +export type ContextUsageBreakdownState = + | { readonly status: "loading" } + | { readonly status: "error"; readonly message: string } + | { readonly status: "ready"; readonly usage: ProviderContextUsage }; + +function BreakdownRow({ row }: { row: ContextUsageRow }) { + return ( +
+
+ ); +} + +function BreakdownGroup({ group }: { group: ProviderContextUsage["groups"][number] }) { + const [open, setOpen] = useState(false); + return ( + + + + +
+ {group.items.map((item) => ( +
+ + {item.name} + {item.detail ? ( + · {item.detail} + ) : null} + + + {formatBreakdownTokens(item.tokens)} + + +
+ ))} +
+
+
+ ); +} + +/** + * Per-category view of what fills the context window. Rendered inside the + * context meter popover once the provider's breakdown has been fetched. + */ +export function ContextUsageBreakdown({ state }: { state: ContextUsageBreakdownState }) { + if (state.status === "loading") { + return ( +
Reading context breakdown…
+ ); + } + if (state.status === "error") { + return
{state.message}
; + } + + const view = buildContextUsageBreakdownView(state.usage); + return ( +
+
+ {view.segments.map((segment) => ( +
+ ))} +
+
+ {view.rows.map((row) => ( + + ))} +
+ {view.groups.length > 0 ? ( +
+ {view.groups.map((group) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index a11f400c0627..3f381530079a 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,10 +1,23 @@ +import type { ProviderContextUsage } from "@t3tools/contracts"; +import { Minimize2Icon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ContextUsageBreakdown, type ContextUsageBreakdownState } from "./ContextUsageBreakdown"; import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; -import { Minimize2Icon } from "lucide-react"; import { composerFloatingLayerProps } from "./composerEventScope"; +/** + * Fetches the provider's context breakdown, or returns a failure message. + * Passed only for providers that report one (Claude today). + */ +export type ContextUsageLoader = () => Promise< + | { readonly ok: true; readonly usage: ProviderContextUsage } + | { readonly ok: false; readonly message: string } +>; + function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { return null; @@ -21,8 +34,54 @@ export function ContextWindowMeter(props: { onCompact?: (() => void) | undefined; compactDisabled?: boolean | undefined; compactDisabledReason?: string | null | undefined; + loadBreakdown?: ContextUsageLoader | undefined; }) { const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props; + const { loadBreakdown } = props; + const [open, setOpen] = useState(false); + const [breakdown, setBreakdown] = useState(null); + // The breakdown is a live read from the provider, so it is refetched when + // the popover opens after the meter has moved. `updatedAt` changes once per + // token-usage event, which is the cheapest "something happened" signal. + const loadedForRef = useRef(null); + const requestIdRef = useRef(0); + + const refreshBreakdown = useCallback( + (loader: ContextUsageLoader) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + loadedForRef.current = usage.updatedAt; + setBreakdown((current) => current ?? { status: "loading" }); + void loader().then((result) => { + if (requestIdRef.current !== requestId) return; + setBreakdown( + result.ok + ? { status: "ready", usage: result.usage } + : { status: "error", message: result.message }, + ); + }); + }, + [usage.updatedAt], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen && loadBreakdown && loadedForRef.current !== usage.updatedAt) { + refreshBreakdown(loadBreakdown); + } + }, + [loadBreakdown, refreshBreakdown, usage.updatedAt], + ); + + // A token-usage event while the popover is open (turn finishing under the + // pointer) refreshes in place rather than showing a stale split. + useEffect(() => { + if (!open || !loadBreakdown || loadedForRef.current === usage.updatedAt) return; + refreshBreakdown(loadBreakdown); + }, [open, loadBreakdown, refreshBreakdown, usage.updatedAt]); + + const showBreakdown = loadBreakdown !== undefined && breakdown !== null; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -36,11 +95,11 @@ export function ContextWindowMeter(props: { : "color-mix(in oklab, var(--color-muted-foreground) 72%, transparent)"; return ( - +
@@ -109,7 +172,9 @@ export function ContextWindowMeter(props: {
)}
- {usage.maxTokens !== null ? ( + {showBreakdown ? ( + + ) : usage.maxTokens !== null ? (
{ + it("orders resident slices by size, then free space, then deferred slices", () => { + const view = buildContextUsageBreakdownView({ + model: "claude-fable-5-1", + totalTokens: 60_000, + maxTokens: 1_000_000, + categories: [ + { name: "Free space", tokens: 940_000, deferred: false }, + { name: "MCP tools (deferred)", tokens: 42_400, deferred: true }, + { name: "Messages", tokens: 12_000, deferred: false }, + { name: "System tools", tokens: 19_200, deferred: false }, + { name: "Custom agents", tokens: 0, deferred: false }, + ], + groups: [], + }); + + expect(view.rows.map((row) => row.name)).toEqual([ + "System tools", + "Messages", + "Custom agents", + "Free space", + "MCP tools (deferred)", + ]); + expect(view.rows.at(-1)?.percentage).toBeNull(); + expect(view.segments.map((row) => row.name)).toEqual(["System tools", "Messages"]); + expect(view.usedPercentage).toBeCloseTo(6); + }); + + it("gives unknown categories a color without reusing the free-space shade", () => { + const view = buildContextUsageBreakdownView({ + model: "m", + totalTokens: 10, + maxTokens: 100, + categories: [ + { name: "Future thing", tokens: 10, deferred: false }, + { name: "Free space", tokens: 90, deferred: false }, + ], + groups: [], + }); + const [future, free] = view.rows; + expect(future?.color).toMatch(/^#/); + expect(free?.color).not.toBe(future?.color); + }); +}); + +describe("formatting", () => { + it("keeps one decimal below 100k like the provider display", () => { + expect(formatBreakdownTokens(87)).toBe("87"); + expect(formatBreakdownTokens(19_200)).toBe("19.2k"); + expect(formatBreakdownTokens(12_000)).toBe("12k"); + expect(formatBreakdownTokens(938_800)).toBe("939k"); + expect(formatBreakdownTokens(1_000_000)).toBe("1M"); + }); + + it("renders percentages with one decimal and blanks deferred rows", () => { + expect(formatBreakdownPercentage(1.92)).toBe("1.9%"); + expect(formatBreakdownPercentage(0.0087)).toBe("0.0%"); + expect(formatBreakdownPercentage(null)).toBe(""); + }); +}); diff --git a/apps/web/src/lib/contextUsageBreakdown.ts b/apps/web/src/lib/contextUsageBreakdown.ts new file mode 100644 index 000000000000..aa027357f4c4 --- /dev/null +++ b/apps/web/src/lib/contextUsageBreakdown.ts @@ -0,0 +1,111 @@ +import type { ProviderContextUsage, ProviderContextUsageCategory } from "@t3tools/contracts"; + +/** + * Colors for the segmented context bar and its legend. Named categories keep a + * stable color across sessions; anything the provider adds later falls back to + * the rotating palette. Free space and deferred slices are drawn muted because + * they do not occupy the window. + */ +const CATEGORY_COLORS: Readonly> = { + "System prompt": "#d9a441", + "System tools": "#4f8ef7", + "MCP tools": "#f0783c", + Messages: "#3fb27f", + Skills: "#e0529a", + "Memory files": "#9a9a9a", + "Custom agents": "#7a7a7a", + "Slash commands": "#8b6ee8", + "Autocompact buffer": "#5a5a5a", +}; + +const FALLBACK_COLORS = ["#4f8ef7", "#f0783c", "#3fb27f", "#d9a441", "#e0529a", "#8b6ee8"]; + +export const FREE_SPACE_CATEGORY = "Free space"; +const MUTED_COLOR = "color-mix(in oklab, var(--color-muted-foreground) 45%, transparent)"; +const FREE_COLOR = "color-mix(in oklab, var(--color-muted-foreground) 18%, transparent)"; + +export interface ContextUsageRow { + readonly name: string; + readonly tokens: number; + /** Null for deferred slices, which are counted but not resident. */ + readonly percentage: number | null; + readonly color: string; + readonly deferred: boolean; + readonly free: boolean; +} + +export interface ContextUsageBreakdownView { + readonly model: string; + readonly totalTokens: number; + readonly maxTokens: number; + readonly usedPercentage: number; + /** Resident slices first (largest to smallest), then free space, then deferred. */ + readonly rows: ReadonlyArray; + /** Only resident slices, for the segmented bar. */ + readonly segments: ReadonlyArray; + readonly groups: ProviderContextUsage["groups"]; +} + +function isFreeSpace(category: ProviderContextUsageCategory): boolean { + return category.name === FREE_SPACE_CATEGORY; +} + +export function buildContextUsageBreakdownView( + usage: ProviderContextUsage, +): ContextUsageBreakdownView { + const maxTokens = Math.max(1, usage.maxTokens); + let fallbackIndex = 0; + const colorFor = (category: ProviderContextUsageCategory): string => { + if (isFreeSpace(category)) return FREE_COLOR; + if (category.deferred) return MUTED_COLOR; + const named = CATEGORY_COLORS[category.name]; + if (named) return named; + const color = FALLBACK_COLORS[fallbackIndex % FALLBACK_COLORS.length] ?? MUTED_COLOR; + fallbackIndex += 1; + return color; + }; + + const byTokensDesc = (left: ProviderContextUsageCategory, right: ProviderContextUsageCategory) => + right.tokens - left.tokens; + const resident = usage.categories + .filter((category) => !category.deferred && !isFreeSpace(category)) + .sort(byTokensDesc); + const free = usage.categories.filter(isFreeSpace); + const deferred = usage.categories.filter((category) => category.deferred).sort(byTokensDesc); + + const toRow = (category: ProviderContextUsageCategory): ContextUsageRow => ({ + name: category.name, + tokens: category.tokens, + percentage: category.deferred ? null : (category.tokens / maxTokens) * 100, + color: colorFor(category), + deferred: category.deferred, + free: isFreeSpace(category), + }); + + const rows = [...resident, ...free, ...deferred].map(toRow); + return { + model: usage.model, + totalTokens: usage.totalTokens, + maxTokens, + usedPercentage: Math.min(100, (usage.totalTokens / maxTokens) * 100), + rows, + segments: rows.filter((row) => !row.deferred && !row.free && row.tokens > 0), + groups: usage.groups, + }; +} + +/** One decimal below 100k, so 19,200 reads as 19.2k like the provider's own display. */ +export function formatBreakdownTokens(value: number): string { + if (!Number.isFinite(value) || value < 0) return "0"; + if (value < 1_000) return `${Math.round(value)}`; + if (value < 100_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + if (value < 1_000_000) return `${Math.round(value / 1_000)}k`; + return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; +} + +/** Deferred slices have no share of the window, so they render an empty cell. */ +export function formatBreakdownPercentage(value: number | null): string { + if (value === null || !Number.isFinite(value)) return ""; + if (value >= 99.95) return "100%"; + return `${value.toFixed(1)}%`; +} diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index b43b58e52627..4d5afb8cdaf1 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,6 +49,20 @@ You can also send `/compact` in an existing conversation. Web and desktop offer a large older thread. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. +## See what fills the context window + +On web and desktop, hover the context meter next to the send button in a Claude +thread. The popover shows how many tokens each part of the context uses: system +prompt, system tools, MCP tools, messages, skills, memory files, and custom +agents. Below the list, expand a row to see the individual MCP tools, memory +files, skills, or agents and their token counts. Deferred tool schemas are listed +without a percentage because they do not occupy the window until Claude loads +them. + +The breakdown comes from Claude Code itself, so it is only available for Claude +threads. Opening the popover on a thread with no running session resumes that +session first. + ## Usage limits If your Claude subscription runs out of usage mid-turn, the thread shows which diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 83881f7ec15f..5e8f7906eac9 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -219,5 +219,9 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + getContextUsage: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:get-context-usage", + tag: WS_METHODS.providerGetContextUsage, + }), }; } diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 3f9570a30233..f9ab656b5c95 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, EventId, @@ -136,6 +136,64 @@ export class ProviderUploadFeedbackError extends Schema.TaggedErrorClass()( + "ProviderContextUsageError", + { + threadId: ThreadId, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to read context usage for thread ${this.threadId}.`; + } +} + const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); export const ProviderEvent = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..5eb09b60cce1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -94,6 +94,9 @@ import { OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; import { + ProviderContextUsage, + ProviderContextUsageError, + ProviderContextUsageInput, ProviderUploadFeedbackError, ProviderUploadFeedbackInput, ProviderUploadFeedbackResult, @@ -259,6 +262,7 @@ export const WS_METHODS = { // Provider methods providerUploadFeedback: "provider.uploadFeedback", + providerGetContextUsage: "provider.getContextUsage", providerAuthStart: "provider.auth.start", providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", @@ -862,6 +866,12 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); +const WsProviderGetContextUsageRpc = Rpc.make(WS_METHODS.providerGetContextUsage, { + payload: ProviderContextUsageInput, + success: ProviderContextUsage, + error: Schema.Union([ProviderContextUsageError, EnvironmentAuthorizationError]), +}); + const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -1255,6 +1265,7 @@ export const WsRpcGroup = RpcGroup.make( WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc, WsProviderUploadFeedbackRpc, + WsProviderGetContextUsageRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 17f0c30a0c36f4d88474e54fee75074c39d5d6f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 16:33:17 -0700 Subject: [PATCH 2/2] chore(web): keep the free-space category name module-private Knip flagged the unused export. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/lib/contextUsageBreakdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/contextUsageBreakdown.ts b/apps/web/src/lib/contextUsageBreakdown.ts index aa027357f4c4..a6c93822d7da 100644 --- a/apps/web/src/lib/contextUsageBreakdown.ts +++ b/apps/web/src/lib/contextUsageBreakdown.ts @@ -20,7 +20,7 @@ const CATEGORY_COLORS: Readonly> = { const FALLBACK_COLORS = ["#4f8ef7", "#f0783c", "#3fb27f", "#d9a441", "#e0529a", "#8b6ee8"]; -export const FREE_SPACE_CATEGORY = "Free space"; +const FREE_SPACE_CATEGORY = "Free space"; const MUTED_COLOR = "color-mix(in oklab, var(--color-muted-foreground) 45%, transparent)"; const FREE_COLOR = "color-mix(in oklab, var(--color-muted-foreground) 18%, transparent)";