From 165c005648a374e0972a875c2035f188589b4d42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:39:39 +0000 Subject: [PATCH 1/9] Redesign context views with a tile grid The Quick View Context card, the composer meter and its popover, and the Context tool now share one visual: 100 tiles over the whole context window, each 1% of it, colored by category, then free space and the hatched response reserve. Categories get their own bars, the composer and Quick View summary use a 10-pip meter, and compaction state is a soft status pill. The Context tool groups Next request, Session and Recorded usage into cards with a cache-share usage bar. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0192A2sztMG1oG9N6WJuYSX7 --- docs/chatgpt-desktop-ui-inspiration.md | 2 +- renderer/components/chat-context-panel.tsx | 219 +++++++----- .../components/context-breakdown.test.tsx | 74 +++- renderer/components/context-breakdown.tsx | 327 +++++++++--------- renderer/components/context-meter.test.tsx | 9 +- renderer/components/context-meter.tsx | 104 +++--- renderer/components/context-tiles.tsx | 181 ++++++++++ renderer/components/environment-panel.tsx | 11 +- 8 files changed, 583 insertions(+), 344 deletions(-) create mode 100644 renderer/components/context-tiles.tsx diff --git a/docs/chatgpt-desktop-ui-inspiration.md b/docs/chatgpt-desktop-ui-inspiration.md index f63b570a7..29c7c3876 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. 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/renderer/components/chat-context-panel.tsx b/renderer/components/chat-context-panel.tsx index 464e55fe2..2fce2c15b 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,95 @@ export function recordedChatUsage(chat: Pick) { }; } +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) => (reported ? value.toLocaleString() : "Unavailable"); + const cacheable = usage.cacheRead + 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 ? ( +
+ + {formatContextTokenCount(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} +
+ {USAGE_PARTS.map((part) => ( +
+
+
+
{token(usage[part.key])}
+
+ ))} +
+
Total reported tokens
+
{token(usage.total)}
+
+
+
Reasoning tokens
+
Unavailable
+
+
+
Total cost
+
Unavailable
+
+
+

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

+
+ ); +} + export function ChatContextPanel({ details, }: { @@ -66,93 +155,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"} -
+
+ {details.providerLabel || "Unknown provider"} + {details.modelLabel || "Unknown model"} + {`${count(usage.userMessages)} user · ${count(usage.assistantMessages)} assistant`}
- ))} -
-
-
-

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}
-
- ))} -
-

- 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..29bf255c6 100644 --- a/renderer/components/context-breakdown.test.tsx +++ b/renderer/components/context-breakdown.test.tsx @@ -6,6 +6,7 @@ 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 { CONTEXT_TILE_COUNT, ContextPips, contextTiles } from "./context-tiles"; afterEach(cleanup); @@ -60,7 +61,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,10 +89,7 @@ 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); }); test("MCP tools by server starts folded and opens to list each server", () => { @@ -111,7 +109,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 +187,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", () => { @@ -213,10 +211,9 @@ test("provider-counted tokens beyond the local itemization get their own segment 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, - ); + 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") === "Provider-counted, not itemized")); cleanup(); } }); @@ -224,5 +221,54 @@ test("provider-counted tokens beyond the local itemization get their own segment 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); +}); + +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")); +}); + +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]); }); diff --git a/renderer/components/context-breakdown.tsx b/renderer/components/context-breakdown.tsx index f3006ce09..7c2752156 100644 --- a/renderer/components/context-breakdown.tsx +++ b/renderer/components/context-breakdown.tsx @@ -1,6 +1,7 @@ -// 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"; @@ -9,88 +10,25 @@ import { contextPressurePercent, formatContextTokenCount, type ChatContextPressureV1, - type ContextCategoryId, type DeferredContextCategory, } 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, + contextSegments, + freeContextTokens, +} 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 { +export function compactionNote(pressure: ChatContextPressureV1, approaching: boolean, compacting: boolean): string { if (compacting) return "Compacting context…"; if (pressure.shouldCompact) return "Aiden will compact context before the next request."; const count = pressure.compressibleHistoryMessages; @@ -100,38 +38,83 @@ 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, fill, - muted = false, - notCounted = false, - className, + share, }: { label: string; tokens: number; - percent: string; fill: string; - muted?: boolean; + /** Bar length as a fraction of the largest category. */ + share: number; +}) { + return ( +
  • + + + {formatContextTokenCount(tokens)} +
  • + ); +} + +function QuietRow({ + label, + tokens, + swatch, + notCounted = false, +}: { + label: string; + tokens: number; + swatch: React.CSSProperties; notCounted?: boolean; - className?: string; }) { return ( -
  • -
  • +
  • ); } @@ -181,101 +164,105 @@ function DeferredFold({ deferred }: { deferred: DeferredContextCategory }) { export function ContextBreakdown({ pressure, compacting = false, + layout = "card", }: { 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"; }): 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 = ( +
    + + {`${percent}%`}{" "} + used + + + {panel + ? `${estimate}${formatContextTokenCount(pressure.contextTokens)} of ${formatContextTokenCount(pressure.inputBudgetTokens)} usable` + : `${formatContextTokenCount(free)} free`} + +
    + ); - 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`} + ); -
      - {segments.map((segment) => ( - - ))} - - - {deferred.map((entry, index) => ( - - ))} -
    + return ( +
    + {panel ? ( +
    +
    + + {caption} +
    +
    + {headline} +
    {legend}
    +
    +
    + ) : ( + <> + {headline} +
    + +
    +
    {caption}
    + {legend} + + )} {deferred.map((entry) => ( ))} -

    + {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..4156f947d 100644 --- a/renderer/components/context-meter.test.tsx +++ b/renderer/components/context-meter.test.tsx @@ -58,12 +58,10 @@ 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", ], ); for (const [, value] of pairs) assert.match(value, /\d/u); @@ -92,8 +90,9 @@ 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/); + assert.match(html, /42% ]*>used<\/span>/); + assert.match(html, /~40K of 96K usable/); }); diff --git a/renderer/components/context-meter.tsx b/renderer/components/context-meter.tsx index 0354ece4f..15832a458 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 } from "./context-breakdown"; +import { ContextPips, ContextTiles } from "./context-tiles"; import { cn } from "../lib/ui-utils"; import { contextPressurePercent, @@ -75,12 +76,12 @@ export function ContextMeter({ aria-label={`Context usage, ${percent} percent${emphasized ? ", will compact before the next request" : ""}`} title="Projected context for the next request" > -
    + {stateText} - + ); } diff --git a/renderer/components/context-tiles.tsx b/renderer/components/context-tiles.tsx new file mode 100644 index 000000000..9ec4e5e79 --- /dev/null +++ b/renderer/components/context-tiles.tsx @@ -0,0 +1,181 @@ +// 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: "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); +} + +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 shows at least one tile; an over-budget projection takes + * tiles from the reserve before 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, Math.round(share(pressure.reservedTokens))); + + 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) => ( + + ))} +
    + ); +} + +/** Ten pips, one per 10% of usable context; any usage lights the first. */ +export function ContextPips({ percent, warning }: { percent: number; warning: boolean }) { + const lit = percent > 0 ? Math.min(10, Math.max(1, Math.round(percent / 10))) : 0; + return ( +