From a85dfecb0e62ffbe880610124828c53c2175d855 Mon Sep 17 00:00:00 2001 From: 123kupola Date: Thu, 25 Jun 2026 17:05:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20glasspane=20attention=20tiers=201-?= =?UTF-8?q?3=20=E2=80=94=20attention=20bar,=20jump,=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 — Attention bar: red-bordered panel between header and table that lists panes needing attention (Error/Stalled) when any exist. Disappears when all panes are healthy. Layout is dynamic — the attention slot is inserted conditionally between header and table. Tier 2 — Jump-to-attention: n/N keys jump to next/prev attention pane. Attention rows get a red background for visual punch (the 'red border' effect — ratatui tables don't support per-row borders). Tier 3 — Attention filter: 'a' key toggles showing only Error/Stalled panes. Reuses the existing filtered_panes() infrastructure. Footer updated with n/N/a keybindings. 3 new tests added: needs_attention_detects_error_and_stalled, attention_bar_renders_when_panes_need_attention, attention_filter_hides_healthy_panes. 13 tests, workspace green (0 failures). --- crates/colibri-glasspane-tui/src/main.rs | 287 +++++++++++++++++++++-- 1 file changed, 263 insertions(+), 24 deletions(-) diff --git a/crates/colibri-glasspane-tui/src/main.rs b/crates/colibri-glasspane-tui/src/main.rs index e28ad95..1007b64 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.stalled +} + struct App { client: DaemonClient, snapshot: Option, @@ -77,6 +81,8 @@ 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 tiers + attention_only: bool, // 'a' filter: show only Error/Stalled panes } impl App { @@ -91,6 +97,7 @@ impl App { session_filter: None, sessions: Vec::new(), session_idx: 0, + attention_only: false, } } @@ -109,13 +116,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 +265,38 @@ impl App { // ── rendering ── fn render(&mut self, f: &mut Frame) { - // Determine layout: detail popup shrinks table area + // Determine layout: attention bar and detail popup are optional slots. + 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 + if has_attention { + constraints.push(Constraint::Length(3)); // 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]); - } else { - self.render_table(f, area[1]); - self.render_footer(f, area[2]); + let mut slot = 0; + self.render_header(f, area[slot]); + slot += 1; + if has_attention { + self.render_attention_bar(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) { @@ -329,6 +398,38 @@ 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 { + "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 = @@ -369,6 +470,11 @@ impl App { .map(|p| { let color = state_color(&p.state, p.stalled); let icon = state_icon(&p.state, p.stalled); + let row_style = if needs_attention(p) { + Style::default().bg(Color::Red) + } else { + Style::default() + }; Row::new(vec![ Cell::from(Span::styled(icon, Style::default().fg(color))), Cell::from(p.id.as_str()), @@ -381,6 +487,7 @@ impl App { Cell::from(p.cwd.as_deref().unwrap_or("—")), Cell::from(if p.stalled { "⚠" } else { "" }), ]) + .style(row_style) }) .collect(); @@ -480,6 +587,10 @@ 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)), @@ -582,6 +693,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"); + } + } _ => {} } } @@ -859,4 +982,120 @@ mod tests { app.rebuild_session_list(); let _ = render_text(&mut app, 20, 5); } + + #[test] + fn needs_attention_detects_error_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 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(&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, + }, + ], + ); + 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("Error"), + "expected 'Error' 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, + }, + ], + ); + let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); + app.snapshot = Some(snap); + app.rebuild_session_list(); + // Without filter: both panes visible + assert_eq!(app.filtered_panes().len(), 2); + // With attention filter: only error pane + app.attention_only = true; + let filtered = app.filtered_panes(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "pane-err"); + } }