Compare commits

...
1 Commits
Author SHA1 Message Date
Miguel Palhas 88b6bba9f2 feat(maestro): show only the program on session cards
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
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`.
2026-09-01 11:11:39 +01:00
8 changed files with 170 additions and 56 deletions
+81
View File
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,4 +375,28 @@ mod tests {
}; };
assert_eq!(nested.name, "a/b"); 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(""), "");
}
} }
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; 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 maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); 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. // Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the // 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 /// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md). /// onto a third row (docs/SIDEBAR.md).
#[test] #[test]
fn meta_line_command_truncates_instead_of_wrapping() { fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude"); 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.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 120, uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name"); 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 { s.blocked_work = Some(InTheWay {
uncommitted_files: 1, uncommitted_files: 1,
..Default::default() ..Default::default()
+19 -14
View File
@@ -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 card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently. the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on `<command>` is unconditional and muted: it names the **program** the session
every card in both clients — no omission in the TUI, no name-vs-command runs — never its arguments. `claude --dangerously-skip-permissions` renders as
comparison gating it. A session named by its command line then shows the same `claude`. Flags say how a session was invoked, which is not what a card is for,
string on both lines; that is accepted, the price of a row that never changes and a long one crowds out every other fragment on the line. It shows on every
height. 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 The program is found by looking through a path, leading `FOO=bar` assignments
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
command line follow as text. Recognition looks through a path, leading is shown as written rather than as an empty fragment.
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark In the **web client** the program is replaced by that tool's icon when it is
standing for the wrong tool. The full command line stays in the card's tooltip one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
either way. The **TUI** keeps the command as text: a terminal cannot be assumed Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
to have a font carrying tool glyphs. 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 ## Colour per activity state
+5 -4
View File
@@ -56,12 +56,13 @@ export interface AttachInfo {
command: string; command: string;
} }
/** Header echoes the sidebar: the command's leading program becomes its tool /** Header echoes the sidebar: the program becomes its tool icon when we have a
* icon when we have a mark for it, remaining words stay as text. */ * 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 { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; 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>`; return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
} }
+6 -4
View File
@@ -209,15 +209,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); 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({ const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c", id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume", command: "claude --dangerously-skip-permissions",
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); 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(".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", () => { it("keeps an unknown command as plain text", () => {
@@ -228,7 +229,8 @@ describe("session cards (#107)", () => {
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull(); 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", () => { it("maps each activity state to its contract slug", () => {
+6 -5
View File
@@ -96,13 +96,14 @@ function blockedFragment(blockers: InTheWay): string {
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`; 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 /** docs/SIDEBAR.md: the card names the program only — arguments never reach
* we have a mark for it, and only the remaining words stay as text. The full * the card. The program becomes its tool icon when we have a mark for it, and
* command line stays in the tooltip either way. */ * 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 { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; 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>`; return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
} }
+7 -7
View File
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
} }
}); });
it("keeps everything after the program as text", () => { it("drops everything after the program", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({ expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude", 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", () => { 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("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode"); expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").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({ expect(splitCommand("hx src/main.rs")).toEqual({
tool: null, tool: null,
rest: "hx src/main.rs", program: "hx",
}); });
// A wrapper with nothing after it is not a tool either. // A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull(); expect(splitCommand("sudo").tool).toBeNull();
+17 -18
View File
@@ -2,11 +2,11 @@
* *
* A session's command line starts with the program that runs it, and that * 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 * 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 * as its own mark buys back the whole fragment's width — the arguments are
* spends on the rest of the command line instead. * dropped, so a card says which tool runs, not how it was invoked.
* *
* Recognition is deliberately narrow: an unrecognised program keeps its text, * Recognition is deliberately narrow: an unrecognised program keeps its name
* so the fragment never renders a mark that stands for the wrong tool. * 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. */ /** 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 { export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */ /** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null; tool: string | null;
/** What is left of the command line once the icon stands for the program. /** The program the command runs, basenamed and stripped of its arguments —
* Empty when the icon says everything the command line said. */ * what a card shows when there is no icon to show instead. */
rest: string; 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 * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0; let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1; i += 1;
continue; continue;
} }
const program = basename(word); const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(program)) { if (WRAPPERS.has(wrapper)) {
i += 1; i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue; continue;
} }
break; break;
} }
if (i >= words.length) return { tool: null, rest: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null; const program = basename(words[i]);
if (!tool) return { tool: null, rest: command.trim() }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
return { tool, rest: words.slice(i + 1).join(" ") };
} }
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); 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. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */