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 let review_status = metadata
1115 .and_then(|value| value.get("review_status"))
1116 .and_then(|value| value.as_str());
1117 let review_error = metadata
1118 .and_then(|value| value.get("review_error"))
1119 .and_then(|value| value.as_str());
1120
1121 let action_color = theme.colors.info.to_color();
1122 let mut result = match (before_tokens, after_tokens) {
1123 (Some(before), Some(after)) => {
1124 format!(
1125 "{} -> {} tokens",
1126 format_compact_count(before),
1127 format_compact_count(after)
1128 )
1129 },
1130 _ => "Context compacted".to_string(),
1131 };
1132
1133 if let Some(count) = archived_messages {
1134 result.push_str(&format!(
1135 ", archived {} {}",
1136 count,
1137 if count == 1 { "message" } else { "messages" }
1138 ));
1139 }
1140 if let Some(count) = preserved_messages {
1141 result.push_str(&format!(
1142 ", preserved {} {}",
1143 count,
1144 if count == 1 { "message" } else { "messages" }
1145 ));
1146 }
1147 if let Some(status) = review_status {
1148 match status {
1149 "reviewed" => result.push_str(", reviewed"),
1150 "draft_validated" => result.push_str(", validated draft"),
1151 _ => {},
1152 }
1153 }
1154 result = append_action_duration(result, duration_secs);
1155
1156 let mut lines = vec![Line::from(vec![
1157 Span::styled("● ", Style::new().fg(action_color).bold()),
1158 Span::styled("Compact(", Style::new().fg(action_color).bold()),
1159 Span::styled(
1160 trigger.to_string(),
1161 Style::new().fg(theme.colors.text_secondary.to_color()),
1162 ),
1163 Span::styled(")", Style::new().fg(action_color).bold()),
1164 ])];
1165 lines.extend(wrap_styled_line(
1166 Line::from(vec![
1167 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1168 Span::styled(
1169 result,
1170 Style::new().fg(theme.colors.text_secondary.to_color()),
1171 ),
1172 ]),
1173 viewport_width,
1174 4,
1175 ));
1176
1177 if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
1178 lines.extend(wrap_styled_line(
1179 Line::from(vec![
1180 Span::styled(" ", Style::new().fg(action_color)),
1181 Span::styled(
1182 format!("review: {}", compact_inline_error(error, 180)),
1183 Style::new().fg(theme.colors.warning.to_color()),
1184 ),
1185 ]),
1186 viewport_width,
1187 4,
1188 ));
1189 }
1190
1191 Some(lines)
1192}
1193
1194fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1195 value
1196 .get(key)?
1197 .as_u64()
1198 .and_then(|value| usize::try_from(value).ok())
1199}
1200
1201fn compact_inline_error(text: &str, max_chars: usize) -> String {
1202 let text = text.trim();
1203 if text.chars().count() <= max_chars {
1204 return text.to_string();
1205 }
1206 let keep = max_chars.saturating_sub(3);
1207 let mut out: String = text.chars().take(keep).collect();
1208 out.push_str("...");
1209 out
1210}
1211
1212fn expand_tabs(s: &str) -> String {
1221 const TAB_WIDTH: usize = 4;
1222 if !s.contains('\t') {
1223 return s.to_string();
1224 }
1225 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1226 let mut col = 0usize;
1227 for ch in s.chars() {
1228 if ch == '\t' {
1229 let n = TAB_WIDTH - (col % TAB_WIDTH);
1230 for _ in 0..n {
1231 out.push(' ');
1232 }
1233 col += n;
1234 } else {
1235 out.push(ch);
1236 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1237 }
1238 }
1239 out
1240}
1241
1242fn render_actions(
1243 actions: &[ActionDisplay],
1244 lines: &mut Vec<Line>,
1245 theme: &Theme,
1246 viewport_width: usize,
1247 blink_on: bool,
1248) {
1249 for (action_idx, action) in actions.iter().enumerate() {
1250 if action_idx > 0 {
1251 lines.push(Line::from(""));
1252 }
1253 if let Some(meta) = &action.metadata
1258 && let ToolMetadata::Questions {
1259 answers,
1260 remembered,
1261 } = &meta.detail
1262 && matches!(action.result, ActionResult::Success { .. })
1263 {
1264 render_question_answers(answers, *remembered, lines, theme, viewport_width);
1265 continue;
1266 }
1267 if let Some(meta) = &action.metadata
1271 && let ToolMetadata::Plan { path, body, .. } = &meta.detail
1272 && matches!(action.result, ActionResult::Success { .. })
1273 {
1274 render_plan_approved(path, body, lines, theme, viewport_width);
1275 continue;
1276 }
1277 let action_color = match action.action_type.as_str() {
1278 "Write" | "Update" => theme.colors.success.to_color(),
1279 "Delete" => theme.colors.warning.to_color(),
1280 _ => theme.colors.info.to_color(),
1281 };
1282
1283 let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1291 Style::new()
1292 .fg(theme.colors.text_disabled.to_color())
1293 .bold()
1294 } else {
1295 Style::new().fg(action_color).bold()
1296 };
1297 push_action_header(
1298 lines,
1299 action,
1300 action_color,
1301 dot_style,
1302 theme,
1303 viewport_width,
1304 );
1305
1306 match &action.result {
1307 ActionResult::Running => {},
1310 ActionResult::Success { .. } => {
1311 push_result_summary(lines, action, action_color, theme, viewport_width);
1312 push_file_preview(lines, &action.details, action_color, theme, viewport_width);
1313 push_diff_preview(lines, &action.details, action_color, theme, viewport_width);
1314 },
1315 ActionResult::Error { error } => {
1316 push_error_rows(lines, error, action.duration_seconds, theme, viewport_width);
1317 },
1318 }
1319 }
1320}
1321
1322fn push_result_summary(
1325 lines: &mut Vec<Line>,
1326 action: &ActionDisplay,
1327 action_color: Color,
1328 theme: &Theme,
1329 viewport_width: usize,
1330) {
1331 let result_msg = match &action.details {
1332 ActionDetails::FileContent { line_count, .. } => {
1333 let base = format!(
1334 "{} {} written",
1335 line_count,
1336 if *line_count == 1 { "line" } else { "lines" }
1337 );
1338 append_action_duration(base, action.duration_seconds)
1339 },
1340 ActionDetails::Diff { summary, .. } => summary.clone(),
1341 ActionDetails::Preview { text, .. } => text.clone(),
1342 ActionDetails::Simple => append_action_duration(String::new(), action.duration_seconds),
1346 };
1347
1348 for (idx, line) in result_msg.lines().enumerate() {
1349 let prefix = if idx == 0 { " ⎿ " } else { " " };
1350 lines.extend(wrap_styled_line(
1353 Line::from(vec![
1354 Span::styled(prefix, Style::new().fg(action_color)),
1355 Span::styled(
1356 line.to_string(),
1357 Style::new().fg(theme.colors.text_secondary.to_color()),
1358 ),
1359 ]),
1360 viewport_width,
1361 4,
1362 ));
1363 }
1364}
1365
1366fn push_file_preview(
1368 lines: &mut Vec<Line>,
1369 details: &ActionDetails,
1370 action_color: Color,
1371 theme: &Theme,
1372 viewport_width: usize,
1373) {
1374 if let ActionDetails::FileContent {
1375 content,
1376 line_count,
1377 } = details
1378 {
1379 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1380 if !preview_lines.is_empty() {
1381 lines.push(Line::from(vec![Span::styled(
1382 " ",
1383 Style::new().fg(action_color),
1384 )]));
1385
1386 let preview_content = preview_lines.join("\n");
1387 let mut parsed = parse_markdown(
1388 &format!("```\n{preview_content}\n```"),
1389 theme,
1390 viewport_width.saturating_sub(4),
1391 );
1392 for parsed_line in parsed.iter_mut() {
1393 let mut new_spans = vec![Span::styled(" ", Style::new().fg(action_color))];
1394 new_spans.append(&mut parsed_line.line.spans);
1395 parsed_line.line.spans = new_spans;
1396 }
1397 lines.extend(
1401 parsed
1402 .into_iter()
1403 .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1404 );
1405
1406 if *line_count > 10 {
1407 lines.push(Line::from(vec![
1408 Span::styled(" ", Style::new().fg(action_color)),
1409 Span::styled(
1410 format!("... ({} more lines)", line_count - 10),
1411 Style::new()
1412 .fg(theme.colors.text_disabled.to_color())
1413 .italic(),
1414 ),
1415 ]));
1416 }
1417 }
1418 }
1419}
1420
1421fn push_diff_preview(
1423 lines: &mut Vec<Line>,
1424 details: &ActionDetails,
1425 action_color: Color,
1426 theme: &Theme,
1427 viewport_width: usize,
1428) {
1429 if let ActionDetails::Diff { diff, .. } = details {
1430 let diff_lines: Vec<&str> = diff.lines().collect();
1431 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1432
1433 if !display_lines.is_empty() {
1434 let removed_bg = theme.colors.diff_removed_bg.to_color();
1435 let added_bg = theme.colors.diff_added_bg.to_color();
1436
1437 for diff_line in &display_lines {
1438 let diff_line = expand_tabs(diff_line);
1445 match parse_diff_line(&diff_line) {
1450 DiffLineKind::Removed => {
1451 push_wrapped_diff_rows(
1452 lines,
1453 format!(" {diff_line}"),
1454 Style::new()
1455 .fg(theme.colors.error.to_color())
1456 .bg(removed_bg),
1457 viewport_width,
1458 );
1459 },
1460 DiffLineKind::Added => {
1461 push_wrapped_diff_rows(
1462 lines,
1463 format!(" {diff_line}"),
1464 Style::new()
1465 .fg(theme.colors.success.to_color())
1466 .bg(added_bg),
1467 viewport_width,
1468 );
1469 },
1470 DiffLineKind::Context => {
1471 lines.extend(wrap_preformatted(
1474 Line::from(vec![
1475 Span::styled(" ", Style::new().fg(action_color)),
1476 Span::styled(
1477 diff_line,
1478 Style::new().fg(theme.colors.text_secondary.to_color()),
1479 ),
1480 ]),
1481 viewport_width,
1482 6,
1483 ));
1484 },
1485 }
1486 }
1487
1488 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1489 if remaining > 0 {
1490 lines.push(Line::from(vec![
1491 Span::styled(" ", Style::new().fg(action_color)),
1492 Span::styled(
1493 format!("... ({remaining} more lines)"),
1494 Style::new()
1495 .fg(theme.colors.text_disabled.to_color())
1496 .italic(),
1497 ),
1498 ]));
1499 }
1500 }
1501 }
1502}
1503
1504fn push_error_rows(
1506 lines: &mut Vec<Line>,
1507 error: &str,
1508 duration_seconds: Option<f64>,
1509 theme: &Theme,
1510 viewport_width: usize,
1511) {
1512 let error = append_action_duration(format!("Error: {error}"), duration_seconds);
1513 for (idx, err_line) in error.lines().enumerate() {
1517 let prefix = if idx == 0 { " ⎿ " } else { " " };
1518 lines.extend(wrap_styled_line(
1519 Line::from(vec![
1520 Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1521 Span::styled(
1522 err_line.to_string(),
1523 Style::new().fg(theme.colors.error.to_color()),
1524 ),
1525 ]),
1526 viewport_width,
1527 4,
1528 ));
1529 }
1530}
1531
1532fn render_plan_approved(
1536 path: &str,
1537 body: &str,
1538 lines: &mut Vec<Line>,
1539 theme: &Theme,
1540 viewport_width: usize,
1541) {
1542 lines.push(Line::from(Span::styled(
1543 format!("● User approved the plan — {path}"),
1544 Style::new().fg(theme.colors.success.to_color()),
1545 )));
1546 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1547 let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
1550 let mut first_row = true;
1551 for mut parsed_line in parsed {
1552 let gutter = if first_row { " ⎿ " } else { " " };
1553 first_row = false;
1554 let mut spans = vec![Span::styled(gutter, gutter_style)];
1555 spans.append(&mut parsed_line.line.spans);
1556 lines.push(Line::from(spans));
1557 }
1558}
1559
1560fn render_question_answers(
1564 answers: &[QuestionAnswer],
1565 remembered: bool,
1566 lines: &mut Vec<Line>,
1567 theme: &Theme,
1568 viewport_width: usize,
1569) {
1570 let header = if remembered {
1571 "User answered the model's questions (remembered):"
1572 } else {
1573 "User answered the model's questions:"
1574 };
1575 lines.push(Line::from(Span::styled(
1576 format!("● {header}"),
1577 Style::new().fg(theme.colors.text_primary.to_color()),
1578 )));
1579
1580 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1581 let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1582 let note_style = Style::new()
1583 .fg(theme.colors.text_disabled.to_color())
1584 .italic();
1585 let wrap_width = viewport_width.saturating_sub(4);
1589 let mut first_row = true;
1590 for answer in answers {
1591 let value = if answer.selected.is_empty() {
1592 "(no selection)".to_string()
1593 } else {
1594 answer.selected.join(", ")
1595 };
1596 let entry = format!("· {} → {}", answer.question, value);
1597 let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1598 .into_iter()
1599 .map(|row| (row, text_style))
1600 .collect();
1601 if let Some(note) = &answer.note {
1602 rows.extend(
1603 wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1604 .into_iter()
1605 .map(|row| (row, note_style)),
1606 );
1607 }
1608 for (row, style) in rows {
1609 let gutter = if first_row { " ⎿ " } else { " " };
1610 first_row = false;
1611 lines.push(Line::from(vec![
1612 Span::styled(gutter, gutter_style),
1613 Span::styled(row, style),
1614 ]));
1615 }
1616 }
1617}
1618
1619const MAX_ACTION_HEADER_ROWS: usize = 4;
1623
1624fn push_action_header(
1632 lines: &mut Vec<Line>,
1633 action: &ActionDisplay,
1634 action_color: Color,
1635 dot_style: Style,
1636 theme: &Theme,
1637 viewport_width: usize,
1638) {
1639 let bold = Style::new().fg(action_color).bold();
1640 let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1641 if action.target.is_empty() {
1642 lines.push(Line::from(vec![
1643 Span::styled("● ", dot_style),
1644 Span::styled(format!("{}()", action.action_type), bold),
1645 ]));
1646 return;
1647 }
1648
1649 let open = format!("{}(", action.action_type);
1650 let first_indent = 2 + open.width();
1654 let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1655 let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1656 let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1657 rows.truncate(MAX_ACTION_HEADER_ROWS);
1658
1659 let last = rows.len().saturating_sub(1);
1660 for (i, row) in rows.into_iter().enumerate() {
1661 let mut spans = if i == 0 {
1662 vec![
1663 Span::styled("● ", dot_style),
1664 Span::styled(open.clone(), bold),
1665 Span::styled(row.trim_start().to_string(), secondary),
1666 ]
1667 } else {
1668 vec![Span::styled(row, secondary)]
1669 };
1670 if i == last {
1671 if truncated {
1672 spans.push(Span::styled(
1673 "…",
1674 Style::new().fg(theme.colors.text_disabled.to_color()),
1675 ));
1676 }
1677 spans.push(Span::styled(")", bold));
1678 }
1679 lines.push(Line::from(spans));
1680 }
1681}
1682
1683fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1687 for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1688 let padded = pad_to_cells(&line_plain_text(&row), width);
1689 lines.push(Line::from(Span::styled(padded, style)));
1690 }
1691}
1692
1693fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1694 if let Some(seconds) = duration_seconds {
1695 if !text.is_empty() {
1698 text.push_str(", ");
1699 }
1700 text.push_str("took ");
1701 text.push_str(&format_action_duration(seconds));
1702 }
1703 text
1704}
1705
1706fn format_action_duration(seconds: f64) -> String {
1707 if seconds < 1.0 {
1708 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1709 } else if seconds < 10.0 {
1710 format!("{seconds:.1}s")
1711 } else {
1712 format!("{}s", seconds.round() as u64)
1713 }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719
1720 #[test]
1721 fn question_answers_render_as_question_arrow_answer_block() {
1722 use mermaid_domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
1723
1724 let theme = Theme::dark();
1725 let answers = vec![
1726 QuestionAnswer {
1727 header: "Snack".to_string(),
1728 question: "Which snack fuels your next coding session?".to_string(),
1729 selected: vec!["Coffee (Recommended)".to_string()],
1730 note: None,
1731 },
1732 QuestionAnswer {
1733 header: "Powers".to_string(),
1734 question: "Which superpowers would you take?".to_string(),
1735 selected: vec![
1736 "Read any codebase instantly".to_string(),
1737 "Bugs reproduce on demand".to_string(),
1738 ],
1739 note: Some("only on weekdays".to_string()),
1740 },
1741 ];
1742 let action = ActionDisplay {
1743 action_type: "ask_user_question".to_string(),
1744 target: String::new(),
1745 result: ActionResult::Success {
1746 output: String::new(),
1747 images: None,
1748 },
1749 details: ActionDetails::Simple,
1750 duration_seconds: Some(93.0),
1751 metadata: Some(ToolRunMetadata {
1752 detail: ToolMetadata::Questions {
1753 answers,
1754 remembered: false,
1755 },
1756 ..Default::default()
1757 }),
1758 };
1759
1760 let mut lines: Vec<Line> = Vec::new();
1761 render_actions(&[action], &mut lines, &theme, 120, true);
1762 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1763 let all = rows.join("\n");
1764
1765 assert_eq!(rows[0], "● User answered the model's questions:");
1766 assert!(
1767 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
1768 "got {:?}",
1769 rows[1]
1770 );
1771 assert!(
1772 all.contains(
1773 "· Which superpowers would you take? → Read any codebase instantly, \
1774 Bugs reproduce on demand"
1775 ),
1776 "got {all}"
1777 );
1778 assert!(all.contains("(note: only on weekdays)"), "got {all}");
1779 assert!(!all.contains("ask_user_question("), "got {all}");
1781 assert!(!all.contains("took"), "got {all}");
1782 }
1783
1784 #[test]
1785 fn diff_background_fills_full_width_with_tabs() {
1786 use mermaid_model::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1791 use ratatui::Terminal;
1792 use ratatui::backend::TestBackend;
1793
1794 let theme = Theme::dark();
1795 let added_bg = theme.colors.diff_added_bg.to_color();
1796 let removed_bg = theme.colors.diff_removed_bg.to_color();
1797 let diff = format!(
1799 " 62{DIFF_REMOVED_MARKER}\tconst out = [];\n 63{DIFF_ADDED_MARKER}\t\tlet fixed = false;\n 64{DIFF_ADDED_MARKER}\t\t\tdeeplyNested();"
1800 );
1801 let action = ActionDisplay {
1802 action_type: "Update".to_string(),
1803 target: "engine.ts".to_string(),
1804 result: ActionResult::Success {
1805 output: String::new(),
1806 images: None,
1807 },
1808 details: ActionDetails::Diff {
1809 summary: "ok".to_string(),
1810 diff,
1811 },
1812 duration_seconds: Some(0.3),
1813 metadata: None,
1814 };
1815
1816 let width: u16 = 60;
1817 let mut lines: Vec<Line> = Vec::new();
1818 render_actions(&[action], &mut lines, &theme, width as usize, true);
1819 let h = lines.len() as u16;
1820 let backend = TestBackend::new(width, h);
1821 let mut term = Terminal::new(backend).unwrap();
1822 term.draw(|f| {
1823 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1824 })
1825 .unwrap();
1826 let buf = term.backend().buffer();
1827
1828 for y in 0..h {
1829 let is_diff_row = (0..width).any(|x| {
1830 let bg = buf[(x, y)].bg;
1831 bg == added_bg || bg == removed_bg
1832 });
1833 if !is_diff_row {
1834 continue;
1835 }
1836 for x in 0..width {
1837 let bg = buf[(x, y)].bg;
1838 assert!(
1839 bg == added_bg || bg == removed_bg,
1840 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1841 );
1842 }
1843 }
1844 }
1845
1846 fn assert_rows_fit(lines: &[Line], width: usize) {
1849 for (i, line) in lines.iter().enumerate() {
1850 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
1851 assert!(
1852 w <= width,
1853 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
1854 line_plain_text(line)
1855 );
1856 }
1857 }
1858
1859 #[test]
1860 fn action_header_and_error_wrap_instead_of_clipping() {
1861 let theme = Theme::dark();
1865 let action = ActionDisplay {
1866 action_type: "Error".to_string(),
1867 target: "Backend error".to_string(),
1868 result: ActionResult::Error {
1869 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(),
1870 },
1871 details: ActionDetails::Simple,
1872 duration_seconds: None,
1873 metadata: None,
1874 };
1875
1876 let width = 60usize;
1877 let mut lines: Vec<Line> = Vec::new();
1878 render_actions(&[action], &mut lines, &theme, width, true);
1879
1880 assert_rows_fit(&lines, width);
1881 let rendered = lines
1882 .iter()
1883 .map(line_plain_text)
1884 .collect::<Vec<_>>()
1885 .join("\n");
1886 assert!(rendered.contains("invalid_request_error"));
1889 assert!(
1890 lines.len() > 2,
1891 "a 140-cell error at width 60 must span multiple rows"
1892 );
1893 }
1894
1895 #[test]
1896 fn action_header_wraps_long_command_and_keeps_closing_paren() {
1897 let theme = Theme::dark();
1898 let action = ActionDisplay {
1899 action_type: "Bash".to_string(),
1900 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
1901 that keeps going well past the sixty cell viewport edge"
1902 .to_string(),
1903 result: ActionResult::Success {
1904 output: String::new(),
1905 images: None,
1906 },
1907 details: ActionDetails::Simple,
1908 duration_seconds: Some(0.1),
1909 metadata: None,
1910 };
1911
1912 let width = 60usize;
1913 let mut lines: Vec<Line> = Vec::new();
1914 render_actions(&[action], &mut lines, &theme, width, true);
1915
1916 assert_rows_fit(&lines, width);
1917 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1918 assert!(rows[0].starts_with("● Bash("));
1919 assert!(
1920 rows.len() >= 2,
1921 "the long command must wrap the header across rows"
1922 );
1923 let last_target_row = rows
1924 .iter()
1925 .rfind(|r| r.trim_end().ends_with(')'))
1926 .expect("wrapped header must still close its paren");
1927 assert!(last_target_row.trim_end().ends_with(')'));
1928 }
1929
1930 #[test]
1931 fn action_header_caps_rows_and_marks_truncation() {
1932 let theme = Theme::dark();
1935 let action = ActionDisplay {
1936 action_type: "Bash".to_string(),
1937 target: "word ".repeat(400),
1938 result: ActionResult::Success {
1939 output: String::new(),
1940 images: None,
1941 },
1942 details: ActionDetails::Simple,
1943 duration_seconds: None,
1944 metadata: None,
1945 };
1946
1947 let width = 60usize;
1948 let mut lines: Vec<Line> = Vec::new();
1949 render_actions(&[action], &mut lines, &theme, width, true);
1950
1951 assert_rows_fit(&lines, width);
1952 let header_rows: Vec<String> = lines
1953 .iter()
1954 .map(line_plain_text)
1955 .take_while(|r| !r.trim_start().starts_with('⎿'))
1956 .collect();
1957 assert_eq!(
1958 header_rows.len(),
1959 MAX_ACTION_HEADER_ROWS,
1960 "header must cap at MAX_ACTION_HEADER_ROWS rows"
1961 );
1962 assert!(
1963 header_rows.last().unwrap().trim_end().ends_with("…)"),
1964 "capped header must end with …) — got {:?}",
1965 header_rows.last().unwrap()
1966 );
1967 }
1968
1969 #[test]
1970 fn action_header_preserves_multiline_command_rows() {
1971 let theme = Theme::dark();
1975 let action = ActionDisplay {
1976 action_type: "Bash".to_string(),
1977 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
1978 result: ActionResult::Success {
1979 output: String::new(),
1980 images: None,
1981 },
1982 details: ActionDetails::Simple,
1983 duration_seconds: None,
1984 metadata: None,
1985 };
1986
1987 let mut lines: Vec<Line> = Vec::new();
1988 render_actions(&[action], &mut lines, &theme, 80, true);
1989
1990 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
1991 assert!(rows[0].contains("python3 - << 'PY'"));
1992 assert!(rows[1].contains("from PIL import Image"));
1993 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
1994 }
1995
1996 #[test]
1997 fn action_result_summary_wraps_instead_of_clipping() {
1998 let theme = Theme::dark();
1999 let action = ActionDisplay {
2000 action_type: "Tasks".to_string(),
2001 target: "update 3 steps".to_string(),
2002 result: ActionResult::Success {
2003 output: String::new(),
2004 images: None,
2005 },
2006 details: ActionDetails::Preview {
2007 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2008 placeholders kept intentionally until real data available. \
2009 Task 2 and 6 deferred., to revisit later"
2010 .to_string(),
2011 line_count: None,
2012 },
2013 duration_seconds: None,
2014 metadata: None,
2015 };
2016
2017 let width = 60usize;
2018 let mut lines: Vec<Line> = Vec::new();
2019 render_actions(&[action], &mut lines, &theme, width, true);
2020
2021 assert_rows_fit(&lines, width);
2022 let rendered = lines
2023 .iter()
2024 .map(line_plain_text)
2025 .collect::<Vec<_>>()
2026 .join("\n");
2027 assert!(
2028 rendered.contains("revisit later"),
2029 "the summary's tail must survive the wrap instead of being clipped"
2030 );
2031 }
2032
2033 #[test]
2034 fn wrapped_line_cache_hit_matches_cache_miss() {
2035 use ratatui::Terminal;
2042 use ratatui::backend::TestBackend;
2043
2044 let theme = Theme::dark();
2045 let messages = vec![
2046 ChatMessage::assistant(
2047 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2048 across this narrow viewport more than once.\n\n\
2049 - a list item that also keeps going past the edge so it wraps too\n\
2050 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2051 ),
2052 ChatMessage::assistant("Short follow-up paragraph."),
2053 ];
2054
2055 let (width, height): (u16, u16) = (40, 40);
2056 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2057 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2058 let mut state = ChatState::new();
2059 term.draw(|f| {
2060 let widget = ChatWidget {
2061 messages: &messages,
2062 content_key: test_content_key(&messages),
2063 theme: &theme,
2064 wrapped_line_cache: cache,
2065 show_reasoning: true,
2066 blink_on: true,
2067 };
2068 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2069 })
2070 .unwrap();
2071 term.backend().buffer().clone()
2072 };
2073
2074 let mut shared = FxHashMap::default();
2075 let miss = render_once(&mut shared);
2076 assert!(!shared.is_empty(), "first render must populate the cache");
2077 let hit = render_once(&mut shared);
2078 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2079
2080 let mut cold_cache = FxHashMap::default();
2081 let cold = render_once(&mut cold_cache);
2082 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2083 }
2084
2085 #[test]
2086 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2087 use ratatui::Terminal;
2091 use ratatui::backend::TestBackend;
2092
2093 let theme = Theme::dark();
2094 let messages = vec![ChatMessage::system(
2095 "Heads up: this model reports no vision capability",
2096 )];
2097 let (width, height): (u16, u16) = (60, 10);
2098 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2099 let mut state = ChatState::new();
2100 let mut cache = FxHashMap::default();
2101 term.draw(|f| {
2102 let widget = ChatWidget {
2103 messages: &messages,
2104 content_key: test_content_key(&messages),
2105 theme: &theme,
2106 wrapped_line_cache: &mut cache,
2107 show_reasoning: true,
2108 blink_on: true,
2109 };
2110 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2111 })
2112 .unwrap();
2113 let buf = term.backend().buffer();
2114 let rows: Vec<String> = (0..height)
2115 .map(|y| {
2116 (0..width)
2117 .map(|x| buf[(x, y)].symbol().to_string())
2118 .collect::<String>()
2119 })
2120 .collect();
2121 let all = rows.join("\n");
2122 assert!(
2123 !all.contains('●'),
2124 "no role bullet on system notices: {all}"
2125 );
2126 assert!(
2127 !all.contains("Today at"),
2128 "no timestamp on system notices: {all}"
2129 );
2130 let row = rows
2131 .iter()
2132 .position(|r| r.contains("Heads up"))
2133 .expect("notice rendered");
2134 assert!(
2135 rows[row].starts_with(" Heads up"),
2136 "2-space indent, nothing in the gutter: {:?}",
2137 rows[row]
2138 );
2139 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2141 buf[(col as u16, row as u16)].fg,
2142 theme.colors.text_meta.to_color(),
2143 "notice text uses the muted meta gray"
2144 );
2145 }
2146
2147 #[test]
2148 fn byte_at_cell_clamps_and_respects_cjk() {
2149 assert_eq!(byte_at_cell("hello", 0), 0);
2150 assert_eq!(byte_at_cell("hello", 3), 3);
2151 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2154 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2157 }
2158
2159 #[test]
2160 fn slice_by_cells_extracts_display_range() {
2161 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2162 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2163 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2164 }
2165
2166 #[test]
2167 fn pad_to_cells_fills_to_display_width() {
2168 assert_eq!(pad_to_cells("ab", 5), "ab ");
2169 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2171 assert_eq!(pad_to_cells("你好", 3), "你好");
2173 assert_eq!(pad_to_cells("", 0), "");
2174 }
2175
2176 #[test]
2177 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2178 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2181 let wrapped = wrap_preformatted(line, 10, 2);
2182 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2183 let first: String = wrapped[0]
2184 .spans
2185 .iter()
2186 .map(|s| s.content.as_ref())
2187 .collect();
2188 assert!(
2189 first.starts_with(" aaaa"),
2190 "indentation must be preserved, got {first:?}"
2191 );
2192 let second: String = wrapped[1]
2193 .spans
2194 .iter()
2195 .map(|s| s.content.as_ref())
2196 .collect();
2197 assert!(
2198 second.starts_with(" "),
2199 "continuation should get the hanging indent, got {second:?}"
2200 );
2201 }
2202
2203 #[test]
2204 fn wrap_preformatted_short_line_unchanged() {
2205 let line = Line::from(vec![Span::raw(" short")]);
2206 let wrapped = wrap_preformatted(line, 40, 2);
2207 assert_eq!(wrapped.len(), 1);
2208 let text: String = wrapped[0]
2209 .spans
2210 .iter()
2211 .map(|s| s.content.as_ref())
2212 .collect();
2213 assert_eq!(text, " short");
2214 }
2215
2216 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2220 let mut st = ChatState::new();
2221 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2222 st.selection = Some(sel);
2223 st
2224 }
2225
2226 #[test]
2227 fn selected_text_single_line() {
2228 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2229 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2230 }
2231
2232 #[test]
2233 fn selected_text_spans_multiple_rows() {
2234 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2235 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2238 }
2239
2240 #[test]
2241 fn selected_text_strips_margin_but_keeps_code_indentation() {
2242 let st = state_with_rows(
2245 &[" fn main() {", " let x = 1;", " }"],
2246 ((0, 0), (2, 3)),
2247 );
2248 assert_eq!(
2249 st.selected_text().as_deref(),
2250 Some("fn main() {\n let x = 1;\n}")
2251 );
2252 }
2253
2254 #[test]
2255 fn selected_text_normalizes_reversed_drag() {
2256 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2258 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2259 }
2260
2261 #[test]
2262 fn selected_text_empty_selection_is_none() {
2263 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2265 assert_eq!(st.selected_text(), None);
2266 }
2267
2268 #[test]
2269 fn highlight_line_cells_splits_spans_on_selection() {
2270 let mut line = Line::from(vec![Span::raw("abcdef")]);
2271 highlight_line_cells(
2272 &mut line,
2273 2,
2274 4,
2275 Style::new().add_modifier(Modifier::REVERSED),
2276 );
2277 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2279 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2280 assert!(
2281 line.spans[1]
2282 .style
2283 .add_modifier
2284 .contains(Modifier::REVERSED)
2285 );
2286 assert!(
2287 !line.spans[0]
2288 .style
2289 .add_modifier
2290 .contains(Modifier::REVERSED)
2291 );
2292 }
2293
2294 #[test]
2295 fn context_checkpoint_renders_as_compact_event() {
2296 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2297 msg.kind = ChatMessageKind::ContextCheckpoint;
2298 msg.metadata = Some(serde_json::json!({
2299 "trigger": "manual",
2300 "before_tokens": 43_800,
2301 "after_tokens": 9_200,
2302 "archived_message_count": 18,
2303 "preserved_message_count": 4,
2304 "duration_secs": 2.4,
2305 "review_status": "reviewed",
2306 }));
2307
2308 let lines =
2309 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2310 let rendered = lines
2311 .iter()
2312 .map(|line| {
2313 line.spans
2314 .iter()
2315 .map(|span| span.content.as_ref())
2316 .collect::<String>()
2317 })
2318 .collect::<Vec<_>>()
2319 .join("\n");
2320
2321 assert!(rendered.contains("Compact(manual)"));
2322 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2323 assert!(rendered.contains("archived 18 messages"));
2324 assert!(rendered.contains("preserved 4 messages"));
2325 assert!(rendered.contains("reviewed"));
2326 assert!(!rendered.contains("full checkpoint summary"));
2327 }
2328
2329 #[test]
2330 fn context_checkpoint_renders_validated_draft() {
2331 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2332 msg.kind = ChatMessageKind::ContextCheckpoint;
2333 msg.metadata = Some(serde_json::json!({
2334 "trigger": "auto_threshold",
2335 "before_tokens": 43_800,
2336 "after_tokens": 9_200,
2337 "archived_message_count": 18,
2338 "preserved_message_count": 4,
2339 "duration_secs": 2.4,
2340 "review_status": "draft_validated",
2341 "review_error": "provider overloaded",
2342 }));
2343
2344 let lines =
2345 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2346 let rendered = lines
2347 .iter()
2348 .map(|line| {
2349 line.spans
2350 .iter()
2351 .map(|span| span.content.as_ref())
2352 .collect::<String>()
2353 })
2354 .collect::<Vec<_>>()
2355 .join("\n");
2356
2357 assert!(rendered.contains("Compact(auto_threshold)"));
2358 assert!(rendered.contains("validated draft"));
2359 assert!(rendered.contains("review: provider overloaded"));
2360 }
2361
2362 #[test]
2363 fn clamp_to_u16_saturates_past_u16_max() {
2364 assert_eq!(clamp_to_u16(0), 0);
2367 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2368 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2369 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2370 }
2371
2372 #[test]
2373 fn frame_memo_hit_matches_miss() {
2374 use ratatui::Terminal;
2380 use ratatui::backend::TestBackend;
2381
2382 let theme = Theme::dark();
2383 let messages = vec![
2384 ChatMessage::assistant(
2385 "# Heading\n\nSome **bold** prose long enough that it wraps across \
2386 this narrow viewport more than once.\n\n- a list item that also \
2387 runs past the edge so it wraps\n- second item",
2388 ),
2389 ChatMessage::assistant("Short follow-up."),
2390 ];
2391
2392 let (width, height): (u16, u16) = (34, 30);
2393 let mut cache = FxHashMap::default();
2394 let mut state = ChatState::new();
2395
2396 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2397 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2398 term.draw(|f| {
2399 let widget = ChatWidget {
2400 messages: &messages,
2401 content_key: test_content_key(&messages),
2402 theme: &theme,
2403 wrapped_line_cache: cache,
2404 show_reasoning: true,
2405 blink_on: true,
2406 };
2407 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
2408 })
2409 .unwrap();
2410 term.backend().buffer().clone()
2411 };
2412
2413 let miss = render(&mut state, &mut cache);
2414 assert!(
2415 state.frame_memo.is_some(),
2416 "first render must populate the frame memo"
2417 );
2418 let hit = render(&mut state, &mut cache);
2419 assert_eq!(
2420 miss, hit,
2421 "frame-memo hit must render identically to the miss"
2422 );
2423 assert!(
2427 !state.last_rendered_rows.is_empty(),
2428 "memo hit must preserve last_rendered_rows from the miss"
2429 );
2430 }
2431
2432 #[test]
2433 fn append_action_duration_handles_empty_base() {
2434 assert_eq!(
2437 append_action_duration(String::new(), Some(0.035)),
2438 "took 35ms"
2439 );
2440 assert_eq!(
2442 append_action_duration("3 lines read".to_string(), Some(1.25)),
2443 "3 lines read, took 1.2s"
2444 );
2445 assert_eq!(append_action_duration(String::new(), None), "");
2447 }
2448
2449 #[test]
2453 fn screen_to_content_subtracts_the_top_pad() {
2454 let mut state = ChatState {
2455 last_chat_area: Some((1, 2, 78, 20)),
2456 last_scroll_position: 0,
2457 last_top_pad: 17,
2458 ..Default::default()
2459 };
2460 assert_eq!(state.screen_to_content(2, 1), None);
2461 assert_eq!(state.screen_to_content(18, 1), None);
2462 assert_eq!(state.screen_to_content(19, 1), Some((0, 0)));
2463 assert_eq!(state.screen_to_content(21, 5), Some((2, 4)));
2464 state.image_click_map = vec![(
2465 0,
2466 ImageClickTarget {
2467 message_index: 0,
2468 image_index: 0,
2469 image_number: None,
2470 },
2471 )];
2472 assert!(state.find_image_at_screen_pos(18).is_none());
2473 assert!(state.find_image_at_screen_pos(19).is_some());
2474 }
2475}