From 6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 13:25:23 +0900 Subject: feat(context-window): show current/max context usage per tab/model Add a 'Context Window' sidebar view showing the live context occupancy (latest request's input+output) against the model's maximum context window, resolved dynamically from the models.dev catalog. - core: models.dev catalog module (resolveContextLimit) with disk cache, TTL, stale-fallback + offline penalty memo; null for unknown models. - api: GET /models/context-limit?provider=&modelId=. - frontend: ContextWindowPanel + computeContextUsage helper; App resolves + caches the active model's max (anthropic/opencode-anthropic only); percent shown to 2 decimals; degrades to bare token count when max unknown. - tests: core catalog (13), api route (3), frontend helper (6). --- packages/frontend/src/App.svelte | 57 +++++++++++++++ .../src/lib/components/ContextWindowPanel.svelte | 85 ++++++++++++++++++++++ .../src/lib/components/SidebarPanel.svelte | 11 +++ packages/frontend/src/lib/context-window.ts | 37 ++++++++++ 4 files changed, 190 insertions(+) create mode 100644 packages/frontend/src/lib/components/ContextWindowPanel.svelte create mode 100644 packages/frontend/src/lib/context-window.ts (limited to 'packages/frontend/src') diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index eaa28e8..0344af4 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -74,6 +74,62 @@ $effect(() => { } }); +// ─── Context-window max lookup ───────────────────────────────── +// Resolve the active model's MAXIMUM context window from models.dev (via the +// API), so the Context Window sidebar view can show `current / max`. Cached +// per provider+model; `null` when unknown (the view then hides the +// denominator/percentage). Only Claude-backed providers are resolvable. +let contextLimit = $state(null); +const contextLimitCache = new Map(); + +$effect(() => { + const tab = tabStore.activeTab; + const keyId = tab?.keyId ?? null; + const modelId = tab?.modelId ?? null; + const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null; + + if (!provider || !modelId) { + contextLimit = null; + return; + } + + const cacheKey = `${provider}/${modelId}`; + if (contextLimitCache.has(cacheKey)) { + contextLimit = contextLimitCache.get(cacheKey) ?? null; + return; + } + + // Clear immediately so a slow/failed fetch can't leave the PREVIOUS + // model's max on screen (which would render this model's tokens against + // the wrong denominator). The view degrades to a bare token count until + // the fetch resolves. + contextLimit = null; + + // Fetch is async; guard against a stale response overwriting a newer + // selection by re-checking the active tab's key/model on resolve. + void (async () => { + try { + const res = await fetch( + `${config.apiBase}/models/context-limit?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`, + ); + if (!res.ok) return; + const data = (await res.json()) as { contextLimit?: number | null }; + const limit = data.contextLimit ?? null; + contextLimitCache.set(cacheKey, limit); + const current = tabStore.activeTab; + const currentProvider = current?.keyId + ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null) + : null; + if (currentProvider === provider && current?.modelId === modelId) { + contextLimit = limit; + } + } catch { + // Leave contextLimit as-is on network error; view falls back to + // showing the bare token count. + } + })(); +}); + onMount(() => { // Apply persisted theme (or the shared DEFAULT_THEME if nothing is // stored) so the first paint matches what the Settings panel will @@ -137,6 +193,7 @@ onMount(() => { tasks={tabStore.activeTab?.tasks ?? []} cacheStats={tabStore.activeTab?.cacheStats ?? null} cacheTabTitle={tabStore.activeTab?.title ?? null} + {contextLimit} permissionLog={tabStore.permissionLog} apiBase={config.apiBase} activeKeyId={tabStore.activeTab?.keyId ?? null} diff --git a/packages/frontend/src/lib/components/ContextWindowPanel.svelte b/packages/frontend/src/lib/components/ContextWindowPanel.svelte new file mode 100644 index 0000000..6c7de05 --- /dev/null +++ b/packages/frontend/src/lib/components/ContextWindowPanel.svelte @@ -0,0 +1,85 @@ + + +
+ {#if !hasUsage} +

+ No context data yet. Send a message — the current context size appears + here after the first response. +

+ {:else} +
+
+ Context Window + {#if tabTitle} + {tabTitle} + {/if} + {#if usage.percent !== null} + {usage.percent.toFixed(2)}% + {/if} +
+ + +
+ {fmt(usage.current)} + {#if usage.max !== null} + / {fmt(usage.max)} + {/if} + tokens +
+ + {#if usage.percent !== null} + + {:else} +

+ Max context size unknown for this model. +

+ {/if} + + {#if modelId} +
+ {modelId} +
+ {/if} +
+ +

+ Current context = the most recent request's prompt + output (what the + model actually held in its window that turn). Grows as the conversation + gets longer. Resets on reload. +

+ {/if} +
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 491b1bd..573a6fc 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js"; import CacheRatePanel from "./CacheRatePanel.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; import ConfigPanel from "./ConfigPanel.svelte"; +import ContextWindowPanel from "./ContextWindowPanel.svelte"; import DebugPanel from "./DebugPanel.svelte"; import KeyUsage from "./KeyUsage.svelte"; import ModelSelector from "./ModelSelector.svelte"; @@ -27,6 +28,7 @@ const { tasks = [], cacheStats = null, cacheTabTitle = null, + contextLimit = null, permissionLog = [], apiBase = "", activeKeyId = null, @@ -47,6 +49,7 @@ const { tasks?: TaskItem[]; cacheStats?: CacheStats | null; cacheTabTitle?: string | null; + contextLimit?: number | null; permissionLog?: LogEntry[]; apiBase?: string; activeKeyId?: string | null; @@ -89,6 +92,7 @@ const viewOptions = [ "Chat Settings", "Key Usage", "Cache Rate", + "Context Window", "Claude Reset", "Model Status", "Tasks", @@ -170,6 +174,13 @@ function contentClass(_selected: string): string { {:else if panel.selected === "Cache Rate"} + {:else if panel.selected === "Context Window"} + {:else if panel.selected === "Claude Reset"} {:else if panel.selected === "Model Status"} diff --git a/packages/frontend/src/lib/context-window.ts b/packages/frontend/src/lib/context-window.ts new file mode 100644 index 0000000..c4321f8 --- /dev/null +++ b/packages/frontend/src/lib/context-window.ts @@ -0,0 +1,37 @@ +import type { CacheStats } from "./types.js"; + +/** + * Context-window occupancy for the current tab/model. + * + * `current` is the size of the model's context on the MOST RECENT request — + * the last turn's full prompt (`inputTokens`, which already includes cached + * tokens for Anthropic) plus what the model generated that turn + * (`outputTokens`). This mirrors how opencode derives context fullness from + * the last assistant message, and reflects what actually occupies the model's + * window — NOT the session-cumulative totals shown by the Cache Rate view. + * + * `max` is the model's maximum context window from models.dev (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100] (unrounded; + * the UI decides the displayed precision), or `null` when + * `max` is unknown — in which case the UI shows the bare token count with no + * denominator or progress bar. + */ +export interface ContextUsage { + current: number; + max: number | null; + percent: number | null; +} + +export function computeContextUsage( + cacheStats: CacheStats | null | undefined, + contextLimit: number | null | undefined, +): ContextUsage { + const last = cacheStats?.last ?? null; + const current = last ? last.inputTokens + last.outputTokens : 0; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + // Precise (unrounded) percentage clamped to [0, 100]; the UI formats the + // decimal places. Kept unrounded so small contexts against huge windows + // (e.g. a few thousand tokens vs. 1,000,000) still read non-zero. + const percent = max ? Math.max(0, Math.min(100, (current / max) * 100)) : null; + return { current, max, percent }; +} -- cgit v1.2.3