This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/sidebar.rs
T
naps62-yoloandClaude Opus 4.8 fd80ae152a
ci / rust (push) Failing after 2m15s
feat(maestro): infer session activity state + foreground (Active/Idle/AwaitingInput) (#18)
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>
2026-06-21 14:51:08 +00:00

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}"
);
}
}