diff --git a/.memory/quick-view-cards.md b/.memory/quick-view-cards.md index e73e255f0..973d13717 100644 --- a/.memory/quick-view-cards.md +++ b/.memory/quick-view-cards.md @@ -27,8 +27,9 @@ Quick View is now Claude-Code-style stacked cards (owner decisions 2026-10-15; p - Categories (ids): `system-prompt`, `builtin-tools`, `mcp-tools`, `skills`, `memory`, `project-instructions`, `mcp-instructions`, `bot-instructions`; zero-token ones are omitted. They use the same chars/4 rule as `estimateStaticContextTokens`, apportioned by largest remainder so they sum exactly to `staticTokens` (leftover tokens go to the biggest fractional remainders, ties to System prompt first). - Measured: built-in vs MCP tools by tool size (`isMcpTool` = codemode tool with discovery); project instructions = the AGENTS.md block measured from the final system prompt (`withoutAgentsInstructions`); MCP server instructions = the admitted `` transcript section measured from the same replayed prompt (`withoutMcpServersSection`, after the AGENTS.md block is stripped), so its initial patch, refreshes and removal are reflected automatically and it is never also recorded; skills, memory and bot instructions = text recorded where the prompt is assembled (`promptSections`), scaled down if recorded longer than the prompt sent; system prompt = what is left, never negative. An idle (ambient) chat's estimated prompt never carries the `mcp_servers` section, so it has no MCP server instructions row (never a row of 0). - Deferred MCP tools (held behind tool search) are listed per server (`discovery.label`, grouped by `discovery.namespace`) with tool count and tokens, shown in muted text with "—" for % and never counted toward the window. Tools already declared are not double-counted. -- Detail UI (`renderer/components/context-breakdown.tsx`): stacked bar over the whole `contextWindow`, categories largest first, then Free and Reserved for response (only Reserved is hatched), and a marker at the `inputBudgetTokens` boundary; legend with tokens and % of window (legend order is Reserved then Free, while the bar runs Free then Reserved); a folded "MCP tools by server" list; a note on compaction state, `compressibleHistoryMessages`, and that deferred tools load through tool search. Colors use `--chart-1..8` only: Messages 1, Built-in tools 2, System prompt 3, MCP tools 4, Skills 5, Memory 6, Project instructions 7 (shared with Bot instructions), MCP server instructions 8. -- When provider usage anchors the projection, `contextTokens` (max of local estimate and provider-anchored usage) can exceed `messageTokens + staticTokens`; the excess is its own used segment, "Provider-counted, not itemized" (faded `--chart-1`), in bar, legend and image label (omitted at 0, also on the fallback path), so used + Free + Reserved spans the whole `contextWindow` and Messages stays the local estimate. +- Detail UI (`renderer/components/context-breakdown.tsx`, tiles in `context-tiles.tsx`, redesigned 2026-10-19): 100 tiles over the whole `contextWindow` (each 1%), used tiles per category by largest remainder (any used context or reserve gets at least one tile), then Free, then the hatched reserve; a row per category with tokens, % of window and a bar relative to the largest category; quiet Reserved, Free and deferred ("not counted") rows; a folded "MCP tools by server" list; a soft status pill on compaction state, `compressibleHistoryMessages`, and deferred tools. `layout="panel"` puts a square grid beside the bars in the Context tool. The composer meter and the Quick View summary use 10 pips (`ContextPips`); the popover warns from the 80% approaching phase on. Colors use `--chart-1..8` only: Messages 1, Built-in tools 2, System prompt 3, MCP tools 4, Skills 5, Memory 6, Project instructions 7 (shared with Bot instructions), MCP server instructions 8. +- Prompt cache timer (2026-10-19): `ChatContextPressureV1.promptCache` ({ttlMs, lastRequestAt}) drives a countdown under the breakdown in Quick View and the Context tool. TTL comes from `promptCacheTtlMs` in `main/services/prompt-cache-lifetimes.ts`: the catalog's `promptCache.short` first, then a sourced table for providers that publish a fixed idle lifetime refreshed on each read (Claude via Anthropic markers or Bedrock 5 min, Bedrock Nova 5 min, OpenAI/Azure GPT-5.6+ 30 min, Kimi K3 and MiniMax M2 explicit caching 5 min, Copilot Claude 1 h / OpenAI 24 h). Best-effort ranges (older OpenAI, DeepSeek, Fireworks, Groq) and undocumented caches (Gemini implicit, xAI, Z.ai, Mistral, Codex) get no timer. Ambient `lastRequestAt` is the newest same-model cached response; live is the request's preparation time, and successful cache-warming refreshes re-send it (`onCacheRefreshed`) and are remembered per chat (`rememberPromptCacheRefresh`) so ambient reads after Stop keep the later expiry. Cache warming itself still uses only the catalog TTL. +- When provider usage anchors the projection, `contextTokens` (max of local estimate and provider-anchored usage) can exceed `messageTokens + staticTokens`; the excess is its own used segment, "Provider-counted, not itemized" (faded `--chart-1`), in tiles, legend and image label (omitted at 0, also on the fallback path), so used + Free + Reserved spans the whole `contextWindow` and Messages stays the local estimate. - Subagent detail (`SubagentStatusGroups` in `subagent-status-groups.tsx`, grouping by `groupSubagentRunsByStatus` in `renderer/lib/subagent-panel-state.ts`): every run grouped Running, Needs attention, Failed, Stopped, Completed; Completed starts folded; Stop on hover or focus for foreground v2 runs that are still active (disabled "Stopping…" while pending, inline error until the next attempt); a row opens that run. Everything derives from `panel.subagentViews[].snapshot`. ## Left out on purpose diff --git a/docs/chatgpt-desktop-ui-inspiration.md b/docs/chatgpt-desktop-ui-inspiration.md index f63b570a7..e439501f8 100644 --- a/docs/chatgpt-desktop-ui-inspiration.md +++ b/docs/chatgpt-desktop-ui-inspiration.md @@ -174,7 +174,7 @@ labels recorded usage. Appearance can hide the meter without hiding the tool. | Card layout | Each section is its own borderless raised card with a title, a chevron after the label, a trailing summary while collapsed, and a header button left of a right-edge grip. There is no `+` menu: cards start collapsed and appear only when relevant. | The grip shows on hover and keyboard focus. Drag it, or press ArrowUp or ArrowDown on it, to reorder; a polite status announces the new position. Order and the cards the user opened persist on this device. A card that appears mid-session fades and scales in; Reduce Motion removes the motion. | | Folder | The first Environment row shows the workspace folder and its parent, with a preferred-editor action when one is installed. Its card header offers an open-external button that reveals the folder in Finder (Files off macOS). | The row opens Files on this workspace. Open failures surface as a toast. The row is disabled without file access and absent when the workspace has no folder. | | Pull request | A Changes row names the chat's pull request number and state (open, draft, merged, closed). It appears only once a pull request exists for this chat, and there is no Create PR button. | Activating it opens the pull request on GitHub. A merged pull request without a title is named by its branch. | -| Context | A card that appears after the first message. Collapsed, it shows a mini meter and the same percentage as the composer meter. Expanded, a stacked bar over the whole context window splits messages and each static category, with Free and Reserved for response, plus a legend with tokens and % of window. | Deferred MCP tools are listed in muted text with `—` for %, since they are not counted until used, and a fold lists them per server. A note covers compaction. Without a breakdown it falls back to Messages and System prompt & tools. The header button opens the Context tool in the sidebar. | +| Context | A card that appears after the first message. Collapsed, it shows the percentage beside a 10-pip meter, the same one the composer uses. Expanded, 100 tiles lay out the whole context window, each tile 1% of it: used tiles colored by category, then free space, then the hatched response reserve. Below, each category has its own row with tokens and a bar relative to the largest one, followed by quieter Reserved, Free and deferred rows. | Deferred MCP tools are marked "not counted", since they only count once used, and a fold lists them per server. A soft status pill covers compaction. For models whose provider documents a fixed prompt cache lifetime (Claude, OpenAI GPT-5.6 and later, Kimi K3, MiniMax M2, Copilot), a countdown shows how long the cache stays warm after the last request; providers that only give a range show none. Without a breakdown it falls back to Messages and System prompt & tools. The header button opens the Context tool in the sidebar, which shows the same tiles as a square beside the bars, next to Session and Recorded usage cards. | | Subagents | A card that appears once the chat has started a subagent and stays for the session. Collapsed, it summarizes active and finished runs. Expanded, runs are grouped Running, Needs attention, Failed, Stopped and Completed. | Completed starts folded behind one disclosure row. Active foreground runs reveal Stop on hover or focus. A row opens that run, and the header button opens the Subagents tool in the sidebar. | ### Review panel states diff --git a/main/services/context-pressure.ts b/main/services/context-pressure.ts index a5ab887d7..6b89e5a80 100644 --- a/main/services/context-pressure.ts +++ b/main/services/context-pressure.ts @@ -14,6 +14,8 @@ import { gitInfo } from "./git.js"; import { modelRetainsSystemUpdates, projectChatContextPressure, + promptCacheWindow, + type PromptCacheRefresh, type GenerationContextOptions, } from "./generation-context.js"; import { resolveCompactionModelMetadata } from "./model-runtime.js"; @@ -53,8 +55,16 @@ export function rememberChatContextProfile( generationProfiles.set(chatId, createGenerationContextProfile(options, scope)); } +const promptCacheRefreshes = new Map(); + +/** Cache warming refreshed this chat's cache; ambient reads keep the later expiry. */ +export function rememberPromptCacheRefresh(chatId: string, refresh: PromptCacheRefresh): void { + promptCacheRefreshes.set(chatId, refresh); +} + export function forgetChatContextProfile(chatId: string): void { generationProfiles.delete(chatId); + promptCacheRefreshes.delete(chatId); ambientProfiles.delete(chatId); journalSnapshots.delete(chatId); } @@ -292,5 +302,7 @@ export async function chatContextPressure( ), ] : messages; - return projectChatContextPressure(projected, options); + const pressure = projectChatContextPressure(projected, options); + const promptCache = promptCacheWindow(model, messages, promptCacheRefreshes.get(chatId)); + return promptCache ? { ...pressure, promptCache } : pressure; } diff --git a/main/services/generation-context.test.ts b/main/services/generation-context.test.ts index d4a5bf7da..f7bfd94b4 100644 --- a/main/services/generation-context.test.ts +++ b/main/services/generation-context.test.ts @@ -10,6 +10,7 @@ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { assertGenerationContextCapacity, chatContextPressureFromProjection, + promptCacheWindow, compactGenerationContext, createGenerationContextTransform, estimateContextBreakdown, @@ -1356,3 +1357,25 @@ test("admitted MCP service guidance is measured from the transcript's mcp_server assert.equal(tokensOf(cleared, "mcp-instructions"), undefined); near(tokensOf(cleared, "system-prompt"), BASE_PROMPT); }); + +test("the prompt cache window follows the last request that used the cache on the same model", () => { + const model = { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.3-codex-spark", promptCache: { short: 300, long: 3600 } }; + const cached = (timestamp: number, cacheRead: number): AssistantMessage => { + const message = assistant(`call-${timestamp}`); + return { ...message, timestamp, usage: { ...message.usage, cacheRead } }; + }; + const messages: AgentMessage[] = [user("a"), cached(1_000, 900), user("b"), cached(5_000, 0)]; + // The newest response did not touch the cache, so the window runs from the earlier one. + assert.deepEqual(promptCacheWindow(model, messages), { ttlMs: 300_000, lastRequestAt: 1_000 }); + // A model without a declared lifetime, a cache never used, or a switched model shows nothing. + assert.equal(promptCacheWindow({ ...model, promptCache: undefined }, messages), undefined); + assert.equal(promptCacheWindow(model, [user("a"), cached(5_000, 0)]), undefined); + assert.equal(promptCacheWindow({ ...model, id: "other-model" }, messages), undefined); + + // A warming refresh after a stopped run extends the cache; an older or other-model one does not. + const warmed = { provider: model.provider, model: model.id, requestStartedAt: 270_000 }; + assert.deepEqual(promptCacheWindow(model, messages, warmed), { ttlMs: 300_000, lastRequestAt: 270_000 }); + assert.deepEqual(promptCacheWindow(model, messages, { ...warmed, requestStartedAt: 500 }), { ttlMs: 300_000, lastRequestAt: 1_000 }); + assert.deepEqual(promptCacheWindow(model, messages, { ...warmed, model: "other-model" }), { ttlMs: 300_000, lastRequestAt: 1_000 }); + assert.equal(promptCacheWindow(model, [user("a"), cached(5_000, 0)], warmed), undefined); +}); diff --git a/main/services/generation-context.ts b/main/services/generation-context.ts index 8e4c74347..c3bd7e19d 100644 --- a/main/services/generation-context.ts +++ b/main/services/generation-context.ts @@ -14,12 +14,14 @@ import type { ContextBreakdownV1, ContextCategoryId, DeferredContextServer, + PromptCacheWindowV1, } from "../../renderer/shared/context-pressure.js"; import { withoutAgentsInstructions } from "./agents-instructions.js"; import { withoutMcpServersSection } from "./mcp-server-instructions.js"; import type { PiDiscoverableTool } from "./pi-tool-discovery.js"; import type { PiRuntimeTool } from "./pi-runtime-tool.js"; +import { promptCacheTtlMs } from "./prompt-cache-lifetimes.js"; const TOOL_RESULT_TEXT_LIMIT_CHARS = 32_000; const RECENT_TOOL_OUTPUT_BUDGET_TOKENS = 40_000; const MIN_RECENT_TOOL_RESULTS = 2; @@ -695,6 +697,40 @@ export function chatContextPressureFromProjection( }; } +/** A cache-warming request that read or wrote the cache without a transcript entry. */ +export interface PromptCacheRefresh { + provider: string; + model: string; + requestStartedAt: number; +} + +/** + * The cache window left by the latest request from this provider and model + * that read or wrote the prompt cache. A model switch starts a new cache, so + * earlier requests from another model never count. A later warming refresh + * of a cache the transcript already shows extends it. + */ +export function promptCacheWindow( + model: Parameters[0], + messages: readonly AgentMessage[], + refresh?: PromptCacheRefresh, +): PromptCacheWindowV1 | undefined { + const ttlMs = promptCacheTtlMs(model); + if (ttlMs === 0) return undefined; + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message.role !== "assistant") continue; + if (message.provider !== model.provider || message.model !== model.id) return undefined; + const usage = message.usage; + if (usage && usage.cacheRead + usage.cacheWrite > 0 && Number.isFinite(message.timestamp)) { + const refreshed = + refresh?.provider === model.provider && refresh.model === model.id && refresh.requestStartedAt > message.timestamp; + return { ttlMs, lastRequestAt: refreshed ? refresh.requestStartedAt : message.timestamp }; + } + } + return undefined; +} + function removeOldHistoricalTurns( messages: AgentMessage[], preserveUserTurns: number, diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index e6639b5c9..dc6950671 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -173,6 +173,7 @@ import type { import type { BotDefinition } from "../../renderer/shared/bots.js"; import type { UsageRequestSource } from "./usage-store-core.js"; import type { ProviderFailureV1 } from "../../renderer/shared/provider-failure.js"; +import type { ChatContextPressureV1 } from "../../renderer/shared/context-pressure.js"; import { compactionFailureLogMetadata } from "./provider-failure.js"; import type { ComputerUseController } from "./computer-use/controller.js"; import { @@ -255,9 +256,11 @@ import { createGenerationContextTransform, modelRetainsSystemUpdates, } from "./generation-context.js"; +import { promptCacheTtlMs } from "./prompt-cache-lifetimes.js"; import { invalidateChatContextJournal, rememberChatContextProfile, + rememberPromptCacheRefresh, } from "./context-pressure.js"; import { generationEmergencyUserError } from "./generation-emergency-outcome.js"; import { buildGeminiWorkspaceSnapshot, GeminiContextCache } from "./gemini-context-cache.js"; @@ -2770,6 +2773,7 @@ export const llmClient = { let piSession: PiSessionPort | undefined; let candidate: PiAgentRuntimeHarness | null = null; let cacheWarmer: PiCacheWarmer | undefined; + let lastContextPressure: ChatContextPressureV1 | undefined; let currentPromptMessage: AgentMessage | undefined; let journalContentOverrides: ReadonlyMap = new Map(); let piJournalHealthy = true; @@ -3321,6 +3325,15 @@ export const llmClient = { recordUsage: async (message, freshRuntime) => { await usageStore.record(assistantUsageRecord({ message, provider: freshRuntime.provider, model: freshRuntime.model, source: "cache-warm" })); }, + // A warming read restarts the provider's cache lifetime without a + // new projection, so the countdown is re-sent from that request. + onCacheRefreshed: (requestStartedAt) => { + rememberPromptCacheRefresh(params.chatId, { provider: model.provider, model: model.id, requestStartedAt }); + const pressure = lastContextPressure; + if (!pressure?.promptCache) return; + lastContextPressure = { ...pressure, promptCache: { ...pressure.promptCache, lastRequestAt: requestStartedAt } }; + sendGeneration(streamId, "chat:context-pressure", { streamId, chatId: params.chatId, pressure: lastContextPressure }); + }, }); } initialization.stopCacheWarming = () => cacheWarmer?.dispose(); @@ -3337,6 +3350,7 @@ export const llmClient = { logger.debug("model-pace", `Could not measure pace for stream ${streamId}.`, error), }); const observedStream: typeof realStream = pace.wrap(warmedStream); + const promptCacheTtl = promptCacheTtlMs(model); candidate = createGenerationHarness(generationProfile, { contributions: runtimeContributions, deferredTools, @@ -3366,10 +3380,16 @@ export const llmClient = { permission, toolsDisabled: runtime.provider.modelMetadata?.[model.id]?.overrides?.toolCall === false, }); + lastContextPressure = { + ...chatContextPressureFromProjection(projection, projectionOptions), + // Projections are emitted as each request is prepared, so the + // cache window restarts now for models that declare one. + ...(promptCacheTtl > 0 ? { promptCache: { ttlMs: promptCacheTtl, lastRequestAt: Date.now() } } : {}), + }; sendGeneration(streamId, "chat:context-pressure", { streamId, chatId: params.chatId, - pressure: chatContextPressureFromProjection(projection, projectionOptions), + pressure: lastContextPressure, }); }, ...agentRuntimeOptions, diff --git a/main/services/pi-cache-warming.test.ts b/main/services/pi-cache-warming.test.ts index c14b17e82..365f33b42 100644 --- a/main/services/pi-cache-warming.test.ts +++ b/main/services/pi-cache-warming.test.ts @@ -38,6 +38,7 @@ function fixture(t: test.TestContext) { const requests: Array = []; const contexts: unknown[] = []; const recorded: unknown[] = []; + const refreshedAt: number[] = []; let database = createEmptyUsageDatabase(); const usageStore = createUsageStore({ load: async () => structuredClone(database), save: async (value) => { database = structuredClone(value); } }); let enabled = true; @@ -68,7 +69,7 @@ function fixture(t: test.TestContext) { return stream; } }, }); - const warmer = new PiCacheWarmer({ clock, signal: signal.signal, enabled: async () => enabled, isCurrent: () => current, resolveRuntime: async () => { resolutions++; await resolutionBarrier; return runtime(); }, recordUsage: async (message, freshRuntime) => { recorded.push(message); await usageStore.record(assistantUsageRecord({ message, provider: freshRuntime.provider, model: freshRuntime.model, source: "cache-warm" })); } }); + const warmer = new PiCacheWarmer({ clock, signal: signal.signal, enabled: async () => enabled, isCurrent: () => current, resolveRuntime: async () => { resolutions++; await resolutionBarrier; return runtime(); }, recordUsage: async (message, freshRuntime) => { recorded.push(message); await usageStore.record(assistantUsageRecord({ message, provider: freshRuntime.provider, model: freshRuntime.model, source: "cache-warm" })); }, onCacheRefreshed: (at) => refreshedAt.push(at) }); t.after(() => warmer.dispose()); const start = (options: SimpleStreamOptions = {}, selected = model) => { const context = normalizeContext({ messages: [{ role: "user", content: "in-memory prompt", timestamp: 1 }] }); @@ -76,7 +77,7 @@ function fixture(t: test.TestContext) { response(usageMessage()); return context; }; - return { usageStore, setCurrent: (value: boolean) => current = value, setWarmCost: (value: number) => warmCost = value, warmer, clock, signal, requests, contexts, recorded, start, setEnabled: (value: boolean) => enabled = value, setKey: (value: string) => key = value, setModel: (value: Model) => runtimeModel = value, setHold: () => hold = true, completeHeld: (reason: WarmOutcome) => completeHeld(reason), setResolutionBarrier: (value: Promise) => resolutionBarrier = value, resolutions: () => resolutions }; + return { refreshedAt, usageStore, setCurrent: (value: boolean) => current = value, setWarmCost: (value: number) => warmCost = value, warmer, clock, signal, requests, contexts, recorded, start, setEnabled: (value: boolean) => enabled = value, setKey: (value: string) => key = value, setModel: (value: Model) => runtimeModel = value, setHold: () => hold = true, completeHeld: (reason: WarmOutcome) => completeHeld(reason), setResolutionBarrier: (value: Promise) => resolutionBarrier = value, resolutions: () => resolutions }; } test("warming waits for real usage, snapshots the prefix and resolves fresh credentials at dispatch", async (t) => { @@ -417,5 +418,7 @@ test("one-token warming completion, provider errors and aborts retain distinct b assert.equal(summary.totals.tokens.cacheRead, 100_000); assert.equal(summary.totals.tokens.output, 1); assert.equal(h.clock.timers.size, reason === "length" ? 1 : 0); + // Only a completed refresh restarts the cache lifetime shown to the user, from its dispatch. + assert.deepEqual(h.refreshedAt, reason === "length" ? [270_000] : []); } }); diff --git a/main/services/pi-cache-warming.ts b/main/services/pi-cache-warming.ts index a1410ba17..75d3c7d76 100644 --- a/main/services/pi-cache-warming.ts +++ b/main/services/pi-cache-warming.ts @@ -91,6 +91,8 @@ export interface PiCacheWarmerDependencies { /** Resolve credentials only when dispatching, never retain them in a timer. */ resolveRuntime(signal: AbortSignal): Promise; recordUsage(message: AssistantMessage, runtime: ResolvedModelRuntime): Promise; + /** A refresh read or wrote the cache, restarting its lifetime from dispatch. */ + onCacheRefreshed?(requestStartedAt: number): void; clock?: CacheWarmingClock; } @@ -209,6 +211,7 @@ export class PiCacheWarmer { run.spentUsd += reportedCost - economics.warmCost; } if (message.stopReason === "error" || message.stopReason === "aborted") { if (this.run === run) this.stop(); return; } + if (message.usage.cacheRead + message.usage.cacheWrite > 0) this.deps.onCacheRefreshed?.(dispatchedAt); if (this.run === run) this.schedule(run, dispatchedAt); } catch { // Warming cannot fail the foreground turn and does not retry a failed refresh. diff --git a/main/services/prompt-cache-lifetimes.test.ts b/main/services/prompt-cache-lifetimes.test.ts new file mode 100644 index 000000000..d593b40bd --- /dev/null +++ b/main/services/prompt-cache-lifetimes.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { promptCacheTtlMs } from "./prompt-cache-lifetimes.js"; + +const require = createRequire(import.meta.url); +const catalogDir = join(dirname(require.resolve("@earendil-works/pi-ai/package.json")), "dist/providers/data"); + +function* catalogModels(value: unknown): Generator> { + if (!value || typeof value !== "object") return; + if ("api" in value && "id" in value) { yield value as Model; return; } + for (const child of Object.values(value)) yield* catalogModels(child); +} + +/** The shipped Pi catalog entry, so the lifetimes follow the real model metadata. */ +function catalogModel(provider: string, id: string): Model { + const catalog = JSON.parse(readFileSync(join(catalogDir, `${provider}.json`), "utf8")); + const model = [...catalogModels(catalog)].find((candidate) => candidate.id === id); + assert.ok(model, `${provider}/${id} is in the catalog`); + return model; +} + +const MINUTE = 60_000; + +test("Claude keeps Anthropic's five-minute cache wherever Aiden sends cache markers", () => { + assert.equal(promptCacheTtlMs(catalogModel("anthropic", "claude-opus-5")), 5 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("amazon-bedrock", "anthropic.claude-haiku-4-5-20251001-v1:0")), 5 * MINUTE); + // OpenRouter serves Claude over chat completions with Anthropic-style markers. + const openRouterClaude = [...catalogModels(JSON.parse(readFileSync(join(catalogDir, "openrouter.json"), "utf8")))] + .find((model) => model.id.includes("claude") && model.api === "openai-completions"); + assert.ok(openRouterClaude); + assert.equal(promptCacheTtlMs(openRouterClaude), 5 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("cloudflare-ai-gateway", "claude-haiku-4-5")), 5 * MINUTE); +}); + +test("Bedrock times only the models it caches", () => { + assert.equal(promptCacheTtlMs(catalogModel("amazon-bedrock", "amazon.nova-lite-v1:0")), 5 * MINUTE); + const uncached = [...catalogModels(JSON.parse(readFileSync(join(catalogDir, "amazon-bedrock.json"), "utf8")))] + .find((model) => !model.id.includes("claude") && !model.id.includes("nova")); + assert.ok(uncached); + assert.equal(promptCacheTtlMs(uncached), 0); +}); + +test("OpenAI's fixed 30-minute cache starts at GPT-5.6; earlier models only give a range", () => { + assert.equal(promptCacheTtlMs(catalogModel("openai", "gpt-5.6-sol")), 30 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("azure", "gpt-6-sol")), 30 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("openai", "gpt-5.5")), 0); + assert.equal(promptCacheTtlMs(catalogModel("azure", "gpt-5.4")), 0); + assert.equal(promptCacheTtlMs(catalogModel("openai-codex", "gpt-5.3-codex-spark")), 0); +}); + +test("GitHub Copilot publishes its own idle lifetimes per model family", () => { + assert.equal(promptCacheTtlMs(catalogModel("github-copilot", "claude-sonnet-5")), 60 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("github-copilot", "gpt-5.6-sol")), 24 * 60 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("github-copilot", "gemini-3.8-flash")), 0); +}); + +test("Kimi K3 and MiniMax M2 use five minutes; models and providers without a documented lifetime show none", () => { + assert.equal(promptCacheTtlMs(catalogModel("moonshotai", "kimi-k3")), 5 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("moonshotai", "kimi-k2.6")), 0); + assert.equal(promptCacheTtlMs(catalogModel("moonshotai-cn", "kimi-k2.7-code")), 0); + assert.equal(promptCacheTtlMs(catalogModel("minimax", "MiniMax-M2.7")), 5 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("minimax-cn", "MiniMax-M2.7-highspeed")), 5 * MINUTE); + assert.equal(promptCacheTtlMs(catalogModel("minimax", "MiniMax-M3")), 0); + for (const provider of ["google", "xai", "deepseek", "zai", "mistral", "groq", "fireworks", "kimi-coding"]) { + for (const model of catalogModels(JSON.parse(readFileSync(join(catalogDir, `${provider}.json`), "utf8")))) { + assert.equal(promptCacheTtlMs(model), 0, `${provider}/${model.id}`); + } + } +}); + +test("a lifetime the catalog declares wins, and a broken one shows nothing", () => { + const custom = { provider: "custom-proxy", id: "claude-opus-5", api: "anthropic-messages" as Api }; + assert.equal(promptCacheTtlMs({ ...custom, promptCache: { short: 120 } }), 2 * MINUTE); + assert.equal(promptCacheTtlMs({ ...custom, promptCache: { short: Number.NaN } }), 0); + assert.equal(promptCacheTtlMs(custom), 5 * MINUTE); +}); diff --git a/main/services/prompt-cache-lifetimes.ts b/main/services/prompt-cache-lifetimes.ts new file mode 100644 index 000000000..262b38e28 --- /dev/null +++ b/main/services/prompt-cache-lifetimes.ts @@ -0,0 +1,93 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; + +type PromptCacheModel = Pick, "provider" | "id" | "api" | "promptCache"> & { + name?: string; + compat?: unknown; +}; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; + +/** + * How long the provider keeps an unused prompt cache for Aiden's chat + * requests, in milliseconds, or 0 when no concrete lifetime is documented. + * + * Aiden sends Pi's default "short" retention, so the lifetimes below are the + * defaults for that request shape. Only providers that publish a fixed idle + * lifetime that each cache read restarts are listed; best-effort ranges + * ("5 to 10 minutes", "a few hours") and undocumented caches return 0 so no + * guessed countdown is shown. Documented lifetimes are guarantees, so a + * cache may outlive the countdown. Sources were checked on 2026-10-19: + * - Claude: https://platform.claude.com/docs/en/build-with-claude/prompt-caching + * (5 min, measured from request start, refreshed on each read; the same on + * Bedrock, Google Cloud and gateways that pass cache_control through, per + * https://openrouter.ai/docs/guides/best-practices/prompt-caching). + * - Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html + * (5 min for Claude and Nova, reset by each hit). + * - OpenAI GPT-5.6 and later: https://developers.openai.com/api/docs/guides/prompt-caching + * (30 min after the latest write or reuse); Azure OpenAI keeps them at + * least 30 min: https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/prompt-caching + * - Moonshot Kimi K3: https://platform.kimi.ai/docs/guide/use-context-caching-feature-of-kimi-api + * (5 min default, each hit renews it; K2.6 and K2.7 do not write caches). + * - MiniMax M2 series, explicit caching: https://platform.minimax.io/docs/api-reference/text-prompt-caching + * (5 min, renewed with continued use; automatic caching varies with load). + * - GitHub Copilot: https://docs.github.com/en/enterprise-cloud@latest/copilot/tutorials/optimize-ai-usage + * (24 h idle for OpenAI models, 1 h for Claude). + */ +export function promptCacheTtlMs(model: PromptCacheModel): number { + const declared = model.promptCache?.short; + if (declared !== undefined) return Number.isFinite(declared) && declared > 0 ? declared * 1000 : 0; + const id = model.id.toLowerCase(); + if (model.provider === "github-copilot") { + if (isClaude(model)) return HOUR; + if (/^(gpt-|o\d)/u.test(id)) return 24 * HOUR; + return 0; + } + if (model.api === "bedrock-converse-stream") { + return bedrockCaches(model) ? 5 * MINUTE : 0; + } + if (isClaude(model) && (model.api === "anthropic-messages" || cacheControlFormat(model) === "anthropic")) { + return 5 * MINUTE; + } + // Explicit caching, which Pi's cache_control markers request, covers M2-series models only. + if ((model.provider === "minimax" || model.provider === "minimax-cn") && model.api === "anthropic-messages" && /^minimax-m2(?:[.-]|$)/u.test(id)) { + return 5 * MINUTE; + } + // Kimi documents cache writes, and so a lifetime, only for K3. + if ((model.provider === "moonshotai" || model.provider === "moonshotai-cn") && /^kimi-k3(?:[.-]|$)/u.test(id)) return 5 * MINUTE; + if ((model.provider === "openai" || model.provider === "azure") && isThirtyMinuteOpenAiModel(model)) { + return 30 * MINUTE; + } + return 0; +} + +function isClaude(model: PromptCacheModel): boolean { + return model.id.toLowerCase().includes("claude") || (model.name ?? "").toLowerCase().includes("claude"); +} + +function cacheControlFormat(model: PromptCacheModel): unknown { + return model.compat && typeof model.compat === "object" + ? (model.compat as { cacheControlFormat?: unknown }).cacheControlFormat + : undefined; +} + +/** Mirrors where Pi adds Bedrock cache points; Nova caches on its own. */ +function bedrockCaches(model: PromptCacheModel): boolean { + const candidates = [model.id, model.name ?? ""].map((value) => value.toLowerCase()); + if (candidates.some((value) => value.includes("amazon.nova"))) return true; + if (!candidates.some((value) => value.includes("claude"))) return false; + return candidates.some((value) => + /fable-5|opus-5|sonnet-5|haiku-5|-4-|claude-3-7-sonnet|claude-3-5-haiku/u.test(value), + ); +} + +/** GPT-5.6 and later use OpenAI's fixed 30-minute cache. */ +function isThirtyMinuteOpenAiModel(model: PromptCacheModel): boolean { + const compat = model.compat as { supportsExplicitPromptCacheMode?: unknown } | undefined; + if (compat?.supportsExplicitPromptCacheMode === true) return true; + const match = /^gpt-(\d+)(?:\.(\d+))?(?:-|$)/u.exec(model.id.toLowerCase()); + if (!match) return false; + const major = Number(match[1]); + const minor = Number(match[2] ?? 0); + return major > 5 || (major === 5 && minor >= 6); +} diff --git a/package.json b/package.json index 544f61eb1..44e0d8185 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "test:provider-failure": "tsx --test main/services/provider-failure.test.ts", "test:web-search": "tsx --test main/services/web-search.test.ts main/services/web-search-core.test.ts main/services/web-search-exa-core.test.ts main/services/web-search-provider-registry-core.test.ts main/services/web-search-provider-registry.test.ts main/services/web-search-credential-core.test.ts main/services/web-search-auth-reuse.test.ts main/services/web-search-rollout.test.ts main/services/web-search-wave1-adapters.test.ts main/services/web-search-wave1-ai.test.ts main/services/web-search-wave2-batch-a.test.ts main/services/web-search-wave2-batch-b.test.ts main/services/web-search-wave4-batch-a.test.ts main/services/web-search-wave4-batch-b.test.ts main/handlers/web-search-contract.test.ts renderer/components/settings/web-search-settings.test.tsx renderer/lib/settings-section.test.ts main/services/web-search-key-pool-core.test.ts main/handlers/web-search-key-pool.test.ts renderer/components/settings/web-search-key-pool.test.tsx", "test:concentrate": "tsx --test main/services/concentrate-provider.test.ts", - "test:compaction": "npm run test:vcc && tsx --test main/services/memory-shared.test.ts main/services/pi-runtime-events.test.ts main/services/pi-cache-warming.test.ts main/services/pi-agent-runtime-harness.test.ts main/services/pi-codemode.test.ts main/services/context-pressure-tools.test.ts main/services/pi-tool-discovery.test.ts main/services/bounded-json.test.ts main/services/pi-compaction-core.test.ts main/services/pi-legacy-session.test.ts main/services/pi-session-migration.test.ts main/services/pi-session-v4-upgrade.test.ts main/services/pi-session-port.test.ts main/services/context-lifecycle-service.test.ts main/services/context-lifecycle-surface-matrix.test.ts main/services/telegram/telegram-session.test.ts main/services/generation-emergency-outcome.test.ts main/services/pi-runtime-effect-store.test.ts main/services/generation-context.test.ts main/services/memory-store.test.ts main/services/memory-context.test.ts main/services/memory-surface-matrix.test.ts main/services/pi-upgrade-evaluation.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx main/services/pi-journal-fork.test.ts main/services/fork-summary-service.test.ts renderer/components/settings/compaction-budget-settings.test.tsx", + "test:compaction": "npm run test:vcc && tsx --test main/services/memory-shared.test.ts main/services/pi-runtime-events.test.ts main/services/pi-cache-warming.test.ts main/services/pi-agent-runtime-harness.test.ts main/services/pi-codemode.test.ts main/services/context-pressure-tools.test.ts main/services/pi-tool-discovery.test.ts main/services/bounded-json.test.ts main/services/pi-compaction-core.test.ts main/services/pi-legacy-session.test.ts main/services/pi-session-migration.test.ts main/services/pi-session-v4-upgrade.test.ts main/services/pi-session-port.test.ts main/services/context-lifecycle-service.test.ts main/services/context-lifecycle-surface-matrix.test.ts main/services/telegram/telegram-session.test.ts main/services/generation-emergency-outcome.test.ts main/services/pi-runtime-effect-store.test.ts main/services/generation-context.test.ts main/services/prompt-cache-lifetimes.test.ts main/services/memory-store.test.ts main/services/memory-context.test.ts main/services/memory-surface-matrix.test.ts main/services/pi-upgrade-evaluation.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx main/services/pi-journal-fork.test.ts main/services/fork-summary-service.test.ts renderer/components/settings/compaction-budget-settings.test.tsx", "test:compaction:packaged": "node --import tsx scripts/pi-session-packaged-restart-acceptance.mjs", "test:compaction:evaluate": "node --import tsx scripts/pi-upgrade-evaluation.mjs", "pi-upgrade:advance": "node --import tsx scripts/pi-upgrade-advance.mjs", @@ -140,9 +140,9 @@ "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", "test:workspace-intake": "tsx --test main/services/workspace-intake.test.ts main/handlers/workspace-intake.test.ts renderer/shared/workspace-intake.test.ts renderer/lib/workspace-menu.test.ts renderer/lib/draft-workspace-menu.test.ts renderer/components/workspace-picker.test.tsx", "test:worktree-on-send": "tsx --test main/services/first-message-worktree.test.ts renderer/shared/worktree-on-send.test.ts renderer/lib/chat-draft-git-mode.test.ts renderer/lib/draft-git-menu.test.ts renderer/lib/draft-worktree-send.test.ts renderer/components/git-branch-picker-draft.test.tsx renderer/components/composer-restore-unsent.test.tsx", - "test:serial": "npm run test:agents-instructions && npm run test:custom-model-options && npm run test:main-shared && tsx --test scripts/plugins-refresh-core.test.mjs renderer/lib/dialog-enter.test.ts renderer/lib/chat-pull-request-chip.test.ts renderer/lib/composer-context-chips.test.ts renderer/components/composer-context-bar.test.tsx renderer/lib/workspace-chip.test.ts renderer/lib/guarded-history.test.ts renderer/lib/workspace-file-open-error.test.ts main/services/tool-output-store.test.ts main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/access-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/durable-fs.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/portable-credential-snapshot.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/local-speech-protocol.test.ts main/services/local-speech-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/git-executable.test.ts main/services/generation-git-context.test.ts main/services/github-pull-request.test.ts main/services/github/github-credentials.test.ts main/services/github/github-api.test.ts main/services/github/github-request-gate.test.ts main/services/github/github-local-repository.test.ts main/services/github/github-pull-request-reader.test.ts main/services/github/github-replay.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-credential-store.test.ts main/services/secrets.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/lib/subagent-context-usage-store.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/composer-slash-palette.test.tsx renderer/components/context-meter.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/components/aiden-activity-mark.test.tsx renderer/lib/remote-settings-polling.test.ts renderer/lib/git-query-sync.test.ts renderer/lib/github-pause.test.ts main/services/git-repo-watcher.test.ts renderer/lib/query-client.test.ts renderer/components/markdown-math.test.tsx renderer/lib/syntax-highlight.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/scroll-follow.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/composer-attachment-operation.test.ts renderer/components/composer-attachments-mounted.test.tsx renderer/lib/context-pressure-feed.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/quick-view-layout.test.ts renderer/components/workbench-panel.test.tsx renderer/components/quick-view-section.test.tsx renderer/components/environment-overview.test.tsx renderer/components/context-breakdown.test.tsx renderer/components/subagent-status-groups.test.tsx renderer/components/quick-view-cards.test.tsx renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/context-pressure.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs main/services/chat-pull-request-store.test.ts main/services/chat-pull-request-service.test.ts main/services/pull-request-create-reconciliation.test.ts main/services/pull-request-current-resolver.test.ts renderer/shared/chat-pull-requests.test.ts main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx renderer/components/computer-use-access-approval.test.tsx renderer/components/settings/computer-use-settings.test.tsx main/services/generation-delta-coalescer.test.ts scripts/check-renderer-bundle-budget.test.mjs scripts/main-runtime-dependencies.test.mjs scripts/build-electron.test.mjs renderer/lib/codex-auth-view-state.test.ts && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design && npm run test:ci-policy && npm run test:devices && npm run test:durable-jobs && npm run test:cli && tsx --test main/services/tool-approval-rules.test.ts renderer/components/settings/tool-approval-settings.test.tsx && npm run test:chat-row-states && npm run test:remote-chat && npm run test:chat-visibility && npm run test:studio-foundation && npm run test:bot-runtime && npm run test:create-images && npm run test:design-studio && npm run test:model-pad && npm run test:model-insights && npm run test:artificial-analysis && npm run test:image-generation && npm run test:preflight && npm run test:command-system && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:concentrate && npm run test:app-controls && npm run test:workspace-intake && npm run test:worktree-on-send && npm run test:workspace-artifacts", + "test:serial": "npm run test:agents-instructions && npm run test:custom-model-options && npm run test:main-shared && tsx --test scripts/plugins-refresh-core.test.mjs renderer/lib/dialog-enter.test.ts renderer/lib/chat-pull-request-chip.test.ts renderer/lib/composer-context-chips.test.ts renderer/components/composer-context-bar.test.tsx renderer/lib/workspace-chip.test.ts renderer/lib/guarded-history.test.ts renderer/lib/workspace-file-open-error.test.ts main/services/tool-output-store.test.ts main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/access-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/durable-fs.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/portable-credential-snapshot.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/local-speech-protocol.test.ts main/services/local-speech-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/prompt-cache-lifetimes.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/git-executable.test.ts main/services/generation-git-context.test.ts main/services/github-pull-request.test.ts main/services/github/github-credentials.test.ts main/services/github/github-api.test.ts main/services/github/github-request-gate.test.ts main/services/github/github-local-repository.test.ts main/services/github/github-pull-request-reader.test.ts main/services/github/github-replay.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-credential-store.test.ts main/services/secrets.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/lib/subagent-context-usage-store.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/composer-slash-palette.test.tsx renderer/components/context-meter.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/components/aiden-activity-mark.test.tsx renderer/lib/remote-settings-polling.test.ts renderer/lib/git-query-sync.test.ts renderer/lib/github-pause.test.ts main/services/git-repo-watcher.test.ts renderer/lib/query-client.test.ts renderer/components/markdown-math.test.tsx renderer/lib/syntax-highlight.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/scroll-follow.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/composer-attachment-operation.test.ts renderer/components/composer-attachments-mounted.test.tsx renderer/lib/context-pressure-feed.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/quick-view-layout.test.ts renderer/components/workbench-panel.test.tsx renderer/components/quick-view-section.test.tsx renderer/components/environment-overview.test.tsx renderer/components/context-breakdown.test.tsx renderer/components/subagent-status-groups.test.tsx renderer/components/quick-view-cards.test.tsx renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/context-pressure.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs main/services/chat-pull-request-store.test.ts main/services/chat-pull-request-service.test.ts main/services/pull-request-create-reconciliation.test.ts main/services/pull-request-current-resolver.test.ts renderer/shared/chat-pull-requests.test.ts main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx renderer/components/computer-use-access-approval.test.tsx renderer/components/settings/computer-use-settings.test.tsx main/services/generation-delta-coalescer.test.ts scripts/check-renderer-bundle-budget.test.mjs scripts/main-runtime-dependencies.test.mjs scripts/build-electron.test.mjs renderer/lib/codex-auth-view-state.test.ts && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design && npm run test:ci-policy && npm run test:devices && npm run test:durable-jobs && npm run test:cli && tsx --test main/services/tool-approval-rules.test.ts renderer/components/settings/tool-approval-settings.test.tsx && npm run test:chat-row-states && npm run test:remote-chat && npm run test:chat-visibility && npm run test:studio-foundation && npm run test:bot-runtime && npm run test:create-images && npm run test:design-studio && npm run test:model-pad && npm run test:model-insights && npm run test:artificial-analysis && npm run test:image-generation && npm run test:preflight && npm run test:command-system && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:concentrate && npm run test:app-controls && npm run test:workspace-intake && npm run test:worktree-on-send && npm run test:workspace-artifacts", "test:chat-visibility": "tsx --test renderer/shared/chat-visibility.test.ts main/services/startup-chat-reconciliation.test.ts", - "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/access-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/durable-fs.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/local-speech-protocol.test.ts main/services/local-speech-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/git-executable.test.ts main/services/generation-git-context.test.ts main/services/github-pull-request.test.ts main/services/github/github-credentials.test.ts main/services/github/github-api.test.ts main/services/github/github-request-gate.test.ts main/services/github/github-local-repository.test.ts main/services/github/github-pull-request-reader.test.ts main/services/github/github-replay.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/lib/subagent-context-usage-store.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/composer-slash-palette.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/components/aiden-activity-mark.test.tsx renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/composer-attachment-operation.test.ts renderer/components/composer-attachments-mounted.test.tsx renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs main/services/tool-output-store.test.ts main/services/chat-pull-request-store.test.ts main/services/chat-pull-request-service.test.ts main/services/pull-request-create-reconciliation.test.ts main/services/pull-request-current-resolver.test.ts renderer/shared/chat-pull-requests.test.ts main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx renderer/components/computer-use-access-approval.test.tsx renderer/components/settings/computer-use-settings.test.tsx", + "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/access-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/durable-fs.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/local-speech-protocol.test.ts main/services/local-speech-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/prompt-cache-lifetimes.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/git-executable.test.ts main/services/generation-git-context.test.ts main/services/github-pull-request.test.ts main/services/github/github-credentials.test.ts main/services/github/github-api.test.ts main/services/github/github-request-gate.test.ts main/services/github/github-local-repository.test.ts main/services/github/github-pull-request-reader.test.ts main/services/github/github-replay.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/lib/subagent-context-usage-store.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/composer-slash-palette.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/components/aiden-activity-mark.test.tsx renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/composer-attachment-operation.test.ts renderer/components/composer-attachments-mounted.test.tsx renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs main/services/tool-output-store.test.ts main/services/chat-pull-request-store.test.ts main/services/chat-pull-request-service.test.ts main/services/pull-request-create-reconciliation.test.ts main/services/pull-request-current-resolver.test.ts renderer/shared/chat-pull-requests.test.ts main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx renderer/components/computer-use-access-approval.test.tsx renderer/components/settings/computer-use-settings.test.tsx", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/access-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx renderer/components/computer-use-access-approval.test.tsx renderer/components/settings/computer-use-settings.test.tsx && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:form-fill": "tsx --test main/services/form-fill/artifacts-core.test.ts main/services/form-fill/extract-core.test.ts main/services/form-fill/helper-client.test.ts main/services/form-fill/planner-core.test.ts main/services/form-fill/batch-core.test.ts main/services/form-fill/service.test.ts main/services/form-fill/eval-fixtures.test.ts main/services/computer-use/controller-form-fill.test.ts renderer/components/form-fill-approval.test.tsx", diff --git a/renderer/components/chat-context-panel.tsx b/renderer/components/chat-context-panel.tsx index 464e55fe2..75a24e037 100644 --- a/renderer/components/chat-context-panel.tsx +++ b/renderer/components/chat-context-panel.tsx @@ -1,8 +1,8 @@ +import type * as React from "react"; import type { Chat } from "../lib/types"; -import type { ChatContextPressureV1 } from "../shared/context-pressure"; -import { contextPressurePercent } from "../shared/context-pressure"; +import { formatContextTokenCount, type ChatContextPressureV1 } from "../shared/context-pressure"; import { parseAssistantTurnStatsV1 } from "../shared/assistant-turn-stats"; -import { ContextMeterDetails } from "./context-meter"; +import { ContextBreakdown } from "./context-breakdown"; import { Text } from "./ui"; export interface ChatContextDetails { @@ -52,6 +52,112 @@ export function recordedChatUsage(chat: Pick) { }; } +/** Like formatContextTokenCount, with billions for long sessions' cumulative totals. */ +function formatUsageTokens(value: number): string { + return value >= 999_950_000 + ? `${(value / 1_000_000_000).toFixed(1).replace(/\.0$/u, "")}B` + : formatContextTokenCount(value); +} + +function Chip({ children }: React.PropsWithChildren) { + return ( + + {children} + + ); +} + +const USAGE_PARTS = [ + { key: "cacheRead", label: "Cache read", fill: "color-mix(in srgb, var(--chart-1) 40%, transparent)" }, + { key: "input", label: "Input (excluding cache)", fill: "var(--chart-1)" }, + { key: "cacheWrite", label: "Cache write", fill: "var(--chart-6)" }, + { key: "output", label: "Output", fill: "var(--chart-5)" }, +] as const; + +function UsageSection({ usage }: { usage: ReturnType }) { + const reported = usage.turns > 0; + const token = (value: number) => value.toLocaleString(); + const cacheable = usage.cacheRead + usage.cacheWrite + usage.input; + const cachePercent = reported && usage.cacheRead > 0 && cacheable > 0 ? Math.round((usage.cacheRead / cacheable) * 100) : null; + const barTotal = USAGE_PARTS.reduce((sum, part) => sum + usage[part.key], 0); + return ( +
+
+

Recorded usage

+ {reported ? ( + + {`${usage.requests.toLocaleString()} ${usage.requests === 1 ? "request" : "requests"}`} + + ) : null} +
+ {reported ? ( +
+ + {formatUsageTokens(usage.total)} + + tokens + {cachePercent !== null ? ( + + {`${cachePercent}% from cache`} + + ) : null} +
+ ) : null} + {reported && barTotal > 0 ? ( +
`${part.label} ${usage[part.key].toLocaleString()}`).join(", ")} + className="mt-3 flex h-2 gap-0.5" + > + {USAGE_PARTS.filter((part) => usage[part.key] > 0).map((part) => ( + + ))} +
+ ) : null} + {!reported ? ( +

+ {usage.assistantMessages > 0 + ? "This provider hasn't reported token usage for this chat." + : "Usage appears after the first reply."} +

+ ) : ( +
+ {USAGE_PARTS.map((part) => ( +
+
+
+
{token(usage[part.key])}
+
+ ))} +
+
Total reported tokens
+
{token(usage.total)}
+
+
+
Reasoning tokens
+
Unavailable
+
+
+
Total cost
+
Unavailable
+
+
+ )} + {reported ? ( +

+ {`Reported by ${usage.turns} of ${usage.assistantMessages} saved assistant turns. Repeated requests count each time; live work and subagents are not included.`} +

+ ) : null} +
+ ); +} + export function ChatContextPanel({ details, }: { @@ -66,93 +172,61 @@ export function ChatContextPanel({ const { chat, pressure, compacting } = details; const usage = recordedChatUsage(chat); const count = (value: number) => value.toLocaleString(); - const token = (value: number) => (usage.turns ? count(value) : "Unavailable"); - const facts = [ - ["Session", chat.title], - ["Provider", details.providerLabel], - ["Model", details.modelLabel], - ["User messages", count(usage.userMessages)], - ["Assistant messages", count(usage.assistantMessages)], + const dates: Array<[string, string]> = [ ["Created", new Date(chat.createdAt).toLocaleString()], ["Last activity", new Date(chat.updatedAt).toLocaleString()], ]; return (
-
- {pressure ? ( - <> - -

- Usable input: {count(pressure.inputBudgetTokens)} tokens · Updated{" "} - {new Date(pressure.computedAt).toLocaleTimeString()} -

-

- {pressure.source === "provider-anchored" - ? "Projection anchored to provider-reported usage; the breakdown is estimated." - : "Estimated from the next request, including your draft."} +

+
+
+

Next request

+ {pressure ? ( + + {`Updated ${new Date(pressure.computedAt).toLocaleTimeString()}`} + + ) : null} +
+ {pressure ? ( + <> + +

+ {pressure.source === "provider-anchored" + ? "Anchored to provider-reported usage; the breakdown is estimated." + : "Estimated from the next request, including your draft."} +

+ + ) : ( + Context projection is unavailable. + )} +
+
+
+

Session

+

+ {chat.title || "Untitled chat"}

- - ) : ( - Context projection is unavailable. - )} -
-
-

Session

-
- {facts.map(([label, value]) => ( -
-
{label}
-
- {value || "Unavailable"} -
-
- ))} -
-
-
-

Recorded usage

-

- Reported usage from {usage.turns} of {usage.assistantMessages} saved - assistant turns. Excludes live work and separately run subagents. - Missing reports are not counted. -

-
- {[ - ["Input (excluding cache)", token(usage.input)], - ["Output", token(usage.output)], - ["Cache read", token(usage.cacheRead)], - ["Cache write", token(usage.cacheWrite)], - ["Total reported tokens", token(usage.total)], - ["Reported requests", token(usage.requests)], - ["Reasoning tokens", "Unavailable"], - ["Total cost", "Unavailable"], - ].map(([label, value]) => ( -
-
{label}
-
{value}
+
+ {details.providerLabel || "Unknown provider"} + {details.modelLabel || "Unknown model"} + {`${count(usage.userMessages)} user · ${count(usage.assistantMessages)} assistant`}
- ))} -
-

- Cumulative usage counts repeated requests. It is separate from the - next request’s context. Session reasoning and cost totals are not - retained. -

-
+
+ {dates.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+ ); } diff --git a/renderer/components/context-breakdown.test.tsx b/renderer/components/context-breakdown.test.tsx index 3c1bdc5f1..821baada8 100644 --- a/renderer/components/context-breakdown.test.tsx +++ b/renderer/components/context-breakdown.test.tsx @@ -6,6 +6,8 @@ import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; import { projectChatContextPressure } from "../../main/services/generation-context.js"; import { formatContextTokenCount, type ChatContextPressureV1 } from "../shared/context-pressure"; import { ContextBreakdown } from "./context-breakdown"; +import { ContextMeter } from "./context-meter"; +import { CONTEXT_TILE_COUNT, ContextPips, contextTiles } from "./context-tiles"; afterEach(cleanup); @@ -60,7 +62,7 @@ function legendLabels(): string[] { test("headline percentage matches the composer meter", () => { render(); - assert.ok(screen.getByText("31% used")); + assert.ok(screen.getByText((_, element) => element?.tagName === "SPAN" && element.textContent === "31% used")); }); test("legend lists messages, then categories largest first, then reserve, free space and deferred", () => { @@ -88,8 +90,6 @@ test("the deferred row shows its tokens and is not counted toward the window", ( .find((item) => item.textContent?.startsWith("MCP tools (deferred)")); assert.ok(deferred); assert.ok(within(deferred).getByText("61.4K")); - // The dash is visual only; assistive tech reads "not counted" instead. - assert.equal(within(deferred).getByText("—").getAttribute("aria-hidden"), "true"); assert.ok(within(deferred).getByText("not counted")); assert.match(screen.getByRole("note").textContent ?? "", /Deferred tools load through tool search and aren't counted until used\./u); }); @@ -111,7 +111,7 @@ test("MCP tools by server starts folded and opens to list each server", () => { assert.ok(within(servers).getByText("47 tools")); }); -test("the bar's image label names every segment", () => { +test("the tile grid's image label names every segment", () => { render(); const label = screen.getByRole("img").getAttribute("aria-label") ?? ""; for (const name of [ @@ -189,10 +189,10 @@ function anchoredPressure(): ChatContextPressureV1 { return projectChatContextPressure([user("hi"), reply, user("next")], options, 1_000); } -function barWeights(): number[] { - return Array.from(screen.getByRole("img").children) - .map((segment) => Number((segment as HTMLElement).style.flexGrow)) - .filter((weight) => Number.isFinite(weight) && weight > 0); +function tileKinds(): string[] { + return Array.from(screen.getByRole("img").querySelectorAll("[data-tile]")).map( + (tile) => (tile as HTMLElement).dataset.tile ?? "", + ); } test("provider-counted tokens beyond the local itemization get their own segment on the whole-window scale", () => { @@ -206,23 +206,155 @@ test("provider-counted tokens beyond the local itemization get their own segment const legend = screen.getByRole("list", { name: "Context by category" }); const row = within(legend) .getAllByRole("listitem") - .find((item) => item.querySelector("[data-legend-label]")?.textContent === "Provider-counted, not itemized"); + .find((item) => item.querySelector("[data-legend-label]")?.textContent === "Not itemized"); assert.ok(row, "missing the provider-counted row"); assert.ok(within(row).getByText(formatContextTokenCount(excess))); - assert.equal(legendLabels()[0], "Provider-counted, not itemized"); + assert.equal(legendLabels()[0], "Not itemized"); assert.ok(within(legend).getByText("Messages")); const label = screen.getByRole("img").getAttribute("aria-label") ?? ""; - assert.ok(label.includes(`Provider-counted, not itemized ${formatContextTokenCount(excess)}`), label); - assert.equal( - barWeights().reduce((sum, weight) => sum + weight, 0), - anchored.contextWindow, - ); + assert.ok(label.includes(`Not itemized ${formatContextTokenCount(excess)}`), label); + const tiles = Array.from(screen.getByRole("img").querySelectorAll("[data-tile]")); + assert.equal(tiles.length, CONTEXT_TILE_COUNT); + assert.ok(tiles.some((tile) => tile.getAttribute("title") === "Not itemized")); cleanup(); } }); test("no provider-counted row when the itemization covers the whole projection", () => { render(); - assert.ok(!legendLabels().includes("Provider-counted, not itemized")); - assert.equal(barWeights().reduce((sum, weight) => sum + weight, 0), 1_000_000); + assert.ok(!legendLabels().includes("Not itemized")); +}); + +test("the grid lays the window out as 100 tiles: used first, then free, then the reserve", () => { + render(); + const kinds = tileKinds(); + assert.equal(kinds.length, 100); + // 299.8K used and 33K reserved in a 1M window. + assert.equal(kinds.filter((kind) => kind === "used").length, 30); + assert.equal(kinds.filter((kind) => kind === "reserved").length, 3); + assert.equal(kinds.lastIndexOf("used") < kinds.indexOf("free"), true); + assert.equal(kinds.lastIndexOf("free") < kinds.indexOf("reserved"), true); + assert.ok(screen.getByText("Each tile is 10K tokens, 1% of the 1M window")); +}); + +test("used tiles split by category always add up to the used share", () => { + const tiles = contextTiles(pressure()); + const perCategory = new Map(); + for (const tile of tiles) { + if (tile.kind === "used") perCategory.set(tile.segment.label, (perCategory.get(tile.segment.label) ?? 0) + 1); + } + assert.equal([...perCategory.values()].reduce((sum, count) => sum + count, 0), 30); + // Each category gets its exact share of the 30 used tiles, rounded down or up. + for (const [label, tokens] of [["Messages", 273_400], ["Built-in tools", 11_800], ["System prompt", 6_200]] as const) { + const exact = (tokens / 299_800) * 30; + const got = perCategory.get(label) ?? 0; + assert.ok(got === Math.floor(exact) || got === Math.ceil(exact), `${label}: ${got} tiles for ${exact.toFixed(2)}`); + } +}); + +test("a tiny context still lights one tile, and an over-budget one eats into the reserve", () => { + const tiny = contextTiles(pressure({ contextTokens: 1_200, messageTokens: 1_000, staticTokens: 200, breakdown: undefined })); + assert.equal(tiny.filter((tile) => tile.kind === "used").length, 1); + + const over = contextTiles( + pressure({ contextTokens: 990_000, messageTokens: 963_600, staticTokens: 26_400, shouldCompact: true }), + ); + assert.equal(over.length, 100); + assert.equal(over.filter((tile) => tile.kind === "used").length, 99); + assert.equal(over.filter((tile) => tile.kind === "reserved").length, 1); + assert.equal(over.filter((tile) => tile.kind === "free").length, 0); +}); + +test("pips light one per 10% of usable context, and at least one once anything is used", () => { + const lit = (percent: number) => { + const { container } = render(); + const value = (container.firstElementChild as HTMLElement).dataset.lit; + cleanup(); + return Number(value); + }; + assert.deepEqual([0, 2, 12, 84, 131].map(lit), [0, 1, 1, 8, 10]); +}); + +test("the composer popover warns as soon as its meter does, before compaction is due", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /^Context usage, 84 percent$/u })); + const popover = screen.getByRole("dialog", { name: "Context usage" }); + assert.match(popover.textContent ?? "", /Approaching the limit/u); + assert.doesNotMatch(popover.textContent ?? "", /Context healthy/u); +}); + +test("a reserve under half a tile still gets one tile", () => { + const kinds = contextTiles(pressure({ reservedTokens: 4_000, inputBudgetTokens: 996_000 })).map((tile) => tile.kind); + assert.equal(kinds.filter((kind) => kind === "reserved").length, 1); +}); + +test("a window too small for Aiden's own prompt says so instead of promising compaction", () => { + // A 2K local model: 2,047 tokens are reserved, leaving a 1-token budget. + render( + , + ); + assert.ok(screen.getByText((_, element) => element?.tagName === "SPAN" && element.textContent === ">999% used")); + const note = screen.getByRole("note").textContent ?? ""; + assert.match(note, /context window is too small/u); + assert.doesNotMatch(note, /will compact/u); + assert.equal(tileKinds().every((kind) => kind === "used"), true); +}); + +test("a category that rounds to nothing in a huge window still reads as present", () => { + render(); + const legend = screen.getByRole("list", { name: "Context by category" }); + const memory = within(legend) + .getAllByRole("listitem") + .find((item) => item.querySelector("[data-legend-label]")?.textContent === "Memory"); + assert.ok(memory); + assert.ok(within(memory).getByText("<0.1%")); +}); + +test("a little context in a huge window reads as under 1%, not 0%", () => { + render( + , + ); + assert.ok(screen.getByText((_, element) => element?.tagName === "SPAN" && element.textContent === "<1% used")); +}); + +test("the prompt cache countdown runs from the last cached request and says when it has lapsed", () => { + const lastRequestAt = 1_000_000; + const promptCache = { ttlMs: 300_000, lastRequestAt }; + const warm = render(); + const group = screen.getByRole("group", { name: "Prompt cache" }); + assert.ok(within(group).getByText("3:48 left")); + assert.ok(within(group).getByText("Kept for 5 min after each request.")); + warm.unmount(); + + render(); + const lapsed = screen.getByRole("group", { name: "Prompt cache" }); + assert.ok(within(lapsed).getByText("Likely expired")); +}); + +test("day-long caches count down in hours", () => { + const lastRequestAt = 1_000_000; + const promptCache = { ttlMs: 24 * 3_600_000, lastRequestAt }; + render(); + const group = screen.getByRole("group", { name: "Prompt cache" }); + assert.ok(within(group).getByText("23:58:30 left")); + assert.ok(within(group).getByText("Kept for 24 h after each request.")); +}); + +test("no prompt cache window means no countdown", () => { + render(); + assert.equal(screen.queryByRole("group", { name: "Prompt cache" }), null); }); diff --git a/renderer/components/context-breakdown.tsx b/renderer/components/context-breakdown.tsx index f3006ce09..01303fa85 100644 --- a/renderer/components/context-breakdown.tsx +++ b/renderer/components/context-breakdown.tsx @@ -1,97 +1,46 @@ -// Quick View's expanded Context detail: where the next request's tokens go, -// drawn over the model's whole context window. Every number comes from the -// main process projection (ChatContextPressureV1); nothing is re-estimated here. +// Context detail shown in Quick View and the Context tool: where the next +// request's tokens go, drawn as 100 tiles over the model's whole context window +// with a bar per category. Every number comes from the main process projection +// (ChatContextPressureV1); nothing is re-estimated here. import * as React from "react"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, Timer } from "lucide-react"; import { cn } from "../lib/ui-utils"; import { contextPressurePercent, formatContextTokenCount, type ChatContextPressureV1, - type ContextCategoryId, type DeferredContextCategory, + type PromptCacheWindowV1, } from "../shared/context-pressure"; - -const CATEGORY_LABELS: Record = { - "system-prompt": "System prompt", - "builtin-tools": "Built-in tools", - "mcp-tools": "MCP tools", - skills: "Skills", - memory: "Memory", - "project-instructions": "Project instructions", - "mcp-instructions": "MCP server instructions", - "bot-instructions": "Bot instructions", -}; - -// Bot instructions shares chart-7 with project instructions: the two never carry distinct meaning together. -const CATEGORY_COLORS: Record = { - "builtin-tools": "var(--chart-2)", - "system-prompt": "var(--chart-3)", - "mcp-tools": "var(--chart-4)", - skills: "var(--chart-5)", - memory: "var(--chart-6)", - "project-instructions": "var(--chart-7)", - "mcp-instructions": "var(--chart-8)", - "bot-instructions": "var(--chart-7)", -}; - -const MESSAGES_COLOR = "var(--chart-1)"; -// Provider-counted excess: the Messages hue, faded, so it reads as "more of the -// conversation" and stays distinct from the hatched Reserved segment. -const UNITEMIZED_COLOR = "color-mix(in srgb, var(--chart-1) 40%, transparent)"; -const FALLBACK_STATIC_COLOR = "var(--chart-2)"; -const FREE_COLOR = "var(--surface-control)"; -const RESERVED_FILL = - "repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-tertiary) 45%, transparent) 0 2px, transparent 2px 5px)"; +import { + ContextTiles, + FREE_COLOR, + RESERVED_FILL, + contextPercentLabel, + contextSegments, + freeContextTokens, + percentOfWindowLabel, +} from "./context-tiles"; const DEFERRED_LABELS: Record = { "mcp-tools": { row: "MCP tools (deferred)", fold: "MCP tools by server" }, }; -interface Segment { - key: string; - label: string; - tokens: number; - fill: string; -} - -function usedSegments(pressure: ChatContextPressureV1): Segment[] { - const statics: Segment[] = pressure.breakdown - ? pressure.breakdown.categories.map((category) => ({ - key: category.id, - label: CATEGORY_LABELS[category.id], - tokens: category.tokens, - fill: CATEGORY_COLORS[category.id], - })) - : [{ key: "static", label: "System prompt & tools", tokens: pressure.staticTokens, fill: FALLBACK_STATIC_COLOR }]; - // contextTokens can exceed the local itemization when the provider's reported - // usage anchors the projection; show that excess instead of hiding it in Free. - const unitemized: Segment = { - key: "unitemized", - label: "Provider-counted, not itemized", - tokens: Math.max(0, pressure.contextTokens - pressure.messageTokens - pressure.staticTokens), - fill: UNITEMIZED_COLOR, - }; - return [ - { key: "messages", label: "Messages", tokens: pressure.messageTokens, fill: MESSAGES_COLOR }, - ...statics, - unitemized, - ] - .filter((segment) => segment.tokens > 0) - .sort((left, right) => right.tokens - left.tokens); -} - -function percentOfWindow(tokens: number, contextWindow: number): string { - return contextWindow > 0 ? `${((tokens / contextWindow) * 100).toFixed(1)}%` : "—"; -} - function pluralMessages(count: number): string { return `${count} earlier ${count === 1 ? "message" : "messages"}`; } -function compactionNote(pressure: ChatContextPressureV1, approaching: boolean, compacting: boolean): string { +/** Aiden's own prompt and tools no longer fit, so compaction cannot help and requests will fail. */ +export function contextWindowTooSmall(pressure: ChatContextPressureV1): boolean { + return pressure.staticTokens > pressure.inputBudgetTokens; +} + +export function compactionNote(pressure: ChatContextPressureV1, approaching: boolean, compacting: boolean): string { if (compacting) return "Compacting context…"; + if (contextWindowTooSmall(pressure)) { + return "This model's context window is too small for Aiden's prompt and tools. Choose a larger-context model or turn off integrations that add tools."; + } if (pressure.shouldCompact) return "Aiden will compact context before the next request."; const count = pressure.compressibleHistoryMessages; if (count === 0) return "Healthy. No earlier messages to compact yet."; @@ -100,42 +49,153 @@ function compactionNote(pressure: ChatContextPressureV1, approaching: boolean, c : `Healthy. ${pluralMessages(count)} could be compacted later.`; } -function LegendRow({ +/** Same presentation threshold as the composer ContextMeter; shouldCompact stays the runtime rule. */ +export function contextApproaching(pressure: ChatContextPressureV1, compacting: boolean): boolean { + return compacting || pressure.shouldCompact || pressure.percentOfUsableInput >= 80; +} + +/** Soft status pill: green while healthy, warning once compaction is near or running. */ +export function ContextStatus({ + warning, + children, + role, + className, +}: React.PropsWithChildren<{ warning: boolean; role?: "note"; className?: string }>) { + return ( +

+

+ ); +} + +function CategoryRow({ label, tokens, - percent, + ofWindow, fill, - muted = false, - notCounted = false, - className, + share, }: { label: string; tokens: number; - percent: string; + /** Percent of the whole context window, e.g. "4.6%". */ + ofWindow: string; fill: string; - muted?: boolean; + /** Bar length as a fraction of the largest category. */ + share: number; +}) { + return ( +
  • + + + + {formatContextTokenCount(tokens)} + {ofWindow} + +
  • + ); +} + +function QuietRow({ + label, + tokens, + swatch, + notCounted = false, +}: { + label: string; + tokens: number; + swatch: React.CSSProperties; notCounted?: boolean; - className?: string; }) { return ( -
  • -
  • +
  • ); } +function cacheTimeLeft(ms: number): string { + const totalSeconds = Math.ceil(ms / 1000); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + if (totalSeconds < 3600) return `${Math.floor(totalSeconds / 60)}:${seconds}`; + return `${Math.floor(totalSeconds / 3600)}:${String(Math.floor(totalSeconds / 60) % 60).padStart(2, "0")}:${seconds}`; +} + +function cacheLifetime(ms: number): string { + const minutes = Math.max(1, Math.round(ms / 60_000)); + return minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`; +} + +/** Counts down the provider's prompt cache from the last request that used it. */ +export function PromptCacheTimer({ cache, initialNow }: { cache: PromptCacheWindowV1; initialNow?: number }) { + const expiresAt = cache.lastRequestAt + cache.ttlMs; + const [clock, setClock] = React.useState(() => Date.now()); + const now = initialNow ?? clock; + const remaining = Math.max(0, Math.min(cache.ttlMs, expiresAt - now)); + React.useEffect(() => { + // Ticks against the wall clock until the cache lapses; nothing else drives + // this. A new window re-reads the clock first, so one that arrives already + // lapsed never shows the time left from an earlier render. + if (initialNow !== undefined) return; + setClock(Date.now()); + if (expiresAt <= Date.now()) return; + const timer = window.setInterval(() => { + const current = Date.now(); + setClock(current); + if (current >= expiresAt) window.clearInterval(timer); + }, 1000); + return () => window.clearInterval(timer); + }, [expiresAt, initialNow]); + const lapsed = remaining === 0; + const low = !lapsed && remaining <= 60_000; + return ( +
    +
    +
    +
    + ); +} + function DeferredFold({ deferred }: { deferred: DeferredContextCategory }) { const [open, setOpen] = React.useState(false); const listId = React.useId(); @@ -181,101 +241,112 @@ function DeferredFold({ deferred }: { deferred: DeferredContextCategory }) { export function ContextBreakdown({ pressure, compacting = false, + layout = "card", + now, }: { pressure: ChatContextPressureV1; /** A compaction is running for this chat right now. */ compacting?: boolean; + /** `card`: Quick View's narrow column. `panel`: the Context tool, with the square grid beside the bars. */ + layout?: "card" | "panel"; + /** Clock seed for the prompt cache countdown; defaults to the current time. */ + now?: number; }): React.JSX.Element { const percent = contextPressurePercent(pressure); - // Same presentation thresholds as the composer ContextMeter; shouldCompact stays the runtime rule. - const approaching = compacting || pressure.shouldCompact || pressure.percentOfUsableInput >= 80; - const contextWindow = pressure.contextWindow; - const segments = usedSegments(pressure); - const free = Math.max(0, pressure.inputBudgetTokens - pressure.contextTokens); + const approaching = contextApproaching(pressure, compacting); + const segments = contextSegments(pressure); + const largest = segments[0]?.tokens ?? 0; + const free = freeContextTokens(pressure); const deferred = pressure.breakdown?.deferred ?? []; - const budgetMarker = contextWindow > 0 ? Math.min(100, (pressure.inputBudgetTokens / contextWindow) * 100) : null; const estimate = pressure.source === "estimated" ? "~" : ""; + const tileTokens = formatContextTokenCount(Math.round(pressure.contextWindow / 100)); + const panel = layout === "panel"; - const barLabel = [ - ...segments.map((segment) => `${segment.label} ${formatContextTokenCount(segment.tokens)}`), - `Free space ${formatContextTokenCount(free)}`, - `Reserved for response ${formatContextTokenCount(pressure.reservedTokens)}`, - ].join(", "); + const headline = ( +
    + + {contextPercentLabel(percent, pressure.contextTokens)}{" "} + used + + + {`${estimate}${formatContextTokenCount(pressure.contextTokens)} of ${formatContextTokenCount(pressure.inputBudgetTokens)} usable`} + +
    + ); - return ( -
    -
    - - {`${percent}% used`} - - - {`${estimate}${formatContextTokenCount(pressure.contextTokens)} / ${formatContextTokenCount(pressure.inputBudgetTokens)} usable`} - -
    + const legend = ( +
      + {segments.map((segment) => ( + 0 ? segment.tokens / largest : 0} + /> + ))} +
    + ); -
    - {segments.map((segment) => ( - - ))} - - - {budgetMarker !== null ? ( -
    + const caption = ( + + {`Each tile is ${tileTokens} tokens, 1% of the ${formatContextTokenCount(pressure.contextWindow)} window`} + + ); -
      - {segments.map((segment) => ( - - ))} - - - {deferred.map((entry, index) => ( - - ))} -
    + return ( +
    + {panel ? ( +
    +
    + + {caption} +
    +
    + {headline} +
    {legend}
    +
    +
    + ) : ( + <> + {headline} +
    + +
    +
    {caption}
    + {legend} + + )} {deferred.map((entry) => ( ))} -

    + {pressure.promptCache ? : null} + + {compactionNote(pressure, approaching, compacting)} {deferred.length > 0 ? " Deferred tools load through tool search and aren't counted until used." : null} -

    +
    ); } diff --git a/renderer/components/context-meter.test.tsx b/renderer/components/context-meter.test.tsx index 6e5b9a11d..bbbb1fdbe 100644 --- a/renderer/components/context-meter.test.tsx +++ b/renderer/components/context-meter.test.tsx @@ -58,12 +58,11 @@ test("the breakdown announces each label as the term and its token count as the assert.deepEqual( pairs.map(([term]) => term), [ - "projected tokens", - "model context", - "reserved for response + safety", "conversation", "system + tools", "recent work", + "reserved", + "model context", ], ); for (const [, value] of pairs) assert.match(value, /\d/u); @@ -92,8 +91,30 @@ test("context details aggregate durable turn totals once without inventing unava const messages = [turn, turn, { id: "old", role: "assistant" as const, content: "legacy", createdAt: 1 }]; assert.deepEqual(recordedChatUsage({ messages }), { input: 100, output: 20, cacheRead: 500, cacheWrite: 40, total: 660, requests: 3, turns: 1, assistantMessages: 2, userMessages: 0 }); const html = renderToStaticMarkup(); - assert.match(html, /Reported usage from 1 of 2 saved assistant turns/); + assert.match(html, /Reported by 1 of 2 saved assistant turns/); assert.match(html, /Total cost<.*Unavailable/); assert.doesNotMatch(html, /\$0\.00/); - assert.match(html, /42% of usable context/); + const text = html.replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " "); + assert.match(text, /42% used/u); + assert.match(text, /~40K of 96K usable/u); + // 500 of 640 prompt tokens (input 100 + cache read 500 + cache write 40) came from cache. + assert.match(text, /78% from cache/u); +}); + + +test("cumulative usage past a billion tokens reads in billions", async () => { + const { ChatContextPanel } = await import("./chat-context-panel.js"); + const usage = { input: 90_000_000, output: 10_000_000, cacheRead: 1_100_000_000, cacheWrite: 0, total: 1_200_000_000, requests: 900 }; + const turn = { id: "a", role: "assistant" as const, content: "done", createdAt: 20, turnStats: { version: 1 as const, startedAt: 10, finishedAt: 20, usage } }; + const html = renderToStaticMarkup(); + assert.match(html, />1\.2B { + const { ChatContextPanel } = await import("./chat-context-panel.js"); + const messages = [{ id: "old", role: "assistant" as const, content: "local reply", createdAt: 1 }]; + const html = renderToStaticMarkup(); + assert.match(html, /hasn(?:'|')t reported token usage/u); + assert.doesNotMatch(html, /Unavailable/u); }); diff --git a/renderer/components/context-meter.tsx b/renderer/components/context-meter.tsx index 0354ece4f..ba21095a8 100644 --- a/renderer/components/context-meter.tsx +++ b/renderer/components/context-meter.tsx @@ -3,8 +3,9 @@ // trips exactly when Aiden would compact; estimated figures carry a ~ prefix. import * as React from "react"; -import { CircleGauge } from "lucide-react"; -import { Button, Popover, PopoverContent, PopoverTrigger, Text } from "./ui"; +import { Button, Popover, PopoverContent, PopoverTrigger } from "./ui"; +import { ContextStatus, compactionNote, contextWindowTooSmall } from "./context-breakdown"; +import { ContextPips, ContextTiles, contextPercentLabel } from "./context-tiles"; import { cn } from "../lib/ui-utils"; import { contextPressurePercent, @@ -51,7 +52,9 @@ export function ContextMeter({ const phase = contextMeterPhase(pressure, compacting, recentlyCompacted); const emphasized = phase === "compaction-pending" || phase === "compacting"; const stateText = - phase === "compaction-pending" + phase !== "compacting" && contextWindowTooSmall(pressure) + ? compactionNote(pressure, true, false) + : phase === "compaction-pending" ? "Aiden will compact context before the next request." : phase === "compacting" ? "Compacting context…" @@ -59,7 +62,10 @@ export function ContextMeter({ ? "Context compacted." : pressure.shouldCompact ? "Aiden will compact context before the next request." - : "Context healthy — no compaction needed before the next request."; + : phase === "approaching" + ? "Approaching the limit. Aiden compacts before a request would cross it." + : "Context healthy. No compaction needed before the next request."; + const warning = emphasized || phase === "approaching"; return ( @@ -75,12 +81,12 @@ export function ContextMeter({ aria-label={`Context usage, ${percent} percent${emphasized ? ", will compact before the next request" : ""}`} title="Projected context for the next request" > - ); } -/** Popover body: labelled terms (dt) with their token values (dd). */ +/** Popover body: the tile grid beside the headline, then labelled terms (dt) with their token values (dd). */ export function ContextMeterDetails({ pressure, percent, @@ -110,68 +116,47 @@ export function ContextMeterDetails({ emphasized: boolean; stateText: string; }) { + const rows: Array<[string, string]> = [ + ["conversation", `~${formatContextTokenCount(pressure.messageTokens)}`], + ["system + tools", `~${formatContextTokenCount(pressure.staticTokens)}`], + ]; + if (pressure.addedAfterUsageAnchorTokens !== undefined) { + rows.push(["recent work", `~${formatContextTokenCount(pressure.addedAfterUsageAnchorTokens)}`]); + } + rows.push(["reserved", `~${formatContextTokenCount(pressure.reservedTokens)}`]); + rows.push(["model context", formatContextTokenCount(pressure.contextWindow)]); return ( <> - - Context - - - {percent}% of usable context - -
    -
    -
    projected tokens
    -
    - {tokenLabel(pressure, pressure.contextTokens)} -
    +
    +
    + + 1% per tile
    -
    -
    model context
    -
    - {formatContextTokenCount(pressure.contextWindow)} -
    +
    + + {contextPercentLabel(percent, pressure.contextTokens)} + + + {`${tokenLabel(pressure, pressure.contextTokens)} of ${formatContextTokenCount(pressure.inputBudgetTokens)} usable`} + +
    + {rows.map(([term, value]) => ( +
    +
    {term}
    +
    {value}
    +
    + ))} +
    -
    -
    reserved for response + safety
    -
    - ~{formatContextTokenCount(pressure.reservedTokens)} -
    -
    -
    - - Estimated breakdown - -
    -
    -
    conversation
    -
    - ~{formatContextTokenCount(pressure.messageTokens)} -
    -
    -
    -
    system + tools
    -
    - ~{formatContextTokenCount(pressure.staticTokens)} -
    -
    - {pressure.addedAfterUsageAnchorTokens !== undefined ? ( -
    -
    recent work
    -
    - ~{formatContextTokenCount(pressure.addedAfterUsageAnchorTokens)} -
    -
    - ) : null} -
    - +
    + {stateText} - + ); } diff --git a/renderer/components/context-tiles.tsx b/renderer/components/context-tiles.tsx new file mode 100644 index 000000000..47d900eeb --- /dev/null +++ b/renderer/components/context-tiles.tsx @@ -0,0 +1,197 @@ +// Shared context visuals: the 100-tile grid (each tile is 1% of the model's +// context window) and the composer's 10-pip meter. Every number comes from the +// main process projection (ChatContextPressureV1); nothing is re-estimated here. + +import { cn } from "../lib/ui-utils"; +import { + formatContextTokenCount, + type ChatContextPressureV1, + type ContextCategoryId, +} from "../shared/context-pressure"; + +export const CONTEXT_TILE_COUNT = 100; + +const CATEGORY_LABELS: Record = { + "system-prompt": "System prompt", + "builtin-tools": "Built-in tools", + "mcp-tools": "MCP tools", + skills: "Skills", + memory: "Memory", + "project-instructions": "Project instructions", + "mcp-instructions": "MCP server instructions", + "bot-instructions": "Bot instructions", +}; + +// Bot instructions shares chart-7 with project instructions: the two never carry distinct meaning together. +const CATEGORY_COLORS: Record = { + "builtin-tools": "var(--chart-2)", + "system-prompt": "var(--chart-3)", + "mcp-tools": "var(--chart-4)", + skills: "var(--chart-5)", + memory: "var(--chart-6)", + "project-instructions": "var(--chart-7)", + "mcp-instructions": "var(--chart-8)", + "bot-instructions": "var(--chart-7)", +}; + +export const MESSAGES_COLOR = "var(--chart-1)"; +// Provider-counted excess: the Messages hue, faded, so it reads as "more of the +// conversation" and stays distinct from the hatched Reserved tiles. +const UNITEMIZED_COLOR = "color-mix(in srgb, var(--chart-1) 40%, transparent)"; +const FALLBACK_STATIC_COLOR = "var(--chart-2)"; +export const FREE_COLOR = "var(--surface-control)"; +export const RESERVED_FILL = + "repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-tertiary) 45%, transparent) 0 2px, transparent 2px 5px)"; + +export interface ContextSegment { + key: string; + label: string; + tokens: number; + fill: string; +} + +/** Used context by category, largest first; zero-token categories are dropped. */ +export function contextSegments(pressure: ChatContextPressureV1): ContextSegment[] { + const statics: ContextSegment[] = pressure.breakdown + ? pressure.breakdown.categories.map((category) => ({ + key: category.id, + label: CATEGORY_LABELS[category.id], + tokens: category.tokens, + fill: CATEGORY_COLORS[category.id], + })) + : [{ key: "static", label: "System prompt & tools", tokens: pressure.staticTokens, fill: FALLBACK_STATIC_COLOR }]; + // contextTokens can exceed the local itemization when the provider's reported + // usage anchors the projection; show that excess instead of hiding it in Free. + const unitemized: ContextSegment = { + key: "unitemized", + label: "Not itemized", + tokens: Math.max(0, pressure.contextTokens - pressure.messageTokens - pressure.staticTokens), + fill: UNITEMIZED_COLOR, + }; + return [ + { key: "messages", label: "Messages", tokens: pressure.messageTokens, fill: MESSAGES_COLOR }, + ...statics, + unitemized, + ] + .filter((segment) => segment.tokens > 0) + .sort((left, right) => right.tokens - left.tokens); +} + +export function freeContextTokens(pressure: ChatContextPressureV1): number { + return Math.max(0, pressure.inputBudgetTokens - pressure.contextTokens); +} + +export type ContextTile = + | { kind: "used"; segment: ContextSegment } + | { kind: "free" } + | { kind: "reserved" }; + +/** + * Lays the window out as 100 tiles: used tiles per category (largest remainder, + * so they always add up), then free, then the response reserve at the end. + * Any used context or reserve shows at least one tile; an over-budget + * projection takes tiles from the reserve once free space runs out. + */ +export function contextTiles(pressure: ChatContextPressureV1): ContextTile[] { + const window = pressure.contextWindow; + if (window <= 0) return Array.from({ length: CONTEXT_TILE_COUNT }, () => ({ kind: "free" }) as const); + const segments = contextSegments(pressure); + const usedTokens = segments.reduce((sum, segment) => sum + segment.tokens, 0); + const share = (tokens: number) => (tokens / window) * CONTEXT_TILE_COUNT; + const usedTiles = + usedTokens > 0 ? Math.min(CONTEXT_TILE_COUNT, Math.max(1, Math.round(share(usedTokens)))) : 0; + const reservedTiles = Math.min( + CONTEXT_TILE_COUNT - usedTiles, + pressure.reservedTokens > 0 ? Math.max(1, Math.round(share(pressure.reservedTokens))) : 0, + ); + + const exact = segments.map((segment) => (segment.tokens / usedTokens) * usedTiles); + const counts = exact.map(Math.floor); + const order = exact + .map((value, index) => ({ index, remainder: value - Math.floor(value) })) + .sort((left, right) => right.remainder - left.remainder); + for (let left = usedTiles - counts.reduce((sum, count) => sum + count, 0), i = 0; left > 0; left--, i++) { + counts[order[i % order.length].index]++; + } + + const tiles: ContextTile[] = []; + segments.forEach((segment, index) => { + for (let n = 0; n < counts[index]; n++) tiles.push({ kind: "used", segment }); + }); + while (tiles.length < CONTEXT_TILE_COUNT - reservedTiles) tiles.push({ kind: "free" }); + while (tiles.length < CONTEXT_TILE_COUNT) tiles.push({ kind: "reserved" }); + return tiles; +} + +/** "Messages 46.3K, …, Free space 818.2K, Reserved for response 66.4K" for the grid's image label. */ +export function contextTilesLabel(pressure: ChatContextPressureV1): string { + return [ + ...contextSegments(pressure).map((segment) => `${segment.label} ${formatContextTokenCount(segment.tokens)}`), + `Free space ${formatContextTokenCount(freeContextTokens(pressure))}`, + `Reserved for response ${formatContextTokenCount(pressure.reservedTokens)}`, + ].join(", "); +} + +export function ContextTiles({ + pressure, + columns, + className, +}: { + pressure: ChatContextPressureV1; + /** 20 lays the grid out as a wide strip; 10 as a square. */ + columns: 10 | 20; + className?: string; +}) { + const tiles = contextTiles(pressure); + return ( +
    + {tiles.map((tile, index) => ( + + ))} +
    + ); +} + +/** "42%"; "<1%" when some context is used but rounds to zero; ">999%" when a model's window is far too small. */ +export function contextPercentLabel(percent: number, usedTokens = 0): string { + if (percent === 0 && usedTokens > 0) return "<1%"; + return percent > 999 ? ">999%" : `${percent}%`; +} + +/** Share of the window with one decimal; a nonzero share never reads as 0.0%. */ +export function percentOfWindowLabel(tokens: number, contextWindow: number): string { + if (contextWindow <= 0) return "—"; + const percent = (tokens / contextWindow) * 100; + return tokens > 0 && percent < 0.05 ? "<0.1%" : `${percent.toFixed(1)}%`; +} + +/** Ten pips, one per 10% of usable context; any usage lights the first. */ +export function ContextPips({ percent, warning, used = percent > 0 }: { percent: number; warning: boolean; used?: boolean }) { + const lit = used ? Math.min(10, Math.max(1, Math.round(percent / 10))) : 0; + return ( +