feat(tui): glasspane attention tiers 1-3 — attention bar, jump, filter

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).
This commit is contained in:
123kupola 2026-06-25 17:05:10 +02:00
parent 2288c12c8b
commit a85dfecb0e

View file

@ -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<GlasspaneSnapshot>,
@ -77,6 +81,8 @@ struct App {
session_filter: Option<String>, // filter by session_id, or None
sessions: Vec<String>, // 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<usize> {
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<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 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<Line> = 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<colibri_glasspane::Pane> =
@ -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");
}
}