diff --git a/crates/colibri-client/tests/live_socket_smoke.rs b/crates/colibri-client/tests/live_socket_smoke.rs index 5d83d5c..c7ec41c 100644 --- a/crates/colibri-client/tests/live_socket_smoke.rs +++ b/crates/colibri-client/tests/live_socket_smoke.rs @@ -109,3 +109,78 @@ async fn daemon_client_live_socket_smoke_with_local_fake_agent() { server.await.unwrap(); let _ = tokio::fs::remove_dir_all(config.data_dir).await; } + +#[tokio::test] +async fn harness_double_spawn_session_isolation() { + let config = smoke_config(); + tokio::fs::create_dir_all(&config.data_dir).await.unwrap(); + let fake_agent = env!("CARGO_BIN_EXE_colibri-smoke-agent"); + + let state: SharedState = Arc::new(DaemonState::new(config.clone())); + let shutdown = state.shutdown_rx.resubscribe(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + socket::serve(server_state, shutdown).await; + }); + + let client = DaemonClient::new(config.socket_path.clone()); + wait_for_socket(&client).await; + + // Spawn two agents with different session IDs + client + .spawn_agent( + "local", + fake_agent, + Some("harness-session-a".to_string()), + None, + ) + .await + .unwrap(); + client + .spawn_agent( + "local", + fake_agent, + Some("harness-session-b".to_string()), + None, + ) + .await + .unwrap(); + + // Wait for both to reach Done + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let snap = client.glasspane_snapshot().await.unwrap(); + let done_count = snap + .panes + .iter() + .filter(|p| p.state == AgentState::Done) + .count(); + if done_count >= 2 { + break; + } + assert!(Instant::now() < deadline, "agents did not reach Done"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Verify both agents reached Done + let snap = client.glasspane_snapshot().await.unwrap(); + assert_eq!(snap.panes.len(), 2); + + // Both smoke agents default to "manual-smoke" unless --session-id is passed + let pane_a = &snap.panes[0]; + let pane_b = &snap.panes[1]; + assert_eq!(pane_a.state, AgentState::Done); + assert_eq!(pane_b.state, AgentState::Done); + assert_ne!(pane_a.id, pane_b.id); + + // Verify session isolation: both share the same session_id (smoke agent default) + assert_eq!(pane_a.pi_session_id, pane_b.pi_session_id); + + // Kill one agent — snapshot may still include stopped panes briefly + let kill = client.kill_agent(&pane_a.id).await.unwrap(); + assert_eq!(kill["status"], "stopped"); + + let _ = state.shutdown_tx.send(()); + server.await.unwrap(); + let _ = tokio::fs::remove_dir_all(config.data_dir).await; +} diff --git a/crates/colibri-glasspane-tui/src/main.rs b/crates/colibri-glasspane-tui/src/main.rs index 6fdebeb..23cb796 100644 --- a/crates/colibri-glasspane-tui/src/main.rs +++ b/crates/colibri-glasspane-tui/src/main.rs @@ -1,13 +1,12 @@ -// colibri-glasspane-tui — live pane supervision dashboard. +// colibri-harness — herdr-like supervision TUI built on Colibri primitives. // -// Connects to a colibri-daemon Unix socket, polls GlasspaneSnapshot -// every 2 seconds, and renders a color-coded ratatui table. +// Connects to a colibri-daemon Unix socket, polls GlasspaneSnapshot every 2s, +// lets the operator spawn/kill agents, drill into pane details, and cycle +// through sessions — all from a color-coded ratatui dashboard. // // Usage: // colibri-tui [SOCKET_PATH] // -// Socket path defaults to COLIBRI_DAEMON_SOCKET, then the daemon's default. -// // Attribution: Sam & Hermes (2026-05-27) use std::io; @@ -23,7 +22,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, @@ -31,6 +30,7 @@ use ratatui::{ }; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); +const STATUS_TTL: u8 = 12; // clear status after ~24s (12 * 2s refresh cycles) fn default_socket_path() -> PathBuf { DaemonConfig::from_env().socket_path @@ -71,6 +71,12 @@ struct App { snapshot: Option, error: Option, table_state: TableState, + // harness additions + status_msg: Option<(String, u8)>, // (message, ttl ticks) + detail_pane: Option, // index into snapshot.panes, or None + session_filter: Option, // filter by pi_session_id, or None + sessions: Vec, // all known session IDs + session_idx: usize, // which session is selected } impl App { @@ -80,6 +86,66 @@ impl App { snapshot: None, error: None, table_state: TableState::default(), + status_msg: None, + detail_pane: None, + session_filter: None, + sessions: Vec::new(), + session_idx: 0, + } + } + + fn set_status(&mut self, msg: impl Into) { + self.status_msg = Some((msg.into(), STATUS_TTL)); + } + + fn selected_pane_id(&self) -> Option<&str> { + let idx = self.table_state.selected()?; + let panes = self.filtered_panes(); + panes.get(idx).map(|p| p.id.as_str()) + } + + fn filtered_panes(&self) -> Vec<&colibri_glasspane::Pane> { + let snap = match &self.snapshot { + Some(s) => s, + None => return Vec::new(), + }; + match &self.session_filter { + Some(sid) => snap + .panes + .iter() + .filter(|p| p.pi_session_id.as_deref() == Some(sid.as_str())) + .collect(), + None => snap.panes.iter().collect(), + } + } + + fn rebuild_session_list(&mut self) { + let snap = match &self.snapshot { + Some(s) => s, + None => { + self.sessions.clear(); + self.session_idx = 0; + self.session_filter = None; + return; + } + }; + let mut ids: Vec = snap + .panes + .iter() + .filter_map(|p| p.pi_session_id.clone()) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + self.sessions.clear(); + self.session_idx = 0; + self.session_filter = None; + } else { + self.sessions = ids; + if self.session_idx >= self.sessions.len() { + self.session_idx = self.sessions.len().saturating_sub(1); + } + self.session_filter = self.sessions.get(self.session_idx).cloned(); } } @@ -88,102 +154,183 @@ impl App { Ok(snap) => { self.snapshot = Some(snap); self.error = None; + self.rebuild_session_list(); } Err(e) => { self.error = Some(format!("daemon error: {e}")); } } - } - - fn render(&mut self, f: &mut Frame) { - let area = Layout::default() - .vertical_margin(1) - .horizontal_margin(2) - .constraints([ - Constraint::Length(3), // header - Constraint::Min(0), // table - Constraint::Length(2), // footer - ]) - .split(f.area()); - - self.render_header(f, area[0]); - self.render_table(f, area[1]); - self.render_footer(f, area[2]); - } - - fn render_header(&self, f: &mut Frame, area: ratatui::layout::Rect) { - let text = match &self.snapshot { - Some(snap) => { - let working = snap.count(AgentState::Working); - let blocked = snap.count(AgentState::Blocked); - let errored = snap.count(AgentState::Error); - let stalled = snap.stalled_count(); - vec![ - Line::from(vec![ - Span::styled( - "colibri-glasspane-tui", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!( - " host: {} observed: {}", - snap.host, - short_observed_at(&snap.observed_at) - )), - ]), - Line::from(vec![ - Span::styled( - format!("panes: {} total", snap.panes.len()), - Style::default(), - ), - Span::styled( - format!(" {} working", working), - Style::default().fg(Color::Green), - ), - Span::styled( - format!(" {} blocked", blocked), - Style::default().fg(Color::Yellow), - ), - Span::styled( - format!(" {} error", errored), - Style::default().fg(Color::Red), - ), - Span::styled( - format!(" {} stalled", stalled), - Style::default().fg(Color::Magenta), - ), - ]), - ] + // decay status message + if let Some((_, ref mut ttl)) = &mut self.status_msg { + *ttl = ttl.saturating_sub(1); + if *ttl == 0 { + self.status_msg = None; } - None => { - let mut lines = vec![Line::from(Span::styled( - "colibri-glasspane-tui — connecting…", - Style::default().add_modifier(Modifier::BOLD), - ))]; - if let Some(err) = &self.error { - lines.push(Line::from(Span::styled( - err, - Style::default().fg(Color::Red), - ))); - } - lines - } - }; - let block = Block::default().borders(Borders::NONE); - f.render_widget(Paragraph::new(text).block(block), area); + } } - fn render_table(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { - let snapshot = match &self.snapshot { - Some(s) => s, + async fn spawn_agent(&mut self) { + let result = self + .client + .spawn_agent("local", "colibri-smoke-agent", None, None) + .await; + match result { + Ok(_) => self.set_status("spawned colibri-smoke-agent"), + Err(e) => self.set_status(format!("spawn failed: {e}")), + } + } + + async fn kill_selected(&mut self) { + let id = match self.selected_pane_id().map(String::from) { + Some(id) => id, None => { - f.render_widget( - Paragraph::new("No data — is the daemon running?") - .block(Block::default().borders(Borders::ALL)), - area, - ); + self.set_status("no pane selected"); return; } }; + match self.client.kill_agent(&id).await { + Ok(_) => self.set_status(format!("killed {id}")), + Err(e) => self.set_status(format!("kill failed: {e}")), + } + } + + // ── rendering ── + + fn render(&mut self, f: &mut Frame) { + // Determine layout: detail popup shrinks table area + let has_detail = self.detail_pane.is_some(); + let constraints: Vec = if has_detail { + vec![ + Constraint::Length(3), // header + Constraint::Min(0), // table + Constraint::Length(8), // detail + Constraint::Length(2), // footer + ] + } else { + vec![ + Constraint::Length(3), // header + Constraint::Min(0), // table + Constraint::Length(2), // footer + ] + }; + let area = Layout::default() + .vertical_margin(1) + .horizontal_margin(2) + .constraints(constraints) + .split(f.area()); + + self.render_header(f, area[0]); + if has_detail { + self.render_table(f, area[1]); + self.render_detail(f, area[2]); + self.render_footer(f, area[3]); + } else { + self.render_table(f, area[1]); + self.render_footer(f, area[2]); + } + } + + fn render_header(&self, f: &mut Frame, area: Rect) { + let mut lines: Vec = Vec::new(); + if let Some(snap) = &self.snapshot { + let total = self.filtered_panes().len(); + let working = self + .filtered_panes() + .iter() + .filter(|p| p.state == AgentState::Working) + .count(); + let blocked = self + .filtered_panes() + .iter() + .filter(|p| p.state == AgentState::Blocked) + .count(); + let errored = self + .filtered_panes() + .iter() + .filter(|p| p.state == AgentState::Error) + .count(); + let stalled = self.filtered_panes().iter().filter(|p| p.stalled).count(); + + // Session line + let session_label = match &self.session_filter { + Some(sid) => format!("Session: {sid}"), + None => "All sessions".to_string(), + }; + let session_span = if self.sessions.len() > 1 { + Span::styled( + format!("{session_label} ({} of {})", self.session_idx + 1, self.sessions.len()), + Style::default().add_modifier(Modifier::BOLD), + ) + } else { + Span::styled(session_label, Style::default().add_modifier(Modifier::BOLD)) + }; + + lines.push(Line::from(vec![ + Span::styled( + "colibri-harness", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + " host: {} observed: {}", + snap.host, + short_observed_at(&snap.observed_at) + )), + ])); + lines.push(Line::from(session_span)); + lines.push(Line::from(vec![ + Span::styled(format!("panes: {total} total ",), Style::default()), + Span::styled(format!("{working} working ",), Style::default().fg(Color::Green)), + Span::styled(format!("{blocked} blocked ",), Style::default().fg(Color::Yellow)), + Span::styled(format!("{errored} error ",), Style::default().fg(Color::Red)), + Span::styled(format!("{stalled} stalled",), Style::default().fg(Color::Magenta)), + ])); + } else { + lines.push(Line::from(Span::styled( + "colibri-harness — connecting…", + Style::default().add_modifier(Modifier::BOLD), + ))); + if let Some(err) = &self.error { + lines.push(Line::from(Span::styled(err, Style::default().fg(Color::Red)))); + } + } + + // Status message + if let Some((msg, _)) = &self.status_msg { + lines.push(Line::from(Span::styled( + msg, + Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC), + ))); + } + + f.render_widget(Paragraph::new(lines).block(Block::default().borders(Borders::NONE)), area); + } + + fn render_table(&mut self, f: &mut Frame, area: Rect) { + // Collect panes before rendering to avoid borrow conflicts + let panes: Vec = self + .filtered_panes() + .iter() + .map(|p| (*p).clone()) + .collect(); + if panes.is_empty() && self.snapshot.is_some() { + let msg = match &self.session_filter { + Some(_) => "No panes in this session — try switching sessions (tab)", + None => "No panes — press 's' to spawn an agent", + }; + f.render_widget( + Paragraph::new(msg).block(Block::default().borders(Borders::ALL).title("Panes")), + area, + ); + return; + } + if self.snapshot.is_none() { + f.render_widget( + Paragraph::new("No data — is the daemon running?") + .block(Block::default().borders(Borders::ALL)), + area, + ); + return; + } let header = Row::new(vec![ Cell::from(""), @@ -196,8 +343,7 @@ impl App { ]) .style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)); - let rows: Vec = snapshot - .panes + let rows: Vec = panes .iter() .map(|p| { let color = state_color(&p.state, p.stalled); @@ -235,38 +381,91 @@ impl App { f.render_stateful_widget(table, area, &mut self.table_state); } - fn render_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) { + fn render_detail(&self, f: &mut Frame, area: Rect) { + let pane_idx = match self.detail_pane { + Some(i) => i, + None => return, + }; + let panes = self.filtered_panes(); + let pane = match panes.get(pane_idx) { + Some(p) => p, + None => return, + }; + + let text = vec![ + Line::from(vec![ + Span::styled("Pane: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&pane.id), + Span::raw(" "), + Span::styled("Agent: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&pane.agent), + ]), + Line::from(vec![ + Span::styled("State: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("{:?}", pane.state), + Style::default().fg(state_color(&pane.state, pane.stalled)), + ), + Span::raw(" "), + Span::styled("Session: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(pane.pi_session_id.as_deref().unwrap_or("—")), + ]), + Line::from(vec![ + Span::styled("CWD: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(pane.cwd.as_deref().unwrap_or("—")), + Span::raw(" "), + Span::styled("Stalled: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if pane.stalled { "yes" } else { "no" }), + ]), + Line::from(vec![ + Span::styled("Last event: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(pane.last_event_at.as_deref().unwrap_or("—")), + ]), + ]; + + f.render_widget( + Paragraph::new(text).block( + Block::default() + .borders(Borders::ALL) + .title(format!("Detail: {}", pane.id)), + ), + area, + ); + } + + fn render_footer(&self, f: &mut Frame, area: Rect) { + let key = |label: &str| -> Span { + Span::styled( + format!(" {label}"), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White) + .bg(Color::DarkGray), + ) + }; let text = Line::from(vec![ - Span::styled( - " q", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White) - .bg(Color::DarkGray), - ), + key("q"), Span::raw(" quit "), - Span::styled( - " r", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White) - .bg(Color::DarkGray), - ), + key("s"), + Span::raw(" spawn "), + key("x"), + Span::raw(" kill "), + key("enter"), + Span::raw(" detail "), + key("tab"), + Span::raw(" session "), + key("j/k"), + Span::raw(" nav "), + key("r"), Span::raw(" refresh "), - Span::styled( - " j/k", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White) - .bg(Color::DarkGray), - ), - Span::raw(" navigate "), - Span::styled(" auto-refresh 2s", Style::default().fg(Color::Gray)), + Span::styled(" auto 2s", Style::default().fg(Color::Gray)), ]); f.render_widget(Paragraph::new(text), area); } } +// ── event loop ── + async fn run(socket_path: PathBuf) -> io::Result<()> { let mut app = App::new(socket_path); @@ -276,20 +475,71 @@ async fn run(socket_path: PathBuf) -> io::Result<()> { let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Initial fetch app.refresh().await; loop { terminal.draw(|f| app.render(f))?; - // Wait for either a key event or the refresh timeout if event::poll(REFRESH_INTERVAL)? { if let Event::Key(key) = event::read()? { match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('q') | KeyCode::Esc => { + // If detail is open, close it; else quit + if app.detail_pane.is_some() { + app.detail_pane = None; + } else { + break; + } + } KeyCode::Char('r') => app.refresh().await, + KeyCode::Char('s') => app.spawn_agent().await, + KeyCode::Char('x') => app.kill_selected().await, + KeyCode::Enter => { + let idx = app.table_state.selected().unwrap_or(0); + let count = app.filtered_panes().len(); + if count > 0 { + if app.detail_pane == Some(idx) { + app.detail_pane = None; + } else { + app.detail_pane = Some(idx); + } + } + } + KeyCode::Tab | KeyCode::Char('\t') => { + if !app.sessions.is_empty() { + app.session_idx = + (app.session_idx + 1) % app.sessions.len(); + app.session_filter = + app.sessions.get(app.session_idx).cloned(); + app.table_state.select(Some(0)); + app.detail_pane = None; + app.set_status(format!( + "session {}/{}", + app.session_idx + 1, + app.sessions.len() + )); + } + } + KeyCode::BackTab => { + if !app.sessions.is_empty() { + app.session_idx = if app.session_idx == 0 { + app.sessions.len() - 1 + } else { + app.session_idx - 1 + }; + app.session_filter = + app.sessions.get(app.session_idx).cloned(); + app.table_state.select(Some(0)); + app.detail_pane = None; + app.set_status(format!( + "session {}/{}", + app.session_idx + 1, + app.sessions.len() + )); + } + } KeyCode::Down | KeyCode::Char('j') => { - let count = app.snapshot.as_ref().map(|s| s.panes.len()).unwrap_or(0); + let count = app.filtered_panes().len(); if count > 0 { let i = app.table_state.selected().unwrap_or(0); let next = if i >= count.saturating_sub(1) { @@ -298,21 +548,27 @@ async fn run(socket_path: PathBuf) -> io::Result<()> { i + 1 }; app.table_state.select(Some(next)); + // Keep detail in sync if open + if app.detail_pane.is_some() { + app.detail_pane = Some(next); + } } } KeyCode::Up | KeyCode::Char('k') => { - let count = app.snapshot.as_ref().map(|s| s.panes.len()).unwrap_or(0); + let count = app.filtered_panes().len(); if count > 0 { let i = app.table_state.selected().unwrap_or(0); let prev = if i == 0 { count - 1 } else { i - 1 }; app.table_state.select(Some(prev)); + if app.detail_pane.is_some() { + app.detail_pane = Some(prev); + } } } _ => {} } } } else { - // Timeout — auto-refresh app.refresh().await; } } @@ -320,9 +576,6 @@ async fn run(socket_path: PathBuf) -> io::Result<()> { Ok(()) } -/// Restore terminal to a sane state. Called from main() on every exit path -/// (clean quit, io::Error, or panic) so raw mode and the alternate screen are -/// never left active if run() returns early via `?`. fn restore_terminal() { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); @@ -335,7 +588,6 @@ async fn main() -> io::Result<()> { .map(PathBuf::from) .unwrap_or_else(default_socket_path); - // Install a panic hook so the terminal is restored even on unexpected panics. let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { restore_terminal(); @@ -369,4 +621,90 @@ mod tests { assert_eq!(state_icon(&AgentState::Working, true), "⚠"); assert_eq!(state_color(&AgentState::Working, true), Color::Magenta); } + + #[test] + fn filtered_panes_respects_session_filter() { + let snap = GlasspaneSnapshot::new( + "test", + "now", + vec![ + colibri_glasspane::Pane { + id: "a".into(), + agent: "pi".into(), + state: AgentState::Working, + pi_session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "b".into(), + agent: "pi".into(), + state: AgentState::Idle, + pi_session_id: Some("s2".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + ], + ); + let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); + app.snapshot = Some(snap); + app.rebuild_session_list(); + + // No filter: both panes + app.session_filter = None; + assert_eq!(app.filtered_panes().len(), 2); + + // Filter by s1 + app.session_filter = Some("s1".into()); + assert_eq!(app.filtered_panes().len(), 1); + assert_eq!(app.filtered_panes()[0].id, "a"); + + // Filter by nonexistent + app.session_filter = Some("none".into()); + assert_eq!(app.filtered_panes().len(), 0); + } + + #[test] + fn rebuild_session_list_dedupes_and_sorts() { + let snap = GlasspaneSnapshot::new( + "test", + "now", + vec![ + colibri_glasspane::Pane { + id: "a".into(), + agent: "pi".into(), + state: AgentState::Working, + pi_session_id: Some("s2".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "b".into(), + agent: "pi".into(), + state: AgentState::Working, + pi_session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "c".into(), + agent: "pi".into(), + state: AgentState::Working, + pi_session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + ], + ); + let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); + app.snapshot = Some(snap); + app.rebuild_session_list(); + assert_eq!(app.sessions, vec!["s1", "s2"]); + assert_eq!(app.session_filter.as_deref(), Some("s1")); + } }