//! 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, 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::>() .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 { 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 { 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 { Folder(TreeFolder), Leaf(T), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TreeFolder { pub name: String, pub path: String, pub children: Vec>, } /// 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(folder: TreeFolder) -> TreeFolder { 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, 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 { 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, } /// 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> { let mut words = Vec::new(); let mut current = String::new(); let mut has_current = false; let mut plain_eq: Option = 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 { 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 { 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; /// 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 { 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 { let j = opts.consume(words, i + 1)?; if words.get(j) != Some(&"dlx") { return None; } dlx_opts.consume(words, j + 1) } /// `bun x ` is bun's `dlx`/`npx` equivalent; `bun run