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:
parent
2288c12c8b
commit
a85dfecb0e
1 changed files with 263 additions and 24 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue