Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f3ef0c5 | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 | ||
|
|
9d9a2e723d | ||
|
|
bbafff5a98 | ||
|
|
17e1dc4049 | ||
|
|
436cb194c3 | ||
|
|
6471da2624 | ||
|
|
2a43c76176 | ||
|
|
54d84e3eaa |
@@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||
SessionStatus, WorktreeSpec,
|
||||
@@ -138,21 +139,6 @@ pub(crate) fn blocked_label(s: &Session) -> String {
|
||||
.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)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
use crate::sessions::abbreviate_dir;
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
|
||||
@@ -62,6 +62,11 @@ pub struct SessionRecord {
|
||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||
/// field is daemon-local bookkeeping only — it never rides the wire.
|
||||
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
||||
/// Flipped once the natural-exit settle step (#82) has been applied or
|
||||
/// refused for this session. `Dead` is observable before settling ends
|
||||
/// (#115), so this is the observable that says its outcomes are safe to
|
||||
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||
pub settled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -294,6 +299,17 @@ impl SessionManager {
|
||||
sessions.get(&id).map(|r| r.session.status)
|
||||
}
|
||||
|
||||
/// Whether the natural-exit settle step (#82) has finished for this
|
||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||
/// before settling ends (#115), so anything asserting on a dead
|
||||
/// session's worktree waits on this rather than inferring settle
|
||||
/// completion from its side effects.
|
||||
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
sessions.get(&id).map(|r| r.settled)
|
||||
}
|
||||
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||
@@ -429,6 +445,7 @@ impl SessionManager {
|
||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -657,6 +674,7 @@ impl SessionManager {
|
||||
worktree_branch: None,
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -1069,6 +1087,14 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settle has now been applied or refused either way (#115): from here on
|
||||
// its outcomes are safe to observe.
|
||||
{
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.settled = true;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = inner.sidecar.write(&session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
|
||||
@@ -946,6 +946,14 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// The settle step has been applied or refused either way — here refused,
|
||||
// since there is no store to record the tree into. Only from this point
|
||||
// is "the tree survived" a settled fact rather than a race (#115).
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
manager.is_settled(s.id).await == Some(true)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"nothing may be destroyed when it cannot be recorded"
|
||||
|
||||
@@ -25,13 +25,25 @@ pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 3] = [
|
||||
// Tool finished: the agent is demonstrably producing work.
|
||||
///
|
||||
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
|
||||
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
||||
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
// Tool finished successfully.
|
||||
("PostToolUse", "Active"),
|
||||
// Permission / idle notices: Claude is blocked on the user.
|
||||
("Notification", "AwaitingInput"),
|
||||
// Turn ended: waiting on the user again.
|
||||
("Stop", "AwaitingInput"),
|
||||
// Turn ended on an API error; the payload's `error` field is forwarded
|
||||
// as the report's `detail` (#114).
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
@@ -39,17 +51,27 @@ pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PostToolUse -> Active (tool finished; agent is working)
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
# Drain the hook payload so the parent never sees a broken pipe.
|
||||
cat >/dev/null 2>&1
|
||||
payload=$(cat 2>/dev/null) || true
|
||||
body="{\"state\":\"$state\"}"
|
||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||
# prose) would risk breaking the JSON body, and the spelling already names
|
||||
# the failure.
|
||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||
if [ -n "$detail" ]; then
|
||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||
fi
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"state\":\"$state\"}" \
|
||||
-d "$body" \
|
||||
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
|
||||
let mut argv = vec!["-c", script, HOOK_MARKER];
|
||||
@@ -26,21 +26,48 @@ fn sh(script: &str, args: &[&str], envs: &[(&str, &str)]) -> std::process::Outpu
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh");
|
||||
// Feed a Claude-Code-style payload on stdin; the script must drain it.
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(br#"{"session_id":"x"}"#)
|
||||
.ok();
|
||||
child.stdin.take().unwrap().write_all(payload).ok();
|
||||
child.wait_with_output().expect("wait")
|
||||
}
|
||||
|
||||
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
|
||||
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
|
||||
/// hand back the raw request text. Runs `run` with the base URL once the
|
||||
/// acceptor thread is in place.
|
||||
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||
});
|
||||
let out = run(format!("http://{addr}"));
|
||||
assert!(out.status.success(), "{:?}", out.stderr);
|
||||
handle.join().unwrap()
|
||||
}
|
||||
|
||||
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("MAESTRO_URL", url.to_string()),
|
||||
("MAESTRO_SESSION_ID", SESSION.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
|
||||
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
|
||||
}
|
||||
|
||||
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[]);
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
|
||||
@@ -51,46 +78,82 @@ fn script_exits_silently_without_maestro_env() {
|
||||
/// activity endpoint and still exits 0.
|
||||
#[test]
|
||||
fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
let raw = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
raw
|
||||
};
|
||||
let handle = std::thread::spawn(server);
|
||||
|
||||
let url = format!("http://{addr}");
|
||||
let session = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
// The installer bakes the mapped state into the command's argv; the
|
||||
// script relays whatever it is given.
|
||||
let out = sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&[("MAESTRO_URL", &url), ("MAESTRO_SESSION_ID", session)],
|
||||
);
|
||||
assert!(out.status.success());
|
||||
|
||||
let request = handle.join().unwrap();
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"x"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.starts_with("POST "), "{request}");
|
||||
assert!(
|
||||
request.contains(&format!("/api/sessions/{session}/activity")),
|
||||
request.contains(&format!("/api/sessions/{SESSION}/activity")),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
}
|
||||
|
||||
/// Every event maps to the state the issue specifies.
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["StopFailure"], "Error");
|
||||
}
|
||||
|
||||
/// A `StopFailure` payload names the API error that ended the turn; the
|
||||
/// script reports its `error` spelling as the report's `detail` so a card
|
||||
/// can show why (#114).
|
||||
#[test]
|
||||
fn script_reports_the_error_spelling_as_detail() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||
/// one at all) must never produce a broken body — no detail is posted.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_an_unexpected_error_value() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// Events whose payload carries no error fields post a bare body — never an
|
||||
/// empty or stale detail.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_events_without_an_error() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["AwaitingInput"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"AwaitingInput""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// A dead or unreachable daemon must never fail — or slow down — a session.
|
||||
@@ -104,6 +167,7 @@ fn script_ignores_daemon_failure() {
|
||||
("MAESTRO_URL", "http://127.0.0.1:1"),
|
||||
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
|
||||
],
|
||||
br#"{"session_id":"x"}"#,
|
||||
);
|
||||
assert!(out.status.success());
|
||||
}
|
||||
@@ -138,9 +202,10 @@ fn fresh_install_registers_every_event() {
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), 3);
|
||||
assert_eq!(cmds.len(), HOOK_EVENTS.len());
|
||||
let joined = cmds.join("\n");
|
||||
assert!(joined.contains("Active"));
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
assert_eq!(joined.matches("AwaitingInput").count(), 2);
|
||||
for cmd in &cmds {
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
@@ -158,7 +223,10 @@ fn install_is_idempotent() {
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(installed_commands(&settings_path(home)).len(), 3);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
HOOK_EVENTS.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An older maestro entry under the same events is updated in place, not
|
||||
@@ -188,7 +256,11 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(cmds.len(), 3, "old entry replaced, not kept: {cmds:?}");
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
HOOK_EVENTS.len(),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
@@ -231,11 +303,11 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
|
||||
// Stale maestro event gone.
|
||||
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
||||
// Our three events present, nothing else maestro-related anywhere.
|
||||
// Our events present, nothing else maestro-related anywhere.
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
3,
|
||||
HOOK_EVENTS.len(),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
pub mod client;
|
||||
pub mod display;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
|
||||
+40
-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
|
||||
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
|
||||
say.
|
||||
A card is always two lines: **line 1** and **line 2**.
|
||||
|
||||
## Line 1 — name + actions
|
||||
|
||||
@@ -25,32 +24,32 @@ badge move off this row into line 2.
|
||||
|
||||
## 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
|
||||
**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:
|
||||
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
|
||||
state's colour, and `<duration>` follows in the same colour. There is no state
|
||||
word and no second badge: the dot exists so the state is still legible when a
|
||||
row is dimmed or the terminal renders the duration in the default fg. The dot
|
||||
and duration are unconditional parts of line 2; the bracketed fragments appear
|
||||
only when their trigger holds. A payload that predates #102 (no usable
|
||||
`activity_since`) contributes no duration — the dot still renders.
|
||||
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||
is no state word and no second badge: the dot exists so the state is still
|
||||
legible when a row is dimmed or the terminal renders the duration in the
|
||||
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||
fragments appear only when their trigger holds.
|
||||
|
||||
`<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
|
||||
`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
|
||||
client ever lets it wrap.
|
||||
|
||||
`<command>` is optional and muted: the session's command line, shown only when
|
||||
the rendered name came from `metadata.name` and differs from the command (the
|
||||
web has always surfaced this; the TUI may omit it).
|
||||
`<command>` is unconditional and muted: the session's command line, shown on
|
||||
every card in both clients — no omission in the TUI, no name-vs-command
|
||||
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
|
||||
|
||||
@@ -107,6 +108,20 @@ When the TTL expires, inference resumes and the session's reported detail —
|
||||
if any — clears with the report. Clients never blend the two: whichever source
|
||||
is in force supplies both the colour and the clock for `activity_since`.
|
||||
|
||||
Two rulings specific to a reported `Error` (#114):
|
||||
|
||||
- **The five-minute TTL applies to `Error` like to any other state.** A
|
||||
session that failed is still failed after five minutes, but a card pinned
|
||||
red forever lies once the session moves on; when the report lapses the card
|
||||
returns to the daemon's own inference — usually `Idle` for a session that
|
||||
has gone quiet.
|
||||
- **Only a new report clears an `Error` before its TTL.** The next
|
||||
`ActivityState` of any kind replaces it (with #106's hook that is typically
|
||||
`PreToolUse` → `Active` when the user prompts again). PTY output and
|
||||
process changes do not clear it — only the agent itself may withdraw what
|
||||
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||
stacking reports.
|
||||
|
||||
If a client receives an `ActivityState` variant it does not know, it must not
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
until the daemon sends a variant it does know. Rust clients deserialize
|
||||
|
||||
Reference in New Issue
Block a user