feat(tui): glasspane attention tiers 1-4 — bar, jump, filter, row highlight #191
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 {
|
struct App {
|
||||||
client: DaemonClient,
|
client: DaemonClient,
|
||||||
snapshot: Option<GlasspaneSnapshot>,
|
snapshot: Option<GlasspaneSnapshot>,
|
||||||
|
|
@ -77,6 +81,7 @@ struct App {
|
||||||
session_filter: Option<String>, // filter by session_id, or None
|
session_filter: Option<String>, // filter by session_id, or None
|
||||||
sessions: Vec<String>, // all known session IDs
|
sessions: Vec<String>, // all known session IDs
|
||||||
session_idx: usize, // which session is selected
|
session_idx: usize, // which session is selected
|
||||||
|
attention_only: bool, // 'a' filter: show only attention panes
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -91,6 +96,7 @@ impl App {
|
||||||
session_filter: None,
|
session_filter: None,
|
||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
session_idx: 0,
|
session_idx: 0,
|
||||||
|
attention_only: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,13 +115,74 @@ impl App {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
match &self.session_filter {
|
let mut panes: Vec<&colibri_glasspane::Pane> = match &self.session_filter {
|
||||||
Some(sid) => snap
|
Some(sid) => snap
|
||||||
.panes
|
.panes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.session_id.as_deref() == Some(sid.as_str()))
|
.filter(|p| p.session_id.as_deref() == Some(sid.as_str()))
|
||||||
.collect(),
|
.collect(),
|
||||||
None => snap.panes.iter().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 ──
|
// ── rendering ──
|
||||||
|
|
||||||
fn render(&mut self, f: &mut Frame) {
|
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 has_detail = self.detail_pane.is_some();
|
||||||
let constraints: Vec<Constraint> = if has_detail {
|
let mut constraints = vec![Constraint::Length(3)]; // header / attention bar
|
||||||
vec![
|
constraints.push(Constraint::Min(0)); // table
|
||||||
Constraint::Length(3), // header
|
if has_detail {
|
||||||
Constraint::Min(0), // table
|
constraints.push(Constraint::Length(8)); // detail
|
||||||
Constraint::Length(8), // detail
|
}
|
||||||
Constraint::Length(2), // footer
|
constraints.push(Constraint::Length(2)); // footer
|
||||||
]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
Constraint::Length(3), // header
|
|
||||||
Constraint::Min(0), // table
|
|
||||||
Constraint::Length(2), // footer
|
|
||||||
]
|
|
||||||
};
|
|
||||||
let area = Layout::default()
|
let area = Layout::default()
|
||||||
.vertical_margin(1)
|
.vertical_margin(1)
|
||||||
.horizontal_margin(2)
|
.horizontal_margin(2)
|
||||||
.constraints(constraints)
|
.constraints(constraints)
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
self.render_header(f, area[0]);
|
let mut slot = 0;
|
||||||
if has_detail {
|
if has_attention {
|
||||||
self.render_table(f, area[1]);
|
self.render_attention_bar(f, area[slot]);
|
||||||
self.render_detail(f, area[2]);
|
|
||||||
self.render_footer(f, area[3]);
|
|
||||||
} else {
|
} else {
|
||||||
self.render_table(f, area[1]);
|
self.render_header(f, area[slot]);
|
||||||
self.render_footer(f, area[2]);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
fn render_header(&self, f: &mut Frame, area: Rect) {
|
||||||
|
|
@ -317,9 +381,7 @@ impl App {
|
||||||
if let Some((msg, _)) = &self.status_msg {
|
if let Some((msg, _)) = &self.status_msg {
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
msg,
|
msg,
|
||||||
Style::default()
|
Style::default().add_modifier(Modifier::ITALIC | Modifier::BOLD),
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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) {
|
fn render_table(&mut self, f: &mut Frame, area: Rect) {
|
||||||
// Collect panes before rendering to avoid borrow conflicts
|
// Collect panes before rendering to avoid borrow conflicts
|
||||||
let panes: Vec<colibri_glasspane::Pane> =
|
let panes: Vec<colibri_glasspane::Pane> =
|
||||||
|
|
@ -364,11 +463,26 @@ impl App {
|
||||||
])
|
])
|
||||||
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
|
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
|
||||||
|
|
||||||
|
let selected = self.table_state.selected();
|
||||||
let rows: Vec<Row> = panes
|
let rows: Vec<Row> = panes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.enumerate()
|
||||||
|
.map(|(i, p)| {
|
||||||
let color = state_color(&p.state, p.stalled);
|
let color = state_color(&p.state, p.stalled);
|
||||||
let icon = state_icon(&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![
|
Row::new(vec![
|
||||||
Cell::from(Span::styled(icon, Style::default().fg(color))),
|
Cell::from(Span::styled(icon, Style::default().fg(color))),
|
||||||
Cell::from(p.id.as_str()),
|
Cell::from(p.id.as_str()),
|
||||||
|
|
@ -381,6 +495,7 @@ impl App {
|
||||||
Cell::from(p.cwd.as_deref().unwrap_or("—")),
|
Cell::from(p.cwd.as_deref().unwrap_or("—")),
|
||||||
Cell::from(if p.stalled { "⚠" } else { "" }),
|
Cell::from(if p.stalled { "⚠" } else { "" }),
|
||||||
])
|
])
|
||||||
|
.style(row_style)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -397,7 +512,7 @@ impl App {
|
||||||
let table = Table::new(rows, widths)
|
let table = Table::new(rows, widths)
|
||||||
.header(header)
|
.header(header)
|
||||||
.block(Block::default().borders(Borders::ALL).title("Panes"))
|
.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);
|
f.render_stateful_widget(table, area, &mut self.table_state);
|
||||||
}
|
}
|
||||||
|
|
@ -461,10 +576,7 @@ impl App {
|
||||||
let key = |label: &str| -> Span {
|
let key = |label: &str| -> Span {
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {label}"),
|
format!(" {label}"),
|
||||||
Style::default()
|
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED),
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
.fg(Color::White)
|
|
||||||
.bg(Color::DarkGray),
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let text = Line::from(vec![
|
let text = Line::from(vec![
|
||||||
|
|
@ -480,9 +592,13 @@ impl App {
|
||||||
Span::raw(" session "),
|
Span::raw(" session "),
|
||||||
key("j/k"),
|
key("j/k"),
|
||||||
Span::raw(" nav "),
|
Span::raw(" nav "),
|
||||||
|
key("n/N"),
|
||||||
|
Span::raw(" attn "),
|
||||||
|
key("a"),
|
||||||
|
Span::raw(" filter "),
|
||||||
key("r"),
|
key("r"),
|
||||||
Span::raw(" refresh "),
|
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);
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> io::Result<()> {
|
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()
|
let socket_path = std::env::args()
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
|
@ -859,4 +992,185 @@ mod tests {
|
||||||
app.rebuild_session_list();
|
app.rebuild_session_list();
|
||||||
let _ = render_text(&mut app, 20, 5);
|
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