/** * @file RunConsole.tsx * @description The run console: everything that renders one run's live * conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx` * (where it was `RunSession`) so the Run page and the Workspace page can both * mount the same console. * * Three pieces live here: * - the envelope stream — user turns, assistant markdown, thinking, tool * uses and tool results, plus the result footer; * - the token / context-window meter rolled up from the envelope log; * - the prompt editor with its `/` slash-command and `@` file autocomplete. * * Props only: no API call except the `@`-file lookup the editor already owned, * and no stream subscription — `envelopes` arrives as a prop, so the page keeps * `useRunStream` and both pages share one subscription per run. * * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Play, Square, Send, RefreshCw, Sparkles, Terminal, CheckCircle2, XCircle, Clock, ExternalLink, Plus, AtSign, Slash as SlashIcon, FileCode, } from "lucide-react"; import { api } from "../../lib/api"; import type { RunHandle, RunMode } from "../../lib/api"; import { MarkdownContent } from "../conversation/MarkdownContent"; import type { AssistantMessage, ContentBlock, Envelope, ResultEnvelope, SystemInit, UserMessage, } from "../../hooks/useRunStream"; // ── Token / context-window meter ────────────────────────────────────── interface TokenStats { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; costUsd: number | null; contextWindow: number | null; } const DEFAULT_CONTEXT_WINDOW = 200_000; /** * Roll up token usage from the in-memory envelope log. Pulls the latest * `usage` block from `stream_event/message_delta` events (live numbers * during streaming) and the canonical `result.usage` envelope when the run * finishes. The 1M-context Opus variants emit `contextWindow` in * `result.modelUsage`; we surface that to size the meter correctly. */ function computeTokens(envelopes: Envelope[]): TokenStats { // Per-turn rolling counters (overwritten as each new turn's message_start // arrives). The latest message_start's input + cache numbers reflect the // current turn's prompt size, which is the right thing to show in the // "Context" gauge. let inputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; // Output is summed across all completed turns plus the running current // turn - claude reports output_tokens as a per-turn (per-message) number, // not cumulative. Without summing, the meter resets every time a new // `message_start` arrives. let completedOutputTokens = 0; let currentTurnOutput = 0; let costUsd: number | null = null; let contextWindow: number | null = null; let sawMessageStart = false; // While we don't have an authoritative output count from message_delta / // result, estimate from the char count in the streaming assistant block // so the meter ticks live as text appears (claude doesn't emit usage on // every text_delta). let outputAuthoritativeForCurrent = false; let streamingChars = 0; const commitTurn = () => { completedOutputTokens += currentTurnOutput; currentTurnOutput = 0; outputAuthoritativeForCurrent = false; streamingChars = 0; }; for (const env of envelopes) { const e = env as { type?: string }; if (e.type === "stream_event") { const ev = ( env as { event?: { type?: string; usage?: Record; message?: { usage?: Record }; }; } ).event; if (!ev) continue; if (ev.type === "message_start") { // Roll the previous turn's running output into the cumulative total // before resetting for this new turn. if (sawMessageStart) commitTurn(); sawMessageStart = true; const u = ev.message?.usage; if (u) { inputTokens = u.input_tokens ?? 0; cacheReadTokens = u.cache_read_input_tokens ?? 0; cacheCreationTokens = u.cache_creation_input_tokens ?? 0; currentTurnOutput = u.output_tokens ?? 0; } } else if (ev.type === "message_delta") { const u = ev.usage; if (u && typeof u.output_tokens === "number") { // Authoritative running output for the current turn. currentTurnOutput = u.output_tokens; outputAuthoritativeForCurrent = true; } } } else if (e.type === "result") { const r = env as ResultEnvelope & { modelUsage?: Record< string, { contextWindow?: number; inputTokens?: number; outputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number; } >; }; // Result is end-of-run: commit any in-flight current turn first. if (currentTurnOutput > 0) { completedOutputTokens += currentTurnOutput; currentTurnOutput = 0; outputAuthoritativeForCurrent = false; } if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd; if (r.modelUsage && typeof r.modelUsage === "object") { for (const m of Object.values(r.modelUsage)) { if (!m || typeof m !== "object") continue; if (typeof m.contextWindow === "number") contextWindow = m.contextWindow; // Prefer modelUsage's per-model totals when available - these are // the canonical per-run numbers. if (typeof m.inputTokens === "number") inputTokens = m.inputTokens; if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens; if (typeof m.cacheCreationInputTokens === "number") cacheCreationTokens = m.cacheCreationInputTokens; if (typeof m.outputTokens === "number") { // modelUsage.outputTokens is the run total for this model - use // it as the canonical cumulative output, replacing our running // sum. completedOutputTokens = m.outputTokens; } } } } else if (e.type === "system" && (env as SystemInit).model) { // Heuristic: 1M Opus has [1m] in the model id const model = (env as SystemInit).model || ""; if (/\[1m\]/i.test(model)) contextWindow = 1_000_000; } else if (e.type === "assistant") { const msg = ( env as { message?: { _streaming?: boolean; content?: ContentBlock[]; usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; }; }; } ).message; if (msg?._streaming) { streamingChars = 0; const blocks = msg.content || []; for (const b of blocks) { if (b.type === "text") { streamingChars += ((b as { text?: string }).text || "").length; } else if (b.type === "thinking") { streamingChars += ((b as { thinking?: string }).thinking || "").length; } } } else if (msg?.usage) { // Transcript-derived seed envelopes carry usage but have no // `message.id` (transcriptToEnvelopes doesn't set one). Live-stream // canonical envelopes always have an id assigned by message_start, // and their tokens are already counted via stream_event / commitTurn // - folding them here would double-count. Use id-presence as the // discriminator: no id → transcript-seeded → fold; id → live → skip. const hasId = !!(msg as { id?: string }).id; if (!hasId) { const u = msg.usage; if (typeof u.input_tokens === "number") inputTokens = u.input_tokens; if (typeof u.cache_read_input_tokens === "number") { cacheReadTokens = u.cache_read_input_tokens; } if (typeof u.cache_creation_input_tokens === "number") { cacheCreationTokens = u.cache_creation_input_tokens; } if (typeof u.output_tokens === "number") { completedOutputTokens += u.output_tokens; } } } } } // While we don't have an authoritative output count for the current turn, // surface the char-based estimate so the meter ticks live during streaming. if (!outputAuthoritativeForCurrent && streamingChars > 0) { const estimate = Math.ceil(streamingChars / 4); if (estimate > currentTurnOutput) currentTurnOutput = estimate; } return { inputTokens, outputTokens: completedOutputTokens + currentTurnOutput, cacheReadTokens, cacheCreationTokens, costUsd, contextWindow, }; } function formatNum(n: number): string { if (n < 1000) return String(n); if (n < 100_000) return (n / 1000).toFixed(1) + "k"; if (n < 1_000_000) return Math.round(n / 1000) + "k"; return (n / 1_000_000).toFixed(2) + "M"; } function TokenMeter({ stats }: { stats: TokenStats }) { const { t } = useTranslation("run"); const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens; const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW; const pct = Math.min(100, Math.round((total / cap) * 100)); // Colour is the whole warning mechanism here - the meter is one status line, // so there is no room for a bar plus five labelled figures. const tone = pct >= 95 ? "text-red-300" : pct >= 80 ? "text-amber-300" : "text-gray-400"; return (
── {`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`} ↑{formatNum(stats.outputTokens)} {stats.cacheReadTokens > 0 && ( ⚡{formatNum(stats.cacheReadTokens)} )} {stats.costUsd != null && ( ${stats.costUsd.toFixed(4)} )}
); } // ── Slash commands (built-in list + user/project/plugin from API) ───── export interface SlashCommand { name: string; description?: string; source: "builtin" | "user" | "project" | "plugin"; filePath?: string; } // Built-in commands the CLI handles itself. We surface them in autocomplete // with a "CLI only" tag so users know they won't actually execute when // sent over stream-json stdin. export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ { name: "help", description: "List available commands", source: "builtin" }, { name: "clear", description: "Clear the conversation", source: "builtin" }, { name: "config", description: "Open the interactive config menu", source: "builtin" }, { name: "model", description: "Change model mid-session", source: "builtin" }, { name: "compact", description: "Compact the conversation context", source: "builtin" }, { name: "memory", description: "Edit CLAUDE.md", source: "builtin" }, { name: "hooks", description: "Manage hooks", source: "builtin" }, { name: "cost", description: "Show session cost", source: "builtin" }, { name: "agents", description: "List subagents", source: "builtin" }, { name: "review", description: "Review current changes", source: "builtin" }, { name: "release-notes", description: "Show CC release notes", source: "builtin" }, { name: "permissions", description: "Edit permission rules", source: "builtin" }, { name: "status", description: "Show session status", source: "builtin" }, { name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" }, { name: "login", description: "Sign in to Claude", source: "builtin" }, { name: "logout", description: "Sign out", source: "builtin" }, { name: "exit", description: "Exit the session", source: "builtin" }, { name: "mcp", description: "Manage MCP servers", source: "builtin" }, { name: "plugin", description: "Manage plugins", source: "builtin" }, { name: "output-style", description: "Change output style", source: "builtin" }, ]; function commandSourceLabel(s: SlashCommand["source"]): string { return s === "builtin" ? "CLI only" : s === "user" ? "user" : s === "project" ? "project" : "plugin"; } function commandSourceTone(s: SlashCommand["source"]): string { return s === "builtin" ? "bg-gray-500/10 text-gray-400 border-gray-500/30" : s === "user" ? "bg-sky-500/10 text-sky-300 border-sky-500/30" : s === "project" ? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" : "bg-violet-500/10 text-violet-300 border-violet-500/30"; } // ── Autocomplete dropdown for slash + @-files ───────────────────────── interface AutocompleteState { kind: "slash" | "file"; query: string; // The position in the textarea where the trigger character starts (so we // can replace from there to the cursor on selection). triggerStart: number; cursor: number; } /** * Tiered slash-command match scoring. Higher = more relevant. Returns 0 for * "doesn't match, hide it." Tiers in descending priority: * 1. Exact name match * 2. Name starts with query * 3. Word boundary (after `-` / `_` / `.`) starts with query * 4. Name contains query (earlier index ranks higher) * 5. Subsequence match across the name * 6. Description contains query - only when query is at least 3 chars, * so a single keystroke can't drag in tangential descriptions. */ function scoreSlashMatch(name: string, description: string | undefined, q: string): number { if (!q) return 1; const n = name.toLowerCase(); if (n === q) return 1000; if (n.startsWith(q)) return 800 - Math.min(n.length, 100); const parts = n.split(/[-_.\s]/); if (parts.some((p) => p.startsWith(q))) { return 600 - Math.min(n.length, 100); } const idx = n.indexOf(q); if (idx >= 0) return 400 - Math.min(idx, 100); if (subsequenceMatch(n, q)) return 200; if (q.length >= 3) { const d = (description || "").toLowerCase(); if (d.includes(q)) return 100; } return 0; } function subsequenceMatch(s: string, q: string): boolean { let i = 0; for (let k = 0; k < s.length && i < q.length; k++) { if (s[k] === q[i]) i++; } return i === q.length; } function detectAutocomplete(value: string, cursor: number): AutocompleteState | null { // Look back from the cursor to find the active "token". A token starts at // the beginning of the line / after whitespace and continues until cursor. let start = cursor; while (start > 0) { const ch = value[start - 1]; if (!ch || /\s/.test(ch)) break; start--; } const tok = value.slice(start, cursor); if (tok.startsWith("/") && tok.length >= 1) { // Only trigger for slash if it's at line start OR right after whitespace. // The detection above already enforces that. return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor }; } if (tok.startsWith("@") && tok.length >= 1) { return { kind: "file", query: tok.slice(1), triggerStart: start, cursor }; } return null; } interface PromptEditorProps { value: string; onChange: (s: string) => void; onSubmit?: () => void; placeholder?: string; rows?: number; slashCommands: SlashCommand[]; fileCwd: string; autoFocus?: boolean; } export function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 4, slashCommands, fileCwd, autoFocus, }: PromptEditorProps) { const { t } = useTranslation("run"); const taRef = useRef(null); const [state, setState] = useState(null); const [active, setActive] = useState(0); const [fileSuggestions, setFileSuggestions] = useState([]); const fileFetchRef = useRef<{ q: string; t: number } | null>(null); // Slash filter - tiered scoring so prefix matches outrank arbitrary // substring hits, name matches outrank description matches, and shorter // names break ties when scores are equal. const slashItems = useMemo(() => { if (!state || state.kind !== "slash") return [] as SlashCommand[]; const q = state.query.toLowerCase(); const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const; if (!q) { return [...slashCommands].sort( (a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name) ); } type Scored = { cmd: SlashCommand; score: number }; const scored: Scored[] = []; for (const cmd of slashCommands) { const score = scoreSlashMatch(cmd.name, cmd.description, q); if (score > 0) scored.push({ cmd, score }); } return scored .sort( (a, b) => b.score - a.score || sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] || a.cmd.name.length - b.cmd.name.length || a.cmd.name.localeCompare(b.cmd.name) ) .map((s) => s.cmd); }, [state, slashCommands]); // File fetch (debounced) useEffect(() => { if (!state || state.kind !== "file") return; const ts = Date.now(); fileFetchRef.current = { q: state.query, t: ts }; const tid = setTimeout(() => { if (fileFetchRef.current?.t !== ts) return; api.run .files(fileCwd, state.query) .then((r) => setFileSuggestions(r.items)) .catch(() => setFileSuggestions([])); }, 120); return () => clearTimeout(tid); }, [state, fileCwd]); const items = state?.kind === "file" ? fileSuggestions : slashItems; useEffect(() => { if (active >= items.length) setActive(Math.max(0, items.length - 1)); }, [items.length, active]); const insertChoice = (choice: SlashCommand | string) => { if (!state || !taRef.current) return; const ta = taRef.current; const before = value.slice(0, state.triggerStart); const after = value.slice(state.cursor); let inserted: string; if (state.kind === "slash") { const c = choice as SlashCommand; inserted = `/${c.name}`; } else { inserted = `@${choice as string}`; } const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after; onChange(next); setState(null); setActive(0); // Re-position cursor after the inserted token + a trailing space requestAnimationFrame(() => { const pos = before.length + inserted.length + 1; ta.focus(); ta.setSelectionRange(pos, pos); }); }; const onKeyDown = (e: React.KeyboardEvent) => { if (state && items.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(items.length - 1, a + 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); return; } if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); const choice = items[active]; if (choice) insertChoice(choice); return; } if (e.key === "Tab") { e.preventDefault(); const choice = items[active]; if (choice) insertChoice(choice); return; } if (e.key === "Escape") { e.preventDefault(); setState(null); return; } } if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); onSubmit?.(); } }; const onTextareaInput = (e: React.ChangeEvent) => { onChange(e.target.value); const ta = e.target; const next = detectAutocomplete(ta.value, ta.selectionStart || 0); setState(next); if (!next) setActive(0); }; const onSelect = (e: React.SyntheticEvent) => { const ta = e.currentTarget; const next = detectAutocomplete(ta.value, ta.selectionStart || 0); setState(next); }; return (