1use crate::render::wrap::{wrap_styled_line, wrap_text_with_indent};
2use chrono::NaiveDate;
3use std::hash::{Hash, Hasher};
4
5use ratatui::{
6 buffer::Buffer,
7 layout::Rect,
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Paragraph, StatefulWidget, Widget},
11};
12use rustc_hash::FxHashMap;
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15use crate::render::markdown::parse_markdown;
16use crate::render::theme::Theme;
17use mermaid_domain::{
18 ActionDetails, ActionDisplay, ActionResult, QuestionAnswer, ToolMetadata, format_compact_count,
19};
20use mermaid_model::diff::{DiffLineKind, parse_diff_line};
21use mermaid_model::models::ChatMessageKind;
22use mermaid_model::models::{ChatMessage, MessageRole};
23use mermaid_model::utils::format_relative_timestamp;
24
25#[derive(Debug, Clone)]
27pub struct ImageClickTarget {
28 pub message_index: usize,
33 pub image_index: usize,
35 pub image_number: Option<u64>,
39}
40
41#[derive(Debug, Clone)]
43pub struct ChatState {
44 scroll_offset: u16,
46 is_user_scrolling: bool,
48 pub image_click_map: Vec<(u16, ImageClickTarget)>,
50 pub last_scroll_position: u16,
52 pub last_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
57 last_rendered_rows: Vec<String>,
61 frame_memo: Option<FrameMemo>,
68 #[cfg(debug_assertions)]
71 debug_key_check: Option<(u64, u64)>,
72}
73
74#[derive(Debug, Clone)]
81struct FrameMemo {
82 key: u64,
84 lines: Vec<Line<'static>>,
86 click_map: Vec<(u16, ImageClickTarget)>,
88}
89
90impl ChatState {
91 #[must_use]
93 pub fn new() -> Self {
94 Self {
95 scroll_offset: 0,
96 is_user_scrolling: false,
97 image_click_map: Vec::new(),
98 last_scroll_position: 0,
99 last_chat_area: None,
100 selection: None,
101 last_rendered_rows: Vec::new(),
102 frame_memo: None,
103 #[cfg(debug_assertions)]
104 debug_key_check: None,
105 }
106 }
107
108 #[must_use]
111 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
112 let max_scroll = content_height.saturating_sub(viewport_height);
113 if self.is_user_scrolling {
114 let capped_offset = self.scroll_offset.min(max_scroll);
117 max_scroll.saturating_sub(capped_offset)
118 } else {
119 max_scroll
121 }
122 }
123
124 pub fn scroll_up(&mut self, amount: u16) {
126 self.is_user_scrolling = true;
127 self.scroll_offset = self.scroll_offset.saturating_add(amount);
128 self.selection = None;
131 }
132
133 pub fn scroll_down(&mut self, amount: u16) {
136 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
137 if self.scroll_offset == 0 {
138 self.is_user_scrolling = false;
140 }
141 self.selection = None;
142 }
143
144 pub fn resume_auto_scroll(&mut self) {
146 self.is_user_scrolling = false;
147 self.scroll_offset = 0;
148 }
149
150 #[must_use]
153 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
154 let (_, area_y, _, area_height) = self.last_chat_area?;
155
156 if screen_row < area_y || screen_row >= area_y + area_height {
158 return None;
159 }
160
161 let viewport_row = screen_row - area_y;
163 let content_line = viewport_row + self.last_scroll_position;
164
165 self.image_click_map
167 .iter()
168 .find(|(line, _)| *line == content_line)
169 .map(|(_, target)| target)
170 }
171
172 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
176 let (area_x, area_y, _, area_height) = self.last_chat_area?;
177 if screen_row < area_y || screen_row >= area_y + area_height {
178 return None;
179 }
180 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
181 let col = screen_col.saturating_sub(area_x) as usize;
182 Some((content_line, col))
183 }
184
185 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
189 self.selection = self
190 .screen_to_content(screen_row, screen_col)
191 .map(|p| (p, p));
192 }
193
194 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
196 if let Some((anchor, _)) = self.selection
197 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
198 {
199 self.selection = Some((anchor, cursor));
200 }
201 }
202
203 #[must_use]
208 pub fn selected_text(&self) -> Option<String> {
209 let (a, b) = self.selection?;
210 let (start, end) = if a <= b { (a, b) } else { (b, a) };
211 if self.last_rendered_rows.is_empty() {
212 return None;
213 }
214 let last = self.last_rendered_rows.len() - 1;
215 let (start_line, start_col) = (start.0.min(last), start.1);
216 let (end_line, end_col) = (end.0.min(last), end.1);
217
218 let mut out = String::new();
219 for line in start_line..=end_line {
220 let row = &self.last_rendered_rows[line];
221 let c0 = if line == start_line { start_col } else { 0 };
222 let c1 = if line == end_line {
223 end_col
224 } else {
225 usize::MAX
226 };
227 let mut piece = slice_by_cells(row, c0, c1).to_string();
228 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
233 while margin > 0 && piece.starts_with(' ') {
234 piece.remove(0);
235 margin -= 1;
236 }
237 out.push_str(piece.trim_end());
238 if line != end_line {
239 out.push('\n');
240 }
241 }
242 if out.is_empty() { None } else { Some(out) }
243 }
244}
245
246const SELECT_MARGIN_CELLS: usize = 2;
250
251fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
256 if width == 0 {
257 return vec![line];
258 }
259 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
260 if total <= width {
261 return vec![line];
262 }
263
264 let base = line.style;
265 let mut out: Vec<Line<'static>> = Vec::new();
266 let mut cur: Vec<Span<'static>> = Vec::new();
267 let mut cur_w = 0usize;
268 let mut on_first = true;
269
270 for span in line.spans {
271 let style = span.style;
272 let mut buf = String::new();
273 for ch in span.content.chars() {
274 let cw = ch.width().unwrap_or(0);
275 let floor = if on_first { 0 } else { indent };
278 if cur_w + cw > width && cur_w > floor {
279 if !buf.is_empty() {
280 cur.push(Span::styled(std::mem::take(&mut buf), style));
281 }
282 out.push(Line::from(std::mem::take(&mut cur)).style(base));
283 on_first = false;
284 cur.push(Span::styled(" ".repeat(indent), base));
285 cur_w = indent;
286 }
287 buf.push(ch);
288 cur_w += cw;
289 }
290 if !buf.is_empty() {
291 cur.push(Span::styled(buf, style));
292 }
293 }
294 if !cur.is_empty() {
295 out.push(Line::from(cur).style(base));
296 }
297 if out.is_empty() {
298 vec![Line::from("").style(base)]
299 } else {
300 out
301 }
302}
303
304fn byte_at_cell(s: &str, target: usize) -> usize {
308 if target == 0 {
309 return 0;
310 }
311 let mut width = 0usize;
312 for (idx, ch) in s.char_indices() {
313 if width >= target {
314 return idx;
315 }
316 width += ch.width().unwrap_or(0);
317 }
318 s.len()
319}
320
321fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
323 let start = byte_at_cell(s, c0);
324 let end = byte_at_cell(s, c1).max(start);
325 &s[start..end]
326}
327
328fn pad_to_cells(s: &str, cells: usize) -> String {
333 let w = s.width();
334 if w >= cells {
335 return s.to_string();
336 }
337 let mut out = String::with_capacity(s.len() + (cells - w));
338 out.push_str(s);
339 out.push_str(&" ".repeat(cells - w));
340 out
341}
342
343fn user_timestamp_padding(
348 role_prefix_width: usize,
349 text_width: usize,
350 timestamp_width: usize,
351 min_gap: usize,
352 content_width: usize,
353) -> usize {
354 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
355 min_gap + content_width.saturating_sub(total_used)
356}
357
358fn line_plain_text(line: &Line) -> String {
360 line.spans.iter().map(|s| s.content.as_ref()).collect()
361}
362
363fn clamp_to_u16(n: usize) -> u16 {
369 u16::try_from(n).unwrap_or(u16::MAX)
370}
371
372fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
376 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
377 let mut width = 0usize;
378 for span in line.spans.drain(..) {
379 let span_w = span.content.width();
380 let (span_start, span_end) = (width, width + span_w);
381 width = span_end;
382
383 let ov0 = c0.max(span_start);
384 let ov1 = c1.min(span_end);
385 if ov1 <= ov0 {
386 new_spans.push(span); continue;
388 }
389
390 let s = span.content.as_ref();
391 let b0 = byte_at_cell(s, ov0 - span_start);
392 let b1 = byte_at_cell(s, ov1 - span_start);
393 if b0 > 0 {
394 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
395 }
396 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
397 if b1 < s.len() {
398 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
399 }
400 }
401 line.spans = new_spans;
402}
403
404impl Default for ChatState {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410pub struct ChatWidget<'a> {
412 pub messages: &'a [ChatMessage],
413 pub theme: &'a Theme,
414 pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
419 pub content_key: u64,
423 pub show_reasoning: bool,
424 pub blink_on: bool,
429 pub today: NaiveDate,
441}
442
443fn wrap_assistant_content(
452 content: &str,
453 content_width: u16,
454 role_prefix: &str,
455 role_color: ratatui::style::Color,
456 theme: &Theme,
457) -> Vec<Line<'static>> {
458 let md_width = (content_width as usize).saturating_sub(2);
460 let parsed = parse_markdown(content, theme, md_width);
461
462 let mut out: Vec<Line<'static>> = Vec::new();
463 for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
464 let preformatted = parsed_line.preformatted;
469 let base_style = parsed_line.line.style;
470
471 let continuation = if preformatted {
476 2
477 } else {
478 2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
479 };
480
481 let mut spans = if line_idx == 0 {
483 vec![Span::styled(
484 format!("{role_prefix} "),
485 Style::new().fg(role_color).bold(),
486 )]
487 } else {
488 vec![Span::raw(" ")]
489 };
490 spans.extend(parsed_line.line.spans);
491 let new_line = Line::from(spans).style(base_style);
492
493 if preformatted {
494 out.extend(wrap_preformatted(new_line, content_width as usize, 2));
497 } else {
498 out.extend(wrap_styled_line(
499 new_line,
500 content_width as usize,
501 continuation,
502 ));
503 }
504 }
505 out
506}
507
508#[cfg(debug_assertions)]
517struct HashWrite<'a, H: Hasher>(&'a mut H);
518
519#[cfg(debug_assertions)]
520impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
521 fn write_str(&mut self, s: &str) -> std::fmt::Result {
522 self.0.write(s.as_bytes());
523 Ok(())
524 }
525}
526
527pub(crate) fn frame_key(
542 content_key: u64,
543 theme_seed: u64,
544 content_width: u16,
545 show_reasoning: bool,
546 today: NaiveDate,
547) -> u64 {
548 let mut h = rustc_hash::FxHasher::default();
549 content_key.hash(&mut h);
550 theme_seed.hash(&mut h);
551 content_width.hash(&mut h);
552 show_reasoning.hash(&mut h);
553 today.hash(&mut h);
557 h.finish()
558}
559
560#[cfg(test)]
564pub(crate) fn test_content_key(messages: &[ChatMessage]) -> u64 {
565 let mut h = rustc_hash::FxHasher::default();
566 messages.len().hash(&mut h);
567 for msg in messages {
568 msg.content.hash(&mut h);
569 msg.thinking.hash(&mut h);
570 std::mem::discriminant(&msg.kind).hash(&mut h);
571 msg.actions.len().hash(&mut h);
572 }
573 h.finish()
574}
575
576#[cfg(debug_assertions)]
585pub(crate) fn frame_fingerprint(
586 messages: &[ChatMessage],
587 theme_seed: u64,
588 content_width: u16,
589 show_reasoning: bool,
590 blink_on: bool,
591) -> u64 {
592 use std::fmt::Write as _;
593 let mut h = rustc_hash::FxHasher::default();
594 theme_seed.hash(&mut h);
595 content_width.hash(&mut h);
596 show_reasoning.hash(&mut h);
597 if messages.iter().any(|m| {
598 m.actions
599 .iter()
600 .any(|a| matches!(a.result, ActionResult::Running))
601 }) {
602 blink_on.hash(&mut h);
603 }
604 messages.len().hash(&mut h);
605 for msg in messages {
606 msg.content.hash(&mut h);
607 msg.thinking.hash(&mut h);
608 msg.timestamp.timestamp().hash(&mut h);
611 msg.images
612 .as_ref()
613 .map_or(0, |imgs| imgs.len())
614 .hash(&mut h);
615 let mut hw = HashWrite(&mut h);
616 let _ = write!(
617 hw,
618 "{:?}|{:?}|{:?}|{:?}",
619 msg.role, msg.kind, msg.metadata, msg.actions
620 );
621 }
622 h.finish()
623}
624
625impl<'a> StatefulWidget for ChatWidget<'a> {
626 type State = ChatState;
627
628 #[expect(
629 clippy::too_many_lines,
630 reason = "predates the lint; see .github/baselines/expect_budget.txt"
631 )]
632 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
633 let code_bg = self.theme.colors.code_background.to_color();
636 let theme_seed = {
637 let mut h = rustc_hash::FxHasher::default();
638 self.theme.colors.foreground.to_color().hash(&mut h);
639 code_bg.hash(&mut h);
640 self.theme.colors.header.to_color().hash(&mut h);
641 h.finish()
642 };
643
644 let content_width = area.width;
646 let content_area = area;
647
648 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
649
650 let frame_key = frame_key(
656 self.content_key,
657 theme_seed,
658 content_width,
659 self.show_reasoning,
660 self.today,
661 );
662 #[cfg(debug_assertions)]
666 {
667 let content_hash = frame_fingerprint(
668 self.messages,
669 theme_seed,
670 content_width,
671 self.show_reasoning,
672 self.blink_on,
673 );
674 if let Some((last_key, last_hash)) = state.debug_key_check {
675 debug_assert!(
676 last_hash == content_hash || last_key != frame_key,
677 "chat frame content changed without a new memo key — a mutation \
678 bypassed ConversationHistory::messages_mut (stale transcript risk)",
679 );
680 }
681 state.debug_key_check = Some((frame_key, content_hash));
682 }
683 let memo = state.frame_memo.take().filter(|m| m.key == frame_key);
691
692 let memo = if let Some(memo) = memo {
693 state.image_click_map = memo.click_map.clone();
695 memo
696 } else {
697 let mut lines: Vec<Line<'static>> = Vec::new();
699
700 state.image_click_map.clear();
702
703 for (idx, msg) in self.messages.iter().enumerate() {
704 if matches!(msg.role, MessageRole::Tool) {
707 continue;
708 }
709
710 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
711 if let Some(event_lines) =
712 render_context_checkpoint_event(msg, self.theme, content_width as usize)
713 {
714 lines.extend(event_lines);
715 lines.push(Line::from(""));
716 }
717 continue;
718 }
719
720 if matches!(msg.kind, ChatMessageKind::RunSummary) {
725 lines.push(Line::from(Span::styled(
726 format!(" {}", msg.content),
727 Style::new().fg(self.theme.colors.text_meta.to_color()),
728 )));
729 lines.push(Line::from(""));
730 continue;
731 }
732
733 if matches!(
739 msg.kind,
740 ChatMessageKind::RecoveryNudge | ChatMessageKind::ContextMarker
741 ) {
742 continue;
743 }
744
745 if matches!(msg.role, MessageRole::System) {
750 let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
751 for wrapped_line in
752 wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
753 {
754 lines.push(Line::from(Span::styled(wrapped_line, meta)));
755 }
756 lines.push(Line::from(""));
757 continue;
758 }
759
760 let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
768 && self.messages[..idx]
769 .iter()
770 .rev()
771 .find(|m| !matches!(m.role, MessageRole::Tool))
772 .is_some_and(crate::render::mergeable_into);
773 if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
774 lines.pop();
775 }
776
777 let (role_prefix, role_color) = match msg.role {
778 MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
779 MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
780 MessageRole::System | MessageRole::Tool => {
781 unreachable!("System and Tool messages handled above")
782 },
783 };
784 let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
788
789 if matches!(msg.role, MessageRole::Assistant) {
790 if let Some(ref thinking) = msg.thinking {
792 let thinking_trimmed = thinking.trim();
794 if thinking_trimmed.is_empty()
795 || thinking_trimmed == "None"
796 || thinking_trimmed == "none"
797 {
798 } else if self.show_reasoning {
800 lines.push(Line::from(vec![
802 Span::styled(
803 "● ",
804 Style::new().fg(self.theme.colors.text_disabled.to_color()),
805 ),
806 Span::styled(
807 "Thinking...",
808 Style::new()
809 .fg(self.theme.colors.text_secondary.to_color())
810 .italic()
811 .dim(),
812 ),
813 ]));
814
815 let wrapped = wrap_text_with_indent(
817 thinking,
818 content_width as usize,
819 2, 2, );
822 for wrapped_line in wrapped {
823 lines.push(Line::from(Span::styled(
824 wrapped_line,
825 Style::new()
826 .fg(self.theme.colors.text_secondary.to_color())
827 .italic()
828 .dim(),
829 )));
830 }
831
832 lines.push(Line::from(""));
834 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
835 continue;
840 }
841 }
842
843 let mut hasher = rustc_hash::FxHasher::default();
851 msg.content.hash(&mut hasher);
852 theme_seed.hash(&mut hasher);
853 content_width.hash(&mut hasher);
854 stitch_onto_prev.hash(&mut hasher);
857 let cache_key = hasher.finish();
858
859 let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
860 cached.clone()
861 } else {
862 let block = wrap_assistant_content(
863 &msg.content,
864 content_width,
865 role_prefix,
866 role_color,
867 self.theme,
868 );
869 self.wrapped_line_cache.insert(cache_key, block.clone());
870 if self.wrapped_line_cache.len()
871 > mermaid_model::constants::MARKDOWN_CACHE_MAX_ENTRIES
872 {
873 let overflow = self.wrapped_line_cache.len()
878 - mermaid_model::constants::MARKDOWN_CACHE_MAX_ENTRIES;
879 let stale: Vec<u64> = self
880 .wrapped_line_cache
881 .keys()
882 .copied()
883 .filter(|&k| k != cache_key)
884 .take(overflow)
885 .collect();
886 for k in stale {
887 self.wrapped_line_cache.remove(&k);
888 }
889 }
890 block
891 };
892 lines.extend(wrapped);
893
894 if !msg.actions.is_empty() {
896 if !msg.content.trim().is_empty() {
898 lines.push(Line::from(""));
899 }
900 render_actions(
901 &msg.actions,
902 &mut lines,
903 self.theme,
904 content_width as usize,
905 self.blink_on,
906 );
907 }
908 } else {
909 let formatted_timestamp = format_relative_timestamp(msg.timestamp, self.today);
911 let timestamp_width = formatted_timestamp.width();
915 let min_gap = 3; let cleaned_content = &msg.content;
919
920 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
924
925 let wrapped = wrap_text_with_indent(
927 cleaned_content,
928 content_width as usize,
929 first_line_reserved, 2, );
932
933 let band_start = lines.len();
934 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
935 if line_idx == 0 {
936 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
939
940 let mut spans = vec![
941 Span::styled(
942 format!("{role_prefix} "),
943 Style::new().fg(role_color).bold(),
944 ),
945 Span::raw(text_content.to_string()),
946 ];
947
948 let pad = user_timestamp_padding(
951 role_prefix_width,
952 text_width,
953 timestamp_width,
954 min_gap,
955 content_width as usize,
956 );
957 spans.push(Span::raw(" ".repeat(pad)));
958 spans.push(Span::styled(
959 formatted_timestamp.clone(),
960 Style::new().fg(self.theme.colors.text_meta.to_color()),
961 ));
962
963 lines.push(Line::from(spans));
964 } else {
965 lines.push(Line::from(wrapped_line.clone()));
967 }
968 }
969
970 if matches!(msg.role, MessageRole::User) {
975 let user_bg = self.theme.colors.user_message_background.to_color();
976 let cw = content_width as usize;
977 for line in &mut lines[band_start..] {
978 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
979 if used < cw {
980 line.spans.push(Span::raw(" ".repeat(cw - used)));
981 }
982 line.style = line.style.bg(user_bg);
983 }
984 }
985 }
986
987 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
994 && let Some(ref images) = msg.images
995 && !images.is_empty()
996 {
997 for (i, _) in images.iter().enumerate() {
998 let content_line = lines.len();
1004 let image_number =
1005 msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
1006 state.image_click_map.push((
1007 clamp_to_u16(content_line),
1008 ImageClickTarget {
1009 message_index: idx,
1010 image_index: i,
1011 image_number,
1012 },
1013 ));
1014 let label = image_number
1019 .map(|n| format!("[Image #{n}]"))
1020 .unwrap_or_else(|| format!("[Image #{}]", i + 1));
1021 lines.push(Line::from(vec![
1022 Span::styled(
1023 " ⎿ ",
1024 Style::new().fg(self.theme.colors.info.to_color()),
1025 ),
1026 Span::styled(
1027 label,
1028 Style::new().fg(self.theme.colors.info.to_color()).italic(),
1029 ),
1030 ]));
1031 }
1032 }
1033
1034 lines.push(Line::from(""));
1035 }
1036
1037 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
1043
1044 FrameMemo {
1050 key: frame_key,
1051 lines,
1052 click_map: state.image_click_map.clone(),
1053 }
1054 };
1055
1056 let content_height = memo.lines.len();
1078 let viewport_height = area.height;
1079
1080 let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
1081 state.last_scroll_position = scroll_pos;
1082
1083 let first = (scroll_pos as usize).min(content_height);
1087 let last = first
1088 .saturating_add(viewport_height as usize)
1089 .min(content_height);
1090 let mut lines: Vec<Line<'static>> = memo.lines[first..last].to_vec();
1091
1092 if let Some((a, b)) = state.selection
1096 && !lines.is_empty()
1097 {
1098 let (start, end) = if a <= b { (a, b) } else { (b, a) };
1099 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
1100 for (offset, line) in lines.iter_mut().enumerate() {
1101 let content_idx = first + offset;
1102 if content_idx < start.0 || content_idx > end.0 {
1103 continue;
1104 }
1105 let c0 = if content_idx == start.0 { start.1 } else { 0 };
1106 let c1 = if content_idx == end.0 {
1107 end.1
1108 } else {
1109 usize::MAX
1110 };
1111 if c1 > c0 {
1112 highlight_line_cells(line, c0, c1, sel_style);
1113 }
1114 }
1115 }
1116
1117 let paragraph = Paragraph::new(lines).block(Block::default()).scroll((0, 0));
1119
1120 paragraph.render(content_area, buf);
1121
1122 state.frame_memo = Some(memo);
1124 }
1125}
1126
1127fn render_context_checkpoint_event(
1128 msg: &ChatMessage,
1129 theme: &Theme,
1130 viewport_width: usize,
1131) -> Option<Vec<Line<'static>>> {
1132 if !matches!(msg.role, MessageRole::User) {
1133 return None;
1134 }
1135
1136 let metadata = msg.metadata.as_ref();
1137 let trigger = metadata
1138 .and_then(|value| value.get("trigger"))
1139 .and_then(|value| value.as_str())
1140 .unwrap_or("manual");
1141 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
1142 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
1143 let archived_messages =
1144 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
1145 let preserved_messages =
1146 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
1147 let duration_secs = metadata
1148 .and_then(|value| value.get("duration_secs"))
1149 .and_then(|value| value.as_f64());
1150 let review_status = metadata
1151 .and_then(|value| value.get("review_status"))
1152 .and_then(|value| value.as_str());
1153 let review_error = metadata
1154 .and_then(|value| value.get("review_error"))
1155 .and_then(|value| value.as_str());
1156
1157 let action_color = theme.colors.info.to_color();
1158 let mut result = match (before_tokens, after_tokens) {
1159 (Some(before), Some(after)) => {
1160 format!(
1161 "{} -> {} tokens",
1162 format_compact_count(before),
1163 format_compact_count(after)
1164 )
1165 },
1166 _ => "Context compacted".to_string(),
1167 };
1168
1169 if let Some(count) = archived_messages {
1170 result.push_str(&format!(
1171 ", archived {} {}",
1172 count,
1173 if count == 1 { "message" } else { "messages" }
1174 ));
1175 }
1176 if let Some(count) = preserved_messages {
1177 result.push_str(&format!(
1178 ", preserved {} {}",
1179 count,
1180 if count == 1 { "message" } else { "messages" }
1181 ));
1182 }
1183 if let Some(status) = review_status {
1184 match status {
1185 "reviewed" => result.push_str(", reviewed"),
1186 "draft_validated" => result.push_str(", validated draft"),
1187 _ => {},
1188 }
1189 }
1190 result = append_action_duration(result, duration_secs);
1191
1192 let mut lines = vec![Line::from(vec![
1193 Span::styled("● ", Style::new().fg(action_color).bold()),
1194 Span::styled("Compact(", Style::new().fg(action_color).bold()),
1195 Span::styled(
1196 trigger.to_string(),
1197 Style::new().fg(theme.colors.text_secondary.to_color()),
1198 ),
1199 Span::styled(")", Style::new().fg(action_color).bold()),
1200 ])];
1201 lines.extend(wrap_styled_line(
1202 Line::from(vec![
1203 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1204 Span::styled(
1205 result,
1206 Style::new().fg(theme.colors.text_secondary.to_color()),
1207 ),
1208 ]),
1209 viewport_width,
1210 4,
1211 ));
1212
1213 if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
1214 lines.extend(wrap_styled_line(
1215 Line::from(vec![
1216 Span::styled(" ", Style::new().fg(action_color)),
1217 Span::styled(
1218 format!("review: {}", compact_inline_error(error, 180)),
1219 Style::new().fg(theme.colors.warning.to_color()),
1220 ),
1221 ]),
1222 viewport_width,
1223 4,
1224 ));
1225 }
1226
1227 Some(lines)
1228}
1229
1230fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1231 value
1232 .get(key)?
1233 .as_u64()
1234 .and_then(|value| usize::try_from(value).ok())
1235}
1236
1237fn compact_inline_error(text: &str, max_chars: usize) -> String {
1238 let text = text.trim();
1239 if text.chars().count() <= max_chars {
1240 return text.to_string();
1241 }
1242 let keep = max_chars.saturating_sub(3);
1243 let mut out: String = text.chars().take(keep).collect();
1244 out.push_str("...");
1245 out
1246}
1247
1248fn expand_tabs(s: &str) -> String {
1257 const TAB_WIDTH: usize = 4;
1258 if !s.contains('\t') {
1259 return s.to_string();
1260 }
1261 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1262 let mut col = 0usize;
1263 for ch in s.chars() {
1264 if ch == '\t' {
1265 let n = TAB_WIDTH - (col % TAB_WIDTH);
1266 for _ in 0..n {
1267 out.push(' ');
1268 }
1269 col += n;
1270 } else {
1271 out.push(ch);
1272 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1273 }
1274 }
1275 out
1276}
1277
1278#[expect(
1279 clippy::too_many_lines,
1280 reason = "predates the lint; see .github/baselines/expect_budget.txt"
1281)]
1282fn render_actions(
1283 actions: &[ActionDisplay],
1284 lines: &mut Vec<Line>,
1285 theme: &Theme,
1286 viewport_width: usize,
1287 blink_on: bool,
1288) {
1289 for (action_idx, action) in actions.iter().enumerate() {
1290 if action_idx > 0 {
1291 lines.push(Line::from(""));
1292 }
1293 if let Some(meta) = &action.metadata
1298 && let ToolMetadata::Questions {
1299 answers,
1300 remembered,
1301 } = &meta.detail
1302 && matches!(action.result, ActionResult::Success { .. })
1303 {
1304 render_question_answers(answers, *remembered, lines, theme, viewport_width);
1305 continue;
1306 }
1307 if let Some(meta) = &action.metadata
1311 && let ToolMetadata::Plan { path, body, .. } = &meta.detail
1312 && matches!(action.result, ActionResult::Success { .. })
1313 {
1314 render_plan_approved(path, body, lines, theme, viewport_width);
1315 continue;
1316 }
1317 let action_color = match action.action_type.as_str() {
1318 "Write" | "Update" => theme.colors.success.to_color(),
1319 "Delete" => theme.colors.warning.to_color(),
1320 _ => theme.colors.info.to_color(),
1321 };
1322
1323 let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1331 Style::new()
1332 .fg(theme.colors.text_disabled.to_color())
1333 .bold()
1334 } else {
1335 Style::new().fg(action_color).bold()
1336 };
1337 push_action_header(
1338 lines,
1339 action,
1340 action_color,
1341 dot_style,
1342 theme,
1343 viewport_width,
1344 );
1345
1346 match &action.result {
1347 ActionResult::Running => {},
1350 ActionResult::Success { .. } => {
1351 let result_msg = match &action.details {
1353 ActionDetails::FileContent { line_count, .. } => {
1354 let base = format!(
1355 "{} {} written",
1356 line_count,
1357 if *line_count == 1 { "line" } else { "lines" }
1358 );
1359 append_action_duration(base, action.duration_seconds)
1360 },
1361 ActionDetails::Diff { summary, .. } => summary.clone(),
1362 ActionDetails::Preview { text, .. } => text.clone(),
1363 ActionDetails::Simple => {
1367 append_action_duration(String::new(), action.duration_seconds)
1368 },
1369 };
1370
1371 for (idx, line) in result_msg.lines().enumerate() {
1372 let prefix = if idx == 0 { " ⎿ " } else { " " };
1373 lines.extend(wrap_styled_line(
1376 Line::from(vec![
1377 Span::styled(prefix, Style::new().fg(action_color)),
1378 Span::styled(
1379 line.to_string(),
1380 Style::new().fg(theme.colors.text_secondary.to_color()),
1381 ),
1382 ]),
1383 viewport_width,
1384 4,
1385 ));
1386 }
1387
1388 if let ActionDetails::FileContent {
1390 content,
1391 line_count,
1392 } = &action.details
1393 {
1394 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1395 if !preview_lines.is_empty() {
1396 lines.push(Line::from(vec![Span::styled(
1397 " ",
1398 Style::new().fg(action_color),
1399 )]));
1400
1401 let preview_content = preview_lines.join("\n");
1402 let mut parsed = parse_markdown(
1403 &format!("```\n{preview_content}\n```"),
1404 theme,
1405 viewport_width.saturating_sub(4),
1406 );
1407 for parsed_line in parsed.iter_mut() {
1408 let mut new_spans =
1409 vec![Span::styled(" ", Style::new().fg(action_color))];
1410 new_spans.append(&mut parsed_line.line.spans);
1411 parsed_line.line.spans = new_spans;
1412 }
1413 lines.extend(
1417 parsed
1418 .into_iter()
1419 .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1420 );
1421
1422 if *line_count > 10 {
1423 lines.push(Line::from(vec![
1424 Span::styled(" ", Style::new().fg(action_color)),
1425 Span::styled(
1426 format!("... ({} more lines)", line_count - 10),
1427 Style::new()
1428 .fg(theme.colors.text_disabled.to_color())
1429 .italic(),
1430 ),
1431 ]));
1432 }
1433 }
1434 }
1435
1436 if let ActionDetails::Diff { diff, .. } = &action.details {
1438 let diff_lines: Vec<&str> = diff.lines().collect();
1439 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1440
1441 if !display_lines.is_empty() {
1442 let removed_bg = theme.colors.diff_removed_bg.to_color();
1443 let added_bg = theme.colors.diff_added_bg.to_color();
1444
1445 for diff_line in &display_lines {
1446 let diff_line = expand_tabs(diff_line);
1453 match parse_diff_line(&diff_line) {
1458 DiffLineKind::Removed => {
1459 push_wrapped_diff_rows(
1460 lines,
1461 format!(" {diff_line}"),
1462 Style::new()
1463 .fg(theme.colors.error.to_color())
1464 .bg(removed_bg),
1465 viewport_width,
1466 );
1467 },
1468 DiffLineKind::Added => {
1469 push_wrapped_diff_rows(
1470 lines,
1471 format!(" {diff_line}"),
1472 Style::new()
1473 .fg(theme.colors.success.to_color())
1474 .bg(added_bg),
1475 viewport_width,
1476 );
1477 },
1478 DiffLineKind::Context => {
1479 lines.extend(wrap_preformatted(
1482 Line::from(vec![
1483 Span::styled(" ", Style::new().fg(action_color)),
1484 Span::styled(
1485 diff_line,
1486 Style::new()
1487 .fg(theme.colors.text_secondary.to_color()),
1488 ),
1489 ]),
1490 viewport_width,
1491 6,
1492 ));
1493 },
1494 }
1495 }
1496
1497 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1498 if remaining > 0 {
1499 lines.push(Line::from(vec![
1500 Span::styled(" ", Style::new().fg(action_color)),
1501 Span::styled(
1502 format!("... ({remaining} more lines)"),
1503 Style::new()
1504 .fg(theme.colors.text_disabled.to_color())
1505 .italic(),
1506 ),
1507 ]));
1508 }
1509 }
1510 }
1511 },
1512 ActionResult::Error { error } => {
1513 let error =
1514 append_action_duration(format!("Error: {error}"), action.duration_seconds);
1515 for (idx, err_line) in error.lines().enumerate() {
1519 let prefix = if idx == 0 { " ⎿ " } else { " " };
1520 lines.extend(wrap_styled_line(
1521 Line::from(vec![
1522 Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1523 Span::styled(
1524 err_line.to_string(),
1525 Style::new().fg(theme.colors.error.to_color()),
1526 ),
1527 ]),
1528 viewport_width,
1529 4,
1530 ));
1531 }
1532 },
1533 }
1534 }
1535}
1536
1537fn render_plan_approved(
1541 path: &str,
1542 body: &str,
1543 lines: &mut Vec<Line>,
1544 theme: &Theme,
1545 viewport_width: usize,
1546) {
1547 lines.push(Line::from(Span::styled(
1548 format!("● User approved the plan — {path}"),
1549 Style::new().fg(theme.colors.success.to_color()),
1550 )));
1551 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1552 let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
1555 let mut first_row = true;
1556 for mut parsed_line in parsed {
1557 let gutter = if first_row { " ⎿ " } else { " " };
1558 first_row = false;
1559 let mut spans = vec![Span::styled(gutter, gutter_style)];
1560 spans.append(&mut parsed_line.line.spans);
1561 lines.push(Line::from(spans));
1562 }
1563}
1564
1565fn render_question_answers(
1569 answers: &[QuestionAnswer],
1570 remembered: bool,
1571 lines: &mut Vec<Line>,
1572 theme: &Theme,
1573 viewport_width: usize,
1574) {
1575 let header = if remembered {
1576 "User answered the model's questions (remembered):"
1577 } else {
1578 "User answered the model's questions:"
1579 };
1580 lines.push(Line::from(Span::styled(
1581 format!("● {header}"),
1582 Style::new().fg(theme.colors.text_primary.to_color()),
1583 )));
1584
1585 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1586 let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1587 let note_style = Style::new()
1588 .fg(theme.colors.text_disabled.to_color())
1589 .italic();
1590 let wrap_width = viewport_width.saturating_sub(4);
1594 let mut first_row = true;
1595 for answer in answers {
1596 let value = if answer.selected.is_empty() {
1597 "(no selection)".to_string()
1598 } else {
1599 answer.selected.join(", ")
1600 };
1601 let entry = format!("· {} → {}", answer.question, value);
1602 let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1603 .into_iter()
1604 .map(|row| (row, text_style))
1605 .collect();
1606 if let Some(note) = &answer.note {
1607 rows.extend(
1608 wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1609 .into_iter()
1610 .map(|row| (row, note_style)),
1611 );
1612 }
1613 for (row, style) in rows {
1614 let gutter = if first_row { " ⎿ " } else { " " };
1615 first_row = false;
1616 lines.push(Line::from(vec![
1617 Span::styled(gutter, gutter_style),
1618 Span::styled(row, style),
1619 ]));
1620 }
1621 }
1622}
1623
1624const MAX_ACTION_HEADER_ROWS: usize = 4;
1628
1629fn push_action_header(
1637 lines: &mut Vec<Line>,
1638 action: &ActionDisplay,
1639 action_color: Color,
1640 dot_style: Style,
1641 theme: &Theme,
1642 viewport_width: usize,
1643) {
1644 let bold = Style::new().fg(action_color).bold();
1645 let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1646 if action.target.is_empty() {
1647 lines.push(Line::from(vec![
1648 Span::styled("● ", dot_style),
1649 Span::styled(format!("{}()", action.action_type), bold),
1650 ]));
1651 return;
1652 }
1653
1654 let open = format!("{}(", action.action_type);
1655 let first_indent = 2 + open.width();
1659 let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1660 let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1661 let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1662 rows.truncate(MAX_ACTION_HEADER_ROWS);
1663
1664 let last = rows.len().saturating_sub(1);
1665 for (i, row) in rows.into_iter().enumerate() {
1666 let mut spans = if i == 0 {
1667 vec![
1668 Span::styled("● ", dot_style),
1669 Span::styled(open.clone(), bold),
1670 Span::styled(row.trim_start().to_string(), secondary),
1671 ]
1672 } else {
1673 vec![Span::styled(row, secondary)]
1674 };
1675 if i == last {
1676 if truncated {
1677 spans.push(Span::styled(
1678 "…",
1679 Style::new().fg(theme.colors.text_disabled.to_color()),
1680 ));
1681 }
1682 spans.push(Span::styled(")", bold));
1683 }
1684 lines.push(Line::from(spans));
1685 }
1686}
1687
1688fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1692 for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1693 let padded = pad_to_cells(&line_plain_text(&row), width);
1694 lines.push(Line::from(Span::styled(padded, style)));
1695 }
1696}
1697
1698fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1699 if let Some(seconds) = duration_seconds {
1700 if !text.is_empty() {
1703 text.push_str(", ");
1704 }
1705 text.push_str("took ");
1706 text.push_str(&format_action_duration(seconds));
1707 }
1708 text
1709}
1710
1711fn format_action_duration(seconds: f64) -> String {
1712 if seconds < 1.0 {
1713 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1714 } else if seconds < 10.0 {
1715 format!("{seconds:.1}s")
1716 } else {
1717 format!("{}s", seconds.round() as u64)
1718 }
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723 use super::*;
1724
1725 fn fixed_today() -> NaiveDate {
1729 NaiveDate::from_ymd_opt(2026, 1, 2).expect("2026-01-02 is a real date")
1730 }
1731
1732 #[test]
1733 fn question_answers_render_as_question_arrow_answer_block() {
1734 use mermaid_domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
1735
1736 let theme = Theme::dark();
1737 let answers = vec![
1738 QuestionAnswer {
1739 header: "Snack".to_string(),
1740 question: "Which snack fuels your next coding session?".to_string(),
1741 selected: vec!["Coffee (Recommended)".to_string()],
1742 note: None,
1743 },
1744 QuestionAnswer {
1745 header: "Powers".to_string(),
1746 question: "Which superpowers would you take?".to_string(),
1747 selected: vec![
1748 "Read any codebase instantly".to_string(),
1749 "Bugs reproduce on demand".to_string(),
1750 ],
1751 note: Some("only on weekdays".to_string()),
1752 },
1753 ];
1754 let action = ActionDisplay {
1755 action_type: "ask_user_question".to_string(),
1756 target: String::new(),
1757 result: ActionResult::Success {
1758 output: String::new(),
1759 images: None,
1760 },
1761 details: ActionDetails::Simple,
1762 duration_seconds: Some(93.0),
1763 metadata: Some(ToolRunMetadata {
1764 detail: ToolMetadata::Questions {
1765 answers,
1766 remembered: false,
1767 },
1768 ..Default::default()
1769 }),
1770 };
1771
1772 let mut lines: Vec<Line> = Vec::new();
1773 render_actions(&[action], &mut lines, &theme, 120, true);
1774 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1775 let all = rows.join("\n");
1776
1777 assert_eq!(rows[0], "● User answered the model's questions:");
1778 assert!(
1779 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
1780 "got {:?}",
1781 rows[1]
1782 );
1783 assert!(
1784 all.contains(
1785 "· Which superpowers would you take? → Read any codebase instantly, \
1786 Bugs reproduce on demand"
1787 ),
1788 "got {all}"
1789 );
1790 assert!(all.contains("(note: only on weekdays)"), "got {all}");
1791 assert!(!all.contains("ask_user_question("), "got {all}");
1793 assert!(!all.contains("took"), "got {all}");
1794 }
1795
1796 #[test]
1797 fn diff_background_fills_full_width_with_tabs() {
1798 use mermaid_model::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1803 use ratatui::Terminal;
1804 use ratatui::backend::TestBackend;
1805
1806 let theme = Theme::dark();
1807 let added_bg = theme.colors.diff_added_bg.to_color();
1808 let removed_bg = theme.colors.diff_removed_bg.to_color();
1809 let diff = format!(
1811 " 62{DIFF_REMOVED_MARKER}\tconst out = [];\n 63{DIFF_ADDED_MARKER}\t\tlet fixed = false;\n 64{DIFF_ADDED_MARKER}\t\t\tdeeplyNested();"
1812 );
1813 let action = ActionDisplay {
1814 action_type: "Update".to_string(),
1815 target: "engine.ts".to_string(),
1816 result: ActionResult::Success {
1817 output: String::new(),
1818 images: None,
1819 },
1820 details: ActionDetails::Diff {
1821 summary: "ok".to_string(),
1822 diff,
1823 },
1824 duration_seconds: Some(0.3),
1825 metadata: None,
1826 };
1827
1828 let width: u16 = 60;
1829 let mut lines: Vec<Line> = Vec::new();
1830 render_actions(&[action], &mut lines, &theme, width as usize, true);
1831 let h = lines.len() as u16;
1832 let backend = TestBackend::new(width, h);
1833 let mut term = Terminal::new(backend).unwrap();
1834 term.draw(|f| {
1835 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1836 })
1837 .unwrap();
1838 let buf = term.backend().buffer();
1839
1840 for y in 0..h {
1841 let is_diff_row = (0..width).any(|x| {
1842 let bg = buf[(x, y)].bg;
1843 bg == added_bg || bg == removed_bg
1844 });
1845 if !is_diff_row {
1846 continue;
1847 }
1848 for x in 0..width {
1849 let bg = buf[(x, y)].bg;
1850 assert!(
1851 bg == added_bg || bg == removed_bg,
1852 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1853 );
1854 }
1855 }
1856 }
1857
1858 fn assert_rows_fit(lines: &[Line], width: usize) {
1861 for (i, line) in lines.iter().enumerate() {
1862 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
1863 assert!(
1864 w <= width,
1865 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
1866 line_plain_text(line)
1867 );
1868 }
1869 }
1870
1871 #[test]
1872 fn action_header_and_error_wrap_instead_of_clipping() {
1873 let theme = Theme::dark();
1877 let action = ActionDisplay {
1878 action_type: "Error".to_string(),
1879 target: "Backend error".to_string(),
1880 result: ActionResult::Error {
1881 error: r#"HTTP error 404: {"error":{"code":"model_not_found","message":"The requested model was not found.","param":null,"type":"invalid_request_error"}}"#.to_string(),
1882 },
1883 details: ActionDetails::Simple,
1884 duration_seconds: None,
1885 metadata: None,
1886 };
1887
1888 let width = 60usize;
1889 let mut lines: Vec<Line> = Vec::new();
1890 render_actions(&[action], &mut lines, &theme, width, true);
1891
1892 assert_rows_fit(&lines, width);
1893 let rendered = lines
1894 .iter()
1895 .map(line_plain_text)
1896 .collect::<Vec<_>>()
1897 .join("\n");
1898 assert!(rendered.contains("invalid_request_error"));
1901 assert!(
1902 lines.len() > 2,
1903 "a 140-cell error at width 60 must span multiple rows"
1904 );
1905 }
1906
1907 #[test]
1908 fn action_header_wraps_long_command_and_keeps_closing_paren() {
1909 let theme = Theme::dark();
1910 let action = ActionDisplay {
1911 action_type: "Bash".to_string(),
1912 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
1913 that keeps going well past the sixty cell viewport edge"
1914 .to_string(),
1915 result: ActionResult::Success {
1916 output: String::new(),
1917 images: None,
1918 },
1919 details: ActionDetails::Simple,
1920 duration_seconds: Some(0.1),
1921 metadata: None,
1922 };
1923
1924 let width = 60usize;
1925 let mut lines: Vec<Line> = Vec::new();
1926 render_actions(&[action], &mut lines, &theme, width, true);
1927
1928 assert_rows_fit(&lines, width);
1929 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1930 assert!(rows[0].starts_with("● Bash("));
1931 assert!(
1932 rows.len() >= 2,
1933 "the long command must wrap the header across rows"
1934 );
1935 let last_target_row = rows
1936 .iter()
1937 .rfind(|r| r.trim_end().ends_with(')'))
1938 .expect("wrapped header must still close its paren");
1939 assert!(last_target_row.trim_end().ends_with(')'));
1940 }
1941
1942 #[test]
1943 fn action_header_caps_rows_and_marks_truncation() {
1944 let theme = Theme::dark();
1947 let action = ActionDisplay {
1948 action_type: "Bash".to_string(),
1949 target: "word ".repeat(400),
1950 result: ActionResult::Success {
1951 output: String::new(),
1952 images: None,
1953 },
1954 details: ActionDetails::Simple,
1955 duration_seconds: None,
1956 metadata: None,
1957 };
1958
1959 let width = 60usize;
1960 let mut lines: Vec<Line> = Vec::new();
1961 render_actions(&[action], &mut lines, &theme, width, true);
1962
1963 assert_rows_fit(&lines, width);
1964 let header_rows: Vec<String> = lines
1965 .iter()
1966 .map(line_plain_text)
1967 .take_while(|r| !r.trim_start().starts_with('⎿'))
1968 .collect();
1969 assert_eq!(
1970 header_rows.len(),
1971 MAX_ACTION_HEADER_ROWS,
1972 "header must cap at MAX_ACTION_HEADER_ROWS rows"
1973 );
1974 assert!(
1975 header_rows.last().unwrap().trim_end().ends_with("…)"),
1976 "capped header must end with …) — got {:?}",
1977 header_rows.last().unwrap()
1978 );
1979 }
1980
1981 #[test]
1982 fn action_header_preserves_multiline_command_rows() {
1983 let theme = Theme::dark();
1987 let action = ActionDisplay {
1988 action_type: "Bash".to_string(),
1989 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
1990 result: ActionResult::Success {
1991 output: String::new(),
1992 images: None,
1993 },
1994 details: ActionDetails::Simple,
1995 duration_seconds: None,
1996 metadata: None,
1997 };
1998
1999 let mut lines: Vec<Line> = Vec::new();
2000 render_actions(&[action], &mut lines, &theme, 80, true);
2001
2002 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2003 assert!(rows[0].contains("python3 - << 'PY'"));
2004 assert!(rows[1].contains("from PIL import Image"));
2005 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
2006 }
2007
2008 #[test]
2009 fn action_result_summary_wraps_instead_of_clipping() {
2010 let theme = Theme::dark();
2011 let action = ActionDisplay {
2012 action_type: "Tasks".to_string(),
2013 target: "update 3 steps".to_string(),
2014 result: ActionResult::Success {
2015 output: String::new(),
2016 images: None,
2017 },
2018 details: ActionDetails::Preview {
2019 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2020 placeholders kept intentionally until real data available. \
2021 Task 2 and 6 deferred., to revisit later"
2022 .to_string(),
2023 line_count: None,
2024 },
2025 duration_seconds: None,
2026 metadata: None,
2027 };
2028
2029 let width = 60usize;
2030 let mut lines: Vec<Line> = Vec::new();
2031 render_actions(&[action], &mut lines, &theme, width, true);
2032
2033 assert_rows_fit(&lines, width);
2034 let rendered = lines
2035 .iter()
2036 .map(line_plain_text)
2037 .collect::<Vec<_>>()
2038 .join("\n");
2039 assert!(
2040 rendered.contains("revisit later"),
2041 "the summary's tail must survive the wrap instead of being clipped"
2042 );
2043 }
2044
2045 #[test]
2046 fn wrapped_line_cache_hit_matches_cache_miss() {
2047 use ratatui::Terminal;
2054 use ratatui::backend::TestBackend;
2055
2056 let theme = Theme::dark();
2057 let messages = vec![
2058 ChatMessage::assistant(
2059 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2060 across this narrow viewport more than once.\n\n\
2061 - a list item that also keeps going past the edge so it wraps too\n\
2062 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2063 ),
2064 ChatMessage::assistant("Short follow-up paragraph."),
2065 ];
2066
2067 let (width, height): (u16, u16) = (40, 40);
2068 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2069 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2070 let mut state = ChatState::new();
2071 term.draw(|f| {
2072 let widget = ChatWidget {
2073 messages: &messages,
2074 content_key: test_content_key(&messages),
2075 theme: &theme,
2076 wrapped_line_cache: cache,
2077 show_reasoning: true,
2078 blink_on: true,
2079 today: fixed_today(),
2080 };
2081 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2082 })
2083 .unwrap();
2084 term.backend().buffer().clone()
2085 };
2086
2087 let mut shared = FxHashMap::default();
2088 let miss = render_once(&mut shared);
2089 assert!(!shared.is_empty(), "first render must populate the cache");
2090 let hit = render_once(&mut shared);
2091 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2092
2093 let mut cold_cache = FxHashMap::default();
2094 let cold = render_once(&mut cold_cache);
2095 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2096 }
2097
2098 #[test]
2099 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2100 use ratatui::Terminal;
2104 use ratatui::backend::TestBackend;
2105
2106 let theme = Theme::dark();
2107 let messages = vec![ChatMessage::system(
2108 "Heads up: this model reports no vision capability",
2109 )];
2110 let (width, height): (u16, u16) = (60, 10);
2111 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2112 let mut state = ChatState::new();
2113 let mut cache = FxHashMap::default();
2114 term.draw(|f| {
2115 let widget = ChatWidget {
2116 messages: &messages,
2117 content_key: test_content_key(&messages),
2118 theme: &theme,
2119 wrapped_line_cache: &mut cache,
2120 show_reasoning: true,
2121 blink_on: true,
2122 today: fixed_today(),
2123 };
2124 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2125 })
2126 .unwrap();
2127 let buf = term.backend().buffer();
2128 let rows: Vec<String> = (0..height)
2129 .map(|y| {
2130 (0..width)
2131 .map(|x| buf[(x, y)].symbol().to_string())
2132 .collect::<String>()
2133 })
2134 .collect();
2135 let all = rows.join("\n");
2136 assert!(
2137 !all.contains('●'),
2138 "no role bullet on system notices: {all}"
2139 );
2140 assert!(
2141 !all.contains("Today at"),
2142 "no timestamp on system notices: {all}"
2143 );
2144 let row = rows
2145 .iter()
2146 .position(|r| r.contains("Heads up"))
2147 .expect("notice rendered");
2148 assert!(
2149 rows[row].starts_with(" Heads up"),
2150 "2-space indent, nothing in the gutter: {:?}",
2151 rows[row]
2152 );
2153 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2155 buf[(col as u16, row as u16)].fg,
2156 theme.colors.text_meta.to_color(),
2157 "notice text uses the muted meta gray"
2158 );
2159 }
2160
2161 #[test]
2162 fn byte_at_cell_clamps_and_respects_cjk() {
2163 assert_eq!(byte_at_cell("hello", 0), 0);
2164 assert_eq!(byte_at_cell("hello", 3), 3);
2165 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2168 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2171 }
2172
2173 #[test]
2174 fn slice_by_cells_extracts_display_range() {
2175 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2176 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2177 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2178 }
2179
2180 #[test]
2181 fn pad_to_cells_fills_to_display_width() {
2182 assert_eq!(pad_to_cells("ab", 5), "ab ");
2183 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2185 assert_eq!(pad_to_cells("你好", 3), "你好");
2187 assert_eq!(pad_to_cells("", 0), "");
2188 }
2189
2190 #[test]
2191 fn user_timestamp_padding_aligns_on_display_cells() {
2192 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
2194 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
2197 assert_eq!(4 + 10 + pad + 8, 40);
2198 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
2200 }
2201
2202 #[test]
2203 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2204 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2207 let wrapped = wrap_preformatted(line, 10, 2);
2208 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2209 let first: String = wrapped[0]
2210 .spans
2211 .iter()
2212 .map(|s| s.content.as_ref())
2213 .collect();
2214 assert!(
2215 first.starts_with(" aaaa"),
2216 "indentation must be preserved, got {first:?}"
2217 );
2218 let second: String = wrapped[1]
2219 .spans
2220 .iter()
2221 .map(|s| s.content.as_ref())
2222 .collect();
2223 assert!(
2224 second.starts_with(" "),
2225 "continuation should get the hanging indent, got {second:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn wrap_preformatted_short_line_unchanged() {
2231 let line = Line::from(vec![Span::raw(" short")]);
2232 let wrapped = wrap_preformatted(line, 40, 2);
2233 assert_eq!(wrapped.len(), 1);
2234 let text: String = wrapped[0]
2235 .spans
2236 .iter()
2237 .map(|s| s.content.as_ref())
2238 .collect();
2239 assert_eq!(text, " short");
2240 }
2241
2242 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2246 let mut st = ChatState::new();
2247 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2248 st.selection = Some(sel);
2249 st
2250 }
2251
2252 #[test]
2253 fn selected_text_single_line() {
2254 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2255 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2256 }
2257
2258 #[test]
2259 fn selected_text_spans_multiple_rows() {
2260 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2261 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2264 }
2265
2266 #[test]
2267 fn selected_text_strips_margin_but_keeps_code_indentation() {
2268 let st = state_with_rows(
2271 &[" fn main() {", " let x = 1;", " }"],
2272 ((0, 0), (2, 3)),
2273 );
2274 assert_eq!(
2275 st.selected_text().as_deref(),
2276 Some("fn main() {\n let x = 1;\n}")
2277 );
2278 }
2279
2280 #[test]
2281 fn selected_text_normalizes_reversed_drag() {
2282 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2284 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2285 }
2286
2287 #[test]
2288 fn selected_text_empty_selection_is_none() {
2289 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2291 assert_eq!(st.selected_text(), None);
2292 }
2293
2294 #[test]
2295 fn highlight_line_cells_splits_spans_on_selection() {
2296 let mut line = Line::from(vec![Span::raw("abcdef")]);
2297 highlight_line_cells(
2298 &mut line,
2299 2,
2300 4,
2301 Style::new().add_modifier(Modifier::REVERSED),
2302 );
2303 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2305 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2306 assert!(
2307 line.spans[1]
2308 .style
2309 .add_modifier
2310 .contains(Modifier::REVERSED)
2311 );
2312 assert!(
2313 !line.spans[0]
2314 .style
2315 .add_modifier
2316 .contains(Modifier::REVERSED)
2317 );
2318 }
2319
2320 #[test]
2321 fn context_checkpoint_renders_as_compact_event() {
2322 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2323 msg.kind = ChatMessageKind::ContextCheckpoint;
2324 msg.metadata = Some(serde_json::json!({
2325 "trigger": "manual",
2326 "before_tokens": 43_800,
2327 "after_tokens": 9_200,
2328 "archived_message_count": 18,
2329 "preserved_message_count": 4,
2330 "duration_secs": 2.4,
2331 "review_status": "reviewed",
2332 }));
2333
2334 let lines =
2335 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2336 let rendered = lines
2337 .iter()
2338 .map(|line| {
2339 line.spans
2340 .iter()
2341 .map(|span| span.content.as_ref())
2342 .collect::<String>()
2343 })
2344 .collect::<Vec<_>>()
2345 .join("\n");
2346
2347 assert!(rendered.contains("Compact(manual)"));
2348 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2349 assert!(rendered.contains("archived 18 messages"));
2350 assert!(rendered.contains("preserved 4 messages"));
2351 assert!(rendered.contains("reviewed"));
2352 assert!(!rendered.contains("full checkpoint summary"));
2353 }
2354
2355 #[test]
2356 fn context_checkpoint_renders_validated_draft() {
2357 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2358 msg.kind = ChatMessageKind::ContextCheckpoint;
2359 msg.metadata = Some(serde_json::json!({
2360 "trigger": "auto_threshold",
2361 "before_tokens": 43_800,
2362 "after_tokens": 9_200,
2363 "archived_message_count": 18,
2364 "preserved_message_count": 4,
2365 "duration_secs": 2.4,
2366 "review_status": "draft_validated",
2367 "review_error": "provider overloaded",
2368 }));
2369
2370 let lines =
2371 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2372 let rendered = lines
2373 .iter()
2374 .map(|line| {
2375 line.spans
2376 .iter()
2377 .map(|span| span.content.as_ref())
2378 .collect::<String>()
2379 })
2380 .collect::<Vec<_>>()
2381 .join("\n");
2382
2383 assert!(rendered.contains("Compact(auto_threshold)"));
2384 assert!(rendered.contains("validated draft"));
2385 assert!(rendered.contains("review: provider overloaded"));
2386 }
2387
2388 #[test]
2394 fn wrap_styled_line_uses_display_width_for_cjk() {
2395 let line = Line::from(Span::raw("你好世界".to_string()));
2399 let wrapped = wrap_styled_line(line, 10, 2);
2400 assert_eq!(
2401 wrapped.len(),
2402 1,
2403 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
2404 wrapped.len()
2405 );
2406 }
2407
2408 #[test]
2411 fn wrap_styled_line_ascii_wraps_when_too_long() {
2412 let line = Line::from(Span::raw(
2413 "the quick brown fox jumps over the lazy dog".to_string(),
2414 ));
2415 let wrapped = wrap_styled_line(line, 15, 2);
2416 assert!(
2417 wrapped.len() >= 2,
2418 "long ASCII input should wrap to multiple lines; got {}",
2419 wrapped.len()
2420 );
2421 }
2422
2423 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
2424 wrapped[0]
2425 .spans
2426 .iter()
2427 .map(|s| s.content.as_ref())
2428 .collect()
2429 }
2430
2431 #[test]
2437 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
2438 let line = Line::from(vec![
2439 Span::raw(" "), Span::raw(
2441 "No source files, no config, no docs, no build system and more words to wrap"
2442 .to_string(),
2443 ),
2444 ]);
2445 let wrapped = wrap_styled_line(line, 30, 2);
2446 assert!(wrapped.len() >= 2, "should wrap");
2447 let first = first_segment_text(&wrapped);
2448 assert!(
2449 first.starts_with(" ") && first.trim_start().starts_with("No source"),
2450 "first wrapped segment must keep the 2-space gutter; got {first:?}"
2451 );
2452 }
2453
2454 #[test]
2460 fn wrap_styled_line_keeps_inline_code_background_across_its_spaces() {
2461 let code = Style::default().bg(ratatui::style::Color::Rgb(40, 40, 40));
2462 let line = Line::from(vec![
2463 Span::raw("read_image_bytes bails with ".to_string()),
2464 Span::styled("No image data found in clipboard".to_string(), code),
2465 Span::raw(" and the effect routes it onward".to_string()),
2466 ]);
2467 let wrapped = wrap_styled_line(line, 40, 2);
2468 assert!(wrapped.len() >= 2, "should wrap");
2469
2470 let spans: Vec<_> = wrapped.iter().flat_map(|l| l.spans.iter()).collect();
2473 let interior_gaps = spans
2474 .windows(3)
2475 .filter(|w| {
2476 w[1].content.as_ref() == " " && w[0].style.bg.is_some() && w[2].style.bg.is_some()
2477 })
2478 .count();
2479 assert!(
2480 interior_gaps >= 3,
2481 "the 5-word code span should keep its background on interior gaps; got \
2482 {interior_gaps} in {:?}",
2483 spans
2484 .iter()
2485 .map(|s| (s.content.as_ref(), s.style.bg))
2486 .collect::<Vec<_>>()
2487 );
2488 assert!(
2489 spans.windows(2).all(|w| {
2490 !(w[0].content.as_ref() == " "
2491 && w[0].style.bg.is_some()
2492 && w[1].style.bg.is_none())
2493 }),
2494 "no highlighted space may leak onto the plain prose that follows"
2495 );
2496 }
2497
2498 #[test]
2504 fn wrap_styled_line_hangs_list_continuation_under_marker() {
2505 let line = Line::from(vec![
2506 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2510 ]);
2511 let wrapped = wrap_styled_line(line, 24, 6);
2512 assert!(wrapped.len() >= 2, "should wrap");
2513 assert!(
2514 first_segment_text(&wrapped).starts_with(" • "),
2515 "first segment keeps gutter + nesting + marker"
2516 );
2517 for cont in &wrapped[1..] {
2518 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2519 assert!(
2520 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2521 "continuation hangs under the item text at col 6; got {t:?}"
2522 );
2523 }
2524 }
2525
2526 #[test]
2529 fn wrap_styled_line_keeps_bullet_at_column_zero() {
2530 let line = Line::from(vec![
2531 Span::raw("● "),
2532 Span::raw(
2533 "a fairly long first line of a message that definitely needs to wrap".to_string(),
2534 ),
2535 ]);
2536 let wrapped = wrap_styled_line(line, 25, 2);
2537 assert!(wrapped.len() >= 2, "should wrap");
2538 assert!(
2539 first_segment_text(&wrapped).starts_with('●'),
2540 "bullet must stay at column 0"
2541 );
2542 }
2543
2544 #[test]
2550 fn wrap_text_with_indent_uses_display_width_for_cjk() {
2551 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2554 assert_eq!(
2555 wrapped.len(),
2556 1,
2557 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2558 wrapped.len(),
2559 wrapped
2560 );
2561 assert_eq!(wrapped[0].trim_start(), "你好世界");
2562 }
2563
2564 #[test]
2567 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2568 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2572 assert!(
2573 wrapped.len() >= 2,
2574 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2575 wrapped.len(),
2576 wrapped
2577 );
2578 }
2579
2580 #[test]
2581 fn clamp_to_u16_saturates_past_u16_max() {
2582 assert_eq!(clamp_to_u16(0), 0);
2585 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2586 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2587 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2588 }
2589
2590 #[test]
2591 fn wrap_text_with_indent_hard_breaks_overlong_token() {
2592 let token = "x".repeat(100);
2596 let width = 20;
2597 let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2598 assert!(
2599 wrapped.len() >= 5,
2600 "a 100-cell token at width 20 must span many rows; got {}",
2601 wrapped.len()
2602 );
2603 for line in &wrapped {
2604 assert!(
2605 line.chars().count() <= width,
2606 "no wrapped row may exceed the width; got {:?} ({} cells)",
2607 line,
2608 line.chars().count()
2609 );
2610 }
2611 let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2613 assert_eq!(
2614 joined, token,
2615 "hard-break must preserve the token's content"
2616 );
2617 }
2618
2619 #[test]
2620 fn wrap_styled_line_hard_breaks_overlong_token() {
2621 let token = "y".repeat(90);
2623 let style = Style::new().fg(ratatui::style::Color::Red);
2624 let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
2625 let width = 24;
2626 let wrapped = wrap_styled_line(line, width, 2);
2627 assert!(
2628 wrapped.len() >= 4,
2629 "must hard-break across rows; got {}",
2630 wrapped.len()
2631 );
2632
2633 let mut reconstructed = String::new();
2634 for l in &wrapped {
2635 let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2636 assert!(
2637 row_cells <= width,
2638 "row exceeds width: {row_cells} > {width}"
2639 );
2640 for s in &l.spans {
2641 if s.content.trim().is_empty() {
2644 continue;
2645 }
2646 assert_eq!(
2647 s.style.fg,
2648 Some(ratatui::style::Color::Red),
2649 "hard-break must preserve the span style"
2650 );
2651 reconstructed.push_str(s.content.as_ref());
2652 }
2653 }
2654 assert_eq!(reconstructed, token, "hard-break must preserve the token");
2655 }
2656
2657 #[test]
2661 fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
2662 let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
2663 let line = Line::from(vec![
2664 Span::raw(" "),
2665 Span::raw("some filler words long enough to force a wrap here "),
2666 Span::styled("underlined-link-text", underlined),
2667 Span::raw(" and a bit more trailing filler after the link"),
2668 ]);
2669 let wrapped = wrap_styled_line(line, 30, 2);
2670 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2671 for l in &wrapped {
2672 for s in &l.spans {
2673 if s.content.chars().all(|c| c == ' ') {
2674 assert_eq!(
2675 s.style,
2676 Style::default(),
2677 "whitespace span {:?} must be unstyled",
2678 s.content
2679 );
2680 }
2681 }
2682 }
2683 }
2684
2685 #[test]
2689 fn wrap_styled_line_no_phantom_space_at_span_boundary() {
2690 let dim = Style::new().fg(ratatui::style::Color::DarkGray);
2691 let line = Line::from(vec![
2692 Span::raw(" "),
2693 Span::raw("filler text that pushes the line well past the width limit "),
2694 Span::styled("(https://example.com)".to_string(), dim),
2695 Span::raw("."),
2696 ]);
2697 let wrapped = wrap_styled_line(line, 30, 2);
2698 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2699 let text: String = wrapped
2700 .iter()
2701 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2702 .collect();
2703 assert!(
2704 text.contains("(https://example.com)."),
2705 "period must stay glued to the URL suffix; got {text:?}"
2706 );
2707 assert!(
2708 !text.contains("(https://example.com) ."),
2709 "no phantom space before the period; got {text:?}"
2710 );
2711 }
2712
2713 #[test]
2717 fn wrap_styled_line_keeps_mid_word_style_change_glued() {
2718 let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
2719 let line = Line::from(vec![
2720 Span::raw(" "),
2721 Span::raw("leading filler words to force wrapping "),
2722 Span::styled("bold", bold),
2723 Span::raw("suffix"),
2724 Span::raw(" trailing filler words to force more wrapping"),
2725 ]);
2726 let wrapped = wrap_styled_line(line, 30, 2);
2727 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2728 let rows: Vec<String> = wrapped
2729 .iter()
2730 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
2731 .collect();
2732 assert_eq!(
2733 rows.iter().filter(|r| r.contains("boldsuffix")).count(),
2734 1,
2735 "glued token must land whole on exactly one row; rows: {rows:?}"
2736 );
2737 for l in &wrapped {
2738 for s in &l.spans {
2739 if s.content.as_ref() == "bold" {
2740 assert_eq!(s.style, bold, "bold fragment keeps its modifier");
2741 }
2742 if s.content.as_ref() == "suffix" {
2743 assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
2744 }
2745 }
2746 }
2747 }
2748
2749 #[test]
2753 fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
2754 let red = Style::new().fg(ratatui::style::Color::Red);
2755 let blue = Style::new().fg(ratatui::style::Color::Blue);
2756 let line = Line::from(vec![
2757 Span::raw(" "),
2758 Span::styled("a".repeat(40), red),
2759 Span::styled("b".repeat(40), blue),
2760 ]);
2761 let width = 24;
2762 let wrapped = wrap_styled_line(line, width, 2);
2763 assert!(
2764 wrapped.len() >= 4,
2765 "80-cell token at width 24 must span >= 4 rows; got {}",
2766 wrapped.len()
2767 );
2768 let mut reconstructed = String::new();
2769 for l in &wrapped {
2770 let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
2771 assert!(
2772 row_cells <= width,
2773 "row exceeds width: {row_cells} > {width}"
2774 );
2775 for s in &l.spans {
2776 if s.content.trim().is_empty() {
2777 continue;
2778 }
2779 let expected = if s.content.contains('a') { red } else { blue };
2780 assert!(
2781 !(s.content.contains('a') && s.content.contains('b')),
2782 "fragments must not merge across the style boundary"
2783 );
2784 assert_eq!(s.style, expected, "fragment style preserved across break");
2785 reconstructed.push_str(s.content.as_ref());
2786 }
2787 }
2788 assert_eq!(
2789 reconstructed,
2790 format!("{}{}", "a".repeat(40), "b".repeat(40)),
2791 "hard-break must preserve the whole glued token"
2792 );
2793 }
2794
2795 #[test]
2798 fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
2799 let line = Line::from(vec![
2800 Span::raw(" "),
2801 Span::raw("filler words that push this line past the wrap width "),
2802 Span::raw("foo"),
2803 Span::raw(" "),
2804 Span::raw("bar"),
2805 ]);
2806 let wrapped = wrap_styled_line(line, 30, 2);
2807 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2808 let text: String = wrapped
2809 .iter()
2810 .map(|l| {
2811 l.spans
2812 .iter()
2813 .map(|s| s.content.as_ref())
2814 .collect::<String>()
2815 })
2816 .collect::<Vec<_>>()
2817 .join("\n");
2818 assert!(
2819 text.contains("foo bar") || text.contains("foo\n bar"),
2820 "whitespace-only span must keep the words apart; got {text:?}"
2821 );
2822 assert!(
2823 !text.contains("foobar"),
2824 "words must not glue; got {text:?}"
2825 );
2826 }
2827
2828 #[test]
2829 fn frame_memo_hit_matches_miss() {
2830 use ratatui::Terminal;
2836 use ratatui::backend::TestBackend;
2837
2838 let theme = Theme::dark();
2839 let messages = vec![
2840 ChatMessage::assistant(
2841 "# Heading\n\nSome **bold** prose long enough that it wraps across \
2842 this narrow viewport more than once.\n\n- a list item that also \
2843 runs past the edge so it wraps\n- second item",
2844 ),
2845 ChatMessage::assistant("Short follow-up."),
2846 ];
2847
2848 let (width, height): (u16, u16) = (34, 30);
2849 let mut cache = FxHashMap::default();
2850 let mut state = ChatState::new();
2851
2852 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2853 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2854 term.draw(|f| {
2855 let widget = ChatWidget {
2856 messages: &messages,
2857 content_key: test_content_key(&messages),
2858 theme: &theme,
2859 wrapped_line_cache: cache,
2860 show_reasoning: true,
2861 blink_on: true,
2862 today: fixed_today(),
2863 };
2864 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
2865 })
2866 .unwrap();
2867 term.backend().buffer().clone()
2868 };
2869
2870 let miss = render(&mut state, &mut cache);
2871 assert!(
2872 state.frame_memo.is_some(),
2873 "first render must populate the frame memo"
2874 );
2875 let hit = render(&mut state, &mut cache);
2876 assert_eq!(
2877 miss, hit,
2878 "frame-memo hit must render identically to the miss"
2879 );
2880 assert!(
2884 !state.last_rendered_rows.is_empty(),
2885 "memo hit must preserve last_rendered_rows from the miss"
2886 );
2887 }
2888
2889 #[test]
2890 fn append_action_duration_handles_empty_base() {
2891 assert_eq!(
2894 append_action_duration(String::new(), Some(0.035)),
2895 "took 35ms"
2896 );
2897 assert_eq!(
2899 append_action_duration("3 lines read".to_string(), Some(1.25)),
2900 "3 lines read, took 1.2s"
2901 );
2902 assert_eq!(append_action_duration(String::new(), None), "");
2904 }
2905}