Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f3ef0c5 | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 |
@@ -3,6 +3,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||||
SessionStatus, WorktreeSpec,
|
SessionStatus, WorktreeSpec,
|
||||||
@@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub(crate) 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||||
|
|
||||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||||
use crate::sessions::abbreviate_dir;
|
|
||||||
|
|
||||||
/// `maestro worktrees list`
|
/// `maestro worktrees list`
|
||||||
pub async fn run_list(
|
pub async fn run_list(
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! 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::Session;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod display;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
#[cfg(feature = "client")]
|
||||||
|
|||||||
+26
-25
@@ -5,8 +5,7 @@ sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
|||||||
Read this before changing either. Both clients implement exactly what is
|
Read this before changing either. Both clients implement exactly what is
|
||||||
written here; if it needs to change, change it here first.
|
written here; if it needs to change, change it here first.
|
||||||
|
|
||||||
A card has **line 1** always and **line 2** only when line 2 has something to
|
A card is always two lines: **line 1** and **line 2**.
|
||||||
say.
|
|
||||||
|
|
||||||
## Line 1 — name + actions
|
## Line 1 — name + actions
|
||||||
|
|
||||||
@@ -25,32 +24,32 @@ badge move off this row into line 2.
|
|||||||
|
|
||||||
## Line 2 — meta line (adaptive)
|
## Line 2 — meta line (adaptive)
|
||||||
|
|
||||||
Rendered only when at least one of these holds:
|
Line 2 always renders — never an empty placeholder row either, since its
|
||||||
|
unconditional fragments (see below) always have something to show. Its
|
||||||
|
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||||
|
itself is not.
|
||||||
|
|
||||||
1. The session has been held in its current activity state longer than
|
Line 2 reads left to right:
|
||||||
**30 seconds** (`activity_since` from #102). A freshly-active session
|
|
||||||
stays one line.
|
|
||||||
2. Its worktree holds blocked work: `blocked_work` is `Some` and not clear
|
|
||||||
(`InTheWay::is_clear()`), rendered in `formatInTheWay`'s phrasing on web
|
|
||||||
and as `⚠ <summary>` on the TUI.
|
|
||||||
3. It has exited: `status == Dead`.
|
|
||||||
4. An agent-reported state (#105) of `Error` is in force (see Precedence).
|
|
||||||
|
|
||||||
Otherwise line 2 is absent — never an empty placeholder row.
|
|
||||||
|
|
||||||
When present, line 2 reads left to right:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
|
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Its minimum content is therefore the dot and the command.
|
||||||
|
|
||||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||||
state's colour, and `<duration>` follows in the same colour. There is no state
|
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||||
word and no second badge: the dot exists so the state is still legible when a
|
is no state word and no second badge: the dot exists so the state is still
|
||||||
row is dimmed or the terminal renders the duration in the default fg. The dot
|
legible when a row is dimmed or the terminal renders the duration in the
|
||||||
and duration are unconditional parts of line 2; the bracketed fragments appear
|
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||||
only when their trigger holds. A payload that predates #102 (no usable
|
fragments appear only when their trigger holds.
|
||||||
`activity_since`) contributes no duration — the dot still renders.
|
|
||||||
|
`<duration>` renders only once the session has been held in its current
|
||||||
|
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||||
|
that threshold the duration is absent and the dot renders alone. A payload
|
||||||
|
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||||
|
duration — the dot still renders. This rule now governs only the duration
|
||||||
|
fragment; it no longer decides whether line 2 exists.
|
||||||
|
|
||||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||||
@@ -60,9 +59,11 @@ truncates with `…` ahead of the trailing fragments — a long detail never
|
|||||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||||
client ever lets it wrap.
|
client ever lets it wrap.
|
||||||
|
|
||||||
`<command>` is optional and muted: the session's command line, shown only when
|
`<command>` is unconditional and muted: the session's command line, shown on
|
||||||
the rendered name came from `metadata.name` and differs from the command (the
|
every card in both clients — no omission in the TUI, no name-vs-command
|
||||||
web has always surfaced this; the TUI may omit it).
|
comparison gating it. A session named by its command line then shows the same
|
||||||
|
string on both lines; that is accepted, the price of a row that never changes
|
||||||
|
height.
|
||||||
|
|
||||||
## Colour per activity state
|
## Colour per activity state
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user