/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md). * * A session's command line starts with the program that runs it, and that * program is nearly always one of a handful of agents or a shell. Rendering it * as its own mark buys back the whole fragment's width — the arguments are * dropped, so a card says which tool runs, not how it was invoked. * * Recognition is deliberately narrow: an unrecognised program keeps its name * as text, so the fragment never renders a mark that stands for the wrong tool. */ /** Program name (already basenamed and lowercased) → icon id. * * `claude-code-acp` — the daemon's actual structured-session child — is * deliberately absent: the daemon launches it directly and ignores the * session's command, so it is never itself a valid user-facing launch form. */ const BY_PROGRAM: Record = { claude: "claude", "claude-code": "claude", codex: "codex", opencode: "opencode", gemini: "gemini", aider: "aider", cursor: "cursor", "cursor-agent": "cursor", pi: "pi", bash: "shell", zsh: "shell", sh: "shell", fish: "shell", dash: "shell", ksh: "shell", nu: "shell", xonsh: "shell", }; /** Human name behind an icon id, for the tooltip and the aria label. */ export const TOOL_LABELS: Record = { claude: "Claude Code", codex: "Codex", opencode: "opencode", gemini: "Gemini CLI", aider: "Aider", cursor: "Cursor", pi: "pi", shell: "Shell", }; export interface CommandParts { /** Icon id, or `null` when the program is not one we have a mark for. */ tool: string | null; /** The program the command runs, basenamed and stripped of its arguments — * what a card shows when there is no icon to show instead. */ program: string; } /** Split a command line into the tool its icon stands for and the program * name — basenamed and stripped of its arguments. Leading `FOO=bar` * assignments and wrapper forms (`sudo`, `env -i`, `npx`, `pnpm dlx`, …) are * looked through, and quoting/escaping is honoured (never evaluated — no * `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a shell would need real * grammar for (pipelines, `&&`/`;`, redirections, substitution, an * unterminated quote) falls back to the naive first word rather than * guessing through it (#199). Mirrors the Rust `command_program`/ * `command_tool` in `crates/maestro-protocol/src/display.rs`. * * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised * `hx src/` keeps `hx` as the program with no tool. */ export function splitCommand(command: string): CommandParts { const words = tokenize(command); if (words === null) { const program = legacyFirstWord(command); return { tool: null, program }; } const i = programIndex(words); if (i >= words.length) return { tool: null, program: command.trim() }; const program = basename(words[i].text); return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program }; } /** The naive "first whitespace word, basenamed" reading used only when * {@link tokenize} rejects the line outright — still something sane to * show, never a wrapper/tool match to act on. */ function legacyFirstWord(command: string): string { const first = command.trim().split(/\s+/).filter(Boolean)[0]; return first === undefined ? command.trim() : basename(first); } /** One token from {@link tokenize}: its dequoted text, and — when both the * name and `=` were entirely unquoted/unescaped in the source — the index of * the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`) * makes the word an executable named `FOO=1`; quoting/escaping only the value * preserves the assignment, matching Bash's assignment-word grammar (#199). */ interface Word { text: string; plainEq: number | null; } /** `FOO=bar`, the shell's own prefix for one command's environment — * recognised only when the `=` came from plain, unquoted/unescaped source * text (#199). */ function isEnvAssignment(word: Word): boolean { if (word.plainEq === null) return false; return /^[A-Za-z_][A-Za-z0-9_]*$/.test(word.text.slice(0, word.plainEq)); } /** Walk `words` past leading `FOO=bar` assignments and known wrapper forms * (interleaved in any order/count) to the index of the actual program. An * assignment run is only consumed at the very start of the line or right * after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) — * `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than * skipping to `claude`, since `command`/`nohup` just exec their next argument * as the program, they don't setenv from an assignment-shaped one (#199). */ function programIndex(words: Word[]): number { const texts = words.map((w) => w.text); let i = 0; let assignmentAllowed = true; for (;;) { if (assignmentAllowed) { while (i < words.length && isEnvAssignment(words[i])) { i += 1; } } if (i >= words.length) break; const wrapper = basename(texts[i]).toLowerCase(); const w = WRAPPER_RESOLVERS[wrapper]; if (!w) break; const next = w.resolve(texts, i); // Not a form of the wrapper we recognise (an unknown option, or — for // `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper // word itself is the program, not a launcher. if (next === null || next <= i) break; i = next; assignmentAllowed = w.acceptsAssignment; } return i; } function basename(word: string): string { const cut = word.lastIndexOf("/"); return cut >= 0 ? word.slice(cut + 1) : word; } /** Word-split `command` the way a shell would (honouring `'…'`, `"…"` and * `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return * `null` the moment it needs real shell grammar this resolver doesn't * attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or * substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command * separator, like `;` — #199), or an unterminated quote/escape. Never * expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A * backslash immediately before a newline, quoted or not, is a line * continuation: removed entirely rather than embedding the newline or * ending the word (#199). */ function tokenize(command: string): Word[] | null { const words: Word[] = []; let current = ""; let hasCurrent = false; let plainEq: number | null = null; let assignmentNameIsPlain = true; let i = 0; const n = command.length; while (i < n) { const c = command[i]; if (c === " " || c === "\t" || c === "\r") { if (hasCurrent) { words.push({ text: current, plainEq }); current = ""; hasCurrent = false; plainEq = null; assignmentNameIsPlain = true; } i += 1; } else if (c === "\n") { return null; } else if ("|&;<>()`".includes(c)) { return null; } else if (c === "$" && command[i + 1] === "(") { return null; } else if (c === "'") { hasCurrent = true; if (plainEq === null) assignmentNameIsPlain = false; i += 1; const close = command.indexOf("'", i); if (close === -1) return null; // unterminated single quote current += command.slice(i, close); i = close + 1; } else if (c === '"') { hasCurrent = true; if (plainEq === null) assignmentNameIsPlain = false; i += 1; for (;;) { if (i >= n) return null; // unterminated double quote const c2 = command[i]; if (c2 === '"') { i += 1; break; } if (c2 === "\\") { const escaped = command[i + 1]; if (escaped === undefined) return null; // trailing backslash if (escaped === "\n") { i += 2; // line continuation, removed entirely continue; } current += '"\\$`'.includes(escaped) ? escaped : "\\" + escaped; i += 2; } else { current += c2; i += 1; } } } else if (c === "\\") { const escaped = command[i + 1]; if (escaped === undefined) return null; // trailing backslash if (escaped === "\n") { i += 2; // line continuation, removed entirely continue; } hasCurrent = true; if (plainEq === null) assignmentNameIsPlain = false; current += escaped; i += 2; } else { hasCurrent = true; if (c === "=" && plainEq === null && assignmentNameIsPlain) { plainEq = current.length; } current += c; i += 1; } } if (hasCurrent) words.push({ text: current, plainEq }); return words; } /** A wrapper's known `-`/`--` options: which take no value, and which * consume either a following word or a `--flag=value` as theirs. A * `-`-prefixed word this table doesn't know halts resolution rather than * guessing past it (#199) — callers read that as {@link consumeOptions} * returning `null`. */ interface OptionTable { flags: Set; valued: Set; /** Whether this wrapper's short options may be combined (`-iv`) or take * an attached value (`-uroot`), the way real getopt-based tools do * (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/`dlx`) * don't get this — guessing at bundling they don't actually support * would resolve through options they'd reject outright. */ bundlesShortOptions: boolean; } /** `-iv` (bundled boolean short flags) or `-uroot` (a valued short option * with its value attached) — getopt's traditional short-option forms. * `false` the moment a character isn't a short option this table knows, or * a valued one ends the word with no attached value left to be its operand * (#199: rejected rather than guessed to come from the next word instead). */ function matchesShortCluster(opts: OptionTable, word: string): boolean { let idx = 1; while (idx < word.length) { const short = "-" + word[idx]; if (opts.flags.has(short)) { idx += 1; continue; } if (opts.valued.has(short)) { return idx + 1 < word.length; } return false; } return true; } /** Consume `opts`'s options starting at `words[i]`, returning the index just * past them (which may equal `i` if there are none) — or `null` on an * option this table doesn't recognise, or a valued option missing its * operand. */ function consumeOptions(opts: OptionTable, words: string[], i: number): number | null { for (;;) { const word = words[i]; if (word === undefined) return i; if (word === "--") return i + 1; if (word === "-" || !word.startsWith("-")) return i; const eq = word.indexOf("="); if (eq >= 0) { if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) { i += 1; continue; } return null; } if (opts.flags.has(word)) { i += 1; continue; } if (opts.valued.has(word)) { const value = words[i + 1]; if (value === undefined || value.length === 0 || value.startsWith("-")) return null; i += 2; continue; } if ( opts.bundlesShortOptions && !word.startsWith("--") && word.length > 2 && matchesShortCluster(opts, word) ) { i += 1; continue; } return null; } } const NO_OPTIONS: OptionTable = { flags: new Set(), valued: new Set(), bundlesShortOptions: false }; const ENV_OPTS: OptionTable = { flags: new Set(["-i", "--ignore-environment", "-0", "--null", "-v", "--debug"]), valued: new Set(["-u", "--unset", "-C", "--chdir"]), bundlesShortOptions: true, }; const SUDO_OPTS: OptionTable = { flags: new Set([ "-i", "--login", "-n", "--non-interactive", "-E", "--preserve-env", "-H", "--set-home", "-A", "--askpass", "-b", "--background", "-k", "--reset-timestamp", "-K", "--remove-timestamp", "-S", "--stdin", ]), valued: new Set(["-u", "--user", "-g", "--group"]), bundlesShortOptions: true, }; /** `-v`/`-V` only print what would run — they never execute it — so * resolving through them would claim a program runs that `command` never * launches (#199). Only `-p`, which still executes normally, is safe to * look through. */ const COMMAND_BUILTIN_OPTS: OptionTable = { flags: new Set(["-p"]), valued: new Set(), bundlesShortOptions: true, }; /** The bash *keyword* `time` only ever parses `-p` (POSIX output format); * `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU * `/usr/bin/time`, a different program a bare `time` word doesn't * necessarily name. Assuming them here would be guessing which `time` * resolves (#199). */ function resolveTime(words: string[], i: number): number | null { let next = i + 1; if (words[next] === "-p") next += 1; if (words[next] === "--") next += 1; const command = words[next]; return command?.startsWith("-") ? null : next; } /** `-c`/`--call` embeds a whole shell command string rather than naming the * program positionally — resolving past it would mean guessing at a * program buried inside that string, so it's left unrecognised and * resolution stops at `npx` (#199). */ const NPX_OPTS: OptionTable = { flags: new Set(["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"]), valued: new Set(["-p", "--package", "--shell"]), bundlesShortOptions: false, }; const PNPM_OPTS: OptionTable = { flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]), // `--package`/`--package=value` is documented in this pre-`dlx` position // (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself // also accepts (`PNPM_DLX_OPTS`). valued: new Set(["-C", "--dir", "--filter", "--package"]), bundlesShortOptions: false, }; const YARN_OPTS: OptionTable = { flags: new Set(["--silent", "-s", "--no-progress", "--non-interactive"]), valued: new Set(["--cwd"]), bundlesShortOptions: false, }; const PNPM_DLX_OPTS: OptionTable = { flags: new Set(["-s", "--silent"]), valued: new Set(["--package"]), bundlesShortOptions: false, }; const YARN_DLX_OPTS: OptionTable = { flags: new Set(["-q", "--quiet"]), valued: new Set(["-p", "--package"]), bundlesShortOptions: false, }; const BUN_X_OPTS: OptionTable = { flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]), valued: new Set(["-p", "--package"]), bundlesShortOptions: false, }; /** `pnpm`/`yarn` only look through to the package behind an explicit `dlx` * — without it, the next word is a `package.json` script name, not a * package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx` * itself takes launcher-specific options too, consumed before the executable. */ function resolveDlx( opts: OptionTable, dlxOpts: OptionTable, words: string[], i: number, ): number | null { const j = consumeOptions(opts, words, i + 1); if (j === null) return null; if (words[j] !== "dlx") return null; return consumeOptions(dlxOpts, words, j + 1); } /** `bun x ` is bun's `dlx`/`npx` equivalent; `bun run