ci / rust (push) Failing after 2m15s
Add an orthogonal activity dimension to sessions, inferred entirely from
structured tmux/`/proc` state — no PTY byte-stream parsing, no work on the
attach hot path.
Protocol: `ActivityState` enum (Active/Idle/AwaitingInput) + three additive,
serde-defaulted `Session` fields (`foreground`, `activity`, `last_activity_at`)
so the snapshot/diff flow stays forward-compatible with older daemons.
Detection (piggybacks the existing ~2s liveness tick, no new timer):
- foreground command via `tmux list-panes -F '#{pane_pid} #{pane_current_command}
#{pane_tty}'`;
- quiescence from a per-`PtyBridge` last-read `Instant`, bumped in `run_reader`;
- AwaitingInput via best-effort `/proc` walk to the deepest descendant: state
`S` + a non-timer `wchan` + an stdio fd on the pane tty. (`/proc/pid/syscall`
is ptrace-gated and unreadable for own sessions, so `wchan` distinguishes a
blocking read from a plain `sleep`.)
- combine: recent output → Active; else input-blocked → AwaitingInput; else
Idle. Emit `SessionUpdated` only on (activity, foreground) change — no diff
storms at steady state.
`maestro list` gains an ACTIVITY column and shows the live foreground. Pure
combine/parse logic is unit-tested; an e2e asserts `cat` → AwaitingInput and
`sleep` → Idle with correct foreground against real tmux.
Closes #18
Closes #8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
7.3 KiB
Rust
226 lines
7.3 KiB
Rust
//! Sidebar widget — daemon→session tree, navigation-only.
|
|
//!
|
|
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
use ratatui::Frame;
|
|
use uuid::Uuid;
|
|
|
|
/// Renderable sidebar state.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Sidebar {
|
|
pub daemons: Vec<DaemonInfo>,
|
|
pub sessions: Vec<Session>,
|
|
pub highlight: Option<Uuid>,
|
|
pub attached: HashSet<Uuid>,
|
|
}
|
|
|
|
impl Sidebar {
|
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
|
self.render_with_focus(f, area, false);
|
|
}
|
|
|
|
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
|
|
/// focus colour when `focused`. Box borders eat too many rows/cols for
|
|
/// the v1 layout — a single vertical rule keeps the panel boundary
|
|
/// without stealing space.
|
|
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
|
let border_style = if focused {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let block = Block::default()
|
|
.borders(Borders::RIGHT)
|
|
.border_style(border_style);
|
|
let inner = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
|
"daemons",
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
))];
|
|
for d in &self.daemons {
|
|
lines.push(Line::from(format!("● {}", d.hostname)));
|
|
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
|
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
|
(SessionStatus::Dead, _) => "✗",
|
|
(SessionStatus::Running, true) => "●",
|
|
(SessionStatus::Running, false) => "○",
|
|
};
|
|
let label = label_for(s);
|
|
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}"), style),
|
|
]));
|
|
}
|
|
}
|
|
f.render_widget(Paragraph::new(lines), inner);
|
|
}
|
|
}
|
|
|
|
/// `metadata.label` if present, else the command line.
|
|
fn label_for(s: &Session) -> String {
|
|
if let Some(serde_json::Value::String(label)) = s.metadata.get("label") {
|
|
label.clone()
|
|
} else {
|
|
s.command.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::Utc;
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::Terminal;
|
|
use std::collections::HashMap;
|
|
|
|
fn daemon(id: &str) -> DaemonInfo {
|
|
DaemonInfo {
|
|
daemon_id: id.into(),
|
|
hostname: id.into(),
|
|
version: "test".into(),
|
|
daemon_url: format!("ws://{id}:7070"),
|
|
home_dir: String::new(),
|
|
}
|
|
}
|
|
|
|
fn session_with_label(id: u128, daemon_id: &str, label: &str) -> Session {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("label".into(), serde_json::Value::String(label.into()));
|
|
Session {
|
|
id: Uuid::from_u128(id),
|
|
daemon_id: daemon_id.into(),
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/tmp"),
|
|
env: Default::default(),
|
|
metadata,
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn renders_hostname_and_session_labels() {
|
|
let sb = Sidebar {
|
|
daemons: vec![daemon("konishi")],
|
|
sessions: vec![
|
|
session_with_label(1, "konishi", "claude"),
|
|
session_with_label(2, "konishi", "bash"),
|
|
],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let rendered = render_to_string(&sb, 28, 12);
|
|
assert!(
|
|
rendered.contains("konishi"),
|
|
"missing daemon name:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("claude"),
|
|
"missing session label:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("bash"),
|
|
"missing session label:\n{rendered}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn attached_session_uses_filled_glyph() {
|
|
let sid = Uuid::from_u128(1);
|
|
let sb = Sidebar {
|
|
daemons: vec![daemon("konishi")],
|
|
sessions: vec![session_with_label(1, "konishi", "claude")],
|
|
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");
|
|
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
|
assert!(
|
|
!line.contains('○'),
|
|
"should not contain hollow glyph: {line:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dead_session_uses_cross_glyph() {
|
|
let mut sess = session_with_label(1, "konishi", "claude");
|
|
sess.status = SessionStatus::Dead;
|
|
let sb = Sidebar {
|
|
daemons: vec![daemon("konishi")],
|
|
sessions: vec![sess],
|
|
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");
|
|
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_command_when_label_metadata_is_absent() {
|
|
let s = Session {
|
|
id: Uuid::from_u128(1),
|
|
daemon_id: "konishi".into(),
|
|
command: "vim".into(),
|
|
cwd: std::path::PathBuf::from("/tmp"),
|
|
env: Default::default(),
|
|
metadata: HashMap::new(),
|
|
status: SessionStatus::Running,
|
|
created_at: Utc::now(),
|
|
foreground: None,
|
|
activity: Default::default(),
|
|
last_activity_at: chrono::Utc::now(),
|
|
};
|
|
let sb = Sidebar {
|
|
daemons: vec![daemon("konishi")],
|
|
sessions: vec![s],
|
|
highlight: None,
|
|
attached: Default::default(),
|
|
};
|
|
let rendered = render_to_string(&sb, 28, 6);
|
|
assert!(
|
|
rendered.contains("vim"),
|
|
"missing fallback command label:\n{rendered}"
|
|
);
|
|
}
|
|
}
|