feat(maestro): show only the program on session cards
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`.
This commit is contained in:
+5
-4
@@ -56,12 +56,13 @@ export interface AttachInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Header echoes the sidebar: the command's leading program becomes its tool
|
||||
* icon when we have a mark for it, remaining words stay as text. */
|
||||
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||
* mark for it and stays as its bare name when we do not. Arguments are
|
||||
* dropped, as on the card. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, rest } = splitCommand(command);
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? rest : command;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,15 +209,16 @@ describe("session cards (#107)", () => {
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the command's arguments as text beside the icon", () => {
|
||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||
command: "claude --resume",
|
||||
command: "claude --dangerously-skip-permissions",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("keeps an unknown command as plain text", () => {
|
||||
@@ -228,7 +229,8 @@ describe("session cards (#107)", () => {
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
|
||||
+6
-5
@@ -96,13 +96,14 @@ function blockedFragment(blockers: InTheWay): string {
|
||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
|
||||
* we have a mark for it, and only the remaining words stay as text. The full
|
||||
* command line stays in the tooltip either way. */
|
||||
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||
* stays as its bare name when we do not. The full command line, arguments and
|
||||
* all, stays in the tooltip either way. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, rest } = splitCommand(command);
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? rest : command;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps everything after the program as text", () => {
|
||||
expect(splitCommand("claude --resume -p hi")).toEqual({
|
||||
it("drops everything after the program", () => {
|
||||
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
|
||||
tool: "claude",
|
||||
rest: "--resume -p hi",
|
||||
program: "claude",
|
||||
});
|
||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
|
||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
|
||||
});
|
||||
|
||||
it("looks through paths, env assignments and wrappers", () => {
|
||||
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
||||
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
||||
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
||||
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
|
||||
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
|
||||
});
|
||||
|
||||
it("leaves an unrecognised program as text, mark-less", () => {
|
||||
it("leaves an unrecognised program as its bare name, mark-less", () => {
|
||||
expect(splitCommand("hx src/main.rs")).toEqual({
|
||||
tool: null,
|
||||
rest: "hx src/main.rs",
|
||||
program: "hx",
|
||||
});
|
||||
// A wrapper with nothing after it is not a tool either.
|
||||
expect(splitCommand("sudo").tool).toBeNull();
|
||||
|
||||
+17
-18
@@ -2,11 +2,11 @@
|
||||
*
|
||||
* 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 most of the fragment's width, which the sidebar
|
||||
* spends on the rest of the command line instead.
|
||||
* 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 text,
|
||||
* so the fragment never renders a mark that stands for the wrong tool.
|
||||
* 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. */
|
||||
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
|
||||
export interface CommandParts {
|
||||
/** Icon id, or `null` when the program is not one we have a mark for. */
|
||||
tool: string | null;
|
||||
/** What is left of the command line once the icon stands for the program.
|
||||
* Empty when the icon says everything the command line said. */
|
||||
rest: string;
|
||||
/** 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 remainder.
|
||||
/** Split a command line into the tool its icon stands for and the program name.
|
||||
*
|
||||
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
|
||||
* `hx src/` keeps the whole line as `rest` with no tool. */
|
||||
* `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;
|
||||
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const program = basename(word);
|
||||
if (WRAPPERS.has(program)) {
|
||||
const wrapper = basename(word).toLowerCase();
|
||||
if (WRAPPERS.has(wrapper)) {
|
||||
i += 1;
|
||||
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
|
||||
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (i >= words.length) return { tool: null, rest: command.trim() };
|
||||
const tool = BY_PROGRAM[basename(words[i])] ?? null;
|
||||
if (!tool) return { tool: null, rest: command.trim() };
|
||||
return { tool, rest: words.slice(i + 1).join(" ") };
|
||||
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).toLowerCase();
|
||||
return cut >= 0 ? word.slice(cut + 1) : word;
|
||||
}
|
||||
|
||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||
|
||||
Reference in New Issue
Block a user