The command fragment named the full command line, so a session spawned with `claude --dangerously-skip-permissions` spent the whole line on a flag. Cards now name the program only — its icon in the web client, its bare name in the TUI — and keep the full command line in the tooltip. Adds `maestro_protocol::display::command_program`, the Rust counterpart of the web's `splitCommand`.
141 lines
5.4 KiB
TypeScript
141 lines
5.4 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.
|
|
*/
|
|
|
|
/** 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<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.
|
|
*
|
|
* `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 `<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>`;
|
|
}
|