/** * @file RunSetup.tsx * @description The run-form pieces the rest of the app reuses (an attached * image's right-click offers View / Remove) — the pane's * own run form is gone, a new session starts from the workspace home's prompt * box (`WorkspaceHome.tsx`): * - `PromptEditor` — the prompt textarea with `/` (commands and skills, * Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter * newlines, and pasted, dropped or picked files (`pickFiles`, a composer's * + menu; images as thumbnails, opened full size in `ImageLightbox`, any * other as a name chip) attached by their saved paths (`POST /api/run/drop-file`, * remembered for the + menu's Recent uploads), and a panel row (Files, * Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`) * as an `@path` chip — a `bare` one takes a drop anywhere on its box; * a borderless (`bare`) one grows with its text up to 40vh; * - `CwdAutocomplete` — the folder field the Add-workspace modal uses; * - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`) * or a fixed one (an AI Proxy's models), plus Custom…; * - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's * "Sonnet 5.5 · Medium": the main models with their hints, the rest under * More models, Custom…, and the Effort slider (Faster → Smarter, a reset * back to the model's own default). * * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction, } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, ChevronRight, FileText, FolderOpen, GitBranch, Home, History as HistoryIcon, RotateCcw, X, } from "lucide-react"; import { api, RUN_EFFORT_CHOICES, RUN_MODEL_CHOICES, type EffortLevel, type ModelChoice, } from "../../lib/api"; import type { CwdSuggestion, CcMdItem } from "../../lib/api"; import type { LaneTool } from "../../lib/types"; import { Select, menuRoom } from "../Select"; import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu"; import { isSubmitEnter } from "../../lib/keys"; import { ImageLightbox } from "../ImageLightbox"; import { ATTACH_MIME, addAttachments, droppedAttachments, droppedSkill, SKILL_MIME, rememberUploads, } from "../../lib/promptAttach"; export interface SlashCommand { name: string; source: "project" | "user" | "plugin" | "builtin"; description?: string; } // Commands the CLI answers itself, and the skills bundled inside it. They are // typed into the pane like any other text, so they do run — they are just not // files anyone can list. // ponytail: a hand-kept list, so it lags new Claude Code releases; no file or // API lists the built-ins, add names here as they ship. const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ "help", "clear", "compact", "config", "model", "memory", "hooks", "cost", "usage", "context", "agents", "review", "permissions", "status", "init", "mcp", "plugin", "reload-plugins", "skills", "output-style", "resume", "rewind", "add-dir", "doctor", "export", "login", "logout", "statusline", "theme", "fast", "exit", "code-review", "security-review", "simplify", "loop", "schedule", "fewer-permission-prompts", "update-config", "claude-api", "run", ].map((name) => ({ name, source: "builtin" })); const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const; const SOURCE_TONE: Record = { project: "text-status-success border-status-success/40", user: "text-accent border-accent/40", plugin: "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; /** Files pasted or dropped so far (images shown as thumbnails); the caller * sends them with the text through `promptWithImages`. */ images: PromptImage[]; onImagesChange: Dispatch>; /** Borderless, for a composer box that draws its own frame — and grows * with its text, from `rows` up to 40% of the screen, then scrolls. */ bare?: boolean; /** An image upload started (true) or settled (false): the caller holds its * send button meanwhile, or the prompt would go out without the image. */ onBusyChange?: (busy: boolean) => void; /** The workspace (id, or a pane's run id) whose panes read the files. */ lane?: number | string; /** Set to a function that opens a file picker (a composer's + menu). */ pickFiles?: MutableRefObject<(() => void) | null>; } /** An attached file: its path on the server's machine, and a local preview * `url` when it is an image. */ export type PromptImage = { path: string; url?: string; name: string }; /** The prompt as sent: the text, then each file's saved path — which is how * the CLI is handed an image or a file. */ export function promptWithImages(text: string, images: PromptImage[]): string { return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); } /** A plain-text paste longer than this is attached as `pasted-text.txt`. */ export const PASTE_AS_FILE_CHARS = 5000; export function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5, slash, fileCwd, images, onImagesChange, bare, onBusyChange, lane, pickFiles, }: PromptEditorProps) { const { t } = useTranslation("run"); const taRef = useRef(null); // A composer grows with its text: reset to its rows, then take what the // text needs; `max-h-[40vh]` caps it and it scrolls from there. useLayoutEffect(() => { const ta = taRef.current; if (!bare || !ta) return; ta.style.height = "auto"; ta.style.height = `${ta.scrollHeight}px`; }, [bare, value]); 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[] })), api.ccConfig.plugins().catch(() => ({ plugins: [] })), ]).then(([c, sk, pl]) => { // A plugin's commands and skills are addressed as `/:`; one // installed at both user and project scope is listed once. const fromPlugins = pl.plugins .filter((p) => p.enabled !== false && p.contributes) .flatMap((p) => [...p.contributes!.items.commands, ...p.contributes!.items.skills].map( (it): SlashCommand => ({ name: `${p.name}:${it.name}`, source: "plugin", description: it.description || undefined, }) ) ) .filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i); if (live) setCommands( [...c.items, ...sk.items].map(toCmd).concat(fromPlugins, 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}`); } }; /** Replace the selection with `text`, caret after it. */ const insert = (text: string) => { const ta = taRef.current; const from = ta?.selectionStart ?? value.length; const to = ta?.selectionEnd ?? from; onChange(value.slice(0, from) + text + value.slice(to)); const pos = from + text.length; requestAnimationFrame(() => { taRef.current?.focus(); taRef.current?.setSelectionRange(pos, pos); }); }; // A pasted or dropped file is saved on the server's machine (the same // route the terminal's drop uses) and shown as a thumbnail or a name chip; // its path is added to the prompt only when it is sent (promptWithImages). const [upload, setUpload] = useState<"uploading" | "failed" | null>(null); const [viewing, setViewing] = useState(null); const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null); const uploading = upload === "uploading"; const busyRef = useRef(onBusyChange); busyRef.current = onBusyChange; useEffect(() => busyRef.current?.(uploading), [uploading]); const attachImages = (list: FileList | File[] | null | undefined) => { const picked = Array.from(list ?? []); if (picked.length === 0) return false; setUpload("uploading"); Promise.all(picked.map((f) => api.run.dropFile(f, lane))) .then((saved) => { setUpload(null); const added = picked.map((f, i) => ({ path: saved[i]!.path, url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined, name: f.name, })); rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url }))); onImagesChange((prev) => [...prev, ...added]); }) .catch(() => setUpload("failed")); return true; }; const removeImage = (img: PromptImage) => { if (img.url) URL.revokeObjectURL(img.url); onImagesChange((prev) => prev.filter((x) => x !== img)); }; const closeViewer = useCallback(() => setViewing(null), []); // The composer's own "Upload or add files" opens this picker; what it picks // takes the same path as a paste or a drop. const fileInput = useRef(null); if (pickFiles) pickFiles.current = () => fileInput.current?.click(); // A drop lands anywhere on the screen around the composer — the nearest // `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home), // else a `bare` editor's parent, the box every caller draws around it and // its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`) // becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading // `/command`, a file is uploaded. const rootRef = useRef(null); const dropRef = useRef<(e: DragEvent) => void>(() => {}); dropRef.current = (e) => { const atts = droppedAttachments(e.dataTransfer); const skill = droppedSkill(e.dataTransfer); if (atts.length) { e.preventDefault(); onImagesChange((prev) => addAttachments(prev, atts)); } else if (skill) { e.preventDefault(); onChange(`/${skill} ${value.replace(/^\s+/, "")}`); } else if (attachImages(e.dataTransfer?.files)) e.preventDefault(); }; useEffect(() => { const zone = rootRef.current?.closest("[data-prompt-drop]") ?? (bare ? rootRef.current?.parentElement : rootRef.current); if (!zone) return; const over = (e: DragEvent) => { const types = e.dataTransfer?.types ?? []; if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return; e.preventDefault(); e.dataTransfer!.dropEffect = "copy"; }; const drop = (e: DragEvent) => dropRef.current(e); zone.addEventListener("dragover", over); zone.addEventListener("drop", drop); return () => { zone.removeEventListener("dragover", over); zone.removeEventListener("drop", drop); }; }, [bare]); 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; } // Alt+Enter is a newline too, as in the CLI's own prompt; a textarea does // nothing on it, so insert it here. if (e.key === "Enter" && e.altKey) { e.preventDefault(); insert("\n"); return; } // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline. // isSubmitEnter guards IME input (Vietnamese/CJK), where Enter commits the // candidate. An image still uploading holds the send. if (isSubmitEnter(e)) { e.preventDefault(); if (!uploading) 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 (
{images.length > 0 && (
{images.map((img) => (
{img.url ? ( ) : ( {img.name} )}
))} {imageMenu && ( setImageMenu(null)}> { setImageMenu(null); setViewing(imageMenu.img); }} > {t("drop.view")} { setImageMenu(null); removeImage(imageMenu.img); }} > {t("drop.remove")} )}
)} {pickFiles && ( { attachImages(e.target.files); e.target.value = ""; }} /> )}