1use kintsugi_core::{Class, Decision, LoggedEvent};
10use ratatui::prelude::*;
11use ratatui::widgets::{
12 Block, BorderType, Borders, Cell, Gauge, Paragraph, Row, Scrollbar, ScrollbarOrientation,
13 ScrollbarState, Table, TableState, Wrap,
14};
15use time::macros::format_description;
16use time::UtcOffset;
17
18use crate::app::{outcome_word, App, Mode, Screen, Tab, MIN_HEIGHT, MIN_WIDTH};
19
20const ACCENT: Color = Color::Yellow; const DANGER: Color = Color::Red; const OKGREEN: Color = Color::Green; const SPLIT_WIDTH: u16 = 100;
25
26pub fn render(f: &mut Frame, app: &App) {
28 let area = f.area();
29 if app.screen == Screen::Splash {
31 crate::splash::render(f, area, app.splash_frame, app.color);
32 return;
33 }
34 if app.screen == Screen::Login {
35 render_login(f, app, area);
36 return;
37 }
38 if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
39 render_too_small(f, area);
40 return;
41 }
42 if app.screen == Screen::Settings {
43 render_settings(f, app, area);
44 return;
45 }
46
47 let rows = Layout::vertical([
48 Constraint::Length(1), Constraint::Min(1), Constraint::Length(2), ])
52 .split(area);
53
54 render_header(f, app, rows[0]);
55 if app.visible().is_empty() {
56 render_empty(f, app, rows[1]);
57 } else if app.mode == Mode::Detail {
58 render_detail(f, app, rows[1], true);
59 } else if rows[1].width >= SPLIT_WIDTH {
60 let cols = Layout::horizontal([Constraint::Percentage(58), Constraint::Percentage(42)])
61 .split(rows[1]);
62 render_list(f, app, cols[0]);
63 render_detail(f, app, cols[1], false);
64 } else {
65 render_list(f, app, rows[1]);
66 }
67 render_footer(f, app, rows[2]);
68}
69
70fn dim(app: &App) -> Style {
71 if app.color {
72 Style::default().add_modifier(Modifier::DIM)
73 } else {
74 Style::default()
75 }
76}
77
78fn accent_fg(app: &App, c: Color) -> Style {
79 if app.color {
80 Style::default().fg(c)
81 } else {
82 Style::default()
83 }
84}
85
86fn render_settings(f: &mut Frame, app: &App, area: Rect) {
89 use crate::app::SettingRow;
90 let rows = Layout::vertical([
91 Constraint::Length(1), Constraint::Min(1), Constraint::Length(2), ])
95 .split(area);
96
97 let editable = app.settings_editable();
99 let lock = if editable {
100 "unlocked for this session"
101 } else {
102 "read-only (not provisioned)"
103 };
104 f.render_widget(
105 Paragraph::new(Line::from(vec![
106 Span::styled("▦ Kintsugi", Style::default().add_modifier(Modifier::BOLD)),
107 Span::styled(" settings", dim(app)),
108 ])),
109 rows[0],
110 );
111 f.render_widget(
112 Paragraph::new(Line::from(Span::styled(lock, dim(app))).right_aligned()),
113 rows[0],
114 );
115
116 let default = kintsugi_core::admin::LockedSettings::default();
118 let s = app.settings.as_ref().unwrap_or(&default);
119 let table_rows = SettingRow::ALL.iter().enumerate().map(|(i, row)| {
120 let selected = i == app.settings_selected;
121 let marker = if selected { "› " } else { " " };
122 let val = row.value(s);
123 let val_style = accent_fg(app, ACCENT);
127 Row::new(vec![
128 Cell::from(format!("{marker}{}", row.label())),
129 Cell::from(Span::styled(val, val_style)),
130 ])
131 });
132 let table = Table::new(table_rows, [Constraint::Length(28), Constraint::Min(10)])
133 .block(panel(app, " locked settings "));
134 f.render_widget(table, rows[1]);
135
136 let foot = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(rows[2]);
138 f.render_widget(
139 Paragraph::new(Line::from(Span::styled(
140 "j/k move · enter/space toggle · esc back",
141 dim(app),
142 ))),
143 foot[0],
144 );
145 if let Some(status) = &app.settings_status {
146 let danger = status.starts_with("could not") || status.contains("read-only");
147 let style = if danger {
148 accent_fg(app, DANGER)
149 } else {
150 accent_fg(app, OKGREEN)
151 };
152 f.render_widget(
153 Paragraph::new(Line::from(Span::styled(status.clone(), style))),
154 foot[1],
155 );
156 }
157}
158
159fn render_login(f: &mut Frame, app: &App, area: Rect) {
161 let masked: String = "•".repeat(app.login_input.chars().count());
163 let mut lines = vec![
164 Line::from(Span::styled(
165 "▦ Kintsugi",
166 accent_fg(app, ACCENT).add_modifier(Modifier::BOLD),
167 )),
168 Line::from(Span::styled("admin-locked", dim(app))),
169 Line::from(""),
170 Line::from("Enter the admin password to manage Kintsugi."),
171 Line::from(""),
172 Line::from(vec![
173 Span::styled("password ", dim(app)),
174 Span::raw(masked),
175 Span::styled("▏", dim(app)),
176 ]),
177 ];
178 if let Some(err) = &app.login_error {
179 lines.push(Line::from(""));
180 lines.push(Line::from(Span::styled(
181 format!("✗ {err}"),
182 accent_fg(app, DANGER).add_modifier(Modifier::BOLD),
183 )));
184 }
185 lines.push(Line::from(""));
186 lines.push(Line::from(Span::styled(
187 "enter unlock · esc quit",
188 dim(app),
189 )));
190
191 let h = (lines.len() as u16 + 2).min(area.height);
193 let w = 52.min(area.width);
194 let card = Rect {
195 x: area.x + (area.width.saturating_sub(w)) / 2,
196 y: area.y + (area.height.saturating_sub(h)) / 2,
197 width: w,
198 height: h,
199 };
200 f.render_widget(
201 Paragraph::new(lines)
202 .block(panel(app, " login "))
203 .alignment(Alignment::Center),
204 card,
205 );
206}
207
208fn render_too_small(f: &mut Frame, area: Rect) {
209 let p = Paragraph::new(format!(
210 "Terminal too small.\nResize to at least {MIN_WIDTH}×{MIN_HEIGHT}."
211 ))
212 .alignment(Alignment::Center)
213 .wrap(Wrap { trim: true });
214 f.render_widget(p, area);
215}
216
217fn render_header(f: &mut Frame, app: &App, area: Rect) {
218 let mut spans = vec![
221 Span::styled("▦ Kintsugi", Style::default().add_modifier(Modifier::BOLD)),
222 Span::raw(" "),
223 ];
224 for (i, tab) in Tab::ALL.iter().enumerate() {
225 if i > 0 {
226 spans.push(Span::styled(" · ", dim(app)));
227 }
228 let active = *tab == app.tab;
229 let label = if active {
232 format!("[{}]", tab.title())
233 } else {
234 format!(" {} ", tab.title())
235 };
236 let style = if active {
237 accent_fg(app, ACCENT).add_modifier(Modifier::BOLD)
238 } else {
239 dim(app)
240 };
241 spans.push(Span::styled(label, style));
242 spans.push(Span::styled(format!(" {}", app.tab_total(*tab)), dim(app)));
243 }
244 f.render_widget(Paragraph::new(Line::from(spans)), area);
245
246 let (total, held, catastrophic) = app.vitals();
248 let mut vitals = vec![Span::styled(format!("{total} events"), dim(app))];
249 if held > 0 {
250 vitals.push(Span::styled(" · ", dim(app)));
251 vitals.push(Span::styled(format!("{held} held"), accent_fg(app, ACCENT)));
252 }
253 if catastrophic > 0 {
254 vitals.push(Span::styled(" · ", dim(app)));
255 vitals.push(Span::styled(
256 format!("{catastrophic} catastrophic"),
257 accent_fg(app, DANGER),
258 ));
259 }
260 vitals.push(Span::styled(" · ", dim(app)));
261 if app.daemon_up {
262 let scorer = app.scorer.as_deref().unwrap_or("ready");
263 vitals.push(Span::styled(
264 format!("● daemon {scorer}"),
265 accent_fg(app, OKGREEN),
266 ));
267 } else {
268 vitals.push(Span::styled("○ daemon down", dim(app)));
269 }
270 f.render_widget(Paragraph::new(Line::from(vitals).right_aligned()), area);
271}
272
273fn render_empty(f: &mut Frame, app: &App, area: Rect) {
274 let block = panel(app, &format!(" {} ", app.tab.title().to_lowercase()));
275 let (headline, hint): (&str, String) = if !app.filter.is_empty() {
277 (
278 "No rows match the filter.",
279 format!("filter: {}", app.filter),
280 )
281 } else {
282 ("Nothing here yet.", app.tab.empty_copy().to_string())
283 };
284 let lines = vec![
285 Line::from(""),
286 Line::from(Span::styled(
287 headline,
288 Style::default().add_modifier(Modifier::BOLD),
289 )),
290 Line::from(""),
291 Line::from(Span::styled(hint, dim(app))),
292 ];
293 f.render_widget(
294 Paragraph::new(lines)
295 .block(block)
296 .alignment(Alignment::Center),
297 area,
298 );
299}
300
301fn panel(app: &App, title: &str) -> Block<'static> {
303 let b = Block::default()
304 .borders(Borders::ALL)
305 .border_type(BorderType::Rounded)
306 .title(Span::styled(title.to_string(), dim(app)));
307 if app.color {
308 b.border_style(Style::default().fg(Color::DarkGray))
309 } else {
310 b
311 }
312}
313
314fn class_tag(c: Class) -> &'static str {
315 match c {
316 Class::Safe => "",
317 Class::Catastrophic => "[catastrophic] ",
318 Class::Ambiguous => "[ambiguous] ",
319 }
320}
321
322fn decision_color(d: Decision) -> Color {
323 match d {
324 Decision::Allow => OKGREEN,
325 Decision::Deny => DANGER,
326 Decision::Hold => ACCENT,
327 }
328}
329
330fn fmt_time(ev: &LoggedEvent, offset: UtcOffset) -> String {
332 let f = format_description!("[hour]:[minute]:[second]");
333 ev.ts
334 .to_offset(offset)
335 .format(&f)
336 .unwrap_or_else(|_| "--:--:--".into())
337}
338
339fn fmt_date(ev: &LoggedEvent, offset: UtcOffset) -> String {
342 let f = format_description!("[month repr:short] [day]");
343 ev.ts
344 .to_offset(offset)
345 .format(&f)
346 .unwrap_or_else(|_| "------".into())
347}
348
349fn fmt_datetime(ev: &LoggedEvent, offset: UtcOffset) -> String {
352 let f = format_description!(
353 "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]"
354 );
355 ev.ts
356 .to_offset(offset)
357 .format(&f)
358 .unwrap_or_else(|_| "—".into())
359}
360
361fn local_day(ev: &LoggedEvent, offset: UtcOffset) -> time::Date {
363 ev.ts.to_offset(offset).date()
364}
365
366fn short_session(ev: &LoggedEvent) -> String {
367 match &ev.session {
368 Some(s) => s.chars().take(8).collect(),
369 None => "—".to_string(),
370 }
371}
372
373const W_DATE: u16 = 6; const W_TIME: u16 = 8; const W_AGENT: u16 = 12;
377const W_SESSION: u16 = 8;
378const W_OUTCOME: u16 = 7; fn render_list(f: &mut Frame, app: &App, area: Rect) {
381 let visible = app.visible();
382 let offset = app.local_offset;
383 let show_session = area.width >= 92;
386
387 let mut head = vec!["date", "time", "agent"];
388 if show_session {
389 head.push("session");
390 }
391 head.push("outcome");
392 head.push("command");
393 let n_cols = head.len() as u16;
394 let header = Row::new(head)
395 .style(dim(app).add_modifier(Modifier::BOLD))
396 .height(1);
397
398 let fixed = W_DATE + W_TIME + W_AGENT + W_OUTCOME + if show_session { W_SESSION } else { 0 };
401 let chrome = 2 + 2 + (n_cols - 1) ;
402 let cmd_width = area.width.saturating_sub(fixed + chrome).max(8) as usize;
403
404 let mut prev_day: Option<time::Date> = None;
405 let rows: Vec<Row> = visible
406 .iter()
407 .map(|ev| {
408 let day = local_day(ev, offset);
411 let date_cell = if prev_day != Some(day) {
412 Cell::from(Span::styled(fmt_date(ev, offset), dim(app)))
413 } else {
414 Cell::from("")
415 };
416 prev_day = Some(day);
417
418 let outcome = Cell::from(Span::styled(
419 outcome_word(ev.decision),
420 accent_fg(app, decision_color(ev.decision)),
421 ));
422 let tag = class_tag(ev.class);
423 let body = ellipsize(&ev.command, cmd_width.saturating_sub(tag.len()));
424 let command = Line::from(vec![
425 Span::styled(tag, accent_fg(app, decision_color(ev.decision))),
426 Span::raw(body),
427 ]);
428 let mut cells = vec![
429 date_cell,
430 Cell::from(fmt_time(ev, offset)),
431 Cell::from(truncate(&ev.agent, W_AGENT as usize)),
432 ];
433 if show_session {
434 cells.push(Cell::from(Span::styled(short_session(ev), dim(app))));
435 }
436 cells.push(outcome);
437 cells.push(Cell::from(command));
438 Row::new(cells)
439 })
440 .collect();
441
442 let mut widths = vec![
443 Constraint::Length(W_DATE),
444 Constraint::Length(W_TIME),
445 Constraint::Length(W_AGENT),
446 ];
447 if show_session {
448 widths.push(Constraint::Length(W_SESSION));
449 }
450 widths.push(Constraint::Length(W_OUTCOME));
451 widths.push(Constraint::Min(10));
452
453 let highlight = if app.color {
454 Style::default()
455 .bg(Color::Indexed(236))
456 .add_modifier(Modifier::BOLD)
457 } else {
458 Style::default().add_modifier(Modifier::REVERSED)
459 };
460 let title = format!(
462 " {} {}/{} ",
463 app.tab.title().to_lowercase(),
464 (app.selected + 1).min(visible.len()),
465 visible.len()
466 );
467 let table = Table::new(rows, widths)
468 .header(header)
469 .block(panel(app, &title))
470 .row_highlight_style(highlight)
471 .highlight_symbol("› ");
472
473 let mut state = TableState::default().with_selected(Some(app.selected));
474 f.render_stateful_widget(table, area, &mut state);
475
476 let rows_on_screen = area.height.saturating_sub(3).max(1) as usize;
482 if visible.len() > rows_on_screen {
483 let mut sb_state = ScrollbarState::new(visible.len())
484 .viewport_content_length(rows_on_screen)
485 .position(app.selected);
486 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
487 .begin_symbol(Some("↑"))
488 .end_symbol(Some("↓"))
489 .track_symbol(Some("│"))
490 .thumb_symbol("█")
491 .style(dim(app));
492 f.render_stateful_widget(
493 scrollbar,
494 area.inner(Margin {
495 vertical: 1,
496 horizontal: 0,
497 }),
498 &mut sb_state,
499 );
500 }
501}
502
503fn ellipsize(s: &str, max: usize) -> String {
506 if max == 0 {
507 return String::new();
508 }
509 truncate(s, max)
510}
511
512fn render_detail(f: &mut Frame, app: &App, area: Rect, full: bool) {
513 let block = panel(
514 app,
515 if full {
516 " detail · esc to go back "
517 } else {
518 " detail "
519 },
520 );
521 let Some(ev) = app.selected_event() else {
522 f.render_widget(
523 Paragraph::new(Line::from(Span::styled(
524 "Select a row to inspect it.",
525 dim(app),
526 )))
527 .block(block),
528 area,
529 );
530 return;
531 };
532
533 let inner = block.inner(area);
534 f.render_widget(block, area);
535
536 let (top, gauge_area) = if ev.risk.is_some() && inner.height >= 4 {
538 let parts = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).split(inner);
539 (parts[0], Some(parts[1]))
540 } else {
541 (inner, None)
542 };
543
544 let label = |k: &str| Span::styled(format!("{k:<9}"), dim(app));
545 let headline = if ev.redacted {
546 "redacted · hidden".to_string()
547 } else {
548 format!("{} · {}", outcome_word(ev.decision), ev.class.as_str())
549 };
550 let mut lines = vec![
551 Line::from(Span::styled(
552 headline,
553 accent_fg(app, decision_color(ev.decision)).add_modifier(Modifier::BOLD),
554 )),
555 Line::from(""),
556 Line::from(vec![label("command"), Span::raw(ev.command.clone())]),
557 Line::from(vec![label("agent"), Span::raw(ev.agent.clone())]),
558 ];
559 if let Some(session) = &ev.session {
560 lines.push(Line::from(vec![
561 label("session"),
562 Span::raw(session.clone()),
563 ]));
564 }
565 lines.push(Line::from(vec![
566 label("when"),
567 Span::raw(fmt_datetime(ev, app.local_offset)),
568 ]));
569 lines.push(Line::from(vec![
570 label("reason"),
571 Span::raw(ev.reason.clone()),
572 ]));
573 if let Some(summary) = &ev.summary {
574 let mut parts = summary.split('\n');
578 if let Some(first) = parts.next() {
579 lines.push(Line::from(vec![
580 label("summary"),
581 Span::raw(first.to_string()),
582 ]));
583 }
584 for cont in parts {
585 if cont.trim().is_empty() {
586 continue;
587 }
588 lines.push(Line::from(vec![
589 Span::raw(" "),
590 Span::raw(cont.to_string()),
591 ]));
592 }
593 }
594 if let Some(snap) = &ev.snapshot_id {
595 lines.push(Line::from(vec![
596 label("snapshot"),
597 Span::raw(snap.chars().take(12).collect::<String>()),
598 ]));
599 }
600 f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), top);
601
602 if let (Some(area), Some(risk)) = (gauge_area, ev.risk) {
603 let color = if ev.class == Class::Catastrophic {
604 DANGER
605 } else {
606 ACCENT
607 };
608 let gauge = Gauge::default()
609 .ratio((risk as f64 / 100.0).clamp(0.0, 1.0))
610 .label(format!("risk {risk}/100"))
611 .gauge_style(accent_fg(app, color))
612 .use_unicode(true);
613 f.render_widget(gauge, gauge_rect(area));
616 }
617}
618
619fn gauge_rect(area: Rect) -> Rect {
621 let width = (area.width / 2).clamp(14, 40).min(area.width);
622 Rect {
623 x: area.x,
624 y: area.y,
625 width,
626 height: 1,
627 }
628}
629
630fn render_footer(f: &mut Frame, app: &App, area: Rect) {
631 let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
632 let help = "j/k move · space/b page · tab · a/d resolve · u undo · / filter · q quit";
633 let total = app.visible().len();
637 let pos = if total > 0 {
638 let per = app.page_rows.max(1);
639 let page = app.selected / per + 1;
640 let pages = total.div_ceil(per);
641 format!("row {}/{} · pg {}/{}", app.selected + 1, total, page, pages)
642 } else {
643 String::new()
644 };
645 let width = area.width as usize;
646 let help_line = if !pos.is_empty() && width > help.chars().count() + pos.chars().count() + 1 {
647 let pad = width - help.chars().count() - pos.chars().count();
648 Line::from(vec![
649 Span::styled(help, dim(app)),
650 Span::raw(" ".repeat(pad)),
651 Span::styled(pos, dim(app)),
652 ])
653 } else {
654 Line::from(Span::styled(help, dim(app)))
655 };
656 f.render_widget(Paragraph::new(help_line), rows[0]);
657
658 let second = match app.mode {
659 Mode::Filter => {
660 let mut spans = vec![
661 Span::styled("/", Style::default().add_modifier(Modifier::BOLD)),
662 Span::raw(app.filter.clone()),
663 Span::styled("▏", dim(app)),
664 ];
665 if app.filter.is_empty() {
666 spans.push(Span::styled(
667 " agent:claude-code · session:4a87 · since:10m · before:1d · or text",
668 dim(app),
669 ));
670 }
671 Line::from(spans)
672 }
673 _ => {
674 if let Some(status) = &app.status {
675 Line::from(Span::raw(status.clone()))
676 } else if !app.filter.is_empty() {
677 Line::from(Span::styled(format!("filter: {}", app.filter), dim(app)))
678 } else {
679 Line::from("")
680 }
681 }
682 };
683 f.render_widget(Paragraph::new(second), rows[1]);
684}
685
686fn truncate(s: &str, max: usize) -> String {
687 if s.chars().count() <= max {
688 s.to_string()
689 } else {
690 let mut t: String = s.chars().take(max.saturating_sub(1)).collect();
691 t.push('…');
692 t
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use kintsugi_core::{EventLog, ProposedCommand, Verdict};
700 use ratatui::backend::TestBackend;
701 use ratatui::Terminal;
702
703 #[test]
704 fn gauge_rect_is_bounded_and_single_row() {
705 let wide = gauge_rect(Rect {
707 x: 5,
708 y: 9,
709 width: 200,
710 height: 2,
711 });
712 assert_eq!(wide.width, 40);
713 assert_eq!(wide.height, 1);
714 assert_eq!((wide.x, wide.y), (5, 9));
715 let narrow = gauge_rect(Rect {
717 x: 0,
718 y: 0,
719 width: 10,
720 height: 2,
721 });
722 assert!(narrow.width <= 10);
723 assert_eq!(narrow.height, 1);
724 }
725
726 fn ev(agent: &str, raw: &str, class: Class, decision: Decision) -> LoggedEvent {
727 let log = EventLog::open_in_memory().unwrap();
728 let cmd = ProposedCommand::new(agent, "/tmp", vec![raw.into()], raw);
729 let mut v = Verdict::rules(class, decision, "rule");
730 if class == Class::Ambiguous {
731 v.risk = Some(60);
732 v.summary = Some("needs your call".into());
733 }
734 log.log_event(&cmd, &v, None).unwrap()
735 }
736
737 fn app_with_events() -> App {
738 let mut app = App::new(false);
739 app.set_events(vec![
740 ev("claude-code", "ls -la", Class::Safe, Decision::Allow),
741 ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
742 ]);
743 app
744 }
745
746 fn buffer_text(app: &App, w: u16, h: u16) -> String {
747 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
748 term.draw(|f| render(f, app)).unwrap();
749 let buf = term.backend().buffer().clone();
750 buf.content().iter().map(|c| c.symbol()).collect()
751 }
752
753 #[test]
754 fn renders_timeline_at_standard_size() {
755 let text = buffer_text(&app_with_events(), 80, 24);
756 assert!(text.contains("Kintsugi"));
757 assert!(text.contains("timeline"));
758 assert!(text.contains("rm -rf /"));
759 assert!(text.contains("held"));
760 assert!(text.contains("[catastrophic]"));
761 assert!(text.contains("q quit"));
762 }
763
764 #[test]
765 fn split_layout_shows_detail_pane_when_wide() {
766 let mut app = app_with_events();
767 app.selected = 1;
768 let text = buffer_text(&app, 120, 24);
769 assert!(text.contains("detail"));
771 assert!(text.contains("reason"));
772 }
773
774 #[test]
775 fn reflows_small_and_large() {
776 let text = buffer_text(&app_with_events(), 50, 8);
777 assert!(text.contains("too small"));
778 let big = buffer_text(&app_with_events(), 200, 60);
779 assert!(big.contains("rm -rf /"));
780 }
781
782 #[test]
783 fn empty_state_is_designed() {
784 let app = App::new(false);
785 let text = buffer_text(&app, 80, 24);
786 assert!(text.contains("Nothing here yet"));
787 assert!(text.contains("wired agent"));
788 }
789
790 #[test]
791 fn detail_view_shows_fields_and_risk() {
792 let mut app = app_with_events();
793 app.set_events(vec![ev(
795 "qwen",
796 "make deploy",
797 Class::Ambiguous,
798 Decision::Hold,
799 )]);
800 app.selected = 0;
801 app.on_key(crossterm::event::KeyCode::Enter);
802 let text = buffer_text(&app, 80, 24);
803 assert!(text.contains("detail"));
804 assert!(text.contains("make deploy"));
805 assert!(text.contains("reason"));
806 assert!(text.contains("risk"));
807 }
808
809 #[test]
810 fn color_mode_renders_without_panic() {
811 let mut app = App::new(true);
813 app.set_events(vec![
814 ev("qwen", "make deploy", Class::Ambiguous, Decision::Hold),
815 ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
816 ]);
817 app.selected = 0; let wide = buffer_text(&app, 120, 24); assert!(wide.contains("make deploy"));
820 assert!(wide.contains("risk"));
821 let narrow = buffer_text(&app, 80, 24); assert!(narrow.contains("held"));
823 }
824
825 #[test]
826 fn settings_screen_lists_rows_and_values() {
827 let mut app = App::new(false);
828 app.open_settings(); let text = buffer_text(&app, 80, 24);
830 assert!(text.contains("locked settings"));
831 assert!(text.contains("recording"));
832 assert!(text.contains("enforcement"));
833 assert!(text.contains("attended"));
834 assert!(text.contains("read-only"));
835 assert!(text.contains("esc back"));
836 }
837
838 #[test]
839 fn login_screen_masks_input_and_shows_errors() {
840 let mut app = App::new(false);
841 app.screen = crate::app::Screen::Login;
843 app.login_input = zeroize::Zeroizing::new("secret".to_string());
844 app.login_error = Some("incorrect password".into());
845 let text = buffer_text(&app, 80, 24);
846 assert!(text.contains("admin-locked"));
847 assert!(text.contains("••••••"), "password must be masked");
848 assert!(!text.contains("secret"), "raw password must never render");
849 assert!(text.contains("incorrect password"));
850 assert!(text.contains("esc quit"));
851 }
852
853 fn ev_at(ts: time::OffsetDateTime, agent: &str, raw: &str) -> LoggedEvent {
854 let mut e = ev(agent, raw, Class::Safe, Decision::Allow);
855 e.ts = ts;
856 e
857 }
858
859 #[test]
860 fn time_and_date_render_in_the_local_offset() {
861 use time::macros::datetime;
862 let e = ev_at(datetime!(2026-06-17 23:30:00 UTC), "shell", "ls");
863 assert_eq!(fmt_time(&e, UtcOffset::UTC), "23:30:00");
865 assert_eq!(fmt_date(&e, UtcOffset::UTC), "Jun 17");
866 let plus2 = UtcOffset::from_hms(2, 0, 0).unwrap();
868 assert_eq!(fmt_time(&e, plus2), "01:30:00");
869 assert_eq!(fmt_date(&e, plus2), "Jun 18");
870 assert!(fmt_datetime(&e, plus2).contains("+02:00"));
871 }
872
873 #[test]
874 fn date_column_groups_by_day() {
875 use time::macros::datetime;
876 let mut app = App::new(false);
877 app.set_events(vec![
878 ev_at(datetime!(2026-06-16 09:00:00 UTC), "shell", "older"),
879 ev_at(datetime!(2026-06-17 09:00:00 UTC), "shell", "first-today"),
880 ev_at(datetime!(2026-06-17 10:00:00 UTC), "shell", "second-today"),
881 ]);
882 let text = buffer_text(&app, 100, 24);
883 assert_eq!(
885 text.matches("Jun 17").count(),
886 1,
887 "consecutive same-day rows share one date label"
888 );
889 assert!(text.contains("Jun 16"));
890 }
891
892 #[test]
893 fn scrollbar_shows_only_when_the_list_overflows() {
894 let mut app = App::new(false);
895 let many: Vec<LoggedEvent> = (0..50)
896 .map(|_| ev("shell", "ls", Class::Safe, Decision::Allow))
897 .collect();
898 app.set_events(many);
899 let overflow = buffer_text(&app, 80, 12);
900 assert!(
901 overflow.contains('█') || overflow.contains('↓'),
902 "an overflowing list must show a scrollbar"
903 );
904
905 app.set_events(vec![ev("shell", "ls", Class::Safe, Decision::Allow)]);
906 let fits = buffer_text(&app, 80, 24);
907 assert!(
908 !fits.contains('█') && !fits.contains('↓'),
909 "no scrollbar when everything fits"
910 );
911 }
912
913 #[test]
914 fn footer_shows_row_and_page_position() {
915 let mut app = App::new(false);
916 app.page_rows = 5;
917 let many: Vec<LoggedEvent> = (0..12)
918 .map(|_| ev("shell", "ls", Class::Safe, Decision::Allow))
919 .collect();
920 app.set_events(many);
921 let text = buffer_text(&app, 100, 14);
922 assert!(text.contains("row 1/12"));
923 assert!(text.contains("pg 1/3"));
924 }
925
926 #[test]
927 fn tab_bar_shows_count_badges() {
928 let mut app = App::new(false);
929 app.set_events(vec![
930 ev("claude-code", "ls", Class::Safe, Decision::Allow),
931 ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
932 ]);
933 let text = buffer_text(&app, 140, 24);
936 assert!(text.contains("Timeline] 2"));
938 assert!(text.contains("Audit 1") || text.contains("Audit 1"));
939 assert!(
941 text.contains("Backstop"),
942 "tab bar should include Backstop:\n{text}"
943 );
944 }
945
946 #[test]
947 fn filter_mode_shows_input_line() {
948 let mut app = app_with_events();
949 app.on_key(crossterm::event::KeyCode::Char('/'));
950 app.on_key(crossterm::event::KeyCode::Char('r'));
951 app.on_key(crossterm::event::KeyCode::Char('m'));
952 let text = buffer_text(&app, 80, 24);
953 assert!(text.contains("/rm"));
954 assert!(text.contains("rm -rf /"));
955 assert!(!text.contains("ls -la"), "filtered out the safe row");
956 }
957}