From 458c81094b8129e43c6a67aed0a309d568ab9a65 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Sun, 21 Jun 2026 21:11:23 +0000 Subject: [PATCH] feat(maestro): nest sessions by live working directory, drop labels Replace the freeform `metadata.label` session grouping with nesting derived from each session's working directory. The daemon's activity watcher already polls each tmux pane every tick, so it now also captures `#{pane_current_path}` and surfaces it as a new `Session.current_cwd` (serde-default, back-compat); `cwd` stays the spawn dir for worktree inference, spawn-similar, and recents. Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms (web + TUI) and `--label` flag are removed. The pane format stays space-delimited (parser splits the first three tokens and keeps the remainder as the path) so a cwd containing spaces survives. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/maestro-cli/src/cli.rs | 3 - crates/maestro-cli/src/sessions.rs | 86 +++++++++++++------- crates/maestro-daemon/src/activity.rs | 10 ++- crates/maestro-daemon/src/broker.rs | 2 + crates/maestro-daemon/src/catalog_mirror.rs | 2 + crates/maestro-daemon/src/registry_client.rs | 2 + crates/maestro-daemon/src/session.rs | 18 +++- crates/maestro-daemon/src/tmux.rs | 66 ++++++++++----- crates/maestro-daemon/tests/sidecar.rs | 3 +- crates/maestro-protocol/src/lib.rs | 14 +++- crates/maestro-registry/src/state.rs | 2 + crates/maestro-registry/tests/daemon_ws.rs | 2 + crates/maestro-registry/tests/http_api.rs | 2 + crates/maestro-tui/src/app.rs | 12 +-- crates/maestro-tui/src/catalog.rs | 2 + crates/maestro-tui/src/overlays.rs | 30 ++----- crates/maestro-tui/src/sidebar.rs | 35 ++++---- crates/maestro-tui/tests/catalog_ws.rs | 2 + web/src/catalog.ts | 60 +++++++++++--- web/src/kit.ts | 15 ++-- web/src/mock.ts | 47 ++++++----- web/src/spawn.ts | 10 +-- web/src/types.ts | 22 ++++- 23 files changed, 280 insertions(+), 167 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index ee927cb..fb7265e 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -65,9 +65,6 @@ pub struct SpawnArgs { /// Command to launch (e.g. `bash`, `claude`). #[arg(long)] pub command: String, - /// Human-readable label, persisted in session metadata (`label`). - #[arg(long)] - pub label: Option, /// Set an env var on the session (`KEY=VALUE`). Repeatable. #[arg(long = "env", short = 'e', value_parser = parse_kv, value_name = "KEY=VALUE")] pub env: Vec<(String, String)>, diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index cdeab8d..b3e6670 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -45,10 +45,7 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result = HashMap::new(); - if let Some(label) = args.label.as_deref() { - metadata.insert("label".into(), Value::String(label.into())); - } + let metadata: HashMap = HashMap::new(); let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) { (Some(repo), Some(branch)) => Some(WorktreeSpec { @@ -119,24 +116,19 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[Da } for (daemon_id, group) in &by_daemon { - let hostname = daemons - .iter() - .find(|d| &d.daemon_id == daemon_id) - .map(|d| d.hostname.as_str()) - .unwrap_or("?"); + let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id); + let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?"); + let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or(""); writeln!(out, "\n{daemon_id} ({hostname})").ok(); writeln!( out, - " {:<36} {:<8} {:<9} {:<20} {}", - "ID", "STATUS", "ACTIVITY", "LABEL", "COMMAND" + " {:<36} {:<8} {:<9} {:<28} {}", + "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" ) .ok(); for s in group { - let label = s - .metadata - .get("label") - .and_then(|v| v.as_str()) - .unwrap_or("-"); + // The pane's live cwd if observed, else the spawn dir, home-abbreviated. + let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), home); let status = match s.status { SessionStatus::Running => "running", SessionStatus::Dead => "dead", @@ -154,14 +146,29 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[Da }; writeln!( out, - " {:<36} {:<8} {:<9} {:<20} {command}", - s.id, status, activity, label + " {:<36} {:<8} {:<9} {:<28} {command}", + s.id, status, activity, dir ) .ok(); } } } +/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web +/// UI's abbreviation so CLI and browser show the same directory shorthand. +fn abbreviate_dir(path: &std::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() +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -169,33 +176,31 @@ mod tests { use chrono::{TimeZone, Utc}; use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus}; - use serde_json::Value; use uuid::Uuid; - fn session(daemon: &str, label: &str, command: &str, id: u128) -> Session { - let mut meta = HashMap::new(); - meta.insert("label".into(), Value::String(label.into())); + fn session(daemon: &str, cwd: &str, command: &str, id: u128) -> Session { Session { id: Uuid::from_u128(id), daemon_id: daemon.into(), - cwd: PathBuf::from("/tmp"), + cwd: PathBuf::from(cwd), command: command.into(), env: HashMap::new(), - metadata: meta, + metadata: HashMap::new(), status: SessionStatus::Running, created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + current_cwd: None, } } #[test] fn formats_grouped_table() { let sessions = vec![ - session("laptop", "personal/maestro", "claude", 1), - session("vm1", "work/api", "bash", 2), - session("laptop", "scratch", "nvim", 3), + session("laptop", "/home/naps/maestro", "claude", 1), + session("vm1", "/srv/work/api", "bash", 2), + session("laptop", "/home/naps/scratch", "nvim", 3), ]; let daemons = vec![ DaemonInfo { @@ -203,7 +208,7 @@ mod tests { hostname: "naps-laptop".into(), version: "0.1.0".into(), daemon_url: "ws://10.0.0.1:7070".into(), - home_dir: String::new(), + home_dir: "/home/naps".into(), }, DaemonInfo { daemon_id: "vm1".into(), @@ -218,15 +223,36 @@ mod tests { let s = String::from_utf8(buf).unwrap(); assert!(s.contains("laptop (naps-laptop)"), "{s}"); assert!(s.contains("vm1 (vm1.lan)"), "{s}"); - assert!(s.contains("personal/maestro"), "{s}"); + // Home dir collapses to ~ in the DIR column. + assert!(s.contains("~/maestro"), "{s}"); + // No home dir → path shown verbatim. + assert!(s.contains("/srv/work/api"), "{s}"); assert!(s.contains("running"), "{s}"); assert!(s.contains("ACTIVITY"), "{s}"); + assert!(s.contains("DIR"), "{s}"); assert!(s.contains("idle"), "{s}"); } + #[test] + fn live_cwd_overrides_spawn_dir_in_table() { + let mut s = session("laptop", "/home/naps/maestro", "bash", 1); + s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli")); + let daemons = vec![DaemonInfo { + daemon_id: "laptop".into(), + hostname: "h".into(), + version: "0.1.0".into(), + daemon_url: "ws://x".into(), + home_dir: "/home/naps".into(), + }]; + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &[s], &daemons); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}"); + } + #[test] fn shows_activity_and_foreground() { - let mut s1 = session("laptop", "work", "bash", 1); + let mut s1 = session("laptop", "/tmp", "bash", 1); s1.activity = ActivityState::AwaitingInput; s1.foreground = Some("claude".into()); // differs from spawn command let daemons = vec![DaemonInfo { diff --git a/crates/maestro-daemon/src/activity.rs b/crates/maestro-daemon/src/activity.rs index 8c9bd9f..7405bb5 100644 --- a/crates/maestro-daemon/src/activity.rs +++ b/crates/maestro-daemon/src/activity.rs @@ -34,8 +34,14 @@ mod tests { #[test] fn recent_output_is_active_regardless_of_block_state() { - assert_eq!(decide(Duration::from_millis(100), false), ActivityState::Active); - assert_eq!(decide(Duration::from_millis(100), true), ActivityState::Active); + assert_eq!( + decide(Duration::from_millis(100), false), + ActivityState::Active + ); + assert_eq!( + decide(Duration::from_millis(100), true), + ActivityState::Active + ); } #[test] diff --git a/crates/maestro-daemon/src/broker.rs b/crates/maestro-daemon/src/broker.rs index 5728f3f..da9a287 100644 --- a/crates/maestro-daemon/src/broker.rs +++ b/crates/maestro-daemon/src/broker.rs @@ -574,6 +574,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, }; handle .complete_forward_create( diff --git a/crates/maestro-daemon/src/catalog_mirror.rs b/crates/maestro-daemon/src/catalog_mirror.rs index 2dd3bad..62a9d06 100644 --- a/crates/maestro-daemon/src/catalog_mirror.rs +++ b/crates/maestro-daemon/src/catalog_mirror.rs @@ -125,6 +125,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-daemon/src/registry_client.rs b/crates/maestro-daemon/src/registry_client.rs index da7cf12..1f24f6d 100644 --- a/crates/maestro-daemon/src/registry_client.rs +++ b/crates/maestro-daemon/src/registry_client.rs @@ -561,6 +561,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 3a16032..69dfc54 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -297,6 +297,8 @@ impl SessionManager { foreground: None, activity: ActivityState::Active, last_activity_at: Utc::now(), + + current_cwd: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -428,6 +430,8 @@ impl SessionManager { foreground: None, activity: ActivityState::Idle, last_activity_at: Utc::now(), + + current_cwd: None, }, }; // The sidecar persists whatever daemon_id was current when the @@ -581,13 +585,21 @@ async fn run_activity_pass(inner: &Arc) { let idle = rec.bridge.idle_for(); let activity = crate::activity::decide(idle, awaiting); let foreground = Some(pane.command.clone()); - // Change-detection on (activity, foreground) is the debounce: nothing - // emitted at steady state. - if rec.session.activity == activity && rec.session.foreground == foreground { + // The pane's live cwd, drives the dir-based session tree; empty means + // tmux reported none. + let current_cwd = (!pane.current_path.is_empty()) + .then(|| std::path::PathBuf::from(&pane.current_path)); + // Change-detection on (activity, foreground, current_cwd) is the + // debounce: nothing emitted at steady state. + if rec.session.activity == activity + && rec.session.foreground == foreground + && rec.session.current_cwd == current_cwd + { None } else { rec.session.activity = activity; rec.session.foreground = foreground; + rec.session.current_cwd = current_cwd; // Wall-clock of the last output ≈ now − idle. rec.session.last_activity_at = Utc::now() - chrono::Duration::from_std(idle).unwrap_or_else(|_| chrono::Duration::zero()); diff --git a/crates/maestro-daemon/src/tmux.rs b/crates/maestro-daemon/src/tmux.rs index d063974..7ece64e 100644 --- a/crates/maestro-daemon/src/tmux.rs +++ b/crates/maestro-daemon/src/tmux.rs @@ -156,10 +156,14 @@ impl Tmux { } /// `tmux list-panes -t maestro- -F '#{pane_pid} #{pane_current_command} - /// #{pane_tty}'` for the session's (single) pane. Used by the activity - /// watcher (NAP-340) to learn the foreground command, the pane's process, - /// and its tty. Returns `Ok(None)` when the session/pane is gone or tmux - /// isn't running, so the watcher degrades to "unknown" rather than erroring. + /// #{pane_tty} #{pane_current_path}'` for the session's (single) pane. Used + /// by the activity watcher (NAP-340) to learn the foreground command, the + /// pane's process, its tty, and its *current* working directory. The path is + /// the last field, so [`parse_pane_line`] takes the first three + /// space-separated tokens and keeps the remainder verbatim — a cwd that + /// itself contains spaces survives intact. Returns `Ok(None)` when the + /// session/pane is gone or tmux isn't running, so the watcher degrades to + /// "unknown" rather than erroring. pub fn pane_info(&self, id: Uuid) -> Result, TmuxError> { let name = session_name(id); let out = self @@ -169,7 +173,7 @@ impl Tmux { "-t", &name, "-F", - "#{pane_pid} #{pane_current_command} #{pane_tty}", + "#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}", ]) .output()?; if !out.status.success() { @@ -262,31 +266,36 @@ fn is_no_server_error(stderr: &str) -> bool { || stderr.contains("error connecting") } -/// A session pane's process id, foreground command, and controlling tty, as -/// reported by `tmux list-panes` (NAP-340). +/// A session pane's process id, foreground command, controlling tty, and +/// current working directory, as reported by `tmux list-panes` (NAP-340). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PaneInfo { pub pane_pid: i32, pub command: String, pub tty: String, + /// `#{pane_current_path}` — the pane's live cwd. Empty if tmux reported none. + pub current_path: String, } -/// Parse one `#{pane_pid} #{pane_current_command} #{pane_tty}` line. `pane_pid` -/// is the leading integer and `pane_tty` the trailing token; everything between -/// is the command (which is normally a single word, but is rejoined defensively -/// in case tmux ever emits a spaced value). +/// Parse one `#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}` +/// line. `pane_pid`, `pane_current_command`, and `pane_tty` are single tokens, so +/// we split on the first three spaces and treat the remainder as the path — +/// which keeps a cwd intact even when it contains spaces. The path field is +/// absent on older format strings, in which case it defaults to empty. fn parse_pane_line(line: &str) -> Option { - let mut parts = line.split_whitespace(); - let pane_pid: i32 = parts.next()?.parse().ok()?; - let rest: Vec<&str> = parts.collect(); - let (tty, command) = rest.split_last()?; + let mut parts = line.splitn(4, ' '); + let pane_pid: i32 = parts.next()?.trim().parse().ok()?; + let command = parts.next()?; + let tty = parts.next()?; + let current_path = parts.next().unwrap_or(""); if command.is_empty() { return None; } Some(PaneInfo { pane_pid, - command: command.join(" "), - tty: (*tty).to_string(), + command: command.to_string(), + tty: tty.to_string(), + current_path: current_path.to_string(), }) } @@ -351,19 +360,34 @@ mod tests { use super::*; #[test] - fn parse_pane_line_splits_pid_command_tty() { - let p = parse_pane_line("12345 claude /dev/pts/3").unwrap(); + fn parse_pane_line_splits_pid_command_tty_path() { + let p = parse_pane_line("12345 claude /dev/pts/3 /home/naps62/maestro").unwrap(); assert_eq!(p.pane_pid, 12345); assert_eq!(p.command, "claude"); assert_eq!(p.tty, "/dev/pts/3"); + assert_eq!(p.current_path, "/home/naps62/maestro"); + } + + #[test] + fn parse_pane_line_keeps_spaces_in_path() { + // The path is the trailing field (split on the first three spaces only), + // so spaces inside it survive intact. + let p = parse_pane_line("9 bash /dev/pts/0 /home/naps62/My Projects/x").unwrap(); + assert_eq!(p.current_path, "/home/naps62/My Projects/x"); + } + + #[test] + fn parse_pane_line_tolerates_missing_path_field() { + // A daemon/tmux that omits the path field still yields the other fields. + let p = parse_pane_line("12345 claude /dev/pts/3").unwrap(); + assert_eq!(p.command, "claude"); + assert_eq!(p.current_path, ""); } #[test] fn parse_pane_line_rejects_malformed() { assert!(parse_pane_line("").is_none()); assert!(parse_pane_line("notapid bash /dev/pts/0").is_none()); - // pid + tty but no command between them. - assert!(parse_pane_line("123 /dev/pts/0").is_none()); } #[test] diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index c853af2..ed92ed4 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -19,7 +19,7 @@ fn sample(id: Uuid, daemon_id: &str) -> Session { cwd: PathBuf::from("/tmp/work"), command: "bash".into(), env: HashMap::from([("FOO".into(), "bar".into())]), - metadata: HashMap::from([("label".into(), Value::String("personal/maestro".into()))]), + metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() @@ -27,6 +27,7 @@ fn sample(id: Uuid, daemon_id: &str) -> Session { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + current_cwd: Some(PathBuf::from("/tmp/work/crates")), } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 614b27a..d236d85 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -51,6 +51,13 @@ pub struct Session { /// from daemons that predate the field. #[serde(default = "epoch")] pub last_activity_at: DateTime, + /// The pane's *current* working directory (`#{pane_current_path}`) — distinct + /// from [`Session::cwd`], the directory it was spawned in. The activity + /// watcher refreshes it as the user `cd`s around; it drives the web UI's + /// directory-based session tree. `None` until first observed, and on payloads + /// from daemons that predate the field. + #[serde(default)] + pub current_cwd: Option, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -660,10 +667,7 @@ mod tests { cwd: PathBuf::from("/home/naps62/projects/maestro"), command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), - metadata: HashMap::from([ - ("name".into(), Value::String("maestro-impl".into())), - ("label".into(), Value::String("personal/maestro".into())), - ]), + metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() @@ -673,6 +677,7 @@ mod tests { last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z") .unwrap() .with_timezone(&Utc), + current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), } } @@ -692,6 +697,7 @@ mod tests { assert!(s.foreground.is_none()); assert_eq!(s.activity, ActivityState::Idle); assert_eq!(s.last_activity_at, epoch()); + assert!(s.current_cwd.is_none()); } #[test] diff --git a/crates/maestro-registry/src/state.rs b/crates/maestro-registry/src/state.rs index 8511b98..385e78a 100644 --- a/crates/maestro-registry/src/state.rs +++ b/crates/maestro-registry/src/state.rs @@ -932,6 +932,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-registry/tests/daemon_ws.rs b/crates/maestro-registry/tests/daemon_ws.rs index aa19c54..74d10d1 100644 --- a/crates/maestro-registry/tests/daemon_ws.rs +++ b/crates/maestro-registry/tests/daemon_ws.rs @@ -108,6 +108,8 @@ fn sample_session(id: Uuid, daemon_id: &str) -> Session { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-registry/tests/http_api.rs b/crates/maestro-registry/tests/http_api.rs index 16378c5..fc3ca36 100644 --- a/crates/maestro-registry/tests/http_api.rs +++ b/crates/maestro-registry/tests/http_api.rs @@ -222,6 +222,8 @@ fn mock_session(daemon_id: &str, request: &CreateSessionRequest) -> Session { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index b5475a7..77b6762 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -223,7 +223,6 @@ impl App { daemon_idx: 0, cwd: String::new(), command: String::new(), - label: String::new(), focused_field: Default::default(), }; self.state.overlay = Some(OverlayKind::Spawn); @@ -300,19 +299,12 @@ impl App { } }; let cwd = std::path::PathBuf::from(self.spawn.cwd.clone()); - let mut metadata = std::collections::HashMap::new(); - if !self.spawn.label.is_empty() { - metadata.insert( - "label".to_string(), - serde_json::Value::String(self.spawn.label.clone()), - ); - } let req = CreateSessionRequest { daemon_id, cwd, command: self.spawn.command.clone(), env: Default::default(), - metadata, + metadata: Default::default(), worktree: None, }; let url = format!("{}/api/sessions", self.daemon_base_url); @@ -552,6 +544,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, }], }); app.dispatch(Action::KillHighlighted).await; diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index c194d3d..99936e4 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -222,6 +222,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 40aeec2..054c0f1 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -17,7 +17,6 @@ pub enum SpawnField { Daemon, Cwd, Command, - Label, Submit, } @@ -27,7 +26,6 @@ pub struct SpawnModal { pub daemon_idx: usize, pub cwd: String, pub command: String, - pub label: String, pub focused_field: SpawnField, } @@ -36,8 +34,7 @@ impl SpawnModal { self.focused_field = match self.focused_field { SpawnField::Daemon => SpawnField::Cwd, SpawnField::Cwd => SpawnField::Command, - SpawnField::Command => SpawnField::Label, - SpawnField::Label => SpawnField::Submit, + SpawnField::Command => SpawnField::Submit, SpawnField::Submit => SpawnField::Daemon, }; } @@ -47,8 +44,7 @@ impl SpawnModal { SpawnField::Daemon => SpawnField::Submit, SpawnField::Cwd => SpawnField::Daemon, SpawnField::Command => SpawnField::Cwd, - SpawnField::Label => SpawnField::Command, - SpawnField::Submit => SpawnField::Label, + SpawnField::Submit => SpawnField::Command, }; } @@ -74,7 +70,6 @@ impl SpawnModal { match self.focused_field { SpawnField::Cwd => self.cwd.push(*c), SpawnField::Command => self.command.push(*c), - SpawnField::Label => self.label.push(*c), SpawnField::Daemon => { if !self.daemons.is_empty() { match c { @@ -101,9 +96,6 @@ impl SpawnModal { SpawnField::Command => { self.command.pop(); } - SpawnField::Label => { - self.label.pop(); - } _ => {} } SpawnInputResult::Consumed @@ -113,7 +105,7 @@ impl SpawnModal { } pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 60, 14); + let area = centered(parent, 60, 12); f.render_widget(Clear, area); let block = Block::default().title(" spawn ").borders(Borders::ALL); let inner = block.inner(area); @@ -122,7 +114,6 @@ impl SpawnModal { Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), - Constraint::Length(2), Constraint::Length(1), ]) .split(inner); @@ -156,16 +147,12 @@ impl SpawnModal { field_row("command:", &self.command, highlight(SpawnField::Command)), rows[2], ); - f.render_widget( - field_row("label:", &self.label, highlight(SpawnField::Label)), - rows[3], - ); f.render_widget( Paragraph::new(Line::from(vec![ratatui::text::Span::styled( "[ Spawn ]", highlight(SpawnField::Submit), )])), - rows[4], + rows[3], ); // Place the cursor at the end of the currently-focused text field so @@ -182,7 +169,6 @@ impl SpawnModal { SpawnField::Command => { f.set_cursor_position((cursor_col_for(&self.command), rows[2].y)) } - SpawnField::Label => f.set_cursor_position((cursor_col_for(&self.label), rows[3].y)), SpawnField::Daemon | SpawnField::Submit => { // Non-text fields are indicated by the REVERSED style; no // cursor placement. @@ -317,13 +303,10 @@ mod tests { daemon_idx: 0, cwd: "/x".into(), command: "bash".into(), - label: String::new(), focused_field: SpawnField::Cwd, }; let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); - for s in [ - "daemon", "cwd", "command", "label", "Spawn", "konishi", "/x", "bash", - ] { + for s in ["daemon", "cwd", "command", "Spawn", "konishi", "/x", "bash"] { assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); } } @@ -335,8 +318,9 @@ mod tests { modal.next_field(); assert_eq!(modal.focused_field, SpawnField::Cwd); modal.next_field(); + assert_eq!(modal.focused_field, SpawnField::Command); modal.next_field(); - modal.next_field(); + assert_eq!(modal.focused_field, SpawnField::Submit); modal.next_field(); assert_eq!(modal.focused_field, SpawnField::Daemon); modal.prev_field(); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 4baf0d3..f8d2b93 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -54,7 +54,7 @@ impl Sidebar { (SessionStatus::Running, true) => "●", (SessionStatus::Running, false) => "○", }; - let label = label_for(s); + let label = name_for(s); let style = if self.highlight == Some(s.id) { Style::default().add_modifier(Modifier::REVERSED) } else { @@ -70,10 +70,10 @@ impl Sidebar { } } -/// `metadata.label` if present, else the command line. -fn label_for(s: &Session) -> String { - if let Some(serde_json::Value::String(label)) = s.metadata.get("label") { - label.clone() +/// `metadata.name` if present, else the command line. +fn name_for(s: &Session) -> String { + if let Some(serde_json::Value::String(name)) = s.metadata.get("name") { + name.clone() } else { s.command.clone() } @@ -97,9 +97,9 @@ mod tests { } } - fn session_with_label(id: u128, daemon_id: &str, label: &str) -> Session { + fn session_named(id: u128, daemon_id: &str, name: &str) -> Session { let mut metadata = HashMap::new(); - metadata.insert("label".into(), serde_json::Value::String(label.into())); + metadata.insert("name".into(), serde_json::Value::String(name.into())); Session { id: Uuid::from_u128(id), daemon_id: daemon_id.into(), @@ -112,6 +112,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + current_cwd: None, } } @@ -131,12 +132,12 @@ mod tests { } #[test] - fn renders_hostname_and_session_labels() { + fn renders_hostname_and_session_names() { let sb = Sidebar { daemons: vec![daemon("konishi")], sessions: vec![ - session_with_label(1, "konishi", "claude"), - session_with_label(2, "konishi", "bash"), + session_named(1, "konishi", "claude"), + session_named(2, "konishi", "bash"), ], highlight: None, attached: Default::default(), @@ -148,11 +149,11 @@ mod tests { ); assert!( rendered.contains("claude"), - "missing session label:\n{rendered}" + "missing session name:\n{rendered}" ); assert!( rendered.contains("bash"), - "missing session label:\n{rendered}" + "missing session name:\n{rendered}" ); } @@ -161,7 +162,7 @@ mod tests { let sid = Uuid::from_u128(1); let sb = Sidebar { daemons: vec![daemon("konishi")], - sessions: vec![session_with_label(1, "konishi", "claude")], + sessions: vec![session_named(1, "konishi", "claude")], highlight: None, attached: [sid].into_iter().collect(), }; @@ -179,7 +180,7 @@ mod tests { #[test] fn dead_session_uses_cross_glyph() { - let mut sess = session_with_label(1, "konishi", "claude"); + let mut sess = session_named(1, "konishi", "claude"); sess.status = SessionStatus::Dead; let sb = Sidebar { daemons: vec![daemon("konishi")], @@ -196,7 +197,7 @@ mod tests { } #[test] - fn falls_back_to_command_when_label_metadata_is_absent() { + fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { id: Uuid::from_u128(1), daemon_id: "konishi".into(), @@ -209,6 +210,8 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, }; let sb = Sidebar { daemons: vec![daemon("konishi")], @@ -219,7 +222,7 @@ mod tests { let rendered = render_to_string(&sb, 28, 6); assert!( rendered.contains("vim"), - "missing fallback command label:\n{rendered}" + "missing fallback command name:\n{rendered}" ); } } diff --git a/crates/maestro-tui/tests/catalog_ws.rs b/crates/maestro-tui/tests/catalog_ws.rs index 87dedbc..a264830 100644 --- a/crates/maestro-tui/tests/catalog_ws.rs +++ b/crates/maestro-tui/tests/catalog_ws.rs @@ -36,6 +36,8 @@ fn session(id: u128, daemon_id: &str) -> Session { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + + current_cwd: None, } } diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 640350e..07ef274 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -2,7 +2,7 @@ import { killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { DaemonInfo, Session } from "./types.js"; -import { sessionLabel, sessionName } from "./types.js"; +import { abbreviateCwd, sessionCwd, sessionName } from "./types.js"; import { openSpawnModal } from "./spawn.js"; import { createThemeToggle } from "./theme.js"; @@ -29,21 +29,26 @@ function compareNodes(a: TreeNode, b: TreeNode): number { return an.localeCompare(bn); } -function buildTree(sessions: Session[], filter: string): TreeNode { +function buildTree( + sessions: Session[], + daemons: Map, + filter: string, +): TreeNode { const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] }; const lc = filter.trim().toLowerCase(); for (const s of sessions) { + const home = daemons.get(s.daemon_id)?.home_dir; + const cwd = sessionCwd(s); + const dir = cwd ? abbreviateCwd(cwd, home) : ""; if (lc) { - const haystack = - `${s.daemon_id} ${sessionName(s)} ${sessionLabel(s) ?? ""}`.toLowerCase(); + const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase(); if (!haystack.includes(lc)) continue; } - const label = sessionLabel(s); - // Host is the first level of nesting; the label path nests beneath it, so - // the daemon no longer needs to ride along on each leaf. - const labelParts = label ? label.split("/").filter(Boolean) : []; - const parts = [s.daemon_id, ...labelParts]; + // Host is the first level of nesting; the working directory nests beneath + // it, so the daemon no longer needs to ride along on each leaf. + const dirParts = dir ? dir.split("/").filter(Boolean) : []; + const parts = [s.daemon_id, ...dirParts]; let cursor = root; for (const part of parts) { const path = cursor.path ? `${cursor.path}/${part}` : part; @@ -64,9 +69,39 @@ function buildTree(sessions: Session[], filter: string): TreeNode { for (const c of n.children) if (c.kind === "folder") sortRec(c); }; sortRec(root); + // Collapse linear directory chains *within* each daemon (but never the daemon + // node itself) so `~/git.naps.pt/naps62/maestro` reads as one compact folder + // rather than four nested ones. + for (const daemon of root.children) { + if (daemon.kind === "folder") { + daemon.children = daemon.children.map((c) => + c.kind === "folder" ? mergeLinear(c) : c, + ); + } + } return root; } +/** 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. */ +function mergeLinear(folder: TreeFolder): TreeFolder { + let cur = folder; + while (cur.children.length === 1 && cur.children[0].kind === "folder") { + const child = cur.children[0]; + cur = { + kind: "folder", + name: `${cur.name}/${child.name}`, + path: child.path, + children: child.children, + }; + } + cur.children = cur.children.map((c) => + c.kind === "folder" ? mergeLinear(c) : c, + ); + return cur; +} + function renderNode(node: TreeNode, depth: number): HTMLElement { if (node.kind === "folder") { const wrap = document.createElement("div"); @@ -406,7 +441,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { const refresh = () => { const state = store.snapshot(); const sessions = [...state.sessions.values()]; - const tree = buildTree(sessions, filter); + const tree = buildTree(sessions, state.daemons, filter); treeHost.replaceChildren(); if (sessions.length === 0) { treeHost.appendChild( @@ -427,8 +462,8 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { document.addEventListener("catalog-rerender", rerenderHandler); // Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir, - // command (matched back to a preset by the form), and label — but with a - // freshly generated name rather than the original's, since it's a new session. + // and command (matched back to a preset by the form) — but with a freshly + // generated name rather than the original's, since it's a new session. const spawnSimilarHandler = (ev: Event) => { const s = (ev as CustomEvent).detail; const snap = store.snapshot(); @@ -438,7 +473,6 @@ export function renderSidebar(store: CatalogStore): SidebarHandle { daemon_id: s.daemon_id, cwd: s.cwd, command: s.command, - label: sessionLabel(s) ?? "", }, snap.sessions, ); diff --git a/web/src/kit.ts b/web/src/kit.ts index 73a055d..32a5b96 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -213,25 +213,24 @@ export function renderKit(): HTMLElement { main.appendChild(el); } - // --- session rows, nested by project label --- + // --- session rows, nested by working directory --- { const { el, body } = section( - "Session rows — nested by host, then project", - "Host is the first level of nesting; the project label nests beneath it, so the daemon no longer rides on each row. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.", + "Session rows — nested by host, then directory", + "Host is the first level of nesting; the working directory (home collapsed to ~) nests beneath it, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.", ); const list = h("div", "kit-tree"); list.append( folderHeader("thinkpad", 3, 0), - folderHeader("maestro", 3, 1), + folderHeader("~/git/maestro", 3, 1), folderHeader("web", 2, 2), leafRow({ name: "redesign", daemon: "", cmd: "claude", status: "running", indent: 3 }), leafRow({ name: "dev-server", daemon: "", cmd: "vite dev", status: "running", indent: 3, active: true }), - folderHeader("server", 1, 2), + folderHeader("crates/registry", 1, 2), leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }), folderHeader("tower", 2, 0), - folderHeader("work", 1, 1), - folderHeader("acme", 1, 2), - leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 3 }), + folderHeader("~/work/acme", 1, 1), + leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 2 }), leafRow({ name: "scratch", daemon: "", cmd: "bash", status: "dead", indent: 1 }), folderHeader("pi", 1, 0), leafRow({ name: "monitor", daemon: "", cmd: "btop", status: "running", indent: 1 }), diff --git a/web/src/mock.ts b/web/src/mock.ts index ab76ffd..076b6e3 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -13,22 +13,25 @@ function sess( daemon: string, command: string, status: "Running" | "Dead", - meta: Record, + cwd: string, + name?: string, ): Session { const seed = parseInt(id.slice(-1), 16) || 0; return { id, daemon_id: daemon, - cwd: "/home/dev", + cwd, command, env: {}, - metadata: meta, + metadata: name ? { name } : {}, status, created_at: "2026-06-15T12:00:00Z", // Inferred fields (#18) so the HUD has real data to render in review. foreground: command.split(" ")[0], activity: ACTIVITIES[seed % ACTIVITIES.length], last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(), + // The session tree nests by current_cwd; default it to the spawn dir. + current_cwd: cwd, }; } @@ -90,24 +93,26 @@ export function seedMock(store: CatalogStore): void { { daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" }, ]; const sessions: Session[] = [ - // maestro/web/* — a project with nested sub-labels (two levels deep) - sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web/frontend" }), - sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", { name: "dev-server", label: "maestro/web/frontend" }), - sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web/frontend" }), - sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", { name: "e2e-fixes", label: "maestro/web/testing" }), - // maestro/server/* — sibling project, also nested - sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server/registry" }), - sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server/registry" }), - sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", { name: "daemon", label: "maestro/server/daemon" }), - // work/* — a second top-level group with its own client nesting - sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", { name: "billing-api", label: "work/acme/api" }), - sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", { name: "dashboard", label: "work/acme/web" }), - // personal/* — flat-ish group - sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", { name: "dotfiles", label: "personal" }), - sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", { name: "monitor", label: "personal" }), - // ungrouped — no label, falls to the tree root (name from command) - sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", {}), - sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", {}), + // ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node. + sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web", "redesign"), + sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"), + sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"), + sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"), + // ~/git/maestro/crates/* — sibling subtree under the same repo. + sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", "/home/dev/git/maestro/crates/registry", "registry-broker"), + sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/registry", "test-loop"), + sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"), + // ~/work/acme/* — a separate top-level dir on the same host. + sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", "/home/dev/work/acme/api", "billing-api"), + sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"), + // ~/git/dotfiles — single session, fully collapsed chain. + sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"), + // pi's home (different home_dir) — sits directly under ~. + sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", "/home/pi", "monitor"), + // outside any home dir → shown verbatim (no ~ collapse). + sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", "/etc"), + // home root on thinkpad — a leaf directly under ~ (name from command). + sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", "/home/dev"), ]; // The store applies RegistryEvents internally; feed a Snapshot the same way // the WS would. `apply` is private, so reach it through an index cast. diff --git a/web/src/spawn.ts b/web/src/spawn.ts index fac501a..d093b5d 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -10,13 +10,12 @@ import type { } from "./types.js"; /** Seed values for the spawn form — used by "clone" to reopen the dialog with - * the same daemon, cwd, command, name, and label as an existing session. */ + * the same daemon, cwd, command, and name as an existing session. */ export interface SpawnPrefill { daemon_id?: string; cwd?: string; command?: string; name?: string; - label?: string; } /** Built-ins used when the daemon's `/api/web/info` presets can't be fetched @@ -74,10 +73,6 @@ export function openSpawnModal( -