Compare commits

...
Author SHA1 Message Date
Miguel Palhas c7c5b2e0ec feat(maestro): show spawn-similar and kill affordances on the highlighted card
Line-1 actions anchor to the card's top-right corner on the highlighted
row only, reserving their width so the name truncates with an ellipsis
before it can ever reach them.
2026-08-24 19:53:27 +01:00
Miguel Palhas 323ead5a83 feat(maestro): give TUI cards explicit card and focus backgrounds
Reversed video made a highlighted card unreadable. Cards now sit on a
black card background painted edge to edge; the highlighted card swaps it
for black-on-cyan, matching the status bar's focus treatment.
2026-08-24 19:50:37 +01:00
Miguel Palhas bb28fe1d6e feat(maestro): render an adaptive meta line under TUI session cards
Per docs/SIDEBAR.md: line 2 carries the activity dot + floored duration in
the state's colour, plus the blocked-work and exited fragments; it appears
only when time-in-state exceeds 30s, work is blocked, the session is dead,
or an Error state is in force. Blocked work moves off line 1. Card lines
truncate with an ellipsis instead of wrapping.
2026-08-24 19:48:23 +01:00
Miguel Palhas 581ec29492 docs(maestro): name the activity dot in the card contract 2026-08-24 19:32:08 +01:00
Miguel Palhas 90b7953b17 merge: sidebar-cards/103-session-env 2026-08-24 19:31:35 +01:00
Miguel Palhas 26e7992c8d merge: sidebar-cards/102-activity-wire 2026-08-24 19:31:35 +01:00
Miguel Palhas 60515dd8c6 merge: sidebar-cards/101-sidebar-contract 2026-08-24 19:31:35 +01:00
Miguel Palhas 1a0d1284e1 feat(maestro): mirror activity_since and Error state in web types 2026-08-24 19:22:47 +01:00
Miguel Palhas fc6f3469b0 feat(maestro): add Error activity state to the wire 2026-08-24 19:21:44 +01:00
Miguel Palhas b95b5c04c1 feat(maestro): add activity_since to the session wire 2026-08-24 19:19:33 +01:00
Miguel Palhas 6af8abccd3 docs(maestro): add SIDEBAR.md session card contract 2026-08-24 19:17:04 +01:00
11 changed files with 661 additions and 69 deletions
+4
View File
@@ -66,6 +66,10 @@ See `docs/INSTALL.md` for end-user install instructions.
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
+2
View File
@@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
ActivityState::Error => "error",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
@@ -176,6 +177,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+2
View File
@@ -380,6 +380,7 @@ impl SessionManager {
foreground: None,
activity: ActivityState::Active,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
@@ -599,6 +600,7 @@ impl SessionManager {
foreground: None,
activity: ActivityState::Idle,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+1
View File
@@ -27,6 +27,7 @@ fn sample(id: Uuid) -> Session {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
}
+42
View File
@@ -80,6 +80,13 @@ pub struct Session {
/// from daemons that predate the field.
#[serde(default = "epoch")]
pub last_activity_at: DateTime<Utc>,
/// Wall-clock time at which the session entered its current
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
/// tracks the last observed PTY output. Populated by #104; defaults to the
/// Unix epoch on payloads from daemons that predate the field.
#[serde(default = "epoch")]
pub activity_since: DateTime<Utc>,
/// 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
@@ -119,6 +126,9 @@ pub enum ActivityState {
Idle,
/// Blocked on a `read()` from the pty — waiting on the user.
AwaitingInput,
/// The agent running in the session reported a failure (#102). Never
/// inferred by the daemon; only set when an agent reports it (#105).
Error,
}
/// Optional worktree request attached to a `CreateSessionRequest`.
@@ -537,6 +547,9 @@ mod tests {
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
.unwrap()
.with_timezone(&Utc),
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
.unwrap()
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
}
@@ -588,6 +601,35 @@ mod tests {
);
}
#[test]
fn activity_since_defaults_to_epoch_on_legacy_payload() {
// A daemon predating #102 omits `activity_since`; it must deserialize
// to the forward-compat default rather than failing, and a present
// value round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.activity_since, epoch());
let full = sample_session();
assert_eq!(full, round_trip(&full));
}
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
#[test]
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
assert_eq!(
serde_json::to_value(ActivityState::Error).unwrap(),
serde_json::json!("Error")
);
assert_eq!(ActivityState::default(), ActivityState::Idle);
}
#[test]
fn activity_state_serializes_pascal_case_and_defaults_idle() {
assert_eq!(
+1
View File
@@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+2
View File
@@ -1127,6 +1127,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}],
@@ -1644,6 +1645,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
+1
View File
@@ -194,6 +194,7 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
+501 -67
View File
@@ -1,10 +1,14 @@
//! Sidebar widget — session list, navigation-only.
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
//!
//! Status glyphs: ● attached / ○ live / ✗ dead.
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
//! Line 2: activity dot + duration in the state's colour, plus the
//! blocked-work and exited fragments when they apply. Absent entirely when
//! it has nothing to say.
use std::collections::HashSet;
use maestro_protocol::{Session, SessionStatus};
use chrono::Utc;
use maestro_protocol::{ActivityState, Session, SessionStatus};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -12,6 +16,26 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
/// Time-in-state alone earns a meta line only once the session has sat in
/// its current activity state this long (docs/SIDEBAR.md).
const META_LINE_AFTER_SECS: i64 = 30;
const INDENT: &str = " ";
const META_INDENT: &str = " ";
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
const ACTIONS: &str = "⧉ ✕";
/// Every card sits on this background so cards read as cards against the
/// panel; the highlighted card swaps it for the focus background instead
/// (`Modifier::REVERSED` made the card unreadable against a background).
const CARD_BG: Color = Color::Black;
fn card_base(highlighted: bool) -> Style {
if highlighted {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().bg(CARD_BG)
}
}
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
@@ -41,36 +65,155 @@ impl Sidebar {
let inner = block.inner(area);
f.render_widget(block, area);
let width = inner.width as usize;
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"sessions",
Style::default().add_modifier(Modifier::BOLD),
))];
for s in &self.sessions {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
// #83: a session whose worktree blocks a kill gets a warning
// glyph; the kill confirm names the specifics.
let blocked = s
.blocked_work
.filter(|b| !b.is_clear())
.map(|_| " ⚠")
.unwrap_or_default();
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}{blocked}"), style),
]));
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
lines.push(Line::from(self.name_row(s, width)));
if let Some(meta) = self.meta_row(s, width) {
lines.push(Line::from(pad_to_width(meta, width, base)));
}
}
f.render_widget(Paragraph::new(lines), inner);
}
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
// Line-1 actions are keyboard affordances shown on the highlighted
// row only; they reserve their width so the name truncates with `…`
// before it can ever reach them (docs/SIDEBAR.md).
let actions_width = if highlighted {
ACTIONS.chars().count()
} else {
0
};
let usable = width.saturating_sub(actions_width);
let prefix = INDENT.chars().count() + 2;
// Keep one column of separation between a truncated name and the
// actions so the ellipsis never touches them.
let sep = if highlighted { 1 } else { 0 };
let name_budget = usable.saturating_sub(prefix + sep);
let mut spans: Vec<Span<'static>> = vec![
Span::styled(INDENT, base),
Span::styled(format!("{glyph} "), base),
];
spans.extend(
fit_to_width(vec![Span::raw(name_for(s))], name_budget)
.into_iter()
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
);
let used: usize = spans.iter().map(|sp| sp.width()).sum();
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
spans.push(Span::styled(
" ".repeat(fill),
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
));
}
if highlighted {
spans.push(Span::styled(ACTIONS, base));
}
spans
}
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
let now = Utc::now();
let held_secs = (now - s.activity_since).num_seconds();
// #83: a worktree that blocks a kill is summarised here rather than
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let errored = s.activity == ActivityState::Error;
if held_secs < META_LINE_AFTER_SECS && blocked.is_none() && !dead && !errored {
return None;
}
let base = card_base(self.highlight == Some(s.id));
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
let duration = format_state_duration(now - s.activity_since);
let mut spans = vec![
Span::styled(META_INDENT, base),
Span::styled("●", dot_style),
Span::styled(" ", base),
Span::styled(duration, dot_style),
];
if let Some(b) = blocked {
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
}
if dead {
spans.push(Span::styled(" exited", muted));
}
Some(fit_to_width(spans, width))
}
}
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
/// word is never spelled out — this colour carries it.
fn activity_color(state: ActivityState) -> Color {
match state {
ActivityState::Active => Color::Green,
ActivityState::Idle => Color::DarkGray,
ActivityState::AwaitingInput => Color::Yellow,
ActivityState::Error => Color::Red,
}
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
/// measured in the current activity state. No days, no rounding up.
fn format_state_duration(elapsed: chrono::Duration) -> String {
let secs = elapsed.num_seconds().max(0);
match secs {
0..=59 => format!("{secs}s"),
60..=3599 => format!("{}m", secs / 60),
_ => format!("{}h", secs / 3600),
}
}
/// Truncate a span list to `max` columns, ending it with `…` when cut. The
/// sidebar is narrow: card lines truncate, they never wrap.
fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
let mut used = 0usize;
for span in spans {
let w = span.width();
if used + w <= max {
used += w;
out.push(span);
continue;
}
let budget = max.saturating_sub(used + 1);
let ellipsis = "…";
if budget > 0 {
let text: String = span.content.chars().take(budget).collect();
out.push(Span::styled(format!("{text}{ellipsis}"), span.style));
} else if used < max {
out.push(Span::styled(ellipsis, span.style));
}
return out;
}
out
}
/// Pad a card row out to the full sidebar width so the card background
/// paints edge to edge rather than stopping at the last glyph.
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
let used: usize = spans.iter().map(|sp| sp.width()).sum();
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
spans.push(Span::styled(" ".repeat(fill), pad_style));
}
spans
}
/// `metadata.name` if present, else the command line.
@@ -85,8 +228,10 @@ fn name_for(s: &Session) -> String {
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use chrono::Duration;
use maestro_protocol::InTheWay;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::Terminal;
use std::collections::HashMap;
@@ -104,25 +249,43 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
terminal.backend().buffer().clone()
}
fn rows(buf: &Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
fn render_to_string(buf: &Buffer) -> String {
rows(buf).join("\n")
}
fn row_containing<'a>(rows: &'a [String], needle: &str) -> &'a str {
rows.iter()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no row containing {needle:?} in {rows:#?}"))
}
/// Row text without the trailing space padding and right border.
fn content(row: &str) -> &str {
row.trim_end().trim_end_matches('│').trim_end()
}
#[test]
@@ -132,7 +295,7 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 12);
let rendered = render_to_string(&draw(&sb, 28, 12));
assert!(
rendered.contains("claude"),
"missing session name:\n{rendered}"
@@ -151,11 +314,8 @@ mod tests {
highlight: None,
attached: [sid].into_iter().collect(),
};
let rendered = render_to_string(&sb, 28, 6);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("claude row");
let all = rows(&draw(&sb, 28, 6));
let line = row_containing(&all, "claude");
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
assert!(
!line.contains('○'),
@@ -172,21 +332,18 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 6);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("claude row");
let buf = draw(&sb, 28, 6);
let all = rows(&buf);
let line = row_containing(&all, "claude");
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
}
/// #83: a session whose worktree blocks a kill carries the warning glyph;
/// a clear or unprobed one does not.
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
/// on the name row; a clear or unprobed worktree never warns.
#[test]
fn blocked_session_gets_a_warning_glyph() {
use maestro_protocol::InTheWay;
fn blocked_session_warns_on_its_meta_line() {
let mut blocked = session_named(1, "claude");
blocked.activity_since = Utc::now() - Duration::seconds(5);
blocked.blocked_work = Some(InTheWay {
unpushed_commits: 4,
..InTheWay::default()
@@ -199,21 +356,158 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 40, 8);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("blocked row");
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
let all = rows(&draw(&sb, 40, 10));
let meta = all
.iter()
.find(|l| l.contains("⚠"))
.expect("blocked card must warn somewhere");
assert!(
meta.contains("4 unpushed commits"),
"warning must carry InTheWay's summary: {meta:?}"
);
let name_row = row_containing(&all, "claude");
assert!(
!name_row.contains('⚠'),
"warning moved off the name row: {name_row:?}"
);
for name in ["bash", "vim"] {
let line = rendered
.lines()
.find(|l| l.contains(name))
.expect("clear row");
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
let idx = all.iter().position(|l| l.contains(name)).unwrap();
assert!(
!all[idx + 1].contains('⚠'),
"{name} must not warn: {:?}",
all[idx + 1]
);
}
}
/// A freshly-active session stays one line — never an empty placeholder
/// row (docs/SIDEBAR.md).
#[test]
fn freshly_active_session_stays_one_line() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(10);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
content(&all[2]).is_empty(),
"line 2 must be absent, got {:?}",
all[2]
);
}
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
/// meta line, with the floored duration next to the dot.
#[test]
fn long_held_session_gets_a_meta_line_with_duration() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(90);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 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"), "expected 1m in: {meta:?}");
}
#[test]
fn duration_format_floors_whole_units() {
let fmt = |secs| format_state_duration(Duration::seconds(secs));
assert_eq!(fmt(0), "0s");
assert_eq!(fmt(45), "45s");
assert_eq!(fmt(59), "59s");
assert_eq!(fmt(60), "1m");
assert_eq!(fmt(240), "4m");
assert_eq!(fmt(3599), "59m");
assert_eq!(fmt(3600), "1h");
assert_eq!(fmt(7200), "2h");
}
/// The dot and duration carry the activity state's colour; the state word
/// is never spelled out (docs/SIDEBAR.md colour table).
#[test]
fn meta_line_dot_uses_the_contract_colour_per_state() {
for (state, expected) in [
(ActivityState::Active, Color::Green),
(ActivityState::Idle, Color::DarkGray),
(ActivityState::AwaitingInput, Color::Yellow),
(ActivityState::Error, Color::Red),
] {
let mut s = session_named(1, "claude");
s.activity = state;
s.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
let found = (0..buf.area.height)
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().fg == Some(expected)));
assert!(found, "expected some cell in fg {expected:?} for {state:?}");
}
}
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
/// even freshly dead, with the muted `exited` fragment.
#[test]
fn dead_session_shows_exited_on_its_meta_line() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dead;
s.activity_since = Utc::now() - Duration::seconds(5);
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx + 1].contains("exited"),
"expected exited fragment in: {:?}",
all[idx + 1]
);
}
/// The sidebar is narrow: the meta line truncates with `…` and never
/// wraps onto a third row (docs/SIDEBAR.md).
#[test]
fn meta_line_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude");
s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay {
uncommitted_files: 120,
..InTheWay::default()
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
assert!(
meta.trim_end().ends_with('…'),
"truncated meta line must end with …: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
"meta line must not wrap: {:?}",
all[idx + 2]
);
}
#[test]
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
@@ -227,8 +521,8 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
};
@@ -237,10 +531,150 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 6);
let rendered = render_to_string(&draw(&sb, 28, 6));
assert!(
rendered.contains("vim"),
"missing fallback command name:\n{rendered}"
);
}
/// Reversed video makes a card unreadable against a background; cards
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
#[test]
fn no_cell_is_ever_reversed() {
let mut highlighted = session_named(1, "claude");
highlighted.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![highlighted],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
for y in 0..buf.area.height {
for x in 0..buf.area.width {
assert!(
!buf[(x, y)]
.style()
.add_modifier
.contains(Modifier::REVERSED),
"reversed video at ({x},{y})"
);
}
}
}
/// The highlighted card gets the focus background; every other card gets
/// a card background distinct from the panel's default.
#[test]
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
let one = session_named(1, "claude");
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
let all = rows(&buf);
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
assert_eq!(
buf[(0, claude_y as u16)].style().bg,
Some(Color::Cyan),
"focused card must sit on the focus background"
);
assert_eq!(
buf[(0, claude_y as u16)].style().fg,
Some(Color::Black),
"focused card text must stay readable on cyan"
);
assert_eq!(
buf[(0, bash_y as u16)].style().bg,
Some(Color::Black),
"unfocused cards must still have a card background"
);
// The panel itself (header row) stays on the terminal default.
assert!(
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
"panel must keep the terminal default background"
);
}
/// The card background paints the full card width, not just the glyphs.
#[test]
fn card_background_covers_the_full_card_width() {
let s = session_named(1, "claude");
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
for x in 0..27 {
assert_eq!(
buf[(x, y)].style().bg,
Some(Color::Black),
"gap in card background at col {x}"
);
}
}
/// Line 1's actions live on the highlighted row's top-right corner.
#[test]
fn highlighted_row_anchors_the_actions_top_right() {
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
assert!(
line.ends_with("⧉ ✕"),
"actions must sit at the right edge: {line:?}"
);
}
#[test]
fn unhighlighted_rows_show_no_actions() {
let one = session_named(1, "claude");
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "bash");
assert!(
!line.contains('⧉') && !line.contains('✕'),
"actions are highlight-only: {line:?}"
);
}
/// The name truncates with `…` before it can ever reach the reserved
/// action width (docs/SIDEBAR.md — overlap is a defect).
#[test]
fn name_truncates_before_reaching_the_actions() {
let mut s = session_named(1, "claude");
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
*name = "an-unreasonably-long-session-name".into();
}
let sb = Sidebar {
sessions: vec![s],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
let name_end = line.find('…').expect("name must truncate with …");
let actions_start = line.find('⧉').expect("actions must still render");
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
assert!(
line.split_at(actions_start).0.ends_with(' '),
"no gap before the actions: {line:?}"
);
}
}
+97
View File
@@ -0,0 +1,97 @@
# SIDEBAR.md — the session card contract
One rule for how a session is rendered as a card in the sidebar, so the web
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.
## Line 1 — name + actions
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
badge move off this row into line 2.
## Line 2 — meta line (adaptive)
Rendered only when at least one of these holds:
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:
```
<dot> <duration> [<blocked work> | exited]
```
`<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.
## Colour per activity state
The state word is never spelled out — the colour carries it.
| Activity state | Web token | ratatui colour |
| ---------------- | --------- | ----------------- |
| `Active` | `--live` | `Color::Green` |
| `Idle` | `--muted` | `Color::DarkGray` |
| `AwaitingInput` | `--warn` | `Color::Yellow` |
| `Error` | `--danger`| `Color::Red` |
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
named colours above; do not introduce RGB values or a new palette module for
this.
The duration text inherits its state's colour; the blocked-work and exited
fragments use the muted colour (`--faint` / default style), since the ⚠ glyph
already signals severity.
## Duration format
Time spent in the **current activity state**, sourced from `activity_since`
(#102) — *not* time since spawn, and *not* time since last PTY output
(`last_activity_at`). Both clients compute it identically:
- `< 60s` → whole seconds, floored: `30s`
- `< 60m` → whole minutes, floored: `4m`
- `≥ 60m` → whole hours, floored: `2h`
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so
the counter never shows a duration the state hasn't actually reached yet.
Clock granularity: re-render at least once every 30s while visible so the
cutover points land within one tick.
## Precedence
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
the TTL the report carries; when the TTL expires, inference resumes. Clients
never blend the two: whichever source is in force supplies both the colour and
the clock for `activity_since`.
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
activity leniently for the same reason.
+8 -2
View File
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
* Absent on payloads from daemons that predate the field. `Error` is only set
* when an agent reports failure (#105); the daemon never infers it. */
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
export interface Session {
id: Uuid;
@@ -27,6 +28,11 @@ export interface Session {
activity?: ActivityState;
/** Wall-clock of the most recent observed PTY output (NAP-340). */
last_activity_at?: IsoTimestamp;
/** Wall-clock of when the session entered its current `activity` state
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
* Defaults to the Unix epoch on payloads from daemons that predate the
* field. */
activity_since?: IsoTimestamp;
/** The pane's *current* working directory, as the daemon's activity watcher
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
* Drives the directory-based session tree. Absent until first observed. */