A Claude Code or Codex spawn now asks for the structured view unless the operator says otherwise: the two spawn forms open with the box ticked and `maestro spawn` takes `--terminal` to opt out. Once the operator works the toggle, a command change no longer re-ticks it under them. The support predicate moves to `maestro_protocol::display` so all three clients gate on the same resolution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1087 lines
38 KiB
Rust
1087 lines
38 KiB
Rust
//! Path-abbreviation helpers shared by every client that renders a session's
|
|
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
|
|
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
|
|
//! and `sessionTreePath` — keep them in sync.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::{NameSource, Session};
|
|
|
|
/// The name a session was given (#125), if any. `None` for one that was never
|
|
/// renamed — clients fall back to the command.
|
|
pub fn custom_name(s: &Session) -> Option<&str> {
|
|
match s.metadata.get("name") {
|
|
Some(serde_json::Value::String(name)) => Some(name.as_str()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Where [`custom_name`] came from (#131). A session carrying no
|
|
/// `name_source` — one named before the field existed — reads as
|
|
/// [`NameSource::User`], so an existing name is never treated as provisional.
|
|
pub fn name_source(s: &Session) -> NameSource {
|
|
match s.metadata.get(crate::NAME_SOURCE_KEY) {
|
|
Some(serde_json::Value::String(source)) => NameSource::from_wire(source),
|
|
_ => NameSource::default(),
|
|
}
|
|
}
|
|
|
|
/// Whether the card should render its name as provisional (#131): a generated
|
|
/// handle the daemon may still replace with one derived from the first
|
|
/// prompt. Both sidebars dim exactly this (docs/SIDEBAR.md).
|
|
pub fn is_provisional_name(s: &Session) -> bool {
|
|
custom_name(s).is_some() && name_source(s) == NameSource::Generated
|
|
}
|
|
|
|
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
|
/// untouched. Mirrors the web's `abbreviateCwd`.
|
|
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
|
let p = path.to_string_lossy();
|
|
if !home.is_empty() {
|
|
if p == home {
|
|
return "~".into();
|
|
}
|
|
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
|
return format!("~/{rest}");
|
|
}
|
|
}
|
|
p.into_owned()
|
|
}
|
|
|
|
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
|
|
/// and — when there was something to collapse — the shortened lead-in a
|
|
/// caller can render dimmed (the web's `.folder-stem`).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AbbreviatedFolderName {
|
|
/// The collapsed earlier segments, joined by `/`, with no trailing
|
|
/// slash. `None` when `name` had two or fewer segments — nothing to
|
|
/// gain from collapsing, so `last` carries the name unabbreviated.
|
|
pub stem: Option<String>,
|
|
pub last: String,
|
|
}
|
|
|
|
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
|
|
/// last segment verbatim, collapse every earlier one to its first character.
|
|
/// Two-or-fewer segments are left alone. Mirrors the web's
|
|
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
|
|
/// caller styles `stem` dim itself rather than parsing markup out of it.
|
|
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
|
|
let parts: Vec<&str> = name.split('/').collect();
|
|
if parts.len() <= 2 {
|
|
return AbbreviatedFolderName {
|
|
stem: None,
|
|
last: name.to_string(),
|
|
};
|
|
}
|
|
let last = parts[parts.len() - 1].to_string();
|
|
let stem = parts[..parts.len() - 1]
|
|
.iter()
|
|
.map(|p| {
|
|
if *p == "~" {
|
|
"~".to_string()
|
|
} else {
|
|
p.chars().next().map(String::from).unwrap_or_default()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
AbbreviatedFolderName {
|
|
stem: Some(stem),
|
|
last,
|
|
}
|
|
}
|
|
|
|
/// The directory a session lives in: its live `current_cwd` if the daemon
|
|
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
|
|
fn session_cwd(s: &Session) -> Option<PathBuf> {
|
|
if let Some(cur) = &s.current_cwd {
|
|
if !cur.as_os_str().is_empty() {
|
|
return Some(cur.clone());
|
|
}
|
|
}
|
|
if !s.cwd.as_os_str().is_empty() {
|
|
return Some(s.cwd.clone());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Where a session sits in the directory tree. A worktree session belongs to
|
|
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
|
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
|
/// session of the same name, and the repository stops being where you look
|
|
/// for the work. Mirrors the web's `sessionTreePath`.
|
|
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
|
|
if let Some(wt) = &s.worktree {
|
|
return Some(wt.base_repo.clone());
|
|
}
|
|
session_cwd(s)
|
|
}
|
|
|
|
/// One node of a directory tree grouping sessions by path, generic over the
|
|
/// leaf payload since building the tree itself (grouping key, sort order,
|
|
/// filtering) is not this crate's job — only [`merge_linear`] is.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum TreeNode<T> {
|
|
Folder(TreeFolder<T>),
|
|
Leaf(T),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TreeFolder<T> {
|
|
pub name: String,
|
|
pub path: String,
|
|
pub children: Vec<TreeNode<T>>,
|
|
}
|
|
|
|
/// Fold a folder with a single folder-child into one node (`a` + `b` →
|
|
/// `a/b`), repeating down the chain, then recurse into the surviving
|
|
/// branches. The merged node keeps the deepest path so collapse/expand state
|
|
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
|
|
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|
let mut cur = folder;
|
|
loop {
|
|
let single_folder_child =
|
|
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
|
|
if !single_folder_child {
|
|
break;
|
|
}
|
|
let child = match cur.children.pop().expect("checked len == 1 above") {
|
|
TreeNode::Folder(f) => f,
|
|
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
|
|
};
|
|
cur = TreeFolder {
|
|
name: format!("{}/{}", cur.name, child.name),
|
|
path: child.path,
|
|
children: child.children,
|
|
};
|
|
}
|
|
cur.children = cur
|
|
.children
|
|
.into_iter()
|
|
.map(|c| match c {
|
|
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
|
|
leaf => leaf,
|
|
})
|
|
.collect();
|
|
cur
|
|
}
|
|
|
|
/// 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, and quoting/escaping is honoured
|
|
/// (never evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion). A command a
|
|
/// shell would need real grammar for (pipelines, `&&`/`;`, redirections,
|
|
/// substitution, an unterminated quote) falls back to the naive first word
|
|
/// rather than guessing through it (#199). Mirrors the web's `splitCommand`.
|
|
pub fn command_program(command: &str) -> String {
|
|
match resolve(command) {
|
|
Some(r) => match r.words.get(r.program_index) {
|
|
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(),
|
|
},
|
|
None => legacy_first_word(command),
|
|
}
|
|
}
|
|
|
|
/// Program name (already basenamed and lowercased) -> the tool id its mark
|
|
/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`;
|
|
/// recognition is deliberately narrow, so an unrecognised program has no tool.
|
|
/// `claude-code-acp` — the daemon's actual structured-session child — is
|
|
/// deliberately absent: the daemon launches it directly and ignores
|
|
/// `req.command`, so it is never itself a valid user-facing launch form.
|
|
const BY_PROGRAM: [(&str, &str); 17] = [
|
|
("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"),
|
|
];
|
|
|
|
/// The tool id a command line's icon stands for (`claude`, `codex`,
|
|
/// `opencode`, ...), or `None` when the program is not one we have a mark for
|
|
/// — including a command line [`resolve`] would only guess at. The command
|
|
/// bar matches this field (`docs/COMMAND-BAR.md`), so it must name the same
|
|
/// tools the web's `splitCommand().tool` does.
|
|
pub fn command_tool(command: &str) -> Option<&'static str> {
|
|
let r = resolve(command)?;
|
|
let program = basename(r.words.get(r.program_index)?).to_lowercase();
|
|
BY_PROGRAM
|
|
.iter()
|
|
.find(|(name, _)| *name == program)
|
|
.map(|(_, tool)| *tool)
|
|
}
|
|
|
|
/// Whether a command line resolves to an agent Maestro can run as a
|
|
/// structured (ACP) session — Claude Code or Codex, the two adapters
|
|
/// `docs/STRUCTURED-VIEW.md` lists. Every client gates its structured-view
|
|
/// control on this, and the daemon rejects a `Structured` request for
|
|
/// anything else, so the answer has to come from one place.
|
|
pub fn supports_structured_view(command: &str) -> bool {
|
|
matches!(command_tool(command), Some("claude" | "codex"))
|
|
}
|
|
|
|
/// [`resolve`]'s outcome: the tokenized command line, and the index within it
|
|
/// of the word that is the actual program (past every assignment/wrapper it
|
|
/// resolved through).
|
|
struct Resolution {
|
|
words: Vec<String>,
|
|
program_index: usize,
|
|
}
|
|
|
|
/// Tokenize `command` and walk past leading `FOO=bar` assignments and known
|
|
/// wrapper forms (interleaved in any order/count) to find the program. An
|
|
/// assignment run is only consumed at the very start of the line or right
|
|
/// after a wrapper whose own semantics accept one there ([`Wrapper::accepts_assignment`]) —
|
|
/// `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
|
|
/// skipping to `claude`, since `command`/`nohup` just exec their next argument
|
|
/// as the program, they don't setenv from an assignment-shaped one (#199).
|
|
/// `None` when [`tokenize`] can't safely make sense of the line at all.
|
|
fn resolve(command: &str) -> Option<Resolution> {
|
|
let words = tokenize(command)?;
|
|
let refs: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
|
|
let mut i = 0;
|
|
let mut assignment_allowed = true;
|
|
loop {
|
|
if assignment_allowed {
|
|
while matches!(words.get(i), Some(word) if is_env_assignment(word)) {
|
|
i += 1;
|
|
}
|
|
}
|
|
let Some(word) = words.get(i) else { break };
|
|
let wrapper = basename(&word.text).to_lowercase();
|
|
let Some(w) = wrapper_resolver(&wrapper) else {
|
|
break;
|
|
};
|
|
match (w.resolve)(&refs, i) {
|
|
Some(next) if next > i => {
|
|
i = next;
|
|
assignment_allowed = w.accepts_assignment;
|
|
}
|
|
// Not a form of the wrapper we recognise (an unknown option, or
|
|
// — for `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it):
|
|
// the wrapper word itself is the program, not a launcher.
|
|
_ => break,
|
|
}
|
|
}
|
|
Some(Resolution {
|
|
words: words.into_iter().map(|w| w.text).collect(),
|
|
program_index: i,
|
|
})
|
|
}
|
|
|
|
/// The naive "first whitespace word, basenamed" reading used only when
|
|
/// [`tokenize`] rejects the line outright — still something sane to show,
|
|
/// never a wrapper/tool match to act on.
|
|
fn legacy_first_word(command: &str) -> String {
|
|
match command.split_whitespace().next() {
|
|
Some(word) => basename(word).to_string(),
|
|
None => command.trim().to_string(),
|
|
}
|
|
}
|
|
|
|
/// One token from [`tokenize`]: its dequoted text, and — when both the name
|
|
/// and `=` were entirely unquoted/unescaped in the source — the byte length
|
|
/// of the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
|
/// makes the word an executable named `FOO=1`; quoting/escaping only the value
|
|
/// preserves the assignment, matching Bash's assignment-word grammar (#199).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct Word {
|
|
text: String,
|
|
plain_eq: Option<usize>,
|
|
}
|
|
|
|
/// Word-split `command` the way a shell would (honouring `'…'`, `"…"` and
|
|
/// `\` quoting/escaping — POSIX quote *removal*, not evaluation) and return
|
|
/// `None` the moment it needs real shell grammar this resolver doesn't
|
|
/// attempt: a pipeline/list operator (`|`, `&`, `;`, `<`, `>`), a subshell or
|
|
/// substitution (`(`, `)`, `` ` ``, `$(`), an unescaped newline (a command
|
|
/// separator, like `;` — #199), or an unterminated quote/escape. Never
|
|
/// expands `$var`/`` `cmd` ``/`$(cmd)` — a bare `$` is kept literal. A
|
|
/// backslash immediately before a newline, quoted or not, is a line
|
|
/// continuation: removed entirely rather than embedding the newline or
|
|
/// ending the word (#199).
|
|
fn tokenize(command: &str) -> Option<Vec<Word>> {
|
|
let mut words = Vec::new();
|
|
let mut current = String::new();
|
|
let mut has_current = false;
|
|
let mut plain_eq: Option<usize> = None;
|
|
let mut assignment_name_is_plain = true;
|
|
let mut chars = command.chars().peekable();
|
|
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
' ' | '\t' | '\r' => {
|
|
if has_current {
|
|
words.push(Word {
|
|
text: std::mem::take(&mut current),
|
|
plain_eq: plain_eq.take(),
|
|
});
|
|
has_current = false;
|
|
assignment_name_is_plain = true;
|
|
}
|
|
}
|
|
'\n' => return None,
|
|
'|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' => return None,
|
|
'$' if chars.peek() == Some(&'(') => return None,
|
|
'\'' => {
|
|
has_current = true;
|
|
if plain_eq.is_none() {
|
|
assignment_name_is_plain = false;
|
|
}
|
|
loop {
|
|
match chars.next() {
|
|
Some('\'') => break,
|
|
Some(c2) => current.push(c2),
|
|
None => return None, // unterminated single quote
|
|
}
|
|
}
|
|
}
|
|
'"' => {
|
|
has_current = true;
|
|
if plain_eq.is_none() {
|
|
assignment_name_is_plain = false;
|
|
}
|
|
loop {
|
|
match chars.next() {
|
|
Some('"') => break,
|
|
Some('\\') => match chars.next() {
|
|
Some(c2 @ ('"' | '\\' | '$' | '`')) => current.push(c2),
|
|
Some('\n') => {} // line continuation, removed entirely
|
|
Some(c2) => {
|
|
current.push('\\');
|
|
current.push(c2);
|
|
}
|
|
None => return None, // trailing backslash
|
|
},
|
|
Some(c2) => current.push(c2),
|
|
None => return None, // unterminated double quote
|
|
}
|
|
}
|
|
}
|
|
'\\' => match chars.next() {
|
|
Some('\n') => {} // line continuation, removed entirely
|
|
Some(c2) => {
|
|
has_current = true;
|
|
if plain_eq.is_none() {
|
|
assignment_name_is_plain = false;
|
|
}
|
|
current.push(c2);
|
|
}
|
|
None => return None, // trailing backslash
|
|
},
|
|
other => {
|
|
has_current = true;
|
|
if other == '=' && plain_eq.is_none() && assignment_name_is_plain {
|
|
plain_eq = Some(current.len());
|
|
}
|
|
current.push(other);
|
|
}
|
|
}
|
|
}
|
|
if has_current {
|
|
words.push(Word {
|
|
text: current,
|
|
plain_eq,
|
|
});
|
|
}
|
|
Some(words)
|
|
}
|
|
|
|
/// A wrapper's known `-`/`--` options: which take no value, and which
|
|
/// consume either a following word or a `--flag=value` as theirs. A
|
|
/// `-`-prefixed word this table doesn't know halts resolution rather than
|
|
/// guessing past it (#199) — callers read that as [`OptionTable::consume`]
|
|
/// returning `None`.
|
|
struct OptionTable {
|
|
flags: &'static [&'static str],
|
|
valued: &'static [&'static str],
|
|
/// Whether this wrapper's short options may be combined (`-iv`) or take
|
|
/// an attached value (`-uroot`), the way real getopt-based tools do
|
|
/// (#199). JS-toolchain wrappers (`npx`, `pnpm`, `yarn`, bun's `x`/
|
|
/// `dlx`) don't get this — guessing at bundling they don't actually
|
|
/// support would resolve through options they'd reject outright.
|
|
bundles_short_options: bool,
|
|
}
|
|
|
|
impl OptionTable {
|
|
/// Consume this table's options starting at `words[i]`, returning the
|
|
/// index just past them (which may equal `i` if there are none) — or
|
|
/// `None` on an option this table doesn't recognise, or a valued option
|
|
/// missing its operand.
|
|
fn consume(&self, words: &[&str], mut i: usize) -> Option<usize> {
|
|
loop {
|
|
let Some(&word) = words.get(i) else {
|
|
return Some(i);
|
|
};
|
|
if word == "--" {
|
|
return Some(i + 1);
|
|
}
|
|
if word == "-" || !word.starts_with('-') {
|
|
return Some(i);
|
|
}
|
|
if let Some((flag, value)) = word.split_once('=') {
|
|
return if self.valued.contains(&flag) && !value.is_empty() {
|
|
i += 1;
|
|
continue;
|
|
} else {
|
|
None
|
|
};
|
|
}
|
|
if self.flags.contains(&word) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if self.valued.contains(&word) {
|
|
let value = words.get(i + 1)?;
|
|
if value.is_empty() || value.starts_with('-') {
|
|
return None;
|
|
}
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if self.bundles_short_options
|
|
&& !word.starts_with("--")
|
|
&& word.len() > 2
|
|
&& self.matches_short_cluster(word)
|
|
{
|
|
i += 1;
|
|
continue;
|
|
}
|
|
return None;
|
|
}
|
|
}
|
|
|
|
/// `-iv` (bundled boolean short flags) or `-uroot` (a valued short
|
|
/// option with its value attached) — getopt's traditional short-option
|
|
/// forms. `false` the moment a character isn't a short option this
|
|
/// table knows, or a valued one ends the word with no attached value
|
|
/// left to be its operand (#199: rejected rather than guessed to come
|
|
/// from the next word instead).
|
|
fn matches_short_cluster(&self, word: &str) -> bool {
|
|
if !word.is_ascii() {
|
|
return false;
|
|
}
|
|
let bytes = word.as_bytes();
|
|
let mut buf = [b'-', 0];
|
|
let mut idx = 1;
|
|
while idx < bytes.len() {
|
|
buf[1] = bytes[idx];
|
|
let short = std::str::from_utf8(&buf).expect("ascii buffer");
|
|
if self.flags.contains(&short) {
|
|
idx += 1;
|
|
continue;
|
|
}
|
|
if self.valued.contains(&short) {
|
|
return idx + 1 < bytes.len();
|
|
}
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
const NO_OPTIONS: OptionTable = OptionTable {
|
|
flags: &[],
|
|
valued: &[],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const ENV_OPTS: OptionTable = OptionTable {
|
|
flags: &[
|
|
"-i",
|
|
"--ignore-environment",
|
|
"-0",
|
|
"--null",
|
|
"-v",
|
|
"--debug",
|
|
],
|
|
valued: &["-u", "--unset", "-C", "--chdir"],
|
|
bundles_short_options: true,
|
|
};
|
|
|
|
const SUDO_OPTS: OptionTable = OptionTable {
|
|
flags: &[
|
|
"-i",
|
|
"--login",
|
|
"-n",
|
|
"--non-interactive",
|
|
"-E",
|
|
"--preserve-env",
|
|
"-H",
|
|
"--set-home",
|
|
"-A",
|
|
"--askpass",
|
|
"-b",
|
|
"--background",
|
|
"-k",
|
|
"--reset-timestamp",
|
|
"-K",
|
|
"--remove-timestamp",
|
|
"-S",
|
|
"--stdin",
|
|
],
|
|
valued: &["-u", "--user", "-g", "--group"],
|
|
bundles_short_options: true,
|
|
};
|
|
|
|
/// `-v`/`-V` only print what would run — they never execute it — so
|
|
/// resolving through them would claim a program runs that `command` never
|
|
/// launches (#199). Only `-p`, which still executes normally, is safe to
|
|
/// look through.
|
|
const COMMAND_BUILTIN_OPTS: OptionTable = OptionTable {
|
|
flags: &["-p"],
|
|
valued: &[],
|
|
bundles_short_options: true,
|
|
};
|
|
|
|
/// The bash *keyword* `time` only ever parses `-p` (POSIX output format);
|
|
/// `-o`/`-v`/`-a`/`-f`/`--verbose`/... belong to the external GNU
|
|
/// `/usr/bin/time`, a different program a bare `time` word doesn't
|
|
/// necessarily name. Assuming them here would be guessing which `time`
|
|
/// resolves (#199).
|
|
fn resolve_time(words: &[&str], i: usize) -> Option<usize> {
|
|
let mut next = i + 1;
|
|
if words.get(next) == Some(&"-p") {
|
|
next += 1;
|
|
}
|
|
if words.get(next) == Some(&"--") {
|
|
next += 1;
|
|
}
|
|
match words.get(next) {
|
|
Some(word) if word.starts_with('-') => None,
|
|
_ => Some(next),
|
|
}
|
|
}
|
|
|
|
/// `-c`/`--call` embeds a whole shell command string rather than naming the
|
|
/// program positionally — resolving past it would mean guessing at a
|
|
/// program buried inside that string, so it's left unrecognised and
|
|
/// resolution stops at `npx` (#199).
|
|
const NPX_OPTS: OptionTable = OptionTable {
|
|
flags: &["-y", "--yes", "--no", "-q", "--quiet", "--ignore-existing"],
|
|
valued: &["-p", "--package", "--shell"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const PNPM_OPTS: OptionTable = OptionTable {
|
|
flags: &[
|
|
"--silent",
|
|
"-s",
|
|
"--stream",
|
|
"--aggregate-output",
|
|
"--no-progress",
|
|
],
|
|
// `--package`/`--package=value` is documented in this pre-`dlx`
|
|
// position (`pnpm --package=x dlx y`), alongside the identical flag
|
|
// `dlx` itself also accepts (`PNPM_DLX_OPTS`).
|
|
valued: &["-C", "--dir", "--filter", "--package"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const YARN_OPTS: OptionTable = OptionTable {
|
|
flags: &["--silent", "-s", "--no-progress", "--non-interactive"],
|
|
valued: &["--cwd"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const PNPM_DLX_OPTS: OptionTable = OptionTable {
|
|
flags: &["-s", "--silent"],
|
|
valued: &["--package"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const YARN_DLX_OPTS: OptionTable = OptionTable {
|
|
flags: &["-q", "--quiet"],
|
|
valued: &["-p", "--package"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
const BUN_X_OPTS: OptionTable = OptionTable {
|
|
flags: &["--bun", "--no-install", "--verbose", "--silent"],
|
|
valued: &["-p", "--package"],
|
|
bundles_short_options: false,
|
|
};
|
|
|
|
/// A resolver for one wrapper form: given the tokenized command and the
|
|
/// index of the wrapper word itself, the index of the word after it (past
|
|
/// its options and any subcommand it requires), or `None` if this occurrence
|
|
/// isn't a form we resolve through.
|
|
type WrapperResolver = fn(&[&str], usize) -> Option<usize>;
|
|
|
|
/// One recognised wrapper form: how to walk past it, and whether an
|
|
/// assignment word is a valid follow-on once it's been walked past.
|
|
struct Wrapper {
|
|
resolve: WrapperResolver,
|
|
/// `true` only for wrappers that themselves interpret a following
|
|
/// `FOO=bar` as an environment assignment for the command they launch
|
|
/// (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
|
|
/// grammar for a brand new simple command (`time`). Every other wrapper
|
|
/// here is a plain executable that execs its next argument as the
|
|
/// program outright — an assignment-shaped one names a program literally
|
|
/// called `FOO=1`, not an env var (#199).
|
|
accepts_assignment: bool,
|
|
}
|
|
|
|
fn wrapper_resolver(name: &str) -> Option<Wrapper> {
|
|
let (resolve, accepts_assignment): (WrapperResolver, bool) = match name {
|
|
"env" => (|words, i| ENV_OPTS.consume(words, i + 1), true),
|
|
"sudo" => (|words, i| SUDO_OPTS.consume(words, i + 1), true),
|
|
"command" => (|words, i| COMMAND_BUILTIN_OPTS.consume(words, i + 1), false),
|
|
"nohup" => (|words, i| NO_OPTIONS.consume(words, i + 1), false),
|
|
"time" => (resolve_time, true),
|
|
"npx" => (|words, i| NPX_OPTS.consume(words, i + 1), false),
|
|
"pnpm" => (
|
|
|words, i| resolve_dlx(&PNPM_OPTS, &PNPM_DLX_OPTS, words, i),
|
|
false,
|
|
),
|
|
"yarn" => (
|
|
|words, i| resolve_dlx(&YARN_OPTS, &YARN_DLX_OPTS, words, i),
|
|
false,
|
|
),
|
|
"bun" => (resolve_bun, false),
|
|
"bunx" => (|words, i| BUN_X_OPTS.consume(words, i + 1), false),
|
|
"uvx" => (|_words, i| Some(i + 1), false),
|
|
_ => return None,
|
|
};
|
|
Some(Wrapper {
|
|
resolve,
|
|
accepts_assignment,
|
|
})
|
|
}
|
|
|
|
/// `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
|
/// — without it, the next word is a `package.json` script name, not a
|
|
/// package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
|
/// itself takes launcher-specific options too, consumed before the executable.
|
|
fn resolve_dlx(
|
|
opts: &OptionTable,
|
|
dlx_opts: &OptionTable,
|
|
words: &[&str],
|
|
i: usize,
|
|
) -> Option<usize> {
|
|
let j = opts.consume(words, i + 1)?;
|
|
if words.get(j) != Some(&"dlx") {
|
|
return None;
|
|
}
|
|
dlx_opts.consume(words, j + 1)
|
|
}
|
|
|
|
/// `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
|
/// (the same package-script ambiguity as bare `pnpm`/`yarn`), so only an `x`
|
|
/// subcommand is looked through. `--bun` (`BUN_X_OPTS`) may follow it before
|
|
/// the executable.
|
|
fn resolve_bun(words: &[&str], i: usize) -> Option<usize> {
|
|
if words.get(i + 1) != Some(&"x") {
|
|
return None;
|
|
}
|
|
BUN_X_OPTS.consume(words, i + 2)
|
|
}
|
|
|
|
/// `FOO=bar`, the shell's own prefix for one command's environment —
|
|
/// recognised only when the `=` came from plain, unquoted/unescaped source
|
|
/// text (#199): `"FOO=1"` and `FOO\=1` name a program called `FOO=1`, not
|
|
/// an assignment.
|
|
fn is_env_assignment(word: &Word) -> bool {
|
|
let Some(eq) = word.plain_eq else {
|
|
return false;
|
|
};
|
|
let name = &word.text[..eq];
|
|
let mut chars = name.chars();
|
|
match chars.next() {
|
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
|
_ => return false,
|
|
}
|
|
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
}
|
|
|
|
fn basename(word: &str) -> &str {
|
|
match word.rsplit_once('/') {
|
|
Some((_, tail)) => tail,
|
|
None => word,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{SessionStatus, SessionWorktree};
|
|
use chrono::{TimeZone, Utc};
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
fn session(cwd: &str) -> Session {
|
|
Session {
|
|
view: Default::default(),
|
|
id: Uuid::from_u128(1),
|
|
cwd: PathBuf::from(cwd),
|
|
command: "bash".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
activity_detail: None,
|
|
last_activity_at: Utc::now(),
|
|
activity_since: Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
last_focused_at: None,
|
|
acp_session_id: None,
|
|
}
|
|
}
|
|
|
|
/// #131: a session named before `name_source` existed reads as `User`,
|
|
/// so an existing name is never dimmed or overwritten.
|
|
#[test]
|
|
fn a_name_without_a_source_is_a_user_name() {
|
|
let mut s = session("/home/u/p");
|
|
s.metadata.insert(
|
|
crate::NAME_KEY.into(),
|
|
serde_json::Value::String("amber-falcon".into()),
|
|
);
|
|
assert_eq!(name_source(&s), NameSource::User);
|
|
assert!(!is_provisional_name(&s));
|
|
}
|
|
|
|
/// Only a generated handle is provisional; a haiku-derived name has
|
|
/// settled and renders like any other (#131).
|
|
#[test]
|
|
fn only_a_generated_name_is_provisional() {
|
|
let mut s = session("/home/u/p");
|
|
s.metadata.insert(
|
|
crate::NAME_KEY.into(),
|
|
serde_json::Value::String("amber-falcon".into()),
|
|
);
|
|
for (stored, source, provisional) in [
|
|
("Generated", NameSource::Generated, true),
|
|
("Haiku", NameSource::Haiku, false),
|
|
("User", NameSource::User, false),
|
|
// A spelling from a newer daemon degrades to the settled variant.
|
|
("Something", NameSource::User, false),
|
|
] {
|
|
s.metadata.insert(
|
|
crate::NAME_SOURCE_KEY.into(),
|
|
serde_json::Value::String(stored.into()),
|
|
);
|
|
assert_eq!(name_source(&s), source, "{stored}");
|
|
assert_eq!(is_provisional_name(&s), provisional, "{stored}");
|
|
}
|
|
}
|
|
|
|
/// A source with no name behind it is not something to dim — the card is
|
|
/// showing its command.
|
|
#[test]
|
|
fn a_source_without_a_name_is_not_provisional() {
|
|
let mut s = session("/home/u/p");
|
|
s.metadata.insert(
|
|
crate::NAME_SOURCE_KEY.into(),
|
|
serde_json::Value::String("Generated".into()),
|
|
);
|
|
assert!(!is_provisional_name(&s));
|
|
}
|
|
|
|
#[test]
|
|
fn abbreviate_dir_collapses_home() {
|
|
assert_eq!(
|
|
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
|
|
"~/maestro"
|
|
);
|
|
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
|
|
}
|
|
|
|
#[test]
|
|
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
|
|
assert_eq!(
|
|
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
|
|
"/srv/work/api"
|
|
);
|
|
assert_eq!(
|
|
abbreviate_dir(Path::new("/srv/work/api"), ""),
|
|
"/srv/work/api"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn abbreviate_folder_name_collapses_the_stem() {
|
|
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
|
|
assert_eq!(
|
|
got,
|
|
AbbreviatedFolderName {
|
|
stem: Some("~/g/n".into()),
|
|
last: "maestro".into(),
|
|
}
|
|
);
|
|
}
|
|
|
|
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
|
|
#[test]
|
|
fn abbreviate_folder_name_two_segment_no_op() {
|
|
assert_eq!(
|
|
abbreviate_folder_name("~/work"),
|
|
AbbreviatedFolderName {
|
|
stem: None,
|
|
last: "~/work".into(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
abbreviate_folder_name("work"),
|
|
AbbreviatedFolderName {
|
|
stem: None,
|
|
last: "work".into(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_tree_path_prefers_worktree_base_repo() {
|
|
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
|
|
s.worktree = Some(SessionWorktree {
|
|
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
|
|
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
|
|
});
|
|
assert_eq!(
|
|
session_tree_path(&s),
|
|
Some(PathBuf::from("/home/n/tea/agent-skills"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
|
|
let s = session("/home/n/tea/arr");
|
|
assert_eq!(
|
|
session_tree_path(&s),
|
|
Some(PathBuf::from("/home/n/tea/arr"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
|
|
let mut s = session("/home/n/tea/arr");
|
|
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
|
|
assert_eq!(
|
|
session_tree_path(&s),
|
|
Some(PathBuf::from("/home/n/tea/arr/crates"))
|
|
);
|
|
}
|
|
|
|
fn leaf(name: &str) -> TreeNode<String> {
|
|
TreeNode::Leaf(name.into())
|
|
}
|
|
|
|
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
|
|
TreeFolder {
|
|
name: name.into(),
|
|
path: path.into(),
|
|
children,
|
|
}
|
|
}
|
|
|
|
/// The web's mergeLinear test: keeps the deepest path so collapse state
|
|
/// survives growth.
|
|
#[test]
|
|
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
|
|
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
|
|
let merged = merge_linear(tree);
|
|
assert_eq!(merged.name, "a/b");
|
|
assert_eq!(merged.path, "a/b");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_linear_stops_where_a_directory_branches() {
|
|
let tree = folder(
|
|
"git",
|
|
"git",
|
|
vec![
|
|
TreeNode::Folder(folder("one", "git/one", vec![])),
|
|
TreeNode::Folder(folder("two", "git/two", vec![])),
|
|
],
|
|
);
|
|
let merged = merge_linear(tree);
|
|
assert_eq!(merged.name, "git");
|
|
assert_eq!(merged.children.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_linear_stops_at_a_leaf() {
|
|
let tree = folder("a", "a", vec![leaf("session")]);
|
|
let merged = merge_linear(tree);
|
|
assert_eq!(merged.name, "a");
|
|
assert_eq!(merged.children, vec![leaf("session")]);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_linear_recurses_into_surviving_branches() {
|
|
let tree = folder(
|
|
"root",
|
|
"root",
|
|
vec![
|
|
TreeNode::Folder(folder(
|
|
"a",
|
|
"root/a",
|
|
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
|
|
)),
|
|
leaf("other"),
|
|
],
|
|
);
|
|
let merged = merge_linear(tree);
|
|
assert_eq!(merged.children.len(), 2);
|
|
let TreeNode::Folder(nested) = &merged.children[0] else {
|
|
panic!("expected folder")
|
|
};
|
|
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");
|
|
}
|
|
|
|
/// The #199 resolver contract: every case in the shared parity fixture,
|
|
/// asserted identically here and by `web/src/toolIcon.test.ts`, so a
|
|
/// resolver regression in either language fails its own suite.
|
|
#[test]
|
|
fn resolves_the_shared_command_resolution_fixture() {
|
|
#[derive(serde::Deserialize)]
|
|
struct Case {
|
|
command: String,
|
|
program: String,
|
|
tool: Option<String>,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct Fixture {
|
|
cases: Vec<Case>,
|
|
}
|
|
let raw = include_str!("../../../fixtures/command-resolution.json");
|
|
let fixture: Fixture = serde_json::from_str(raw).expect("valid fixture JSON");
|
|
assert!(!fixture.cases.is_empty());
|
|
for case in &fixture.cases {
|
|
assert_eq!(
|
|
command_program(&case.command),
|
|
case.program,
|
|
"program for {:?}",
|
|
case.command
|
|
);
|
|
assert_eq!(
|
|
command_tool(&case.command),
|
|
case.tool.as_deref(),
|
|
"tool for {:?}",
|
|
case.command
|
|
);
|
|
}
|
|
}
|
|
|
|
fn word(text: &str) -> Word {
|
|
Word {
|
|
text: text.into(),
|
|
plain_eq: None,
|
|
}
|
|
}
|
|
|
|
/// Shell-aware tokenization (#199): quoting/escaping is honoured, never
|
|
/// evaluated — no `$var`/`` `cmd` ``/`$(cmd)` expansion is attempted.
|
|
#[test]
|
|
fn tokenize_handles_quotes_and_escapes_without_expanding_anything() {
|
|
assert_eq!(
|
|
tokenize("claude --resume"),
|
|
Some(vec![word("claude"), word("--resume")])
|
|
);
|
|
assert_eq!(
|
|
tokenize("\"/opt/my agent/claude\" --resume"),
|
|
Some(vec![word("/opt/my agent/claude"), word("--resume")])
|
|
);
|
|
assert_eq!(
|
|
tokenize("FOO=\"bar baz\" claude"),
|
|
Some(vec![
|
|
Word {
|
|
text: "FOO=bar baz".into(),
|
|
plain_eq: Some(3),
|
|
},
|
|
word("claude"),
|
|
])
|
|
);
|
|
assert_eq!(
|
|
tokenize("claude --name=$HOME"),
|
|
Some(vec![
|
|
word("claude"),
|
|
Word {
|
|
text: "--name=$HOME".into(),
|
|
plain_eq: Some(6),
|
|
},
|
|
]),
|
|
"a bare $ is kept literal, never expanded"
|
|
);
|
|
}
|
|
|
|
/// Real shell grammar this resolver doesn't attempt — pipelines, list
|
|
/// operators, subshells/substitution, an unescaped newline, an
|
|
/// unterminated quote — is rejected outright rather than guessed
|
|
/// through (#199).
|
|
#[test]
|
|
fn tokenize_rejects_ambiguous_shell_grammar() {
|
|
for command in [
|
|
"claude | tee log.txt",
|
|
"claude; echo done",
|
|
"claude && echo done",
|
|
"echo hi $(claude)",
|
|
"claude \"unterminated",
|
|
"claude 'unterminated",
|
|
"claude \\",
|
|
"claude\nclaude-code", // an unescaped newline separates commands, like `;`
|
|
] {
|
|
assert_eq!(tokenize(command), None, "{command:?}");
|
|
}
|
|
}
|
|
|
|
/// A backslash immediately before a newline is a line continuation:
|
|
/// removed entirely, joining what it split rather than embedding a
|
|
/// literal newline or ending the command (#199).
|
|
#[test]
|
|
fn tokenize_treats_backslash_newline_as_a_continuation() {
|
|
assert_eq!(
|
|
tokenize("clau\\\nde --resume"),
|
|
Some(vec![word("claude"), word("--resume")])
|
|
);
|
|
}
|
|
|
|
/// Only a plain, unquoted/unescaped `=` marks an assignment boundary —
|
|
/// quoting or escaping the `=` itself disqualifies the word, even
|
|
/// though the dequoted text looks identical (#199).
|
|
#[test]
|
|
fn tokenize_only_marks_plain_unquoted_equals_signs_as_assignments() {
|
|
assert_eq!(tokenize("FOO=1 claude").unwrap()[0].plain_eq, Some(3));
|
|
assert_eq!(tokenize("\"FOO=1\" claude").unwrap()[0].plain_eq, None);
|
|
assert_eq!(tokenize("FOO\\=1 claude").unwrap()[0].plain_eq, None);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_view_support_follows_the_resolved_program() {
|
|
assert!(supports_structured_view("claude --resume"));
|
|
assert!(supports_structured_view("FOO=1 /usr/local/bin/codex exec"));
|
|
assert!(supports_structured_view("npx claude-code"));
|
|
assert!(!supports_structured_view("opencode"));
|
|
assert!(!supports_structured_view("bash -l"));
|
|
assert!(!supports_structured_view("claude-code-acp"));
|
|
}
|
|
}
|