feat(tui): glasspane attention tiers 1-4 — bar, jump, filter, row highlight
Some checks failed
CI / port (pull_request) Has been cancelled
CI / agent-jail-pkgs (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / markdown (pull_request) Has been cancelled

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)
This commit is contained in:
Sam & Claude 2026-06-25 18:33:47 +02:00
parent 95bf3f396d
commit c858cde01c

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.state == AgentState::Blocked || pane.stalled
}
struct App {
client: DaemonClient,
snapshot: Option<GlasspaneSnapshot>,
@ -77,6 +81,7 @@ 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_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<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 +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<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 / 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<Line> = 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<colibri_glasspane::Pane> =
@ -364,11 +463,26 @@ impl App {
])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
let selected = self.table_state.selected();
let rows: Vec<Row> = 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}"
);
}
}