This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/toolIcon.ts
T

502 lines
18 KiB
TypeScript

/** 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<string, string> = {
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<string, string> = {
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. */
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
while (i < words.length) {
if (isEnvAssignment(words[i])) {
i += 1;
continue;
}
const wrapper = basename(texts[i]).toLowerCase();
const resolver = WRAPPER_RESOLVERS[wrapper];
if (!resolver) break;
const next = resolver(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;
}
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<string>;
valued: Set<string>;
/** 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 <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
* (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
* subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
* the executable. */
function resolveBun(words: string[], i: number): number | null {
if (words[i + 1] !== "x") return null;
return consumeOptions(BUN_X_OPTS, words, i + 2);
}
/** A resolver for one wrapper form: given the tokenized command and the
* index of the wrapper word itself, the index of the word after it (past
* its options and any subcommand it requires), or `null` if this occurrence
* isn't a form we resolve through. */
type WrapperResolver = (words: string[], i: number) => number | null;
const WRAPPER_RESOLVERS: Record<string, WrapperResolver> = {
env: (words, i) => consumeOptions(ENV_OPTS, words, i + 1),
sudo: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
command: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
nohup: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
time: resolveTime,
npx: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
pnpm: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
yarn: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
bun: resolveBun,
bunx: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
uvx: (_words, i) => i + 1,
};
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
const long = k % 2 === 0;
const tip = long ? 1.1 : 3.4;
const waist = long ? 6.3 : 6.6;
const half = long ? 1.05 : 0.85;
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
}).join("");
return `<g fill="#d97757">${rays}</g>`;
}
const SVGS: Record<string, string> = {
claude: burst(),
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
// wide apart: three fatter loops merge into a blob at 13px.
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
</g>`,
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
// An isometric cube, the shape Cursor's mark is built from.
cursor: `<g fill="currentColor">
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
</g>`,
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 4 L6.4 8 L2.6 12"/>
<path d="M8.4 12.6 H13.4"/>
</g>`,
opencode: monogram("oc"),
aider: monogram("ai"),
pi: monogram("π"),
};
/** Tools whose own mark is a wordmark get their initials instead — inventing
* a logo for them would be worse than saying which letters they start with.
* No chip around the letters: a border this small eats the height the letters
* need to stay apart. */
function monogram(text: string): string {
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
}
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
export function toolIconSvg(tool: string): string | null {
const body = SVGS[tool];
if (!body) return null;
const label = TOOL_LABELS[tool] ?? tool;
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
}