Skip to main content

kintsugi_tui/
ui.rs

1//! Rendering for the Kintsugi TUI.
2//!
3//! Craft borrowed from the ratatui showcase — bordered panels, a split
4//! list/detail layout, a real risk gauge, proper selection highlight — but the
5//! design language stays Kintsugi's: calm until it must shout. One reserved danger
6//! accent, every state also a word (never color alone), `NO_COLOR` honored via
7//! [`App::color`], reflows at any size, and a deliberate "too small" notice.
8
9use 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; // the one reserved accent (held / ambiguous)
21const DANGER: Color = Color::Red; // denied / catastrophic
22const OKGREEN: Color = Color::Green; // allowed
23/// Below this width the detail pane stacks out; the list takes the full width.
24const SPLIT_WIDTH: u16 = 100;
25
26/// Render the whole UI for the current frame.
27pub fn render(f: &mut Frame, app: &App) {
28    let area = f.area();
29    // The splash and login own the whole screen and render at any size.
30    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), // header
49        Constraint::Min(1),    // body
50        Constraint::Length(2), // footer
51    ])
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
86/// The settings control panel: the locked settings as a selectable list, each a
87/// label + current value, with a save/result line. Read-only when unprovisioned.
88fn render_settings(f: &mut Frame, app: &App, area: Rect) {
89    use crate::app::SettingRow;
90    let rows = Layout::vertical([
91        Constraint::Length(1), // header
92        Constraint::Min(1),    // body
93        Constraint::Length(2), // footer
94    ])
95    .split(area);
96
97    // Header.
98    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    // Body: the settings table.
117    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        // The danger accent is reserved: only fail-closed "on" and the value of
124        // require-password-to-stop "off" (a loosening) warrant attention; here we
125        // keep it calm and use the accent for the *on* booleans.
126        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    // Footer: help + transient status.
137    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
159/// The admin password gate. Centered card, masked input, error under the field.
160fn render_login(f: &mut Frame, app: &App, area: Rect) {
161    // Mask the password with bullets — its length is the only thing on screen.
162    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    // A bordered card, centered, sized to the content.
192    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    // Left: brand + tab bar. The active tab is bracketed *and* bold/accent, so it
219    // reads as selected without relying on color (NO_COLOR-safe).
220    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        // The active tab is bracketed *and* bold/accent, so it reads as selected
230        // without color (NO_COLOR-safe); each tab carries its row count as a badge.
231        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    // Right: live vitals — counts (global) and daemon health, all worded.
247    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    // Distinguish "this slice is genuinely empty" from "the filter hid everything".
276    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
301/// A rounded bordered panel with a title.
302fn 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
330/// `HH:MM:SS` in the viewer's local zone (events are stored in UTC).
331fn 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
339/// `Mon DD` in the viewer's local zone — the day a row's command happened. Used
340/// as a group label: shown only when the date changes down the list.
341fn 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
349/// Full `YYYY-MM-DD HH:MM:SS ±HH:MM` for the detail pane — unambiguous, with the
350/// offset spelled out so there's no doubt which zone it's in.
351fn 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
361/// The local calendar day of an event, for deciding when to print a date label.
362fn 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
373// Fixed column widths (the command column flexes to fill the rest).
374const W_DATE: u16 = 6; // "Jun 17"
375const W_TIME: u16 = 8; // "11:43:30"
376const W_AGENT: u16 = 12;
377const W_SESSION: u16 = 8;
378const W_OUTCOME: u16 = 7; // "allowed"
379
380fn render_list(f: &mut Frame, app: &App, area: Rect) {
381    let visible = app.visible();
382    let offset = app.local_offset;
383    // Show a session column when there's room; the detail pane always has the
384    // full id, so on narrow terminals we drop the column rather than scroll.
385    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    // Budget for the flexing command column, so we can ellipsize cleanly instead
399    // of letting ratatui hard-clip mid-word at the panel edge.
400    let fixed = W_DATE + W_TIME + W_AGENT + W_OUTCOME + if show_session { W_SESSION } else { 0 };
401    let chrome = 2 /* borders */ + 2 /* highlight symbol */ + (n_cols - 1) /* column gaps */;
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            // Group by day: print the date only when it changes down the list, so
409            // a long run of same-day rows reads as one block (git-log style).
410            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    // Title carries the position so the count is always in view: "timeline 42/830".
461    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    // A scrollbar on the right border when the list overflows the viewport, so
477    // there's a visible sense of position and how much is off-screen. Drawn on
478    // the border itself (not over data); calm in monochrome too. The viewport is
479    // derived from the render area (height minus the two borders and the header
480    // row), not the event loop's page_rows, so it's correct in any render path.
481    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
503/// Truncate a single-line string to `max` display columns, appending '…' when it
504/// doesn't fit. `max == 0` yields an empty string.
505fn 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    // Reserve a gauge row for held/ambiguous items that carry a risk score.
537    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        // The model summary may carry "• " pointer lines (newline-separated);
575        // a single Span won't break on '\n', so render each line on its own —
576        // the label on the first, indented continuations after.
577        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        // Auto width: size the bar to ~half the panel (bounded 14..=40), one row
614        // high, so it reads as a meter — not a full-width block that overruns.
615        f.render_widget(gauge, gauge_rect(area));
616    }
617}
618
619/// A bounded, single-row sub-rect for the risk meter inside its reserved area.
620fn 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    // Right-aligned position indicator so paging has a frame of reference — both
634    // the row and the page of the current viewport — shown only when it fits
635    // without crowding the help (narrow terminals show help alone).
636    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        // Wide panel: capped at 40, one row high, anchored at the area origin.
706        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        // Narrow panel: never wider than the area it's given.
716        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        // The detail panel and its labels appear alongside the list.
770        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        // Add an ambiguous (risk-bearing) row and open it.
794        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        // Exercises the color branches (accent fg, border style, highlight, gauge).
812        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; // the ambiguous row carries a risk score → gauge shows
818        let wide = buffer_text(&app, 120, 24); // split + detail + gauge
819        assert!(wide.contains("make deploy"));
820        assert!(wide.contains("risk"));
821        let narrow = buffer_text(&app, 80, 24); // list only
822        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(); // read-only defaults (no vault)
829        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        // Force the login screen without a real vault by setting state directly.
842        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        // UTC: late evening on the 17th.
864        assert_eq!(fmt_time(&e, UtcOffset::UTC), "23:30:00");
865        assert_eq!(fmt_date(&e, UtcOffset::UTC), "Jun 17");
866        // +02:00 rolls both the clock and the calendar day forward.
867        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        // Each day is labelled once; the second same-day row repeats no date.
884        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        // Render wide so the full tab bar (incl. the Backstop tab) has room and
934        // isn't overlapped by the right-aligned vitals strip.
935        let text = buffer_text(&app, 140, 24);
936        // Timeline holds both; Audit holds only the catastrophic one.
937        assert!(text.contains("Timeline] 2"));
938        assert!(text.contains("Audit  1") || text.contains("Audit 1"));
939        // The dedicated backstop tab is present in the bar.
940        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}