b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
1098 lines
40 KiB
TypeScript
1098 lines
40 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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<string, number>;
|
||
message?: { usage?: Record<string, number> };
|
||
};
|
||
}
|
||
).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 (
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border px-3 py-1.5 font-mono text-[11.5px] text-fg-muted">
|
||
<span className="select-none opacity-60" aria-hidden>
|
||
──
|
||
</span>
|
||
<span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span>
|
||
<span title={t("tokens.output")}>↑{formatNum(stats.outputTokens)}</span>
|
||
{stats.cacheReadTokens > 0 && (
|
||
<span className="text-status-success/70" title={t("tokens.cacheRead")}>
|
||
⚡{formatNum(stats.cacheReadTokens)}
|
||
</span>
|
||
)}
|
||
{stats.costUsd != null && (
|
||
<span className="text-fg-secondary">${stats.costUsd.toFixed(4)}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<HTMLTextAreaElement | null>(null);
|
||
const [state, setState] = useState<AutocompleteState | null>(null);
|
||
const [active, setActive] = useState(0);
|
||
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
|
||
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<HTMLTextAreaElement>) => {
|
||
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<HTMLTextAreaElement>) => {
|
||
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<HTMLTextAreaElement>) => {
|
||
const ta = e.currentTarget;
|
||
const next = detectAutocomplete(ta.value, ta.selectionStart || 0);
|
||
setState(next);
|
||
};
|
||
|
||
return (
|
||
<div className="relative">
|
||
<textarea
|
||
ref={taRef}
|
||
autoFocus={autoFocus}
|
||
value={value}
|
||
onChange={onTextareaInput}
|
||
onKeyDown={onKeyDown}
|
||
onSelect={onSelect}
|
||
placeholder={placeholder}
|
||
rows={rows}
|
||
spellCheck={false}
|
||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed"
|
||
/>
|
||
{state && (
|
||
<div className="absolute z-30 left-0 right-0 bottom-full mb-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted inline-flex items-center gap-1.5">
|
||
{state.kind === "slash" ? (
|
||
<>
|
||
<SlashIcon className="w-3 h-3" />
|
||
{t("autocomplete.slashHint")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<AtSign className="w-3 h-3" />
|
||
{t("autocomplete.fileHint")}
|
||
</>
|
||
)}
|
||
</div>
|
||
{items.length === 0 ? (
|
||
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("autocomplete.noMatches")}</div>
|
||
) : state.kind === "slash" ? (
|
||
(items as SlashCommand[]).map((c, idx) => (
|
||
<button
|
||
key={`${c.source}:${c.name}`}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => insertChoice(c)}
|
||
onMouseEnter={() => setActive(idx)}
|
||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||
idx === active ? "bg-accent/15" : "hover:bg-surface-3"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||
<span
|
||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`}
|
||
>
|
||
{commandSourceLabel(c.source)}
|
||
</span>
|
||
</div>
|
||
{c.description && (
|
||
<div className="text-[10.5px] text-fg-muted truncate mt-0.5">{c.description}</div>
|
||
)}
|
||
</button>
|
||
))
|
||
) : (
|
||
(items as string[]).map((p, idx) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => insertChoice(p)}
|
||
onMouseEnter={() => setActive(idx)}
|
||
className={`w-full text-left px-3 py-1.5 transition-colors flex items-center gap-2 ${
|
||
idx === active ? "bg-accent/15" : "hover:bg-surface-3"
|
||
}`}
|
||
>
|
||
<FileCode className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||
<span className="font-mono text-[11px] text-fg-secondary truncate">{p}</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Live run session ─────────────────────────────────────────────────
|
||
|
||
interface RunConsoleProps {
|
||
handle: RunHandle;
|
||
envelopes: Envelope[];
|
||
mode: RunMode;
|
||
isLive: boolean;
|
||
hasFinished: boolean;
|
||
followUp: string;
|
||
onFollowUpChange: (s: string) => void;
|
||
busy: "start" | "send" | "stop" | "attach" | null;
|
||
onSend: () => void;
|
||
onStop: () => void;
|
||
onNewRun: () => void;
|
||
slashCommands: SlashCommand[];
|
||
}
|
||
|
||
export function RunConsole(props: RunConsoleProps) {
|
||
const { t } = useTranslation("run");
|
||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||
const [pinnedToBottom, setPinnedToBottom] = useState(true);
|
||
|
||
// Track whether the user has scrolled away - if so, don't yank them back.
|
||
useEffect(() => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
const onScroll = () => {
|
||
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight);
|
||
setPinnedToBottom(distance < 80);
|
||
};
|
||
el.addEventListener("scroll", onScroll, { passive: true });
|
||
return () => el.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
// Auto-scroll on new envelopes if the user is pinned to bottom.
|
||
useEffect(() => {
|
||
if (!pinnedToBottom) return;
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
el.scrollTop = el.scrollHeight;
|
||
}, [props.envelopes.length, pinnedToBottom]);
|
||
|
||
const result = useMemo(
|
||
() => props.envelopes.find((e) => e.type === "result") as ResultEnvelope | undefined,
|
||
[props.envelopes]
|
||
);
|
||
const init = useMemo(
|
||
() => props.envelopes.find((e) => e.type === "system") as SystemInit | undefined,
|
||
[props.envelopes]
|
||
);
|
||
const tokenStats = useMemo(() => computeTokens(props.envelopes), [props.envelopes]);
|
||
|
||
return (
|
||
// flex-1 + min-h-0 lets us fill the viewport-locked parent, while the
|
||
// inner stream area's overflow-auto keeps long chats scrollable inside
|
||
// the panel - never the page.
|
||
<div className="flex flex-col flex-1 min-h-0 rounded-lg border border-border bg-black/40">
|
||
{/* Toolbar - one dense strip, mono so it reads as a terminal chrome */}
|
||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 font-mono text-[11.5px]">
|
||
<StatusPill status={props.handle.status} />
|
||
<ModeBadge mode={props.mode} />
|
||
{init?.model && <span className="text-fg-muted">{init.model}</span>}
|
||
{props.handle.sessionId && (
|
||
<span className="truncate text-fg-muted">{props.handle.sessionId.slice(0, 8)}</span>
|
||
)}
|
||
<div className="flex-1" />
|
||
{props.isLive && (
|
||
<button
|
||
onClick={props.onStop}
|
||
disabled={props.busy === "stop"}
|
||
className="inline-flex items-center gap-1 text-status-danger hover:text-status-danger disabled:opacity-60 transition-colors"
|
||
>
|
||
<Square className="w-3 h-3" />
|
||
{props.busy === "stop" ? t("actions.stopping") : t("actions.stop")}
|
||
</button>
|
||
)}
|
||
{props.handle.sessionId && (
|
||
<Link
|
||
to={`/sessions/${encodeURIComponent(props.handle.sessionId)}`}
|
||
className="inline-flex items-center gap-1 text-fg-secondary hover:text-fg-primary transition-colors"
|
||
>
|
||
<ExternalLink className="w-3 h-3" />
|
||
{t("actions.viewSession")}
|
||
</Link>
|
||
)}
|
||
{/* Always available - lets the user leave a running run in the
|
||
background and start another one. The original is still in the
|
||
Active Runs dropdown for re-attach. */}
|
||
<button
|
||
onClick={props.onNewRun}
|
||
className="inline-flex items-center gap-1 text-accent hover:brightness-125 transition-colors"
|
||
>
|
||
<Plus className="w-3 h-3" />
|
||
{t("actions.newRun")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Stream area */}
|
||
<div ref={scrollRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto px-3 py-2">
|
||
{props.envelopes.length === 0 && <EmptyStream isLive={props.isLive} />}
|
||
{props.envelopes.map((env, i) => (
|
||
<EnvelopeRow key={i} envelope={env} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Live token / context-window meter */}
|
||
<TokenMeter stats={tokenStats} />
|
||
|
||
{/* Footer banner once finished */}
|
||
{props.hasFinished && result && <ResultFooter result={result} />}
|
||
|
||
{/* Follow-up input - only for conversation mode while live */}
|
||
{props.mode === "conversation" && props.isLive && (
|
||
<div className="border-t border-border px-3 py-2">
|
||
<PromptEditor
|
||
value={props.followUp}
|
||
onChange={props.onFollowUpChange}
|
||
onSubmit={props.onSend}
|
||
placeholder={t("fields.promptPlaceholder")}
|
||
rows={2}
|
||
slashCommands={props.slashCommands}
|
||
fileCwd={props.handle.cwd}
|
||
/>
|
||
<div className="mt-2 flex items-center justify-between">
|
||
<div className="text-[10px] text-fg-muted">{t("hint.shortcut")} · / · @</div>
|
||
<button
|
||
onClick={props.onSend}
|
||
disabled={!props.followUp.trim() || props.busy === "send"}
|
||
className="inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
<Send className="w-3 h-3" />
|
||
{props.busy === "send" ? t("actions.sending") : t("actions.send")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
function EmptyStream({ isLive }: { isLive: boolean }) {
|
||
const { t } = useTranslation("run");
|
||
if (isLive) {
|
||
return (
|
||
<div className="text-center py-12 text-fg-muted flex flex-col items-center gap-2">
|
||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||
<span className="text-xs">{t("status.spawning")}</span>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="text-center py-12 flex flex-col items-center gap-2">
|
||
<Sparkles className="w-6 h-6 text-fg-muted" />
|
||
<div className="text-sm font-medium text-fg-secondary">{t("empty.title")}</div>
|
||
<div className="text-xs text-fg-muted max-w-md">{t("empty.body")}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StatusPill({ status }: { status: string }) {
|
||
const { t } = useTranslation("run");
|
||
const idle = {
|
||
color: "bg-surface-3 text-fg-secondary border-border",
|
||
icon: Clock as typeof Play,
|
||
};
|
||
const config: Record<string, { color: string; icon: typeof Play }> = {
|
||
spawning: {
|
||
color: "bg-status-warning/15 text-status-warning border-status-warning/30",
|
||
icon: RefreshCw,
|
||
},
|
||
running: {
|
||
color: "bg-status-success/15 text-status-success border-status-success/30",
|
||
icon: Sparkles,
|
||
},
|
||
completed: {
|
||
color: "bg-status-success/15 text-status-success border-status-success/30",
|
||
icon: CheckCircle2,
|
||
},
|
||
error: {
|
||
color: "bg-status-danger/15 text-status-danger border-status-danger/30",
|
||
icon: XCircle,
|
||
},
|
||
killed: { color: "bg-surface-4/15 text-fg-secondary border-border-light/30", icon: Square },
|
||
abandoned: {
|
||
color: "bg-orange-500/10 text-orange-300 border-orange-500/30",
|
||
icon: Square,
|
||
},
|
||
idle,
|
||
};
|
||
const c = config[status] ?? idle;
|
||
const Icon = c.icon;
|
||
const animate = status === "spawning" || status === "running";
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium border ${c.color}`}
|
||
>
|
||
<Icon
|
||
className={`w-3 h-3 ${animate ? (status === "spawning" ? "animate-spin" : "animate-pulse") : ""}`}
|
||
/>
|
||
{t(`status.${status}`)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function ModeBadge({ mode }: { mode: RunMode }) {
|
||
const { t } = useTranslation("run");
|
||
return (
|
||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border inline-flex items-center gap-1">
|
||
{mode === "conversation" ? (
|
||
<Terminal className="w-3 h-3" />
|
||
) : (
|
||
<Sparkles className="w-3 h-3" />
|
||
)}
|
||
{t(`mode.${mode}`)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── Envelope rendering ───────────────────────────────────────────────
|
||
|
||
function EnvelopeRow({ envelope }: { envelope: Envelope }) {
|
||
if (!envelope || typeof envelope !== "object") return null;
|
||
switch (envelope.type) {
|
||
case "user":
|
||
return <UserTurn env={envelope as UserMessage} />;
|
||
case "assistant":
|
||
return <AssistantTurn env={envelope as AssistantMessage} />;
|
||
case "system":
|
||
return null; // init metadata is shown in the toolbar
|
||
case "result":
|
||
return null; // shown in the footer
|
||
case "stream_event":
|
||
return null; // kept in state for token accounting only - never rendered
|
||
default:
|
||
// Unknown envelope: render compact JSON for transparency
|
||
return <UnknownTurn env={envelope} />;
|
||
}
|
||
}
|
||
|
||
function extractText(content: ContentBlock[] | string | undefined): string {
|
||
if (!content) return "";
|
||
if (typeof content === "string") return content;
|
||
return content
|
||
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
|
||
.map((b) => b.text)
|
||
.join("\n");
|
||
}
|
||
|
||
function UserTurn({ env }: { env: UserMessage }) {
|
||
const content = env.message?.content;
|
||
|
||
// Tool results live inside user.message.content as { type: "tool_result", ... }.
|
||
const toolResults = Array.isArray(content)
|
||
? (content.filter((b) => b.type === "tool_result") as Extract<
|
||
ContentBlock,
|
||
{ type: "tool_result" }
|
||
>[])
|
||
: [];
|
||
const text = extractText(content);
|
||
|
||
if (toolResults.length > 0 && !text) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{toolResults.map((tr, i) => (
|
||
<ToolResultBlock key={i} result={tr} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex gap-2 font-mono text-[12.5px] leading-relaxed">
|
||
<span className="select-none text-indigo-400" aria-hidden>
|
||
>
|
||
</span>
|
||
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-fg-secondary">
|
||
{text || "-"}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AssistantTurn({ env }: { env: AssistantMessage }) {
|
||
const content = env.message?.content;
|
||
const blocks = Array.isArray(content)
|
||
? content
|
||
: content
|
||
? [{ type: "text", text: content } as ContentBlock]
|
||
: [];
|
||
const text = blocks
|
||
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
|
||
.map((b) => b.text)
|
||
.join("\n");
|
||
const toolUses = blocks.filter(
|
||
(b): b is ContentBlock & { type: "tool_use" } => b.type === "tool_use"
|
||
);
|
||
const thinking = blocks.filter(
|
||
(b): b is ContentBlock & { type: "thinking" } => b.type === "thinking"
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
{thinking.map((th, i) => (
|
||
<ThinkingBlock key={`th-${i}`} text={th.thinking || ""} />
|
||
))}
|
||
{text && (
|
||
// The gutter glyph is the only speaker marker - no avatar, no label
|
||
// row. Prose keeps its markdown rendering; only the chrome is gone.
|
||
<div className="flex gap-2">
|
||
<span
|
||
className="select-none font-mono text-[12.5px] leading-relaxed text-accent"
|
||
aria-hidden
|
||
>
|
||
›
|
||
</span>
|
||
<div className="min-w-0 flex-1 text-[13px] leading-relaxed text-fg-secondary prose-claude">
|
||
<MarkdownContent text={text} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{toolUses.map((tu) => (
|
||
<ToolUseBlock key={tu.id} toolUse={tu} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ThinkingBlock({ text }: { text: string }) {
|
||
const { t } = useTranslation("run");
|
||
const [open, setOpen] = useState(false);
|
||
if (!text) return null;
|
||
return (
|
||
<div className="font-mono text-[12px] leading-relaxed">
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex items-center gap-2 text-violet-400/70 hover:text-violet-300 transition-colors"
|
||
>
|
||
<span className="select-none" aria-hidden>
|
||
{open ? "\u25be" : "\u25b8"}
|
||
</span>
|
||
<span className="italic">{t("events.thinking")}</span>
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-0.5 border-l border-violet-500/25 pl-3 ml-1.5 whitespace-pre-wrap break-words text-violet-200/60">
|
||
{text}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolUseBlock({ toolUse }: { toolUse: Extract<ContentBlock, { type: "tool_use" }> }) {
|
||
const { t } = useTranslation("run");
|
||
const [open, setOpen] = useState(false);
|
||
const summary = describeToolInput(toolUse.input);
|
||
return (
|
||
<div className="font-mono text-[12px] leading-relaxed">
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
|
||
title={t("events.tool")}
|
||
>
|
||
<span className="select-none text-status-warning" aria-hidden>
|
||
●
|
||
</span>
|
||
<span className="font-medium text-status-warning">{toolUse.name}</span>
|
||
{summary && <span className="truncate text-fg-muted">{summary}</span>}
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-status-warning/25 pl-3 whitespace-pre-wrap break-words text-fg-secondary">
|
||
{JSON.stringify(toolUse.input, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "tool_result" }> }) {
|
||
const { t } = useTranslation("run");
|
||
const [open, setOpen] = useState(false);
|
||
const text =
|
||
typeof result.content === "string"
|
||
? result.content
|
||
: Array.isArray(result.content)
|
||
? result.content
|
||
.map((c) => {
|
||
if (c == null) return "";
|
||
if (typeof c === "string") return c;
|
||
const obj = c as { text?: string };
|
||
return obj.text || JSON.stringify(c);
|
||
})
|
||
.join("\n")
|
||
: JSON.stringify(result.content);
|
||
const lines = text.split("\n").length;
|
||
const tone = result.is_error ? "text-status-danger" : "text-status-success/90";
|
||
// First line is the useful one nine times out of ten, so it doubles as the
|
||
// collapsed summary instead of a generic "tool result" label.
|
||
const firstLine = text.split("\n").find((l) => l.trim()) || "";
|
||
return (
|
||
<div className={`font-mono text-[12px] leading-relaxed ${tone}`}>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
|
||
>
|
||
<span className="select-none opacity-70" aria-hidden>
|
||
{open ? "\u2514" : "\u2514"}
|
||
</span>
|
||
<span className="truncate opacity-90">{firstLine || t("events.toolResult")}</span>
|
||
{lines > 1 && <span className="ml-auto shrink-0 text-[11px] opacity-50">{lines}L</span>}
|
||
</button>
|
||
{open && (
|
||
<pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-current/25 pl-3 whitespace-pre-wrap break-words opacity-80">
|
||
{text}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UnknownTurn({ env }: { env: Envelope }) {
|
||
return (
|
||
<details className="font-mono text-[11.5px] leading-relaxed text-fg-muted">
|
||
<summary className="cursor-pointer">? {(env.type as string) || "unknown"}</summary>
|
||
<pre className="mt-0.5 ml-1.5 max-h-48 overflow-auto border-l border-border pl-3 whitespace-pre-wrap break-words text-fg-muted">
|
||
{JSON.stringify(env, null, 2)}
|
||
</pre>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function describeToolInput(input: unknown): string {
|
||
if (!input || typeof input !== "object") return "";
|
||
const obj = input as Record<string, unknown>;
|
||
// Common Claude Code tool inputs: file_path, path, command, pattern…
|
||
for (const k of ["file_path", "path", "command", "pattern", "url", "name"]) {
|
||
const v = obj[k];
|
||
if (typeof v === "string" && v) return v.length > 80 ? v.slice(0, 80) + "…" : v;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function ResultFooter({ result }: { result: ResultEnvelope }) {
|
||
const { t } = useTranslation("run");
|
||
const isError = result.is_error;
|
||
const parts: string[] = [];
|
||
if (typeof result.duration_ms === "number")
|
||
parts.push(`${(result.duration_ms / 1000).toFixed(1)}s`);
|
||
if (typeof result.total_cost_usd === "number") parts.push(`$${result.total_cost_usd.toFixed(4)}`);
|
||
if (typeof result.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns);
|
||
return (
|
||
<div
|
||
className={`border-t border-border px-3 py-1.5 font-mono text-[11.5px] ${
|
||
isError ? "text-status-danger" : "text-status-success/90"
|
||
}`}
|
||
>
|
||
<span className="select-none opacity-60" aria-hidden>
|
||
──{" "}
|
||
</span>
|
||
{isError ? t("status.error") : t("status.completed")}
|
||
{parts.length > 0 && <span className="text-fg-muted"> · {parts.join(" · ")}</span>}
|
||
</div>
|
||
);
|
||
}
|