502 lines
18 KiB
TypeScript
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>`;
|
|
}
|