summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte8
-rw-r--r--src/core/metrics/format.test.ts44
-rw-r--r--src/core/metrics/format.ts39
-rw-r--r--src/core/metrics/index.ts3
-rw-r--r--src/features/chat/index.ts1
-rw-r--r--src/features/chat/ui/Composer.svelte152
-rw-r--r--src/features/chat/ui/ContextSizeBadge.svelte20
7 files changed, 230 insertions, 37 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 32db54f..dbb346a 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -10,7 +10,6 @@
ChatView,
Composer,
manifest as chatManifest,
- ContextSizeBadge,
ModelSelector,
} from "../features/chat";
import { manifest as conversationCacheManifest } from "../features/conversation-cache";
@@ -217,8 +216,11 @@
<ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
</div>
- <ContextSizeBadge contextSize={store.activeChat.currentContextSize} />
- <Composer onSend={handleSend} />
+ <Composer
+ onSend={handleSend}
+ contextSize={store.activeChat.currentContextSize}
+ status={store.activeChat.error ? "error" : "idle"}
+ />
</div>
<!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts
index 7c143d7..6a4bd38 100644
--- a/src/core/metrics/format.test.ts
+++ b/src/core/metrics/format.test.ts
@@ -2,8 +2,10 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
computeCachePct,
+ computeContextUsage,
computeExpectedCachePct,
computeTps,
+ formatCompactTokens,
formatContextSize,
viewCacheRate,
viewExpectedCache,
@@ -323,3 +325,45 @@ describe("formatContextSize", () => {
expect(formatContextSize(0)).toBe("0 tokens in context");
});
});
+
+describe("formatCompactTokens", () => {
+ it("renders sub-1k counts as-is", () => {
+ expect(formatCompactTokens(0)).toBe("0");
+ expect(formatCompactTokens(812)).toBe("812");
+ });
+
+ it("renders thousands with one decimal (rounded ≥100k)", () => {
+ expect(formatCompactTokens(12300)).toBe("12.3k");
+ expect(formatCompactTokens(150000)).toBe("150k");
+ });
+
+ it("renders millions with one decimal", () => {
+ expect(formatCompactTokens(1_200_000)).toBe("1.2M");
+ expect(formatCompactTokens(1_000_000)).toBe("1.0M");
+ });
+});
+
+describe("computeContextUsage", () => {
+ it("computes an unrounded clamped percent against the limit", () => {
+ const u = computeContextUsage(34102, 1_000_000);
+ expect(u.current).toBe(34102);
+ expect(u.max).toBe(1_000_000);
+ expect(u.percent).toBeCloseTo(3.4102, 4);
+ });
+
+ it("treats unknown contextSize as current 0", () => {
+ const u = computeContextUsage(undefined, 1_000_000);
+ expect(u.current).toBe(0);
+ expect(u.percent).toBe(0);
+ });
+
+ it("clamps percent to [0,100] and over-limit reads 100", () => {
+ expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100);
+ });
+
+ it("max null (no/zero limit) ⇒ percent null", () => {
+ expect(computeContextUsage(5000, null).percent).toBeNull();
+ expect(computeContextUsage(5000, 0).percent).toBeNull();
+ expect(computeContextUsage(5000, null).max).toBeNull();
+ });
+});
diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts
index d8dd2cc..4d69f25 100644
--- a/src/core/metrics/format.ts
+++ b/src/core/metrics/format.ts
@@ -28,6 +28,45 @@ export function formatContextSize(n: number | undefined): string {
return `${formatTokens(n)} tokens in context`;
}
+/**
+ * Compact token count for a slim status bar: `812`, `12.3k`, `1.2M`. Full
+ * thousands-separated numbers live elsewhere; this trades precision for width.
+ */
+export function formatCompactTokens(n: number): string {
+ if (n < 1000) return `${n}`;
+ if (n < 1_000_000) {
+ const k = n / 1000;
+ return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`;
+ }
+ const m = n / 1_000_000;
+ return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`;
+}
+
+/**
+ * Context-window occupancy: the current size against a max window limit.
+ *
+ * `current` is the latest turn's context size (0 when unknown); `max` is the
+ * model's window limit (or `null` when unknown). `percent` is
+ * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the
+ * precision) — so a few-thousand-token context against a 1,000,000 window still
+ * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator).
+ */
+export interface ContextUsage {
+ readonly current: number;
+ readonly max: number | null;
+ readonly percent: number | null;
+}
+
+export function computeContextUsage(
+ contextSize: number | undefined,
+ contextLimit: number | null | undefined,
+): ContextUsage {
+ const current = contextSize ?? 0;
+ const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
+ const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100));
+ return { current, max, percent };
+}
+
/** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */
export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null {
if (elapsedMs === undefined || elapsedMs <= 0) return null;
diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts
index 773d697..36cd96f 100644
--- a/src/core/metrics/index.ts
+++ b/src/core/metrics/index.ts
@@ -1,7 +1,10 @@
export {
+ type ContextUsage,
computeCachePct,
+ computeContextUsage,
computeExpectedCachePct,
computeTps,
+ formatCompactTokens,
formatContextSize,
viewCacheRate,
viewExpectedCache,
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index adfb670..18ed693 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -6,7 +6,6 @@ export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
export { default as Composer } from "./ui/Composer.svelte";
-export { default as ContextSizeBadge } from "./ui/ContextSizeBadge.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 3762340..24c2c19 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,7 +1,59 @@
<script lang="ts">
- let { onSend }: { onSend: (text: string) => void } = $props();
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+
+ // Placeholder context-window limit until the backend reports a real
+ // per-model max (see backend-handoff §3). Hardcoded to 1,000,000 tokens.
+ const MAX_CONTEXT = 1_000_000;
+ const MAX_LINES = 7;
+
+ let {
+ onSend,
+ contextSize = undefined,
+ status = "idle",
+ }: {
+ onSend: (text: string) => void;
+ // Current context occupancy (latest turn's contextSize), or `undefined`
+ // when unknown — the status bar then shows "— tokens", never 0%.
+ contextSize?: number | undefined;
+ // Coarse agent status for the status-bar icon.
+ status?: "idle" | "running" | "error";
+ } = $props();
let text = $state("");
+ let inputEl: HTMLTextAreaElement | undefined;
+
+ const hasText = $derived(text.trim().length > 0);
+ const usage = $derived(computeContextUsage(contextSize, MAX_CONTEXT));
+ const hasUsage = $derived(contextSize !== undefined);
+
+ // 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 resize(): void {
+ const el = inputEl;
+ if (!el) return;
+ el.style.height = "auto";
+ const style = getComputedStyle(el);
+ const lineHeight = Number.parseFloat(style.lineHeight) || 20;
+ const paddingY =
+ Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
+ const borderY =
+ Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
+ const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
+ const next = Math.min(el.scrollHeight, maxHeight);
+ el.style.height = `${next}px`;
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
+ }
+
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
function handleSubmit(): void {
const trimmed = text.trim();
@@ -18,16 +70,90 @@
}
</script>
-<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
- <textarea
- class="textarea textarea-bordered flex-1"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder="Type a message..."
- rows="3"
- aria-label="Message input"
- ></textarea>
- <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}>
- Send
- </button>
+<form
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
+>
+ <!-- Top bar: expanding textarea + send button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ placeholder="Type a message..."
+ rows="1"
+ aria-label="Message input"
+ ></textarea>
+ <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
+ Send
+ </button>
+ </div>
+
+ <!-- Bottom status bar: status icon · context-window fill · token count -->
+ <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
+ <span class="shrink-0">
+ {#if status === "running"}
+ <span class="loading loading-spinner loading-xs text-primary"></span>
+ {:else if status === "error"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-error"
+ aria-label="Error"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="8" x2="12" y2="12"></line>
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
+ </svg>
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ aria-label="Idle"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {/if}
+ </span>
+
+ {#if usage.percent !== null}
+ <progress
+ class="progress h-2 flex-1 {fillClass(usage.percent)}"
+ value={usage.percent}
+ max="100"
+ ></progress>
+ {:else}
+ <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
+ {/if}
+
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if hasUsage}
+ {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
+ class="text-base-content/40"
+ >
+ / {formatCompactTokens(usage.max)}</span
+ >{/if}
+ {#if usage.percent !== null}
+ <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
+ {/if}
+ {:else}
+ <span class="text-base-content/40">— tokens</span>
+ {/if}
+ </span>
+ </div>
</form>
diff --git a/src/features/chat/ui/ContextSizeBadge.svelte b/src/features/chat/ui/ContextSizeBadge.svelte
deleted file mode 100644
index 475d54f..0000000
--- a/src/features/chat/ui/ContextSizeBadge.svelte
+++ /dev/null
@@ -1,20 +0,0 @@
-<script lang="ts">
- import { formatContextSize } from "../../../core/metrics";
-
- let {
- contextSize,
- }: {
- // The conversation's current context size (tokens occupied), or `undefined`
- // ("unknown") when no finalized turn has reported one yet. Never `0` for the
- // unknown case — `formatContextSize` renders a placeholder instead.
- contextSize: number | undefined;
- } = $props();
-
- const label = $derived(formatContextSize(contextSize));
-</script>
-
-<div class="px-4 pb-1 text-xs opacity-60" aria-live="polite">
- <span title="The model's max context window is not reported yet — current usage only.">
- {label}
- </span>
-</div>