From c858cde01c4ca35a2575a6e201971006bad04bc8 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 18:33:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20glasspane=20attention=20tiers=201-?= =?UTF-8?q?4=20=E2=80=94=20bar,=20jump,=20filter,=20row=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit needs_attention() = Error + Blocked + Stalled (free function, single source of truth). Includes Blocked because glasspane doc comments say Blocked = 'operator attention needed' (queue_update / pending steering). Tier 1 — Attention bar: Red-bordered panel with '⚠ ATTENTION (N)' title replaces the header when any pane needs attention. Shows pane id, reason, and agent. Tier 2 — Jump keys (n/N): n = next attention pane, N = previous (wrapping). Respects session scope via filtered_panes(). Detail pane follows the jump. Tier 3 — Attention filter (a key): Toggles attention_only on App. Composes with session filter. Tier 4 — Row highlight: Attention rows get red background when unselected, inverted dark-gray+light-red+bold when selected. Global row_highlight neutralized. Also: - fix(tui): remove hardcoded dark-terminal assumptions — theme-agnostic - fix(tui): force crossterm color output — override NO_COLOR=1 inherited from Hermes sessions (crossterm honours no-color.org standard) --- crates/colibri-glasspane-tui/src/main.rs | 380 +++++++++++++++++++++-- 1 file changed, 347 insertions(+), 33 deletions(-) diff --git a/crates/colibri-glasspane-tui/src/main.rs b/crates/colibri-glasspane-tui/src/main.rs index e28ad95..60fff66 100644 --- a/crates/colibri-glasspane-tui/src/main.rs +++ b/crates/colibri-glasspane-tui/src/main.rs @@ -66,6 +66,10 @@ fn state_icon(state: &AgentState, stalled: bool) -> &'static str { } } +fn needs_attention(pane: &colibri_glasspane::Pane) -> bool { + pane.state == AgentState::Error || pane.state == AgentState::Blocked || pane.stalled +} + struct App { client: DaemonClient, snapshot: Option, @@ -77,6 +81,7 @@ struct App { session_filter: Option, // filter by session_id, or None sessions: Vec, // all known session IDs session_idx: usize, // which session is selected + attention_only: bool, // 'a' filter: show only attention panes } impl App { @@ -91,6 +96,7 @@ impl App { session_filter: None, sessions: Vec::new(), session_idx: 0, + attention_only: false, } } @@ -109,13 +115,74 @@ impl App { Some(s) => s, None => return Vec::new(), }; - match &self.session_filter { + let mut panes: Vec<&colibri_glasspane::Pane> = match &self.session_filter { Some(sid) => snap .panes .iter() .filter(|p| p.session_id.as_deref() == Some(sid.as_str())) .collect(), None => snap.panes.iter().collect(), + }; + if self.attention_only { + panes.retain(|p| needs_attention(p)); + } + panes + } + + fn attention_count(&self) -> usize { + let snap = match &self.snapshot { + Some(s) => s, + None => return 0, + }; + snap.panes.iter().filter(|p| needs_attention(p)).count() + } + + /// Build a list of filtered-pane indices that need attention. + fn attention_indices(&self) -> Vec { + self.filtered_panes() + .iter() + .enumerate() + .filter(|(_, p)| needs_attention(p)) + .map(|(i, _)| i) + .collect() + } + + fn jump_next_attention(&mut self) { + let indices = self.attention_indices(); + if indices.is_empty() { + self.set_status("no attention panes"); + return; + } + let current = self.table_state.selected().unwrap_or(0); + let next = indices + .iter() + .find(|&&i| i > current) + .or_else(|| indices.first()) + .copied() + .unwrap_or(0); + self.table_state.select(Some(next)); + if self.detail_pane.is_some() { + self.detail_pane = Some(next); + } + } + + fn jump_prev_attention(&mut self) { + let indices = self.attention_indices(); + if indices.is_empty() { + self.set_status("no attention panes"); + return; + } + let current = self.table_state.selected().unwrap_or(0); + let prev = indices + .iter() + .rev() + .find(|&&i| i < current) + .or_else(|| indices.last()) + .copied() + .unwrap_or(0); + self.table_state.select(Some(prev)); + if self.detail_pane.is_some() { + self.detail_pane = Some(prev); } } @@ -197,37 +264,34 @@ impl App { // ── rendering ── fn render(&mut self, f: &mut Frame) { - // Determine layout: detail popup shrinks table area + let has_attention = self.attention_count() > 0; 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 mut constraints = vec![Constraint::Length(3)]; // header / attention bar + constraints.push(Constraint::Min(0)); // table + if has_detail { + constraints.push(Constraint::Length(8)); // detail + } + constraints.push(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]); + let mut slot = 0; + if has_attention { + self.render_attention_bar(f, area[slot]); } else { - self.render_table(f, area[1]); - self.render_footer(f, area[2]); + self.render_header(f, area[slot]); } + slot += 1; + self.render_table(f, area[slot]); + slot += 1; + if has_detail { + self.render_detail(f, area[slot]); + slot += 1; + } + self.render_footer(f, area[slot]); } fn render_header(&self, f: &mut Frame, area: Rect) { @@ -317,9 +381,7 @@ impl App { if let Some((msg, _)) = &self.status_msg { lines.push(Line::from(Span::styled( msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::ITALIC), + Style::default().add_modifier(Modifier::ITALIC | Modifier::BOLD), ))); } @@ -329,6 +391,43 @@ impl App { ); } + fn render_attention_bar(&self, f: &mut Frame, area: Rect) { + let panes = self.filtered_panes(); + let attention: Vec<&colibri_glasspane::Pane> = panes + .iter() + .filter(|p| needs_attention(p)) + .copied() + .collect(); + if attention.is_empty() { + return; + } + let lines: Vec = attention + .iter() + .map(|p| { + let reason = if p.state == AgentState::Error { + "Error" + } else if p.state == AgentState::Blocked { + "Blocked" + } else { + "Stalled" + }; + Line::from(Span::styled( + format!("pane '{}' — {} ({})", p.id, reason, p.agent), + Style::default().fg(Color::Red), + )) + }) + .collect(); + f.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(format!("⚠ ATTENTION ({})", attention.len())), + ), + area, + ); + } + fn render_table(&mut self, f: &mut Frame, area: Rect) { // Collect panes before rendering to avoid borrow conflicts let panes: Vec = @@ -364,11 +463,26 @@ impl App { ]) .style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)); + let selected = self.table_state.selected(); let rows: Vec = panes .iter() - .map(|p| { + .enumerate() + .map(|(i, p)| { let color = state_color(&p.state, p.stalled); let icon = state_icon(&p.state, p.stalled); + let row_style = if needs_attention(p) { + if Some(i) == selected { + Style::default() + .add_modifier(Modifier::REVERSED | Modifier::BOLD) + .fg(Color::Red) + } else { + Style::default().bg(Color::Red).fg(Color::White) + } + } else if Some(i) == selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; Row::new(vec![ Cell::from(Span::styled(icon, Style::default().fg(color))), Cell::from(p.id.as_str()), @@ -381,6 +495,7 @@ impl App { Cell::from(p.cwd.as_deref().unwrap_or("—")), Cell::from(if p.stalled { "⚠" } else { "" }), ]) + .style(row_style) }) .collect(); @@ -397,7 +512,7 @@ impl App { let table = Table::new(rows, widths) .header(header) .block(Block::default().borders(Borders::ALL).title("Panes")) - .row_highlight_style(Style::default().bg(Color::DarkGray)); + .row_highlight_style(Style::default()); f.render_stateful_widget(table, area, &mut self.table_state); } @@ -461,10 +576,7 @@ impl App { let key = |label: &str| -> Span { Span::styled( format!(" {label}"), - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White) - .bg(Color::DarkGray), + Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED), ) }; let text = Line::from(vec![ @@ -480,9 +592,13 @@ impl App { Span::raw(" session "), key("j/k"), Span::raw(" nav "), + key("n/N"), + Span::raw(" attn "), + key("a"), + Span::raw(" filter "), key("r"), Span::raw(" refresh "), - Span::styled(" auto 2s", Style::default().fg(Color::Gray)), + Span::styled(" auto 2s", Style::default()), ]); f.render_widget(Paragraph::new(text), area); } @@ -582,6 +698,18 @@ async fn run(socket_path: PathBuf) -> io::Result<()> { } } } + KeyCode::Char('n') => app.jump_next_attention(), + KeyCode::Char('N') => app.jump_prev_attention(), + KeyCode::Char('a') => { + app.attention_only = !app.attention_only; + app.table_state.select(Some(0)); + app.detail_pane = None; + if app.attention_only { + app.set_status("attention filter ON"); + } else { + app.set_status("attention filter OFF"); + } + } _ => {} } } @@ -600,6 +728,11 @@ fn restore_terminal() { #[tokio::main] async fn main() -> io::Result<()> { + // Override NO_COLOR — this is a TUI dashboard, colors are essential. + // Hermes sessions can leak NO_COLOR=1 into subprocess environments, + // and crossterm honours that per https://no-color.org. Force colors on. + crossterm::style::force_color_output(true); + let socket_path = std::env::args() .nth(1) .map(PathBuf::from) @@ -859,4 +992,185 @@ mod tests { app.rebuild_session_list(); let _ = render_text(&mut app, 20, 5); } + + // ── attention tests ── + + #[test] + fn needs_attention_detects_error_blocked_and_stalled() { + let err = colibri_glasspane::Pane { + id: "e".into(), + agent: "z".into(), + state: AgentState::Error, + session_id: None, + last_event_at: None, + cwd: None, + stalled: false, + }; + let blocked = colibri_glasspane::Pane { + id: "b".into(), + agent: "z".into(), + state: AgentState::Blocked, + session_id: None, + last_event_at: None, + cwd: None, + stalled: false, + }; + let stalled = colibri_glasspane::Pane { + id: "s".into(), + agent: "z".into(), + state: AgentState::Working, + session_id: None, + last_event_at: None, + cwd: None, + stalled: true, + }; + let ok = colibri_glasspane::Pane { + id: "o".into(), + agent: "z".into(), + state: AgentState::Working, + session_id: None, + last_event_at: None, + cwd: None, + stalled: false, + }; + assert!(needs_attention(&err)); + assert!(needs_attention(&blocked)); + assert!(needs_attention(&stalled)); + assert!(!needs_attention(&ok)); + } + + #[test] + fn attention_bar_renders_when_panes_need_attention() { + let snap = GlasspaneSnapshot::new( + "osa", + "2026-06-25T12:00:00Z", + vec![ + colibri_glasspane::Pane { + id: "pane-ok".into(), + agent: "zot".into(), + state: AgentState::Working, + session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "pane-err".into(), + agent: "zot".into(), + state: AgentState::Error, + session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "pane-blocked".into(), + agent: "zot".into(), + state: AgentState::Blocked, + 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(); + let text = render_text(&mut app, 80, 24); + assert!( + text.contains("ATTENTION"), + "expected attention bar in: {text}" + ); + assert!( + text.contains("pane-err"), + "expected error pane in attention bar: {text}" + ); + assert!( + text.contains("pane-blocked"), + "expected blocked pane in attention bar: {text}" + ); + assert!(text.contains("Error"), "expected 'Error' reason in: {text}"); + assert!( + text.contains("Blocked"), + "expected 'Blocked' reason in: {text}" + ); + } + + #[test] + fn attention_filter_hides_healthy_panes() { + let snap = GlasspaneSnapshot::new( + "osa", + "2026-06-25T12:00:00Z", + vec![ + colibri_glasspane::Pane { + id: "pane-ok".into(), + agent: "zot".into(), + state: AgentState::Working, + session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "pane-err".into(), + agent: "zot".into(), + state: AgentState::Error, + session_id: Some("s1".into()), + last_event_at: None, + cwd: None, + stalled: false, + }, + colibri_glasspane::Pane { + id: "pane-blocked".into(), + agent: "zot".into(), + state: AgentState::Blocked, + 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(); + // Without filter: all panes visible + assert_eq!(app.filtered_panes().len(), 3); + // With attention filter: only error + blocked panes + app.attention_only = true; + let filtered = app.filtered_panes(); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].id, "pane-err"); + assert_eq!(filtered[1].id, "pane-blocked"); + } + + #[test] + fn attention_bar_does_not_render_when_all_healthy() { + let snap = GlasspaneSnapshot::new( + "osa", + "2026-06-25T12:00:00Z", + vec![colibri_glasspane::Pane { + id: "pane-ok".into(), + agent: "zot".into(), + state: AgentState::Working, + 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(); + let text = render_text(&mut app, 80, 24); + assert!( + !text.contains("ATTENTION"), + "should not show attention bar when all healthy: {text}" + ); + assert!( + text.contains("colibri-harness"), + "should show normal header when no attention: {text}" + ); + } }