summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 13:25:23 +0900
committerAdam Malczewski <[email protected]>2026-06-02 13:25:23 +0900
commit6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02 (patch)
tree78b30dedd471ab76177b3631a956ab160615e303 /packages/frontend/src/lib
parent3f629a8469fe483243671e1ca15582a111e96541 (diff)
downloaddispatch-6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02.tar.gz
dispatch-6433cc42de1ceca7210e2b64ad3b98b3a5ce7d02.zip
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).
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/components/ContextWindowPanel.svelte85
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte11
-rw-r--r--packages/frontend/src/lib/context-window.ts37
3 files changed, 133 insertions, 0 deletions
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 @@
+<script lang="ts">
+import { computeContextUsage } from "../context-window.js";
+import type { CacheStats } from "../types.js";
+
+const {
+ cacheStats = null,
+ contextLimit = null,
+ tabTitle = null,
+ modelId = null,
+}: {
+ cacheStats?: CacheStats | null;
+ contextLimit?: number | null;
+ tabTitle?: string | null;
+ modelId?: string | null;
+} = $props();
+
+const usage = $derived(computeContextUsage(cacheStats, contextLimit));
+
+// As the window fills, escalate color: calm → warning → danger.
+function fillClass(pct: number): string {
+ if (pct >= 90) return "progress-error";
+ if (pct >= 70) return "progress-warning";
+ return "progress-success";
+}
+
+function fmt(n: number): string {
+ return n.toLocaleString();
+}
+
+const hasUsage = $derived((cacheStats?.last ?? null) !== null);
+</script>
+
+<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
+ {#if !hasUsage}
+ <p class="text-xs text-base-content/50">
+ No context data yet. Send a message — the current context size appears
+ here after the first response.
+ </p>
+ {:else}
+ <div class="bg-base-200 rounded-lg p-2">
+ <div class="flex items-center gap-1.5 mb-2">
+ <span class="text-xs font-semibold">Context Window</span>
+ {#if tabTitle}
+ <span class="badge badge-xs badge-ghost">{tabTitle}</span>
+ {/if}
+ {#if usage.percent !== null}
+ <span class="badge badge-xs ml-auto">{usage.percent.toFixed(2)}%</span>
+ {/if}
+ </div>
+
+ <!-- Headline: current / max (or just current when max is unknown) -->
+ <div class="flex items-baseline gap-1.5">
+ <span class="text-lg font-mono font-semibold">{fmt(usage.current)}</span>
+ {#if usage.max !== null}
+ <span class="text-xs text-base-content/50 font-mono">/ {fmt(usage.max)}</span>
+ {/if}
+ <span class="text-xs text-base-content/40 ml-1">tokens</span>
+ </div>
+
+ {#if usage.percent !== null}
+ <progress
+ class="progress w-full h-2 mt-1.5 {fillClass(usage.percent)}"
+ value={usage.percent}
+ max="100"
+ ></progress>
+ {:else}
+ <p class="text-xs text-base-content/40 mt-1.5">
+ Max context size unknown for this model.
+ </p>
+ {/if}
+
+ {#if modelId}
+ <div class="text-xs text-base-content/40 mt-1.5 truncate" title={modelId}>
+ {modelId}
+ </div>
+ {/if}
+ </div>
+
+ <p class="text-xs text-base-content/40">
+ 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.
+ </p>
+ {/if}
+</div>
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 {
<KeyUsage {keys} {apiBase} />
{:else if panel.selected === "Cache Rate"}
<CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} />
+ {:else if panel.selected === "Context Window"}
+ <ContextWindowPanel
+ {cacheStats}
+ {contextLimit}
+ tabTitle={cacheTabTitle}
+ modelId={activeModelId}
+ />
{:else if panel.selected === "Claude Reset"}
<ClaudeReset {apiBase} />
{: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 };
+}