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..a6c93822d7da --- /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"]; + +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,