feat: chat context-usage display, /compact slash command, auto-compact

Adds visibility into how much of the model context window the chat agent
is using and a way to free space when it fills up.

Backend
- New ContextUsage{used_chars, budget_chars} returned from chat_send
  alongside messages (return type ChatTurnResult). Computed by running
  build_history once at end of turn and counting char bytes — same data
  path as the actual LLM call, so the count is exact for the chosen
  budget unit.
- CONTEXT_BUDGET_CHARS = 24,000 (~6-8K tokens). Tuned for Ollama
  defaults; can be exposed via AiSettings later.
- New chat_compact Tauri command. Splits the thread at the last user
  turn, LLM-summarises everything before it (3-6 bullet points,
  language-aware, < 800 chars), and returns a thread of
  [Assistant("📋 Compacted N messages: …"), <last_user_turn?>]. The
  recent user turn is preserved untouched so the agent can keep
  answering it.
- render_thread_for_summary skips QueryResult.rows entirely so a single
  large run_query can't blow the summariser's context.
- 3 new unit tests (last_user_turn_index, render skipping rows, empty
  thread no-op).

Frontend
- ChatPanel header gets a usage badge: progress bar + `Xk / Yk tok ·
  P%`, color-coded green (<30%) / muted (<60%) / amber (<85%) / red
  (≥85%). Tooltip explains and nudges /compact when ≥60%.
- Compact button next to Clear in the header.
- Slash commands in ChatComposer: /compact, /clear.
- Empty-state shows the slash-command hint.
- Auto-compact: if the previous turn pushed usage past 85% AND the
  thread has more than one message, the next user turn first runs
  chat_compact transparently before chat_send. The compaction surfaces
  as a visible Assistant("📋 Compacted …") message so the user can see
  what the agent kept.
- app-store gets chatUsage map per tab + replaceChatThread + setChatUsage
  actions; closeTab and clearChatThread clean up usage too.

Verification: cargo check clean, cargo test --lib 53 pass (+3),
tsc --noEmit clean, vitest run 20 pass.
This commit is contained in:
2026-05-06 19:44:11 +03:00
parent b41c84dab8
commit 27fed0dbf8
8 changed files with 454 additions and 31 deletions

View File

@@ -3,9 +3,15 @@ import { useChat } from "@/hooks/use-chat";
import { ChatComposer } from "./ChatComposer";
import { ChatMessageView } from "./ChatMessageView";
import { Button } from "@/components/ui/button";
import { Eraser, Sparkles } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Eraser, Sparkles, Layers } from "lucide-react";
import { useAppStore } from "@/stores/app-store";
import { useAiSettings } from "@/hooks/use-ai";
import type { ContextUsage } from "@/types";
interface Props {
tabId: string;
@@ -13,7 +19,7 @@ interface Props {
}
export function ChatPanel({ tabId, connectionId }: Props) {
const { messages, pending, send, clear } = useChat(tabId, connectionId);
const { messages, pending, usage, send, clear, compact } = useChat(tabId, connectionId);
const dbFlavors = useAppStore((s) => s.dbFlavors);
const flavor = dbFlavors[connectionId];
const { data: aiSettings } = useAiSettings();
@@ -33,21 +39,39 @@ export function ChatPanel({ tabId, connectionId }: Props) {
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-primary/70" />
<span className="font-medium">AI Assistant</span>
{flavor && <span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">· {flavor}</span>}
{flavor && (
<span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">
· {flavor}
</span>
)}
{aiSettings?.model && (
<span className="text-[10px] text-muted-foreground/60">· {aiSettings.model}</span>
)}
</div>
<Button
size="xs"
variant="ghost"
onClick={clear}
disabled={messages.length === 0 || pending}
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Eraser className="h-3 w-3" />
Clear
</Button>
<div className="flex items-center gap-2">
<UsageBadge usage={usage} />
<Button
size="xs"
variant="ghost"
onClick={() => compact()}
disabled={messages.length === 0 || pending}
title="Summarize older messages to free context (also: type /compact)"
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Layers className="h-3 w-3" />
Compact
</Button>
<Button
size="xs"
variant="ghost"
onClick={clear}
disabled={messages.length === 0 || pending}
className="h-6 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Eraser className="h-3 w-3" />
Clear
</Button>
</div>
</div>
<div ref={scrollerRef} className="min-h-0 flex-1 overflow-y-auto">
@@ -69,7 +93,7 @@ export function ChatPanel({ tabId, connectionId }: Props) {
disabled={pending || !aiReady}
placeholder={
aiReady
? "Ask in plain language. The agent will browse schema and run read-only queries."
? "Ask in plain language. /compact to summarise, /clear to wipe."
: "Configure an AI model in Settings to enable chat."
}
/>
@@ -78,6 +102,50 @@ export function ChatPanel({ tabId, connectionId }: Props) {
);
}
function UsageBadge({ usage }: { usage: ContextUsage | undefined }) {
if (!usage || usage.budget_chars === 0) return null;
const ratio = Math.min(usage.used_chars / usage.budget_chars, 1.5);
const usedTok = Math.round(usage.used_chars / 3 / 100) / 10; // ~k-tokens with 1 decimal
const budgetTok = Math.round(usage.budget_chars / 3 / 100) / 10;
const percent = Math.round(ratio * 100);
let toneClass = "text-muted-foreground/70";
if (ratio >= 0.85) toneClass = "text-destructive";
else if (ratio >= 0.6) toneClass = "text-amber-500";
else if (ratio >= 0.3) toneClass = "text-emerald-500/80";
const trackClass = "h-1.5 w-12 overflow-hidden rounded-full bg-muted";
let fillClass = "bg-emerald-500/70";
if (ratio >= 0.85) fillClass = "bg-destructive";
else if (ratio >= 0.6) fillClass = "bg-amber-500";
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-[10px]">
<div className={trackClass}>
<div
className={fillClass}
style={{
height: "100%",
width: `${Math.min(ratio, 1) * 100}%`,
}}
/>
</div>
<span className={toneClass}>
{usedTok}k / {budgetTok}k tok · {percent}%
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
Approximate context usage. {usage.used_chars.toLocaleString()} chars sent to the model
last turn out of {usage.budget_chars.toLocaleString()} budget.
{ratio >= 0.6 && " Type /compact (or click Compact) to summarise older history."}
</TooltipContent>
</Tooltip>
);
}
function PendingIndicator() {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
@@ -102,6 +170,11 @@ function EmptyState({ aiReady, flavor }: { aiReady: boolean; flavor: string | un
? `Connected to ${flavor ?? "database"}. Try: "How many rows in each table?", "Top 10 customers by total spend", "Show me last week's orders".`
: "Open Settings → AI to choose an Ollama model. Tusk will then assist with natural-language queries."}
</p>
{aiReady && (
<p className="text-[11px] text-muted-foreground/60">
Slash commands: <code>/compact</code> · <code>/clear</code>
</p>
)}
</div>
</div>
);