Compare commits

...
Author SHA1 Message Date
Miguel Palhas c118020b29 docs(maestro): make sidebar card line 2 unconditional
Line 2 was rendered only past a 30s activity hold, blocked work,
exit, or reported Error, and <command> was gated behind a
metadata.name comparison and TUI-optional. Both worked against
scanability: cards changed height as sessions aged, and fresh
sessions hid their command.

Card is now always two lines. The 30s hold rule survives, demoted
to gating only the <duration> fragment. <command> is unconditional
in both clients.

Closes #116
2026-08-25 12:24:56 +01:00
Miguel Palhas 7cb14c7aae chore: empty commit to test the nix-autodeploy webhook
ci / web (push) Successful in 36s
ci / rust (push) Successful in 4m39s
ci / flake (push) Successful in 7m9s
2026-08-25 10:37:16 +01:00
Miguel Palhas 80a9399905 feat(maestro): session cards in the sidebar
ci / web (push) Successful in 29s
ci / rust (push) Successful in 4m25s
ci / flake (push) Successful in 6m34s
Turns each sidebar row into a card: name plus actions on line 1, an
adaptive meta line below carrying a colour-coded activity dot, the time
held in that state, any agent-reported detail, and the blocked-work or
exited markers that used to crowd the name row.

Renders the activity dimension the daemon already inferred and adds an
Error state fed by a Claude Code hook, which maestro installs globally
and addresses per session through MAESTRO_SESSION_ID / MAESTRO_URL.
Both clients implement one contract, docs/SIDEBAR.md.

Closes #101, #102, #103, #104, #105, #106, #107, #108, #110, #111, #112,
#113, #114, #115.
2026-08-24 23:26:59 +01:00
Miguel Palhas 9d9a2e723d merge: sidebar-cards/115-fourth-flake 2026-08-24 23:13:38 +01:00
Miguel Palhas bbafff5a98 fix(maestro): add settle-finished observable for natural-exit test
The no-retained-store test asserts a negative — the tree survives — so
waiting on that negative proves nothing: settle could still remove the
tree one instant after the assertion passes. mark_session_dead now flips
a per-record settled flag once the exit rule has been applied or refused,
exposed as SessionManager::is_settled; the test waits on it before
asserting.

Refs #115
2026-08-24 23:12:46 +01:00
Miguel Palhas 17e1dc4049 merge: sidebar-cards/114-hook-error 2026-08-24 23:01:14 +01:00
Miguel Palhas 436cb194c3 merge: sidebar-cards/113-more-flakes 2026-08-24 23:00:54 +01:00
Miguel Palhas 6471da2624 feat(maestro): report StopFailure as Error with the error spelling as detail 2026-08-24 22:54:21 +01:00
Miguel Palhas 2a43c76176 feat(maestro): report Active while a long tool call is still running 2026-08-24 22:52:51 +01:00
Miguel Palhas f689c916bf fix(maestro): poll awaiting-input until leaderless state holds
The pipeline leader exits as soon as both stages are forked, which can
beat the surviving stage opening the tty and parking on read — the one-
shot assertion then ran against a group with no parked member yet.
Poll for the answer, accepting it only while the foreground group is
still leaderless so a shell that regained the tty cannot satisfy it.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 88233cb96d fix(maestro): sync watcher re-emit test to real event flow
Two races. First, the watcher updates the snapshot and emits
SessionUpdated as two steps, so the drain could run between them: the
legitimate breakdown emission then arrived inside the quiet window.
Subscribe before the watcher starts and consume through the breakdown
event so the window begins after the last real change.

Second, the assertion treated any SessionUpdated as a failed
suppression, but the activity pass may legitimately announce an
Active→Idle transition in that window (ACTIVE_WINDOW is 3s), carrying
today's blocked_work unchanged. Only a changed indicator counts as a
re-emit now.

Fixes #113
2026-08-24 22:50:02 +01:00
Miguel Palhas 54d84e3eaa docs(maestro): settle Error report TTL and clearing rulings 2026-08-24 22:46:46 +01:00
Miguel Palhas efbccb4e11 fix(maestro): wait for settle in pristine natural-exit test
Dead status is observable a moment before the settle step removes the
worktree, so the assertions raced the removal under parallel load. Wait
for the tree to be gone and the store to stay empty, same as the
retained-store wait cfebfd4 added to the guarded sibling.

Fixes #113
2026-08-24 22:41:54 +01:00
Miguel Palhas 41c498401b merge: sidebar-cards/112-detail-render 2026-08-24 22:30:49 +01:00
Miguel Palhas 6ca347b657 merge: sidebar-cards/106-hooks-install 2026-08-24 22:30:49 +01:00
Miguel Palhas f60587a6d6 merge: sidebar-cards base into 112-detail-render 2026-08-24 22:22:09 +01:00
Miguel Palhas 43946c3c46 feat(maestro): render agent-reported detail on TUI session cards 2026-08-24 22:22:06 +01:00
Miguel Palhas c24a2133d4 feat(maestro): render agent-reported detail on web session cards 2026-08-24 22:13:59 +01:00
Miguel Palhas 75ccfe10d2 docs(maestro): pin detail truncation ahead of trailing meta fragments 2026-08-24 22:13:04 +01:00
10 changed files with 433 additions and 84 deletions
+26
View File
@@ -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 });
+19 -4
View File
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
"never observed a foreground group whose leader had exited"
);
assert!(
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
"a surviving pipeline member still holds the tty"
);
// The leader can be gone while the surviving stage is still setting up:
// `true` exits as soon as both stages are forked, before the reader has
// opened the tty and parked. Poll for the answer, but only accept it
// while the state this test exists for still holds — otherwise a shell
// that regained the terminal would satisfy it vacuously.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut holds = false;
while std::time::Instant::now() < deadline {
let groups = ProcGroups::snapshot();
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
});
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
holds = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(holds, "a surviving pipeline member still holds the tty");
}
+55 -6
View File
@@ -397,6 +397,12 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
// Subscribe before the watcher starts so every pass's emission is
// captured — otherwise the breakdown event can land between the
// snapshot observation below and the drain, and the quiet window
// would see it as an illegitimate re-emit.
let mut events = fx.manager.events();
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
@@ -418,14 +424,38 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
})
.await;
// A steady state must not re-emit every tick (#83 change-detection).
let mut events = fx.manager.events();
// Consume through the emission that carried the breakdown (#83
// change-detection): the quiet window below must start after the last
// legitimate change, not race it into the channel.
loop {
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
Ok(Ok(CatalogEvent::SessionUpdated { session }))
if matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
) =>
{
break;
}
Ok(Ok(_)) => continue,
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
}
}
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
"an unchanged worktree must not re-emit SessionUpdated"
);
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
// The activity pass may still legitimately announce e.g. Active→Idle
// (its debounce is time-based, not worktree-based), and its payload
// carries today's blocked_work unchanged.
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
assert!(
matches!(
&session.blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
),
"an unchanged worktree must not re-emit a changed indicator"
);
}
}
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
@@ -848,6 +878,17 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
})
.await;
// (`Dead` is observable a moment before the settle step removes the tree.)
wait_for_async(Duration::from_secs(3), || async {
!wt_path.exists()
&& fx
.retained_store()
.load_all()
.map(|all| all.is_empty())
.unwrap_or(false)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
@@ -905,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"
+28 -6
View File
@@ -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
+112 -40
View File
@@ -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:?}"
);
}
+103 -2
View File
@@ -160,12 +160,26 @@ impl Sidebar {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
}
// The trailing fragments are measured up front so a long detail
// truncates ahead of them instead of pushing them off the line
// (docs/SIDEBAR.md).
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
}
if dead {
spans.push(Span::styled(" exited", muted));
tail.push(Span::styled(" exited", muted));
}
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let budget = width.saturating_sub(head_w + tail_w);
spans.extend(fit_to_width(
vec![Span::styled(format!(" {detail}"), muted)],
budget,
));
}
spans.extend(tail);
Some(fit_to_width(spans, width))
}
}
@@ -531,6 +545,93 @@ mod tests {
);
}
/// #112: the agent-reported detail sits right after the duration on the
/// meta line, in the muted style (docs/SIDEBAR.md).
#[test]
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
let mut s = session_named(1, "claude");
s.activity = ActivityState::Error;
s.activity_since = Utc::now() - Duration::seconds(90);
s.activity_detail = Some("cargo build failed".into());
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(
meta.contains("1m"),
"duration precedes the detail: {meta:?}"
);
assert!(
meta.contains("cargo build failed"),
"reported detail must render: {meta:?}"
);
}
/// A fresh Error report earns line 2 on its own (contract rule 4), with
/// the detail even though no duration has accrued.
#[test]
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
let mut s = session_named(1, "claude");
s.activity = ActivityState::Error;
s.activity_since = Utc::now() - Duration::seconds(5);
s.activity_detail = Some("waiting for approval".into());
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx + 1].contains("waiting for approval"),
"detail must render on a fresh report's own meta line: {:?}",
all[idx + 1]
);
}
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
/// trailing fragments — a long detail never pushes blocked work or
/// `exited` off the line, and never wraps.
#[test]
fn long_detail_yields_to_blocked_work_and_exited() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dead;
s.activity_since = Utc::now() - Duration::seconds(5);
s.activity_detail =
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
s.blocked_work = Some(InTheWay {
unpushed_commits: 2,
..InTheWay::default()
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
assert!(
meta.contains("2 unpushed commits"),
"blocked work stays on the line: {meta:?}"
);
assert!(
meta.contains("exited"),
"exited fragment stays on the line: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
"meta line must not wrap: {:?}",
all[idx + 2]
);
}
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
/// `activity_since` and so contributes no duration — a card must not claim
/// the session has been idle since 1970. The dot still renders when some
+44 -26
View File
@@ -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,41 +24,46 @@ 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
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it.
lapses at its TTL or is replaced, the detail goes with it. The detail
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
@@ -104,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
+32
View File
@@ -105,6 +105,38 @@ describe("blocked-work indicator (#83)", () => {
});
});
describe("agent-reported detail (#112)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("renders the reported detail right after the duration on line 2", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000020",
status: "Dead",
activity: "Error",
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
);
});
it("omits the detail when no report is in force", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000021",
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
+3
View File
@@ -42,6 +42,9 @@ function metaLine(s: Session, now: number): string {
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
+11
View File
@@ -851,6 +851,17 @@ textarea {
flex: 0 0 auto;
}
/* Agent-reported detail (#112): sits after the duration, shrinks with an
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
signals severity and "exited" dims with the row. */
.card-flag {