A Structured session's adapter hands back an ACP session id from session/new; a respawn will pass it to session/load (#135). This is a new field rather than #161's agent_session_id: that one is the Claude Code conversation id the activity hook reports and `claude --resume` takes, minted by Claude Code itself. The ACP id is minted by the adapter, only exists for Structured sessions, and is what the adapter takes back. Overloading one field with two ids from two processes would leave a respawn unable to tell which kind it holds.
535 lines
18 KiB
Rust
535 lines
18 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
|
|
}
|
|
|
|
/// 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(),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
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.
|
|
/// 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 program = command_program(command).to_lowercase();
|
|
BY_PROGRAM
|
|
.iter()
|
|
.find(|(name, _)| *name == program)
|
|
.map(|(_, tool)| *tool)
|
|
}
|
|
|
|
/// `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::*;
|
|
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");
|
|
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(""), "");
|
|
}
|
|
|
|
/// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same
|
|
/// tools the web's `splitCommand().tool` does.
|
|
#[test]
|
|
fn command_tool_names_the_mark_the_card_shows() {
|
|
assert_eq!(command_tool("claude --resume"), Some("claude"));
|
|
assert_eq!(command_tool("npx opencode"), Some("opencode"));
|
|
assert_eq!(command_tool("/bin/zsh -l"), Some("shell"));
|
|
assert_eq!(command_tool("CLAUDE"), Some("claude"));
|
|
}
|
|
|
|
/// Narrow on purpose: an unrecognised program has no mark, so the
|
|
/// candidate has no `tool` field to match rather than a wrong one.
|
|
#[test]
|
|
fn command_tool_is_absent_for_an_unrecognised_program() {
|
|
assert_eq!(command_tool("hx src/main.rs"), None);
|
|
assert_eq!(command_tool(""), None);
|
|
}
|
|
}
|