Upgrade colibri-glasspane-tui → colibri-harness (Sam & Hermes)
Herdr-like supervision TUI built on Colibri primitives:
Step 1-2: spawn/kill keybindings + pane detail popup
- s: spawn agent (local smoke agent)
- x: kill selected pane
- Enter: detail popup (pane ID, state, session, CWD, stalled, last event)
- Esc: close detail / quit
Step 3: Wire colibri-smoke-agent as local provider
- TUI calls DaemonClient::spawn_agent('local', 'colibri-smoke-agent')
- Status messages for spawn/kill results
Step 4: Multi-session grouping
- tab/shift+tab: cycle session filter
- Header shows active session and count
- Panes filtered by pi_session_id
- Rebuild session list on every snapshot refresh
Step 5: Harness double-spawn smoke test
- Two agents, same session, verify both reach Done
- Kill one, verify stopped
- Full lifecycle: spawn→working→blocked→done→kill
65 tests, 0 warnings, 0 clippy errors.
This commit is contained in:
parent
0f27039367
commit
eb37784f97
2 changed files with 539 additions and 126 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GlasspaneSnapshot>,
|
||||
error: Option<String>,
|
||||
table_state: TableState,
|
||||
// harness additions
|
||||
status_msg: Option<(String, u8)>, // (message, ttl ticks)
|
||||
detail_pane: Option<usize>, // index into snapshot.panes, or None
|
||||
session_filter: Option<String>, // filter by pi_session_id, or None
|
||||
sessions: Vec<String>, // 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<String>) {
|
||||
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<String> = 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<Constraint> = 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<Line> = 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<colibri_glasspane::Pane> = 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<Row> = snapshot
|
||||
.panes
|
||||
let rows: Vec<Row> = 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue