1use crate::render::wrap::{wrap_styled_line, wrap_text_with_indent};
2use std::hash::{Hash, Hasher};
3
4use ratatui::{
5 buffer::Buffer,
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Paragraph, StatefulWidget, Widget},
10};
11use rustc_hash::FxHashMap;
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14use crate::render::markdown::parse_markdown;
15use crate::render::theme::Theme;
16use mermaid_domain::{
17 ActionDetails, ActionDisplay, ActionResult, QuestionAnswer, ToolMetadata, format_compact_count,
18};
19use mermaid_model::diff::{DiffLineKind, parse_diff_line};
20use mermaid_model::models::ChatMessageKind;
21use mermaid_model::models::{ChatMessage, MessageRole};
22
23#[derive(Debug, Clone)]
25pub struct ImageClickTarget {
26 pub message_index: usize,
31 pub image_index: usize,
33 pub image_number: Option<u64>,
37}
38
39#[derive(Debug, Clone)]
41pub struct ChatState {
42 scroll_offset: u16,
44 is_user_scrolling: bool,
46 pub image_click_map: Vec<(u16, ImageClickTarget)>,
48 pub last_scroll_position: u16,
50 pub last_top_pad: u16,
55 pub last_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
60 last_rendered_rows: Vec<String>,
64 frame_memo: Option<FrameMemo>,
71 #[cfg(debug_assertions)]
74 debug_key_check: Option<(u64, u64)>,
75}
76
77#[derive(Debug, Clone)]
84struct FrameMemo {
85 key: u64,
87 lines: Vec<Line<'static>>,
89 click_map: Vec<(u16, ImageClickTarget)>,
91}
92
93impl ChatState {
94 #[must_use]
96 pub fn new() -> Self {
97 Self {
98 scroll_offset: 0,
99 is_user_scrolling: false,
100 image_click_map: Vec::new(),
101 last_scroll_position: 0,
102 last_top_pad: 0,
103 last_chat_area: None,
104 selection: None,
105 last_rendered_rows: Vec::new(),
106 frame_memo: None,
107 #[cfg(debug_assertions)]
108 debug_key_check: None,
109 }
110 }
111
112 #[must_use]
115 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
116 let max_scroll = content_height.saturating_sub(viewport_height);
117 if self.is_user_scrolling {
118 let capped_offset = self.scroll_offset.min(max_scroll);
121 max_scroll.saturating_sub(capped_offset)
122 } else {
123 max_scroll
125 }
126 }
127
128 pub fn scroll_up(&mut self, amount: u16) {
130 self.is_user_scrolling = true;
131 self.scroll_offset = self.scroll_offset.saturating_add(amount);
132 self.selection = None;
135 }
136
137 pub fn scroll_down(&mut self, amount: u16) {
140 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
141 if self.scroll_offset == 0 {
142 self.is_user_scrolling = false;
144 }
145 self.selection = None;
146 }
147
148 pub fn resume_auto_scroll(&mut self) {
150 self.is_user_scrolling = false;
151 self.scroll_offset = 0;
152 }
153
154 #[must_use]
157 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
158 let (_, area_y, _, area_height) = self.last_chat_area?;
159
160 if screen_row < area_y || screen_row >= area_y + area_height {
162 return None;
163 }
164
165 let viewport_row = screen_row - area_y;
168 if viewport_row < self.last_top_pad {
169 return None;
170 }
171 let content_line = viewport_row - self.last_top_pad + self.last_scroll_position;
172
173 self.image_click_map
175 .iter()
176 .find(|(line, _)| *line == content_line)
177 .map(|(_, target)| target)
178 }
179
180 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
184 let (area_x, area_y, _, area_height) = self.last_chat_area?;
185 if screen_row < area_y || screen_row >= area_y + area_height {
186 return None;
187 }
188 let viewport_row = screen_row - area_y;
189 if viewport_row < self.last_top_pad {
190 return None;
191 }
192 let content_line =
193 (viewport_row - self.last_top_pad) as usize + self.last_scroll_position as usize;
194 let col = screen_col.saturating_sub(area_x) as usize;
195 Some((content_line, col))
196 }
197
198 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
202 self.selection = self
203 .screen_to_content(screen_row, screen_col)
204 .map(|p| (p, p));
205 }
206
207 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
209 if let Some((anchor, _)) = self.selection
210 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
211 {
212 self.selection = Some((anchor, cursor));
213 }
214 }
215
216 #[must_use]
221 pub fn selected_text(&self) -> Option<String> {
222 let (a, b) = self.selection?;
223 let (start, end) = if a <= b { (a, b) } else { (b, a) };
224 if self.last_rendered_rows.is_empty() {
225 return None;
226 }
227 let last = self.last_rendered_rows.len() - 1;
228 let (start_line, start_col) = (start.0.min(last), start.1);
229 let (end_line, end_col) = (end.0.min(last), end.1);
230
231 let mut out = String::new();
232 for line in start_line..=end_line {
233 let row = &self.last_rendered_rows[line];
234 let c0 = if line == start_line { start_col } else { 0 };
235 let c1 = if line == end_line {
236 end_col
237 } else {
238 usize::MAX
239 };
240 let mut piece = slice_by_cells(row, c0, c1).to_string();
241 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
246 while margin > 0 && piece.starts_with(' ') {
247 piece.remove(0);
248 margin -= 1;
249 }
250 out.push_str(piece.trim_end());
251 if line != end_line {
252 out.push('\n');
253 }
254 }
255 if out.is_empty() { None } else { Some(out) }
256 }
257}
258
259const SELECT_MARGIN_CELLS: usize = 2;
263
264fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
269 if width == 0 {
270 return vec![line];
271 }
272 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
273 if total <= width {
274 return vec![line];
275 }
276
277 let base = line.style;
278 let mut out: Vec<Line<'static>> = Vec::new();
279 let mut cur: Vec<Span<'static>> = Vec::new();
280 let mut cur_w = 0usize;
281 let mut on_first = true;
282
283 for span in line.spans {
284 let style = span.style;
285 let mut buf = String::new();
286 for ch in span.content.chars() {
287 let cw = ch.width().unwrap_or(0);
288 let floor = if on_first { 0 } else { indent };
291 if cur_w + cw > width && cur_w > floor {
292 if !buf.is_empty() {
293 cur.push(Span::styled(std::mem::take(&mut buf), style));
294 }
295 out.push(Line::from(std::mem::take(&mut cur)).style(base));
296 on_first = false;
297 cur.push(Span::styled(" ".repeat(indent), base));
298 cur_w = indent;
299 }
300 buf.push(ch);
301 cur_w += cw;
302 }
303 if !buf.is_empty() {
304 cur.push(Span::styled(buf, style));
305 }
306 }
307 if !cur.is_empty() {
308 out.push(Line::from(cur).style(base));
309 }
310 if out.is_empty() {
311 vec![Line::from("").style(base)]
312 } else {
313 out
314 }
315}
316
317fn byte_at_cell(s: &str, target: usize) -> usize {
321 if target == 0 {
322 return 0;
323 }
324 let mut width = 0usize;
325 for (idx, ch) in s.char_indices() {
326 if width >= target {
327 return idx;
328 }
329 width += ch.width().unwrap_or(0);
330 }
331 s.len()
332}
333
334fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
336 let start = byte_at_cell(s, c0);
337 let end = byte_at_cell(s, c1).max(start);
338 &s[start..end]
339}
340
341fn pad_to_cells(s: &str, cells: usize) -> String {
346 let w = s.width();
347 if w >= cells {
348 return s.to_string();
349 }
350 let mut out = String::with_capacity(s.len() + (cells - w));
351 out.push_str(s);
352 out.push_str(&" ".repeat(cells - w));
353 out
354}
355
356fn line_plain_text(line: &Line) -> String {
358 line.spans.iter().map(|s| s.content.as_ref()).collect()
359}
360
361fn clamp_to_u16(n: usize) -> u16 {
367 u16::try_from(n).unwrap_or(u16::MAX)
368}
369
370fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
374 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
375 let mut width = 0usize;
376 for span in line.spans.drain(..) {
377 let span_w = span.content.width();
378 let (span_start, span_end) = (width, width + span_w);
379 width = span_end;
380
381 let ov0 = c0.max(span_start);
382 let ov1 = c1.min(span_end);
383 if ov1 <= ov0 {
384 new_spans.push(span); continue;
386 }
387
388 let s = span.content.as_ref();
389 let b0 = byte_at_cell(s, ov0 - span_start);
390 let b1 = byte_at_cell(s, ov1 - span_start);
391 if b0 > 0 {
392 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
393 }
394 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
395 if b1 < s.len() {
396 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
397 }
398 }
399 line.spans = new_spans;
400}
401
402impl Default for ChatState {
403 fn default() -> Self {
404 Self::new()
405 }
406}
407
408pub struct ChatWidget<'a> {
410 pub messages: &'a [ChatMessage],
411 pub theme: &'a Theme,
412 pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
417 pub content_key: u64,
421 pub show_reasoning: bool,
422 pub blink_on: bool,
427}
428
429fn wrap_assistant_content(
438 content: &str,
439 content_width: u16,
440 role_prefix: &str,
441 role_color: ratatui::style::Color,
442 theme: &Theme,
443) -> Vec<Line<'static>> {
444 let md_width = (content_width as usize).saturating_sub(2);
446 let parsed = parse_markdown(content, theme, md_width);
447
448 let mut out: Vec<Line<'static>> = Vec::new();
449 for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
450 let preformatted = parsed_line.preformatted;
455 let base_style = parsed_line.line.style;
456
457 let continuation = if preformatted {
462 2
463 } else {
464 2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
465 };
466
467 let mut spans = if line_idx == 0 {
469 vec![Span::styled(
470 format!("{role_prefix} "),
471 Style::new().fg(role_color).bold(),
472 )]
473 } else {
474 vec![Span::raw(" ")]
475 };
476 spans.extend(parsed_line.line.spans);
477 let new_line = Line::from(spans).style(base_style);
478
479 if preformatted {
480 out.extend(wrap_preformatted(new_line, content_width as usize, 2));
483 } else {
484 out.extend(wrap_styled_line(
485 new_line,
486 content_width as usize,
487 continuation,
488 ));
489 }
490 }
491 out
492}
493
494#[cfg(debug_assertions)]
503struct HashWrite<'a, H: Hasher>(&'a mut H);
504
505#[cfg(debug_assertions)]
506impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
507 fn write_str(&mut self, s: &str) -> std::fmt::Result {
508 self.0.write(s.as_bytes());
509 Ok(())
510 }
511}
512
513pub(crate) fn frame_key(
528 content_key: u64,
529 theme_seed: u64,
530 content_width: u16,
531 show_reasoning: bool,
532) -> u64 {
533 let mut h = rustc_hash::FxHasher::default();
534 content_key.hash(&mut h);
535 theme_seed.hash(&mut h);
536 content_width.hash(&mut h);
537 show_reasoning.hash(&mut h);
538 h.finish()
539}
540
541#[cfg(test)]
545pub(crate) fn test_content_key(messages: &[ChatMessage]) -> u64 {
546 let mut h = rustc_hash::FxHasher::default();
547 messages.len().hash(&mut h);
548 for msg in messages {
549 msg.content.hash(&mut h);
550 msg.thinking.hash(&mut h);
551 std::mem::discriminant(&msg.kind).hash(&mut h);
552 msg.actions.len().hash(&mut h);
553 }
554 h.finish()
555}
556
557#[cfg(debug_assertions)]
566pub(crate) fn frame_fingerprint(
567 messages: &[ChatMessage],
568 theme_seed: u64,
569 content_width: u16,
570 show_reasoning: bool,
571 blink_on: bool,
572) -> u64 {
573 use std::fmt::Write as _;
574 let mut h = rustc_hash::FxHasher::default();
575 theme_seed.hash(&mut h);
576 content_width.hash(&mut h);
577 show_reasoning.hash(&mut h);
578 if messages.iter().any(|m| {
579 m.actions
580 .iter()
581 .any(|a| matches!(a.result, ActionResult::Running))
582 }) {
583 blink_on.hash(&mut h);
584 }
585 messages.len().hash(&mut h);
586 for msg in messages {
587 msg.content.hash(&mut h);
588 msg.thinking.hash(&mut h);
589 msg.timestamp.timestamp().hash(&mut h);
590 msg.images
591 .as_ref()
592 .map_or(0, |imgs| imgs.len())
593 .hash(&mut h);
594 let mut hw = HashWrite(&mut h);
595 let _ = write!(
596 hw,
597 "{:?}|{:?}|{:?}|{:?}",
598 msg.role, msg.kind, msg.metadata, msg.actions
599 );
600 }
601 h.finish()
602}
603
604impl<'a> StatefulWidget for ChatWidget<'a> {
605 type State = ChatState;
606
607 #[expect(
608 clippy::too_many_lines,
609 reason = "the transcript assembly on a memo miss: one loop over the messages handling \
610 each kind (checkpoint, summary, system, stitched continuation, thinking, assistant prose \
611 through the wrap cache, user band, image rows) and recording click targets as it goes; \
612 the loop body reads the widget, the click map and the line index together, so it would \
613 need to become a builder over all three, and this is the hot path the render-cost \
614 invariants are written against"
615 )]
616 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
617 let code_bg = self.theme.colors.code_background.to_color();
620 let theme_seed = {
621 let mut h = rustc_hash::FxHasher::default();
622 self.theme.colors.foreground.to_color().hash(&mut h);
623 code_bg.hash(&mut h);
624 self.theme.colors.header.to_color().hash(&mut h);
625 h.finish()
626 };
627
628 let content_width = area.width;
630 let content_area = area;
631
632 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
633
634 let frame_key = frame_key(
640 self.content_key,
641 theme_seed,
642 content_width,
643 self.show_reasoning,
644 );
645 #[cfg(debug_assertions)]
649 {
650 let content_hash = frame_fingerprint(
651 self.messages,
652 theme_seed,
653 content_width,
654 self.show_reasoning,
655 self.blink_on,
656 );
657 if let Some((last_key, last_hash)) = state.debug_key_check {
658 debug_assert!(
659 last_hash == content_hash || last_key != frame_key,
660 "chat frame content changed without a new memo key — a mutation \
661 bypassed ConversationHistory::messages_mut (stale transcript risk)",
662 );
663 }
664 state.debug_key_check = Some((frame_key, content_hash));
665 }
666 let memo = state.frame_memo.take().filter(|m| m.key == frame_key);
674
675 let memo = if let Some(memo) = memo {
676 state.image_click_map = memo.click_map.clone();
678 memo
679 } else {
680 let mut lines: Vec<Line<'static>> = Vec::new();
682
683 state.image_click_map.clear();
685
686 for (idx, msg) in self.messages.iter().enumerate() {
687 if matches!(msg.role, MessageRole::Tool) {
690 continue;
691 }
692
693 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
694 if let Some(event_lines) =
695 render_context_checkpoint_event(msg, self.theme, content_width as usize)
696 {
697 lines.extend(event_lines);
698 lines.push(Line::from(""));
699 }
700 continue;
701 }
702
703 if matches!(msg.kind, ChatMessageKind::RunSummary) {
708 lines.push(Line::from(Span::styled(
709 format!(" {}", msg.content),
710 Style::new().fg(self.theme.colors.text_meta.to_color()),
711 )));
712 lines.push(Line::from(""));
713 continue;
714 }
715
716 if matches!(
722 msg.kind,
723 ChatMessageKind::RecoveryNudge | ChatMessageKind::ContextMarker
724 ) {
725 continue;
726 }
727
728 if matches!(msg.role, MessageRole::System) {
733 let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
734 for wrapped_line in
735 wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
736 {
737 lines.push(Line::from(Span::styled(wrapped_line, meta)));
738 }
739 lines.push(Line::from(""));
740 continue;
741 }
742
743 let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
751 && self.messages[..idx]
752 .iter()
753 .rev()
754 .find(|m| !matches!(m.role, MessageRole::Tool))
755 .is_some_and(crate::render::mergeable_into);
756 if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
757 lines.pop();
758 }
759
760 let (role_prefix, role_color) = match msg.role {
761 MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
762 MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
763 MessageRole::System | MessageRole::Tool => {
764 unreachable!("System and Tool messages handled above")
765 },
766 };
767 let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
771
772 if matches!(msg.role, MessageRole::Assistant) {
773 if let Some(ref thinking) = msg.thinking {
775 let thinking_trimmed = thinking.trim();
777 if thinking_trimmed.is_empty()
778 || thinking_trimmed == "None"
779 || thinking_trimmed == "none"
780 {
781 } else if self.show_reasoning {
783 lines.push(Line::from(vec![
785 Span::styled(
786 "● ",
787 Style::new().fg(self.theme.colors.text_disabled.to_color()),
788 ),
789 Span::styled(
790 "Thinking...",
791 Style::new()
792 .fg(self.theme.colors.text_secondary.to_color())
793 .italic()
794 .dim(),
795 ),
796 ]));
797
798 let wrapped = wrap_text_with_indent(
800 thinking,
801 content_width as usize,
802 2, 2, );
805 for wrapped_line in wrapped {
806 lines.push(Line::from(Span::styled(
807 wrapped_line,
808 Style::new()
809 .fg(self.theme.colors.text_secondary.to_color())
810 .italic()
811 .dim(),
812 )));
813 }
814
815 lines.push(Line::from(""));
817 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
818 continue;
823 }
824 }
825
826 let mut hasher = rustc_hash::FxHasher::default();
834 msg.content.hash(&mut hasher);
835 theme_seed.hash(&mut hasher);
836 content_width.hash(&mut hasher);
837 stitch_onto_prev.hash(&mut hasher);
840 let cache_key = hasher.finish();
841
842 let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
843 cached.clone()
844 } else {
845 let block = wrap_assistant_content(
846 &msg.content,
847 content_width,
848 role_prefix,
849 role_color,
850 self.theme,
851 );
852 self.wrapped_line_cache.insert(cache_key, block.clone());
853 if self.wrapped_line_cache.len()
854 > mermaid_model::constants::MARKDOWN_CACHE_MAX_ENTRIES
855 {
856 let overflow = self.wrapped_line_cache.len()
861 - mermaid_model::constants::MARKDOWN_CACHE_MAX_ENTRIES;
862 let stale: Vec<u64> = self
863 .wrapped_line_cache
864 .keys()
865 .copied()
866 .filter(|&k| k != cache_key)
867 .take(overflow)
868 .collect();
869 for k in stale {
870 self.wrapped_line_cache.remove(&k);
871 }
872 }
873 block
874 };
875 lines.extend(wrapped);
876
877 if !msg.actions.is_empty() {
879 if !msg.content.trim().is_empty() {
881 lines.push(Line::from(""));
882 }
883 render_actions(
884 &msg.actions,
885 &mut lines,
886 self.theme,
887 content_width as usize,
888 self.blink_on,
889 );
890 }
891 } else {
892 let cleaned_content = &msg.content;
897 let role_prefix_width = role_prefix.width() + 1; let wrapped = wrap_text_with_indent(
901 cleaned_content,
902 content_width as usize,
903 role_prefix_width, 2, );
906
907 let band_start = lines.len();
908 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
909 if line_idx == 0 {
910 let text_content = wrapped_line.trim_start(); lines.push(Line::from(vec![
912 Span::styled(
913 format!("{role_prefix} "),
914 Style::new().fg(role_color).bold(),
915 ),
916 Span::raw(text_content.to_string()),
917 ]));
918 } else {
919 lines.push(Line::from(wrapped_line.clone()));
921 }
922 }
923
924 if matches!(msg.role, MessageRole::User) {
929 let user_bg = self.theme.colors.user_message_background.to_color();
930 let cw = content_width as usize;
931 for line in &mut lines[band_start..] {
932 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
933 if used < cw {
934 line.spans.push(Span::raw(" ".repeat(cw - used)));
935 }
936 line.style = line.style.bg(user_bg);
937 }
938 }
939 }
940
941 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
948 && let Some(ref images) = msg.images
949 && !images.is_empty()
950 {
951 for (i, _) in images.iter().enumerate() {
952 let content_line = lines.len();
958 let image_number =
959 msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
960 state.image_click_map.push((
961 clamp_to_u16(content_line),
962 ImageClickTarget {
963 message_index: idx,
964 image_index: i,
965 image_number,
966 },
967 ));
968 let label = image_number
973 .map(|n| format!("[Image #{n}]"))
974 .unwrap_or_else(|| format!("[Image #{}]", i + 1));
975 lines.push(Line::from(vec![
976 Span::styled(
977 " ⎿ ",
978 Style::new().fg(self.theme.colors.info.to_color()),
979 ),
980 Span::styled(
981 label,
982 Style::new().fg(self.theme.colors.info.to_color()).italic(),
983 ),
984 ]));
985 }
986 }
987
988 lines.push(Line::from(""));
989 }
990
991 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
997
998 FrameMemo {
1004 key: frame_key,
1005 lines,
1006 click_map: state.image_click_map.clone(),
1007 }
1008 };
1009
1010 let content_height = memo.lines.len();
1032 let viewport_height = area.height;
1033
1034 let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
1035 state.last_scroll_position = scroll_pos;
1036 let top_pad = viewport_height.saturating_sub(clamp_to_u16(content_height));
1040 state.last_top_pad = top_pad;
1041 let content_area = Rect {
1042 y: content_area.y.saturating_add(top_pad),
1043 height: content_area.height.saturating_sub(top_pad),
1044 ..content_area
1045 };
1046
1047 let first = (scroll_pos as usize).min(content_height);
1051 let last = first
1052 .saturating_add(viewport_height as usize)
1053 .min(content_height);
1054 let mut lines: Vec<Line<'static>> = memo.lines[first..last].to_vec();
1055
1056 if let Some((a, b)) = state.selection
1060 && !lines.is_empty()
1061 {
1062 let (start, end) = if a <= b { (a, b) } else { (b, a) };
1063 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
1064 for (offset, line) in lines.iter_mut().enumerate() {
1065 let content_idx = first + offset;
1066 if content_idx < start.0 || content_idx > end.0 {
1067 continue;
1068 }
1069 let c0 = if content_idx == start.0 { start.1 } else { 0 };
1070 let c1 = if content_idx == end.0 {
1071 end.1
1072 } else {
1073 usize::MAX
1074 };
1075 if c1 > c0 {
1076 highlight_line_cells(line, c0, c1, sel_style);
1077 }
1078 }
1079 }
1080
1081 let paragraph = Paragraph::new(lines).block(Block::default()).scroll((0, 0));
1083
1084 paragraph.render(content_area, buf);
1085
1086 state.frame_memo = Some(memo);
1088 }
1089}
1090
1091fn render_context_checkpoint_event(
1092 msg: &ChatMessage,
1093 theme: &Theme,
1094 viewport_width: usize,
1095) -> Option<Vec<Line<'static>>> {
1096 if !matches!(msg.role, MessageRole::User) {
1097 return None;
1098 }
1099
1100 let metadata = msg.metadata.as_ref();
1101 let trigger = metadata
1102 .and_then(|value| value.get("trigger"))
1103 .and_then(|value| value.as_str())
1104 .unwrap_or("manual");
1105 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
1106 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
1107 let archived_messages =
1108 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
1109 let preserved_messages =
1110 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
1111 let duration_secs = metadata
1112 .and_then(|value| value.get("duration_secs"))
1113 .and_then(|value| value.as_f64());
1114
1115 let action_color = theme.colors.info.to_color();
1116 let mut result = match (before_tokens, after_tokens) {
1117 (Some(before), Some(after)) => {
1118 format!(
1119 "{} -> {} tokens",
1120 format_compact_count(before),
1121 format_compact_count(after)
1122 )
1123 },
1124 _ => "Context compacted".to_string(),
1125 };
1126
1127 if let Some(count) = archived_messages {
1128 result.push_str(&format!(
1129 ", archived {} {}",
1130 count,
1131 if count == 1 { "message" } else { "messages" }
1132 ));
1133 }
1134 if let Some(count) = preserved_messages {
1135 result.push_str(&format!(
1136 ", preserved {} {}",
1137 count,
1138 if count == 1 { "message" } else { "messages" }
1139 ));
1140 }
1141 result = append_action_duration(result, duration_secs);
1142
1143 let mut lines = vec![Line::from(vec![
1144 Span::styled("● ", Style::new().fg(action_color).bold()),
1145 Span::styled("Compact(", Style::new().fg(action_color).bold()),
1146 Span::styled(
1147 trigger.to_string(),
1148 Style::new().fg(theme.colors.text_secondary.to_color()),
1149 ),
1150 Span::styled(")", Style::new().fg(action_color).bold()),
1151 ])];
1152 lines.extend(wrap_styled_line(
1153 Line::from(vec![
1154 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1155 Span::styled(
1156 result,
1157 Style::new().fg(theme.colors.text_secondary.to_color()),
1158 ),
1159 ]),
1160 viewport_width,
1161 4,
1162 ));
1163
1164 Some(lines)
1165}
1166
1167fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1168 value
1169 .get(key)?
1170 .as_u64()
1171 .and_then(|value| usize::try_from(value).ok())
1172}
1173
1174fn expand_tabs(s: &str) -> String {
1183 const TAB_WIDTH: usize = 4;
1184 if !s.contains('\t') {
1185 return s.to_string();
1186 }
1187 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1188 let mut col = 0usize;
1189 for ch in s.chars() {
1190 if ch == '\t' {
1191 let n = TAB_WIDTH - (col % TAB_WIDTH);
1192 for _ in 0..n {
1193 out.push(' ');
1194 }
1195 col += n;
1196 } else {
1197 out.push(ch);
1198 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1199 }
1200 }
1201 out
1202}
1203
1204fn render_actions(
1205 actions: &[ActionDisplay],
1206 lines: &mut Vec<Line>,
1207 theme: &Theme,
1208 viewport_width: usize,
1209 blink_on: bool,
1210) {
1211 for (action_idx, action) in actions.iter().enumerate() {
1212 if action_idx > 0 {
1213 lines.push(Line::from(""));
1214 }
1215 if let Some(meta) = &action.metadata
1220 && let ToolMetadata::Questions {
1221 answers,
1222 remembered,
1223 } = &meta.detail
1224 && matches!(action.result, ActionResult::Success { .. })
1225 {
1226 render_question_answers(answers, *remembered, lines, theme, viewport_width);
1227 continue;
1228 }
1229 let action_color = match action.action_type.as_str() {
1230 "Write" | "Update" => theme.colors.success.to_color(),
1231 "Delete" => theme.colors.warning.to_color(),
1232 _ => theme.colors.info.to_color(),
1233 };
1234
1235 let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1243 Style::new()
1244 .fg(theme.colors.text_disabled.to_color())
1245 .bold()
1246 } else {
1247 Style::new().fg(action_color).bold()
1248 };
1249 push_action_header(
1250 lines,
1251 action,
1252 action_color,
1253 dot_style,
1254 theme,
1255 viewport_width,
1256 );
1257
1258 match &action.result {
1259 ActionResult::Running => {},
1262 ActionResult::Success { .. } => {
1263 push_result_summary(lines, action, action_color, theme, viewport_width);
1264 push_file_preview(lines, &action.details, action_color, theme, viewport_width);
1265 push_diff_preview(lines, &action.details, action_color, theme, viewport_width);
1266 },
1267 ActionResult::Error { error } => {
1268 push_error_rows(lines, error, action.duration_seconds, theme, viewport_width);
1269 },
1270 }
1271 }
1272}
1273
1274fn push_result_summary(
1277 lines: &mut Vec<Line>,
1278 action: &ActionDisplay,
1279 action_color: Color,
1280 theme: &Theme,
1281 viewport_width: usize,
1282) {
1283 let result_msg = match &action.details {
1284 ActionDetails::FileContent { line_count, .. } => {
1285 let base = format!(
1286 "{} {} written",
1287 line_count,
1288 if *line_count == 1 { "line" } else { "lines" }
1289 );
1290 append_action_duration(base, action.duration_seconds)
1291 },
1292 ActionDetails::Diff { summary, .. } => summary.clone(),
1293 ActionDetails::Preview { text, .. } => text.clone(),
1294 ActionDetails::Simple => append_action_duration(String::new(), action.duration_seconds),
1298 };
1299
1300 for (idx, line) in result_msg.lines().enumerate() {
1301 let prefix = if idx == 0 { " ⎿ " } else { " " };
1302 lines.extend(wrap_styled_line(
1305 Line::from(vec![
1306 Span::styled(prefix, Style::new().fg(action_color)),
1307 Span::styled(
1308 line.to_string(),
1309 Style::new().fg(theme.colors.text_secondary.to_color()),
1310 ),
1311 ]),
1312 viewport_width,
1313 4,
1314 ));
1315 }
1316}
1317
1318fn push_file_preview(
1320 lines: &mut Vec<Line>,
1321 details: &ActionDetails,
1322 action_color: Color,
1323 theme: &Theme,
1324 viewport_width: usize,
1325) {
1326 if let ActionDetails::FileContent {
1327 content,
1328 line_count,
1329 } = details
1330 {
1331 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1332 if !preview_lines.is_empty() {
1333 lines.push(Line::from(vec![Span::styled(
1334 " ",
1335 Style::new().fg(action_color),
1336 )]));
1337
1338 let preview_content = preview_lines.join("\n");
1339 let mut parsed = parse_markdown(
1340 &format!("```\n{preview_content}\n```"),
1341 theme,
1342 viewport_width.saturating_sub(4),
1343 );
1344 for parsed_line in parsed.iter_mut() {
1345 let mut new_spans = vec![Span::styled(" ", Style::new().fg(action_color))];
1346 new_spans.append(&mut parsed_line.line.spans);
1347 parsed_line.line.spans = new_spans;
1348 }
1349 lines.extend(
1353 parsed
1354 .into_iter()
1355 .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1356 );
1357
1358 if *line_count > 10 {
1359 lines.push(Line::from(vec![
1360 Span::styled(" ", Style::new().fg(action_color)),
1361 Span::styled(
1362 format!("... ({} more lines)", line_count - 10),
1363 Style::new()
1364 .fg(theme.colors.text_disabled.to_color())
1365 .italic(),
1366 ),
1367 ]));
1368 }
1369 }
1370 }
1371}
1372
1373fn push_diff_preview(
1375 lines: &mut Vec<Line>,
1376 details: &ActionDetails,
1377 action_color: Color,
1378 theme: &Theme,
1379 viewport_width: usize,
1380) {
1381 if let ActionDetails::Diff { diff, .. } = details {
1382 let diff_lines: Vec<&str> = diff.lines().collect();
1383 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1384
1385 if !display_lines.is_empty() {
1386 let removed_bg = theme.colors.diff_removed_bg.to_color();
1387 let added_bg = theme.colors.diff_added_bg.to_color();
1388
1389 for diff_line in &display_lines {
1390 let diff_line = expand_tabs(diff_line);
1397 match parse_diff_line(&diff_line) {
1402 DiffLineKind::Removed => {
1403 push_wrapped_diff_rows(
1404 lines,
1405 format!(" {diff_line}"),
1406 Style::new()
1407 .fg(theme.colors.error.to_color())
1408 .bg(removed_bg),
1409 viewport_width,
1410 );
1411 },
1412 DiffLineKind::Added => {
1413 push_wrapped_diff_rows(
1414 lines,
1415 format!(" {diff_line}"),
1416 Style::new()
1417 .fg(theme.colors.success.to_color())
1418 .bg(added_bg),
1419 viewport_width,
1420 );
1421 },
1422 DiffLineKind::Context => {
1423 lines.extend(wrap_preformatted(
1426 Line::from(vec![
1427 Span::styled(" ", Style::new().fg(action_color)),
1428 Span::styled(
1429 diff_line,
1430 Style::new().fg(theme.colors.text_secondary.to_color()),
1431 ),
1432 ]),
1433 viewport_width,
1434 6,
1435 ));
1436 },
1437 }
1438 }
1439
1440 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1441 if remaining > 0 {
1442 lines.push(Line::from(vec![
1443 Span::styled(" ", Style::new().fg(action_color)),
1444 Span::styled(
1445 format!("... ({remaining} more lines)"),
1446 Style::new()
1447 .fg(theme.colors.text_disabled.to_color())
1448 .italic(),
1449 ),
1450 ]));
1451 }
1452 }
1453 }
1454}
1455
1456fn push_error_rows(
1458 lines: &mut Vec<Line>,
1459 error: &str,
1460 duration_seconds: Option<f64>,
1461 theme: &Theme,
1462 viewport_width: usize,
1463) {
1464 let error = append_action_duration(format!("Error: {error}"), duration_seconds);
1465 for (idx, err_line) in error.lines().enumerate() {
1469 let prefix = if idx == 0 { " ⎿ " } else { " " };
1470 lines.extend(wrap_styled_line(
1471 Line::from(vec![
1472 Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1473 Span::styled(
1474 err_line.to_string(),
1475 Style::new().fg(theme.colors.error.to_color()),
1476 ),
1477 ]),
1478 viewport_width,
1479 4,
1480 ));
1481 }
1482}
1483
1484fn render_question_answers(
1488 answers: &[QuestionAnswer],
1489 remembered: bool,
1490 lines: &mut Vec<Line>,
1491 theme: &Theme,
1492 viewport_width: usize,
1493) {
1494 let header = if remembered {
1495 "User answered the model's questions (remembered):"
1496 } else {
1497 "User answered the model's questions:"
1498 };
1499 lines.push(Line::from(Span::styled(
1500 format!("● {header}"),
1501 Style::new().fg(theme.colors.text_primary.to_color()),
1502 )));
1503
1504 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1505 let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1506 let note_style = Style::new()
1507 .fg(theme.colors.text_disabled.to_color())
1508 .italic();
1509 let wrap_width = viewport_width.saturating_sub(4);
1513 let mut first_row = true;
1514 for answer in answers {
1515 let value = if answer.selected.is_empty() {
1516 "(no selection)".to_string()
1517 } else {
1518 answer.selected.join(", ")
1519 };
1520 let entry = format!("· {} → {}", answer.question, value);
1521 let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1522 .into_iter()
1523 .map(|row| (row, text_style))
1524 .collect();
1525 if let Some(note) = &answer.note {
1526 rows.extend(
1527 wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1528 .into_iter()
1529 .map(|row| (row, note_style)),
1530 );
1531 }
1532 for (row, style) in rows {
1533 let gutter = if first_row { " ⎿ " } else { " " };
1534 first_row = false;
1535 lines.push(Line::from(vec![
1536 Span::styled(gutter, gutter_style),
1537 Span::styled(row, style),
1538 ]));
1539 }
1540 }
1541}
1542
1543const MAX_ACTION_HEADER_ROWS: usize = 4;
1547
1548fn push_action_header(
1556 lines: &mut Vec<Line>,
1557 action: &ActionDisplay,
1558 action_color: Color,
1559 dot_style: Style,
1560 theme: &Theme,
1561 viewport_width: usize,
1562) {
1563 let bold = Style::new().fg(action_color).bold();
1564 let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1565 if action.target.is_empty() {
1566 lines.push(Line::from(vec![
1567 Span::styled("● ", dot_style),
1568 Span::styled(format!("{}()", action.action_type), bold),
1569 ]));
1570 return;
1571 }
1572
1573 let open = format!("{}(", action.action_type);
1574 let first_indent = 2 + open.width();
1578 let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1579 let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1580 let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1581 rows.truncate(MAX_ACTION_HEADER_ROWS);
1582
1583 let last = rows.len().saturating_sub(1);
1584 for (i, row) in rows.into_iter().enumerate() {
1585 let mut spans = if i == 0 {
1586 vec![
1587 Span::styled("● ", dot_style),
1588 Span::styled(open.clone(), bold),
1589 Span::styled(row.trim_start().to_string(), secondary),
1590 ]
1591 } else {
1592 vec![Span::styled(row, secondary)]
1593 };
1594 if i == last {
1595 if truncated {
1596 spans.push(Span::styled(
1597 "…",
1598 Style::new().fg(theme.colors.text_disabled.to_color()),
1599 ));
1600 }
1601 spans.push(Span::styled(")", bold));
1602 }
1603 lines.push(Line::from(spans));
1604 }
1605}
1606
1607fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1611 for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1612 let padded = pad_to_cells(&line_plain_text(&row), width);
1613 lines.push(Line::from(Span::styled(padded, style)));
1614 }
1615}
1616
1617fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1618 if let Some(seconds) = duration_seconds {
1619 if !text.is_empty() {
1622 text.push_str(", ");
1623 }
1624 text.push_str("took ");
1625 text.push_str(&format_action_duration(seconds));
1626 }
1627 text
1628}
1629
1630fn format_action_duration(seconds: f64) -> String {
1631 if seconds < 1.0 {
1632 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1633 } else if seconds < 10.0 {
1634 format!("{seconds:.1}s")
1635 } else {
1636 format!("{}s", seconds.round() as u64)
1637 }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642 use super::*;
1643
1644 #[test]
1645 fn question_answers_render_as_question_arrow_answer_block() {
1646 use mermaid_domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
1647
1648 let theme = Theme::dark();
1649 let answers = vec![
1650 QuestionAnswer {
1651 header: "Snack".to_string(),
1652 question: "Which snack fuels your next coding session?".to_string(),
1653 selected: vec!["Coffee (Recommended)".to_string()],
1654 note: None,
1655 },
1656 QuestionAnswer {
1657 header: "Powers".to_string(),
1658 question: "Which superpowers would you take?".to_string(),
1659 selected: vec![
1660 "Read any codebase instantly".to_string(),
1661 "Bugs reproduce on demand".to_string(),
1662 ],
1663 note: Some("only on weekdays".to_string()),
1664 },
1665 ];
1666 let action = ActionDisplay {
1667 action_type: "ask_user_question".to_string(),
1668 target: String::new(),
1669 result: ActionResult::Success {
1670 output: String::new(),
1671 images: None,
1672 },
1673 details: ActionDetails::Simple,
1674 duration_seconds: Some(93.0),
1675 metadata: Some(ToolRunMetadata {
1676 detail: ToolMetadata::Questions {
1677 answers,
1678 remembered: false,
1679 },
1680 ..Default::default()
1681 }),
1682 };
1683
1684 let mut lines: Vec<Line> = Vec::new();
1685 render_actions(&[action], &mut lines, &theme, 120, true);
1686 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1687 let all = rows.join("\n");
1688
1689 assert_eq!(rows[0], "● User answered the model's questions:");
1690 assert!(
1691 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
1692 "got {:?}",
1693 rows[1]
1694 );
1695 assert!(
1696 all.contains(
1697 "· Which superpowers would you take? → Read any codebase instantly, \
1698 Bugs reproduce on demand"
1699 ),
1700 "got {all}"
1701 );
1702 assert!(all.contains("(note: only on weekdays)"), "got {all}");
1703 assert!(!all.contains("ask_user_question("), "got {all}");
1705 assert!(!all.contains("took"), "got {all}");
1706 }
1707
1708 #[test]
1709 fn diff_background_fills_full_width_with_tabs() {
1710 use mermaid_model::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1715 use ratatui::Terminal;
1716 use ratatui::backend::TestBackend;
1717
1718 let theme = Theme::dark();
1719 let added_bg = theme.colors.diff_added_bg.to_color();
1720 let removed_bg = theme.colors.diff_removed_bg.to_color();
1721 let diff = format!(
1723 " 62{DIFF_REMOVED_MARKER}\tconst out = [];\n 63{DIFF_ADDED_MARKER}\t\tlet fixed = false;\n 64{DIFF_ADDED_MARKER}\t\t\tdeeplyNested();"
1724 );
1725 let action = ActionDisplay {
1726 action_type: "Update".to_string(),
1727 target: "engine.ts".to_string(),
1728 result: ActionResult::Success {
1729 output: String::new(),
1730 images: None,
1731 },
1732 details: ActionDetails::Diff {
1733 summary: "ok".to_string(),
1734 diff,
1735 },
1736 duration_seconds: Some(0.3),
1737 metadata: None,
1738 };
1739
1740 let width: u16 = 60;
1741 let mut lines: Vec<Line> = Vec::new();
1742 render_actions(&[action], &mut lines, &theme, width as usize, true);
1743 let h = lines.len() as u16;
1744 let backend = TestBackend::new(width, h);
1745 let mut term = Terminal::new(backend).unwrap();
1746 term.draw(|f| {
1747 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1748 })
1749 .unwrap();
1750 let buf = term.backend().buffer();
1751
1752 for y in 0..h {
1753 let is_diff_row = (0..width).any(|x| {
1754 let bg = buf[(x, y)].bg;
1755 bg == added_bg || bg == removed_bg
1756 });
1757 if !is_diff_row {
1758 continue;
1759 }
1760 for x in 0..width {
1761 let bg = buf[(x, y)].bg;
1762 assert!(
1763 bg == added_bg || bg == removed_bg,
1764 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1765 );
1766 }
1767 }
1768 }
1769
1770 fn assert_rows_fit(lines: &[Line], width: usize) {
1773 for (i, line) in lines.iter().enumerate() {
1774 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
1775 assert!(
1776 w <= width,
1777 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
1778 line_plain_text(line)
1779 );
1780 }
1781 }
1782
1783 #[test]
1784 fn action_header_and_error_wrap_instead_of_clipping() {
1785 let theme = Theme::dark();
1789 let action = ActionDisplay {
1790 action_type: "Error".to_string(),
1791 target: "Backend error".to_string(),
1792 result: ActionResult::Error {
1793 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(),
1794 },
1795 details: ActionDetails::Simple,
1796 duration_seconds: None,
1797 metadata: None,
1798 };
1799
1800 let width = 60usize;
1801 let mut lines: Vec<Line> = Vec::new();
1802 render_actions(&[action], &mut lines, &theme, width, true);
1803
1804 assert_rows_fit(&lines, width);
1805 let rendered = lines
1806 .iter()
1807 .map(line_plain_text)
1808 .collect::<Vec<_>>()
1809 .join("\n");
1810 assert!(rendered.contains("invalid_request_error"));
1813 assert!(
1814 lines.len() > 2,
1815 "a 140-cell error at width 60 must span multiple rows"
1816 );
1817 }
1818
1819 #[test]
1820 fn action_header_wraps_long_command_and_keeps_closing_paren() {
1821 let theme = Theme::dark();
1822 let action = ActionDisplay {
1823 action_type: "Bash".to_string(),
1824 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
1825 that keeps going well past the sixty cell viewport edge"
1826 .to_string(),
1827 result: ActionResult::Success {
1828 output: String::new(),
1829 images: None,
1830 },
1831 details: ActionDetails::Simple,
1832 duration_seconds: Some(0.1),
1833 metadata: None,
1834 };
1835
1836 let width = 60usize;
1837 let mut lines: Vec<Line> = Vec::new();
1838 render_actions(&[action], &mut lines, &theme, width, true);
1839
1840 assert_rows_fit(&lines, width);
1841 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1842 assert!(rows[0].starts_with("● Bash("));
1843 assert!(
1844 rows.len() >= 2,
1845 "the long command must wrap the header across rows"
1846 );
1847 let last_target_row = rows
1848 .iter()
1849 .rfind(|r| r.trim_end().ends_with(')'))
1850 .expect("wrapped header must still close its paren");
1851 assert!(last_target_row.trim_end().ends_with(')'));
1852 }
1853
1854 #[test]
1855 fn action_header_caps_rows_and_marks_truncation() {
1856 let theme = Theme::dark();
1859 let action = ActionDisplay {
1860 action_type: "Bash".to_string(),
1861 target: "word ".repeat(400),
1862 result: ActionResult::Success {
1863 output: String::new(),
1864 images: None,
1865 },
1866 details: ActionDetails::Simple,
1867 duration_seconds: None,
1868 metadata: None,
1869 };
1870
1871 let width = 60usize;
1872 let mut lines: Vec<Line> = Vec::new();
1873 render_actions(&[action], &mut lines, &theme, width, true);
1874
1875 assert_rows_fit(&lines, width);
1876 let header_rows: Vec<String> = lines
1877 .iter()
1878 .map(line_plain_text)
1879 .take_while(|r| !r.trim_start().starts_with('⎿'))
1880 .collect();
1881 assert_eq!(
1882 header_rows.len(),
1883 MAX_ACTION_HEADER_ROWS,
1884 "header must cap at MAX_ACTION_HEADER_ROWS rows"
1885 );
1886 assert!(
1887 header_rows.last().unwrap().trim_end().ends_with("…)"),
1888 "capped header must end with …) — got {:?}",
1889 header_rows.last().unwrap()
1890 );
1891 }
1892
1893 #[test]
1894 fn action_header_preserves_multiline_command_rows() {
1895 let theme = Theme::dark();
1899 let action = ActionDisplay {
1900 action_type: "Bash".to_string(),
1901 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
1902 result: ActionResult::Success {
1903 output: String::new(),
1904 images: None,
1905 },
1906 details: ActionDetails::Simple,
1907 duration_seconds: None,
1908 metadata: None,
1909 };
1910
1911 let mut lines: Vec<Line> = Vec::new();
1912 render_actions(&[action], &mut lines, &theme, 80, true);
1913
1914 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1915 assert!(rows[0].contains("python3 - << 'PY'"));
1916 assert!(rows[1].contains("from PIL import Image"));
1917 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
1918 }
1919
1920 #[test]
1921 fn action_result_summary_wraps_instead_of_clipping() {
1922 let theme = Theme::dark();
1923 let action = ActionDisplay {
1924 action_type: "Tasks".to_string(),
1925 target: "update 3 steps".to_string(),
1926 result: ActionResult::Success {
1927 output: String::new(),
1928 images: None,
1929 },
1930 details: ActionDetails::Preview {
1931 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
1932 placeholders kept intentionally until real data available. \
1933 Task 2 and 6 deferred., to revisit later"
1934 .to_string(),
1935 line_count: None,
1936 },
1937 duration_seconds: None,
1938 metadata: None,
1939 };
1940
1941 let width = 60usize;
1942 let mut lines: Vec<Line> = Vec::new();
1943 render_actions(&[action], &mut lines, &theme, width, true);
1944
1945 assert_rows_fit(&lines, width);
1946 let rendered = lines
1947 .iter()
1948 .map(line_plain_text)
1949 .collect::<Vec<_>>()
1950 .join("\n");
1951 assert!(
1952 rendered.contains("revisit later"),
1953 "the summary's tail must survive the wrap instead of being clipped"
1954 );
1955 }
1956
1957 #[test]
1958 fn wrapped_line_cache_hit_matches_cache_miss() {
1959 use ratatui::Terminal;
1966 use ratatui::backend::TestBackend;
1967
1968 let theme = Theme::dark();
1969 let messages = vec![
1970 ChatMessage::assistant(
1971 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
1972 across this narrow viewport more than once.\n\n\
1973 - a list item that also keeps going past the edge so it wraps too\n\
1974 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
1975 ),
1976 ChatMessage::assistant("Short follow-up paragraph."),
1977 ];
1978
1979 let (width, height): (u16, u16) = (40, 40);
1980 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
1981 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
1982 let mut state = ChatState::new();
1983 term.draw(|f| {
1984 let widget = ChatWidget {
1985 messages: &messages,
1986 content_key: test_content_key(&messages),
1987 theme: &theme,
1988 wrapped_line_cache: cache,
1989 show_reasoning: true,
1990 blink_on: true,
1991 };
1992 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
1993 })
1994 .unwrap();
1995 term.backend().buffer().clone()
1996 };
1997
1998 let mut shared = FxHashMap::default();
1999 let miss = render_once(&mut shared);
2000 assert!(!shared.is_empty(), "first render must populate the cache");
2001 let hit = render_once(&mut shared);
2002 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2003
2004 let mut cold_cache = FxHashMap::default();
2005 let cold = render_once(&mut cold_cache);
2006 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2007 }
2008
2009 #[test]
2010 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2011 use ratatui::Terminal;
2015 use ratatui::backend::TestBackend;
2016
2017 let theme = Theme::dark();
2018 let messages = vec![ChatMessage::system(
2019 "Heads up: this model reports no vision capability",
2020 )];
2021 let (width, height): (u16, u16) = (60, 10);
2022 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2023 let mut state = ChatState::new();
2024 let mut cache = FxHashMap::default();
2025 term.draw(|f| {
2026 let widget = ChatWidget {
2027 messages: &messages,
2028 content_key: test_content_key(&messages),
2029 theme: &theme,
2030 wrapped_line_cache: &mut cache,
2031 show_reasoning: true,
2032 blink_on: true,
2033 };
2034 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2035 })
2036 .unwrap();
2037 let buf = term.backend().buffer();
2038 let rows: Vec<String> = (0..height)
2039 .map(|y| {
2040 (0..width)
2041 .map(|x| buf[(x, y)].symbol().to_string())
2042 .collect::<String>()
2043 })
2044 .collect();
2045 let all = rows.join("\n");
2046 assert!(
2047 !all.contains('●'),
2048 "no role bullet on system notices: {all}"
2049 );
2050 assert!(
2051 !all.contains("Today at"),
2052 "no timestamp on system notices: {all}"
2053 );
2054 let row = rows
2055 .iter()
2056 .position(|r| r.contains("Heads up"))
2057 .expect("notice rendered");
2058 assert!(
2059 rows[row].starts_with(" Heads up"),
2060 "2-space indent, nothing in the gutter: {:?}",
2061 rows[row]
2062 );
2063 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2065 buf[(col as u16, row as u16)].fg,
2066 theme.colors.text_meta.to_color(),
2067 "notice text uses the muted meta gray"
2068 );
2069 }
2070
2071 #[test]
2072 fn byte_at_cell_clamps_and_respects_cjk() {
2073 assert_eq!(byte_at_cell("hello", 0), 0);
2074 assert_eq!(byte_at_cell("hello", 3), 3);
2075 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2078 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2081 }
2082
2083 #[test]
2084 fn slice_by_cells_extracts_display_range() {
2085 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2086 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2087 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2088 }
2089
2090 #[test]
2091 fn pad_to_cells_fills_to_display_width() {
2092 assert_eq!(pad_to_cells("ab", 5), "ab ");
2093 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2095 assert_eq!(pad_to_cells("你好", 3), "你好");
2097 assert_eq!(pad_to_cells("", 0), "");
2098 }
2099
2100 #[test]
2101 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2102 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2105 let wrapped = wrap_preformatted(line, 10, 2);
2106 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2107 let first: String = wrapped[0]
2108 .spans
2109 .iter()
2110 .map(|s| s.content.as_ref())
2111 .collect();
2112 assert!(
2113 first.starts_with(" aaaa"),
2114 "indentation must be preserved, got {first:?}"
2115 );
2116 let second: String = wrapped[1]
2117 .spans
2118 .iter()
2119 .map(|s| s.content.as_ref())
2120 .collect();
2121 assert!(
2122 second.starts_with(" "),
2123 "continuation should get the hanging indent, got {second:?}"
2124 );
2125 }
2126
2127 #[test]
2128 fn wrap_preformatted_short_line_unchanged() {
2129 let line = Line::from(vec![Span::raw(" short")]);
2130 let wrapped = wrap_preformatted(line, 40, 2);
2131 assert_eq!(wrapped.len(), 1);
2132 let text: String = wrapped[0]
2133 .spans
2134 .iter()
2135 .map(|s| s.content.as_ref())
2136 .collect();
2137 assert_eq!(text, " short");
2138 }
2139
2140 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2144 let mut st = ChatState::new();
2145 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2146 st.selection = Some(sel);
2147 st
2148 }
2149
2150 #[test]
2151 fn selected_text_single_line() {
2152 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2153 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2154 }
2155
2156 #[test]
2157 fn selected_text_spans_multiple_rows() {
2158 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2159 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2162 }
2163
2164 #[test]
2165 fn selected_text_strips_margin_but_keeps_code_indentation() {
2166 let st = state_with_rows(
2169 &[" fn main() {", " let x = 1;", " }"],
2170 ((0, 0), (2, 3)),
2171 );
2172 assert_eq!(
2173 st.selected_text().as_deref(),
2174 Some("fn main() {\n let x = 1;\n}")
2175 );
2176 }
2177
2178 #[test]
2179 fn selected_text_normalizes_reversed_drag() {
2180 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2182 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2183 }
2184
2185 #[test]
2186 fn selected_text_empty_selection_is_none() {
2187 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2189 assert_eq!(st.selected_text(), None);
2190 }
2191
2192 #[test]
2193 fn highlight_line_cells_splits_spans_on_selection() {
2194 let mut line = Line::from(vec![Span::raw("abcdef")]);
2195 highlight_line_cells(
2196 &mut line,
2197 2,
2198 4,
2199 Style::new().add_modifier(Modifier::REVERSED),
2200 );
2201 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2203 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2204 assert!(
2205 line.spans[1]
2206 .style
2207 .add_modifier
2208 .contains(Modifier::REVERSED)
2209 );
2210 assert!(
2211 !line.spans[0]
2212 .style
2213 .add_modifier
2214 .contains(Modifier::REVERSED)
2215 );
2216 }
2217
2218 #[test]
2219 fn context_checkpoint_renders_as_compact_event() {
2220 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2221 msg.kind = ChatMessageKind::ContextCheckpoint;
2222 msg.metadata = Some(serde_json::json!({
2223 "trigger": "manual",
2224 "before_tokens": 43_800,
2225 "after_tokens": 9_200,
2226 "archived_message_count": 18,
2227 "preserved_message_count": 4,
2228 "duration_secs": 2.4,
2229 }));
2230
2231 let lines =
2232 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2233 let rendered = lines
2234 .iter()
2235 .map(|line| {
2236 line.spans
2237 .iter()
2238 .map(|span| span.content.as_ref())
2239 .collect::<String>()
2240 })
2241 .collect::<Vec<_>>()
2242 .join("\n");
2243
2244 assert!(rendered.contains("Compact(manual)"));
2245 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2246 assert!(rendered.contains("archived 18 messages"));
2247 assert!(rendered.contains("preserved 4 messages"));
2248 assert!(!rendered.contains("full checkpoint summary"));
2249 }
2250
2251 #[test]
2252 fn clamp_to_u16_saturates_past_u16_max() {
2253 assert_eq!(clamp_to_u16(0), 0);
2256 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2257 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2258 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2259 }
2260
2261 #[test]
2262 fn frame_memo_hit_matches_miss() {
2263 use ratatui::Terminal;
2269 use ratatui::backend::TestBackend;
2270
2271 let theme = Theme::dark();
2272 let messages = vec![
2273 ChatMessage::assistant(
2274 "# Heading\n\nSome **bold** prose long enough that it wraps across \
2275 this narrow viewport more than once.\n\n- a list item that also \
2276 runs past the edge so it wraps\n- second item",
2277 ),
2278 ChatMessage::assistant("Short follow-up."),
2279 ];
2280
2281 let (width, height): (u16, u16) = (34, 30);
2282 let mut cache = FxHashMap::default();
2283 let mut state = ChatState::new();
2284
2285 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2286 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2287 term.draw(|f| {
2288 let widget = ChatWidget {
2289 messages: &messages,
2290 content_key: test_content_key(&messages),
2291 theme: &theme,
2292 wrapped_line_cache: cache,
2293 show_reasoning: true,
2294 blink_on: true,
2295 };
2296 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
2297 })
2298 .unwrap();
2299 term.backend().buffer().clone()
2300 };
2301
2302 let miss = render(&mut state, &mut cache);
2303 assert!(
2304 state.frame_memo.is_some(),
2305 "first render must populate the frame memo"
2306 );
2307 let hit = render(&mut state, &mut cache);
2308 assert_eq!(
2309 miss, hit,
2310 "frame-memo hit must render identically to the miss"
2311 );
2312 assert!(
2316 !state.last_rendered_rows.is_empty(),
2317 "memo hit must preserve last_rendered_rows from the miss"
2318 );
2319 }
2320
2321 #[test]
2322 fn append_action_duration_handles_empty_base() {
2323 assert_eq!(
2326 append_action_duration(String::new(), Some(0.035)),
2327 "took 35ms"
2328 );
2329 assert_eq!(
2331 append_action_duration("3 lines read".to_string(), Some(1.25)),
2332 "3 lines read, took 1.2s"
2333 );
2334 assert_eq!(append_action_duration(String::new(), None), "");
2336 }
2337
2338 #[test]
2343 fn a_zero_duration_would_render_as_a_fabricated_measurement() {
2344 assert_eq!(
2345 append_action_duration("blocked by policy".to_string(), Some(0.0)),
2346 "blocked by policy, took 1ms"
2347 );
2348 assert_eq!(
2349 append_action_duration("blocked by policy".to_string(), None),
2350 "blocked by policy"
2351 );
2352 }
2353
2354 #[test]
2358 fn screen_to_content_subtracts_the_top_pad() {
2359 let mut state = ChatState {
2360 last_chat_area: Some((1, 2, 78, 20)),
2361 last_scroll_position: 0,
2362 last_top_pad: 17,
2363 ..Default::default()
2364 };
2365 assert_eq!(state.screen_to_content(2, 1), None);
2366 assert_eq!(state.screen_to_content(18, 1), None);
2367 assert_eq!(state.screen_to_content(19, 1), Some((0, 0)));
2368 assert_eq!(state.screen_to_content(21, 5), Some((2, 4)));
2369 state.image_click_map = vec![(
2370 0,
2371 ImageClickTarget {
2372 message_index: 0,
2373 image_index: 0,
2374 image_number: None,
2375 },
2376 )];
2377 assert!(state.find_image_at_screen_pos(18).is_none());
2378 assert!(state.find_image_at_screen_pos(19).is_some());
2379 }
2380}