From 2756730aeba633c3cc331500bcfb9a67d85dc892 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Tue, 2 Jun 2026 14:39:20 +0900
Subject: Add status bar beneath chat input with send/stop button and context
 display

Restructure ChatInput into two stacked bars:
- Top bar: auto-resizing textarea + fixed-width send/stop button that
  morphs in place (no layout shift) across idle/generating states.
- Bottom bar: agent status icon, context-window fill bar, and compact
  token count + percent (inert bar when model max is unknown).

Wire contextLimit prop from App.svelte into ChatInput, reusing the
shared computeContextUsage helper so it agrees with the sidebar.
---
 .../frontend/src/lib/components/ChatInput.svelte   | 145 ++++++++++++++++-----
 1 file changed, 110 insertions(+), 35 deletions(-)

(limited to 'packages/frontend/src/lib/components/ChatInput.svelte')

diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 0c99078..95712c7 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -1,6 +1,9 @@
 <script lang="ts">
+import { computeContextUsage } from "../context-window.js";
 import { tabStore } from "../tabs.svelte.js";
 
+const { contextLimit = null }: { contextLimit?: number | null } = $props();
+
 const MAX_LINES = 7;
 
 let inputEl: HTMLTextAreaElement | undefined;
@@ -8,6 +11,36 @@ let inputValue = $state("");
 
 const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
 const tabId = $derived(tabStore.activeTab?.id ?? "");
+const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
+
+const isRunning = $derived(agentStatus === "running");
+const hasText = $derived(inputValue.trim().length > 0);
+// While generating with an empty box, the primary action is "stop". With text
+// in the box, it stays "send" (the message is queued behind the live turn).
+const showStop = $derived(isRunning && !hasText);
+
+const usage = $derived(computeContextUsage(cacheStats, contextLimit));
+const hasUsage = $derived((cacheStats?.last ?? null) !== null);
+
+// As the window fills, escalate color: calm → warning → danger. Mirrors the
+// Context Window sidebar view so the two displays agree.
+function fillClass(pct: number): string {
+	if (pct >= 90) return "progress-error";
+	if (pct >= 70) return "progress-warning";
+	return "progress-success";
+}
+
+// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live
+// in the sidebar's Context Window panel.
+function fmtCompact(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`;
+}
 
 $effect(() => {
 	inputEl?.focus();
@@ -49,45 +82,87 @@ function submit() {
 	inputValue = "";
 	tabStore.sendMessage(text);
 }
+
+function primaryAction() {
+	if (showStop) {
+		tabStore.stopGeneration(tabId);
+		return;
+	}
+	submit();
+}
 </script>
 
-<div class="flex items-end gap-2 p-3">
-	{#if agentStatus === "running"}
+<div class="flex flex-col">
+	<!-- Top bar: expanding textarea + send/stop action -->
+	<div class="flex items-end gap-2 px-3 pt-3 pb-2">
+		<textarea
+			bind:this={inputEl}
+			bind:value={inputValue}
+			rows="1"
+			placeholder="Type a message..."
+			class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
+			onkeydown={handleKeydown}
+			oninput={resize}
+		></textarea>
+		<!-- Single fixed-width button across all states so the layout never
+		     shifts when it morphs between Send and Stop. -->
 		<button
 			type="button"
-			class="btn btn-ghost gap-1 btn-sm lg:btn-xs"
-			onclick={() => tabStore.stopGeneration(tabId)}
-			title="Stop generation"
+			class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
+			disabled={!showStop && !hasText}
+			onclick={primaryAction}
+			title={showStop ? "Stop generation" : "Send message"}
 		>
-			<span class="loading loading-spinner loading-sm text-primary" style="pointer-events: auto"></span>
-			<span class="text-xs">Stop</span>
+			{#if showStop}
+				<span class="loading loading-spinner loading-sm"></span>
+				Stop
+			{:else}
+				Send
+			{/if}
 		</button>
-	{:else if agentStatus === "idle"}
-		<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="w-5 h-5 text-success shrink-0 mb-2">
-			<polyline points="20 6 9 17 4 12"></polyline>
-		</svg>
-	{:else if agentStatus === "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="w-5 h-5 text-error shrink-0 mb-2">
-			<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>
-	{/if}
-	<textarea
-		bind:this={inputEl}
-		bind:value={inputValue}
-		rows="1"
-		placeholder="Type a message..."
-		class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
-		onkeydown={handleKeydown}
-		oninput={resize}
-	></textarea>
-	<button
-		type="button"
-		class="btn btn-primary"
-		disabled={!inputValue.trim()}
-		onclick={submit}
-	>
-		Send
-	</button>
+	</div>
+
+	<!-- Bottom bar: status icon · context progress · token count -->
+	<div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50">
+		<!-- Status icon -->
+		<span class="shrink-0">
+			{#if agentStatus === "running"}
+				<span class="loading loading-spinner loading-xs text-primary"></span>
+			{:else if agentStatus === "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="w-4 h-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="w-4 h-4 text-success" aria-label="Idle">
+					<polyline points="20 6 9 17 4 12"></polyline>
+				</svg>
+			{/if}
+		</span>
+
+		<!-- Context-window fill bar -->
+		{#if usage.percent !== null}
+			<progress
+				class="progress flex-1 h-2 {fillClass(usage.percent)}"
+				value={usage.percent}
+				max="100"
+			></progress>
+		{:else}
+			<!-- Model's max context is unknown → inert, disabled bar. -->
+			<progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress>
+		{/if}
+
+		<!-- Context size + percent -->
+		<span class="shrink-0 font-mono whitespace-nowrap">
+			{#if hasUsage}
+				{fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(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>
 </div>
-- 
cgit v1.2.3

