1use 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 pub scroll: u16,
52 pub screen_reader: bool,
54 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 let max_off = total.saturating_sub(rows);
80 let offset = usize::from(self.scroll).min(max_off);
81 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 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#[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 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 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 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 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 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 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}