From 88b6bba9f2ea9fa522038f36bb3fab133dd9f2d8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 1 Sep 2026 11:11:39 +0100 Subject: [PATCH] feat(maestro): show only the program on session cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- crates/maestro-protocol/src/display.rs | 81 ++++++++++++++++++++++++++ crates/maestro-tui/src/sidebar.rs | 33 +++++++++-- docs/SIDEBAR.md | 33 ++++++----- web/src/attach.ts | 9 +-- web/src/catalog.test.ts | 10 ++-- web/src/catalog.ts | 11 ++-- web/src/toolIcon.test.ts | 14 ++--- web/src/toolIcon.ts | 35 ++++++----- 8 files changed, 170 insertions(+), 56 deletions(-) diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index 582503d..47dc29a 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -140,6 +140,63 @@ pub fn merge_linear(folder: TreeFolder) -> TreeFolder { cur } +/// Command wrappers that run another program: the program is what follows. +const WRAPPERS: [&str; 8] = [ + "sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command", +]; + +/// The program a command line runs, basenamed and stripped of its arguments — +/// what a session card shows per `docs/SIDEBAR.md`, which names the program +/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`, +/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`. +pub fn command_program(command: &str) -> String { + let words: Vec<&str> = command.split_whitespace().collect(); + let mut i = 0; + while i < words.len() { + let word = words[i]; + if is_env_assignment(word) { + i += 1; + continue; + } + let wrapper = basename(word).to_lowercase(); + if WRAPPERS.contains(&wrapper.as_str()) { + i += 1; + if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") { + i += 1; + } + continue; + } + break; + } + match words.get(i) { + Some(word) => basename(word).to_string(), + // Nothing but wrappers and assignments: say what was there rather than + // rendering an empty fragment. + None => command.trim().to_string(), + } +} + +/// `FOO=bar`, the shell's own prefix for one command's environment. +fn is_env_assignment(word: &str) -> bool { + let mut chars = word.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + let rest: String = chars.collect(); + match rest.split_once('=') { + Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), + None => false, + } +} + +fn basename(word: &str) -> &str { + match word.rsplit_once('/') { + Some((_, tail)) => tail, + None => word, + } +} + #[cfg(test)] mod tests { use super::*; @@ -318,4 +375,28 @@ mod tests { }; assert_eq!(nested.name, "a/b"); } + + #[test] + fn command_program_drops_arguments() { + assert_eq!( + command_program("claude --dangerously-skip-permissions"), + "claude" + ); + assert_eq!(command_program(" zsh -l "), "zsh"); + assert_eq!(command_program("hx src/main.rs"), "hx"); + } + + #[test] + fn command_program_looks_through_paths_env_and_wrappers() { + assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude"); + assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude"); + assert_eq!(command_program("npx opencode"), "opencode"); + assert_eq!(command_program("pnpm dlx opencode run"), "opencode"); + } + + #[test] + fn command_program_keeps_a_line_that_is_only_wrappers() { + assert_eq!(command_program("sudo"), "sudo"); + assert_eq!(command_program(""), ""); + } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 5e79422..01518a0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet}; use std::path::PathBuf; use chrono::Utc; -use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path}; +use maestro_protocol::display::{ + abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path, +}; use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -297,7 +299,8 @@ impl Sidebar { if dead { tail.push(Span::styled(" exited", muted)); } - let command = Span::styled(format!(" {}", s.command), muted); + // docs/SIDEBAR.md: the card names the program, never its arguments. + let command = Span::styled(format!(" {}", command_program(&s.command)), muted); // Reserve the triggered status fragments before detail and command. // If the row is exceptionally narrow, keep one column for the @@ -874,12 +877,34 @@ mod tests { ); } + /// docs/SIDEBAR.md: line 2 names the program, never its arguments. + #[test] + fn meta_line_drops_the_commands_arguments() { + let mut s = session_named(1, "handle"); + s.command = "claude --dangerously-skip-permissions".into(); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + }; + let all = rows(&draw(&sb, 60, 6)); + let meta = content(&all[3]); + assert!( + meta.contains("claude"), + "line 2 must name the program: {meta:?}" + ); + assert!( + !meta.contains("dangerously"), + "line 2 must not carry arguments: {meta:?}" + ); + } + /// The sidebar is narrow: the command truncates with `…` and never wraps /// onto a third row (docs/SIDEBAR.md). #[test] fn meta_line_command_truncates_instead_of_wrapping() { let mut s = session_named(1, "claude"); - s.command = "a command that cannot fit".into(); + s.command = "a-program-name-that-cannot-fit".into(); s.activity_since = Utc::now() - Duration::seconds(5); s.blocked_work = Some(InTheWay { uncommitted_files: 120, @@ -1099,7 +1124,7 @@ mod tests { #[test] fn blocked_warning_survives_before_a_truncated_command() { let mut s = session_named(1, "name"); - s.command = "a command that cannot fit".into(); + s.command = "a-program-name-that-cannot-fit".into(); s.blocked_work = Some(InTheWay { uncommitted_files: 1, ..Default::default() diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index af08c4a..499147f 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web card's tooltip and the wording of the kill refusal, so no two clients describe the same tree differently. -`` is unconditional and muted: the session's command line, shown on -every card in both clients — no omission in the TUI, no name-vs-command -comparison gating it. A session named by its command line then shows the same -string on both lines; that is accepted, the price of a row that never changes -height. +`` is unconditional and muted: it names the **program** the session +runs — never its arguments. `claude --dangerously-skip-permissions` renders as +`claude`. Flags say how a session was invoked, which is not what a card is for, +and a long one crowds out every other fragment on the line. It shows on every +card in both clients — no omission in the TUI, no name-vs-command comparison +gating it. A session named by its program then shows the same string on both +lines; that is accepted, the price of a row that never changes height. -In the **web client** the command's leading program is replaced by that tool's -icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini -CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the -command line follow as text. Recognition looks through a path, leading -`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an -unrecognised program keeps its full text, so the card never shows a mark -standing for the wrong tool. The full command line stays in the card's tooltip -either way. The **TUI** keeps the command as text: a terminal cannot be assumed -to have a font carrying tool glyphs. +The program is found by looking through a path, leading `FOO=bar` assignments +and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename: +`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers +is shown as written rather than as an empty fragment. + +In the **web client** the program is replaced by that tool's icon when it is +one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider, +Cursor, pi, and any shell — and nothing follows it as text. An unrecognised +program keeps its name as text, so the card never shows a mark standing for the +wrong tool. The full command line, arguments and all, stays in the card's +tooltip either way. The **TUI** keeps the program as text: a terminal cannot be +assumed to have a font carrying tool glyphs. ## Colour per activity state diff --git a/web/src/attach.ts b/web/src/attach.ts index 38d303d..58da3ec 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -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 `${icon ?? ""}${text ? escapeHtml(text) : ""}`; } diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index 00afeeb..3d4c576 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -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(".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(".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", () => { diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 0ec782e..d1fc206 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -96,13 +96,14 @@ function blockedFragment(blockers: InTheWay): string { return `${stats}`; } -/** 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 `${icon ?? ""}${text ? `${escapeHtml(text)}` : ""}`; } diff --git a/web/src/toolIcon.test.ts b/web/src/toolIcon.test.ts index 0af0745..2c3246b 100644 --- a/web/src/toolIcon.test.ts +++ b/web/src/toolIcon.test.ts @@ -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(); diff --git a/web/src/toolIcon.ts b/web/src/toolIcon.ts index 3c57759..e86224e 100644 --- a/web/src/toolIcon.ts +++ b/web/src/toolIcon.ts @@ -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 = { 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. */