diff --git a/client/src/components/run/RunConsole.tsx b/client/src/components/run/RunConsole.tsx deleted file mode 100644 index e8f5342..0000000 --- a/client/src/components/run/RunConsole.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -/** - * @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-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary"; - 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-surface-4/10 text-fg-secondary border-border-light/30" - : s === "user" - ? "bg-sky-500/10 text-sky-300 border-sky-500/30" - : s === "project" - ? "bg-status-success/10 text-status-success border-status-success/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 ( -
-