Skip to main content

zero_tui/widgets/
conversation.rs

1//! Conversation pane — scrollback of log entries with an
2//! explicit offset-from-bottom cursor.
3//!
4//! # Scrollback model
5//!
6//! `ConversationPane::scroll` is the number of rows the viewport
7//! is shifted *up* from the newest entry. A scroll of 0 is the
8//! "stuck to bottom" state — new entries appear at the bottom and
9//! the oldest-visible entry scrolls up to make room. Any non-zero
10//! scroll detaches the viewport; newly appended entries continue
11//! to grow the backing log but do not yank the viewport. The
12//! input layer re-zeroes the offset on submit so command output
13//! always lands in view.
14//!
15//! The pane clamps scroll to `[0, max_offset]` where `max_offset`
16//! is `log.len() - visible_rows`; scrolling past either end is a
17//! no-op rather than a panic so a held PageUp does not wrap.
18//!
19//! # Screen-reader mode
20//!
21//! When `screen_reader` is set the pane switches to a plainer
22//! render path:
23//!
24//! - timestamps drop their DIM modifier (AT-SPI and NVDA often
25//!   skip dimmed text entirely);
26//! - entry kind becomes an explicit `[system]` / `[alert]`
27//!   prefix instead of relying on color; and
28//! - the reversed-video and bold modifiers are removed so a
29//!   high-contrast terminal does not double-style the row.
30//!
31//! Keyboard behavior is unchanged — PageUp/PageDown still scroll
32//! whether the mode is on or off.
33
34use chrono::{DateTime, Utc};
35use ratatui::buffer::Buffer;
36use ratatui::layout::Rect;
37use ratatui::style::{Modifier, Style};
38use ratatui::text::{Line, Span};
39use ratatui::widgets::Widget;
40
41use crate::app::log::{ConversationLog, EntryKind};
42use crate::theme::Theme;
43
44#[derive(Debug)]
45pub struct ConversationPane<'a> {
46    pub log: &'a ConversationLog,
47    pub theme: Theme,
48    /// Rows of scroll *up* from the newest entry. Clamped here at
49    /// render time; the input layer can stash arbitrary values
50    /// without worrying about log length.
51    pub scroll: u16,
52    /// Plain-ASCII / explicit-role rendering path.
53    pub screen_reader: bool,
54    /// When set, timestamps expand from `HH:MM:SS` to `MM-DD
55    /// HH:MM:SS` so entries that cross midnight are still
56    /// readable without having to check the status bar. Toggled
57    /// via `/verbose`.
58    pub verbose: bool,
59}
60
61impl Widget for ConversationPane<'_> {
62    fn render(self, area: Rect, buf: &mut Buffer) {
63        let rows = usize::from(area.height);
64        if rows == 0 {
65            return;
66        }
67        for y in area.top()..area.bottom() {
68            for x in area.left()..area.right() {
69                buf[(x, y)].set_char(' ');
70            }
71        }
72
73        let total = self.log.len();
74        if total == 0 {
75            return;
76        }
77
78        // Clamp scroll so we never drop off the top.
79        let max_off = total.saturating_sub(rows);
80        let offset = usize::from(self.scroll).min(max_off);
81        // Determine the first-visible index: from bottom minus
82        // offset, step back by rows.
83        let end_exclusive = total - offset;
84        let start = end_exclusive.saturating_sub(rows);
85        let slice = &self.log.entries()[start..end_exclusive];
86
87        let start_y = area.top();
88        for (i, entry) in slice.iter().enumerate() {
89            let y = start_y + u16::try_from(i).unwrap_or(u16::MAX);
90            if y >= area.bottom() {
91                break;
92            }
93            let row_area = Rect {
94                x: area.x,
95                y,
96                width: area.width,
97                height: 1,
98            };
99            render_entry(
100                entry.at,
101                entry.kind,
102                &entry.text,
103                &self.theme,
104                EntryOpts {
105                    screen_reader: self.screen_reader,
106                    verbose: self.verbose,
107                },
108                row_area,
109                buf,
110            );
111        }
112
113        // "You are scrolled up" cue — a single-cell glyph in the
114        // top-right corner. Keeps the operator oriented when they
115        // step back through history.
116        if offset > 0 && area.width > 0 {
117            let x = area.right().saturating_sub(1);
118            buf[(x, start_y)].set_char('↑').set_style(
119                Style::default()
120                    .fg(self.theme.caution)
121                    .add_modifier(Modifier::BOLD),
122            );
123        }
124    }
125}
126
127/// Per-entry rendering knobs bundled together to keep
128/// [`render_entry`] under the 7-arg clippy cap. Adding a new
129/// knob should land here rather than growing a longer signature.
130#[derive(Debug, Clone, Copy)]
131struct EntryOpts {
132    screen_reader: bool,
133    verbose: bool,
134}
135
136fn render_entry(
137    at: DateTime<Utc>,
138    kind: EntryKind,
139    text: &str,
140    theme: &Theme,
141    opts: EntryOpts,
142    area: Rect,
143    buf: &mut Buffer,
144) {
145    let EntryOpts {
146        screen_reader,
147        verbose,
148    } = opts;
149    // Verbose mode prepends the month/day. Year is omitted even
150    // in verbose because a conversation pane rarely spans months
151    // and the extra width would push body text off narrower
152    // terminals. Operators who need full dates have the
153    // `/sessions` listing already.
154    let ts = if verbose {
155        at.format("%m-%d %H:%M:%S ").to_string()
156    } else {
157        at.format("%H:%M:%S ").to_string()
158    };
159    let ts_style = if screen_reader {
160        Style::default().fg(theme.metadata)
161    } else {
162        Style::default()
163            .fg(theme.metadata)
164            .add_modifier(Modifier::DIM)
165    };
166    let ts_span = Span::styled(ts, ts_style);
167
168    // Color is the same in both rendering modes; only the bold
169    // modifier on Prompt / Alert rows differs. Screen-reader mode
170    // drops the modifier so AT tooling does not see a doubled-up
171    // style on top of the `[role]` prefix.
172    let base_color = match kind {
173        EntryKind::Prompt | EntryKind::Command => theme.primary,
174        EntryKind::System => theme.metadata,
175        EntryKind::Warn => theme.caution,
176        EntryKind::Alert => theme.alert,
177    };
178    let mut body_style = Style::default().fg(base_color);
179    if !screen_reader && matches!(kind, EntryKind::Prompt | EntryKind::Alert) {
180        body_style = body_style.add_modifier(Modifier::BOLD);
181    }
182
183    let mut spans: Vec<Span<'static>> = Vec::with_capacity(3);
184    spans.push(ts_span);
185    if screen_reader {
186        spans.push(Span::styled(
187            format!("{} ", role_prefix(kind)),
188            Style::default().fg(theme.metadata),
189        ));
190    }
191    spans.push(Span::styled(text.to_string(), body_style));
192    Line::from(spans).render(area, buf);
193}
194
195const fn role_prefix(kind: EntryKind) -> &'static str {
196    match kind {
197        EntryKind::Prompt => "[you]",
198        EntryKind::System => "[system]",
199        EntryKind::Command => "[command]",
200        EntryKind::Warn => "[warn]",
201        EntryKind::Alert => "[alert]",
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::app::log::LogEntry;
209    use ratatui::Terminal;
210    use ratatui::backend::TestBackend;
211
212    fn mk_log(rows: usize) -> ConversationLog {
213        let mut log = ConversationLog::with_capacity(0);
214        for i in 0..rows {
215            log.push(LogEntry::new(EntryKind::System, format!("row-{i:02}")));
216        }
217        log
218    }
219
220    fn render(log: &ConversationLog, scroll: u16, screen_reader: bool) -> Vec<String> {
221        render_v(log, scroll, screen_reader, false)
222    }
223
224    fn render_v(
225        log: &ConversationLog,
226        scroll: u16,
227        screen_reader: bool,
228        verbose: bool,
229    ) -> Vec<String> {
230        let backend = TestBackend::new(40, 4);
231        let mut term = Terminal::new(backend).expect("term");
232        term.draw(|f| {
233            let w = ConversationPane {
234                log,
235                theme: Theme::default(),
236                scroll,
237                screen_reader,
238                verbose,
239            };
240            f.render_widget(w, f.area());
241        })
242        .expect("draw");
243        let buf = term.backend().buffer().clone();
244        (0..buf.area.height)
245            .map(|y| {
246                (0..buf.area.width)
247                    .map(|x| buf[(x, y)].symbol().to_string())
248                    .collect::<String>()
249                    .trim_end()
250                    .to_string()
251            })
252            .collect()
253    }
254
255    #[test]
256    fn scroll_zero_sticks_to_bottom_newest_rows_visible() {
257        let log = mk_log(10);
258        let rendered = render(&log, 0, false);
259        // 4 visible rows → rows 06..09.
260        assert!(rendered[3].ends_with("row-09"), "got {:?}", rendered[3]);
261        assert!(rendered[0].ends_with("row-06"), "got {:?}", rendered[0]);
262    }
263
264    #[test]
265    fn scroll_shifts_viewport_and_shows_up_arrow_cue() {
266        let log = mk_log(10);
267        let rendered = render(&log, 3, false);
268        // With 4 visible rows and scroll=3, visible slice ends
269        // at index 10 - 3 = 7 → rows 03..06.
270        assert!(rendered[3].contains("row-06"), "got {:?}", rendered[3]);
271        assert!(rendered[0].contains("row-03"), "got {:?}", rendered[0]);
272        assert!(
273            rendered[0].contains('↑'),
274            "scrolled-up cue missing: {:?}",
275            rendered[0]
276        );
277    }
278
279    #[test]
280    fn scroll_past_top_clamps_without_panicking() {
281        let log = mk_log(5);
282        // width 4 rows, 5 entries, scroll way past top → clamp.
283        let rendered = render(&log, 1_000, false);
284        assert!(rendered[0].contains("row-00"), "got {:?}", rendered[0]);
285    }
286
287    #[test]
288    fn screen_reader_mode_prefixes_role_label() {
289        let mut log = ConversationLog::with_capacity(0);
290        log.push(LogEntry::new(EntryKind::Alert, "kill-switch tripped"));
291        let rendered = render(&log, 0, true);
292        assert!(
293            rendered[0].contains("[alert]"),
294            "screen-reader mode must emit an explicit role prefix; got {:?}",
295            rendered[0]
296        );
297    }
298
299    #[test]
300    fn verbose_mode_prepends_month_day_to_timestamp() {
301        // Default rendering leads with `HH:MM:SS `; verbose
302        // mode leads with `MM-DD HH:MM:SS `. Assert both via
303        // a regex-free substring check: look for a `-`
304        // separator in the first 6 columns of the verbose
305        // render (the `MM-DD` chunk) that is absent in the
306        // default render.
307        let mut log = ConversationLog::with_capacity(0);
308        log.push(LogEntry::new(EntryKind::System, "hello"));
309        let default = render(&log, 0, false);
310        let verbose = render_v(&log, 0, false, true);
311        let default_prefix = &default[0][..default[0].len().min(6)];
312        let verbose_prefix = &verbose[0][..verbose[0].len().min(6)];
313        assert!(
314            !default_prefix.contains('-'),
315            "default render should not have date: prefix={default_prefix:?}"
316        );
317        assert!(
318            verbose_prefix.contains('-'),
319            "verbose render must prepend a date: prefix={verbose_prefix:?}"
320        );
321    }
322
323    #[test]
324    fn default_mode_omits_role_prefix() {
325        let mut log = ConversationLog::with_capacity(0);
326        log.push(LogEntry::new(EntryKind::Alert, "kill-switch tripped"));
327        let rendered = render(&log, 0, false);
328        assert!(
329            !rendered[0].contains("[alert]"),
330            "default render must not emit role prefix; got {:?}",
331            rendered[0]
332        );
333    }
334}