/** * @file RunSetup.tsx * @description The pre-run setup panel: everything the user picks before a run * exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so * the Run page and the Workspace page can both mount the same panel. * * What lives here: * - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd / * model / effort fields, plus per-tool advanced fields (permission-mode for * Claude; sandbox and approval for Codex), plus the concurrency hint and * the Start button. Its disabled state is driven by the `binaryFound` prop * for the lane's tool, so a missing binary is a surfaced state here rather * than a probe of its own. Picking a session to resume starts that run * immediately — a resume carries its own history, so there is nothing to * type first; the prompt stays optional for resumes and required for fresh * runs. Codex lanes show a "resume --last" button instead of the session * picker (no sessions in the dashboard database to list). * - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`, * `ModelPicker`, and the small `Field` layout helper. * * Props only for `RunSetup`: no `/stage` call, no lane API call, and no run * lifecycle — the page owns `api.run.start` and hands the result back through * `onStart`. `SessionPicker` keeps the one API call it already owned (listing * past sessions to resume) and `PromptEditor` keeps its `@`-file lookup. * * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Play, RefreshCw, AlertCircle, ChevronDown, ShieldAlert, X, FolderOpen, GitBranch, Home, History as HistoryIcon, Search, RotateCcw, Lock, } from "lucide-react"; import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api"; import type { CwdSuggestion, DashboardRunHistoryItem, RunListResponse, EffortLevel, PermissionMode, RunStartArgs, CodexSandbox, CodexApproval, CcMdItem, } from "../../lib/api"; import type { Session, LaneTool } from "../../lib/types"; import { LANE_TOOL_LABEL } from "../../lib/types"; import { Select } from "../Select"; export interface SlashCommand { name: string; source: "project" | "user" | "builtin"; description?: string; } // Commands the CLI answers itself. They are typed into the pane like any // other text, so they do run — they are just not files anyone can list. const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ "help", "clear", "compact", "config", "model", "memory", "hooks", "cost", "agents", "review", "permissions", "status", "init", "mcp", "plugin", "output-style", "exit", ].map((name) => ({ name, source: "builtin" })); const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const; const SOURCE_TONE: Record = { project: "text-status-success border-status-success/40", user: "text-accent border-accent/40", builtin: "text-fg-muted border-border", }; /** Tiered match: exact > prefix > word prefix > substring > subsequence > * description (3+ chars only, so one keystroke drags nothing tangential in). */ 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); if (n.split(/[-_.:\s]/).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); let i = 0; for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++; if (i === q.length) return 200; if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100; return 0; } interface Trigger { kind: "slash" | "file"; query: string; /** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */ start: number; cursor: number; } /** The `/…` or `@…` token the cursor is in, if any. A token starts at the * line start or after whitespace, so `a/b` and `me@x.io` never trigger. */ function detectTrigger(value: string, cursor: number): Trigger | null { let start = cursor; while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--; const tok = value.slice(start, cursor); if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor }; if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor }; return null; } interface PromptEditorProps { value: string; onChange: (s: string) => void; onSubmit: () => void; placeholder: string; rows?: number; /** Offer slash commands. Off for codex: these are Claude Code's. */ slash: boolean; /** Where `@` looks for files and project commands/skills are read from. */ fileCwd: string; } function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5, slash, fileCwd, }: PromptEditorProps) { const { t } = useTranslation("run"); const taRef = useRef(null); const [trigger, setTrigger] = useState(null); const [active, setActive] = useState(0); const [files, setFiles] = useState([]); const [commands, setCommands] = useState(null); const [commandsCwd, setCommandsCwd] = useState(fileCwd); if (commandsCwd !== fileCwd) { setCommandsCwd(fileCwd); setCommands(null); } const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind; // Commands and skills are read the first time `/` is typed for this cwd — // two directory walks nobody should pay for on every render of the form. useEffect(() => { if (kind !== "slash" || commands) return; let live = true; const toCmd = (c: CcMdItem): SlashCommand => ({ name: c.name, source: c.scope, description: c.frontmatter?.description || c.preview.slice(0, 80), }); Promise.all([ api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), ]).then(([c, sk]) => { if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS)); }); return () => { live = false; }; }, [kind, commands, fileCwd]); // Files: debounced, and only the newest query may land. const query = trigger?.query ?? ""; useEffect(() => { if (kind !== "file") return; let live = true; const tid = window.setTimeout(() => { api.run .files(fileCwd, query) .then((r) => live && setFiles(r.items)) .catch(() => live && setFiles([])); }, 120); return () => { live = false; window.clearTimeout(tid); }; }, [kind, query, fileCwd]); const slashItems = useMemo(() => { if (kind !== "slash" || !commands) return []; const q = query.toLowerCase(); return commands .map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) })) .filter((x) => x.score > 0) .sort( (a, b) => b.score - a.score || SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] || a.cmd.name.localeCompare(b.cmd.name) ) .map((x) => x.cmd); }, [kind, commands, query]); const count = kind === "file" ? files.length : slashItems.length; const current = Math.min(active, Math.max(0, count - 1)); const pick = (token: string) => { if (!trigger) return; const before = value.slice(0, trigger.start); const after = value.slice(trigger.cursor); const sep = after.startsWith(" ") ? "" : " "; onChange(before + token + sep + after); setTrigger(null); setActive(0); const pos = before.length + token.length + 1; requestAnimationFrame(() => { taRef.current?.focus(); taRef.current?.setSelectionRange(pos, pos); }); }; const pickAt = (i: number) => { if (kind === "file") { const f = files[i]; if (f) pick(`@${f}`); } else { const c = slashItems[i]; if (c) pick(`/${c.name}`); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (kind && count > 0) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const step = e.key === "ArrowDown" ? 1 : -1; setActive(Math.min(count - 1, Math.max(0, current + step))); return; } if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); pickAt(current); return; } } if (kind && e.key === "Escape") { e.preventDefault(); setTrigger(null); return; } // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline. // isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate. if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); onSubmit(); } }; const retrack = (ta: HTMLTextAreaElement) => { setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0)); }; const rowClass = (i: number) => `w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`; return (