feat(tui): glasspane attention tiers 1-4 — bar, jump, filter, row highlight
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:
parent
95bf3f396d
commit
c858cde01c
1 changed files with 347 additions and 33 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.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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue