/** 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. */ /** Command wrappers that run another program: the tool is what follows. */ const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]); /** Program name (already basenamed and lowercased) → icon id. */ 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. * * `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 = command.trim().split(/\s+/).filter(Boolean); let i = 0; // Leading `FOO=bar` assignments and wrappers stand in front of the real // program; `pnpm dlx x` / `yarn dlx x` need one more word skipped. while (i < words.length) { const word = words[i]; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i += 1; continue; } const wrapper = basename(word).toLowerCase(); if (WRAPPERS.has(wrapper)) { i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1; continue; } break; } if (i >= words.length) return { tool: null, program: command.trim() }; const program = basename(words[i]); return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program }; } function basename(word: string): string { const cut = word.lastIndexOf("/"); return cut >= 0 ? word.slice(cut + 1) : word; } /** 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 ``; }).join(""); return `${rays}`; } const SVGS: Record = { 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: ` `, gemini: ``, // An isometric cube, the shape Cursor's mark is built from. cursor: ` `, shell: ` `, 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}`; } /** The `` 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 `${label}${body}`; }