1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10use rustc_hash::FxHashMap;
11use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13use crate::domain::{
14 ActionDetails, ActionDisplay, ActionResult, QuestionAnswer, ToolMetadata, format_compact_count,
15};
16use crate::models::ChatMessageKind;
17use crate::models::{ChatMessage, MessageRole};
18use crate::render::diff::{DiffLineKind, parse_diff_line};
19use crate::render::markdown::parse_markdown;
20use crate::render::theme::Theme;
21use crate::utils::format_relative_timestamp;
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_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
55 last_rendered_rows: Vec<String>,
59 frame_memo: Option<FrameMemo>,
66}
67
68#[derive(Debug, Clone)]
75struct FrameMemo {
76 key: u64,
78 lines: Vec<Line<'static>>,
80 click_map: Vec<(u16, ImageClickTarget)>,
82}
83
84impl ChatState {
85 pub fn new() -> Self {
87 Self {
88 scroll_offset: 0,
89 is_user_scrolling: false,
90 image_click_map: Vec::new(),
91 last_scroll_position: 0,
92 last_chat_area: None,
93 selection: None,
94 last_rendered_rows: Vec::new(),
95 frame_memo: None,
96 }
97 }
98
99 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
102 let max_scroll = content_height.saturating_sub(viewport_height);
103 if self.is_user_scrolling {
104 let capped_offset = self.scroll_offset.min(max_scroll);
107 max_scroll.saturating_sub(capped_offset)
108 } else {
109 max_scroll
111 }
112 }
113
114 pub fn scroll_up(&mut self, amount: u16) {
116 self.is_user_scrolling = true;
117 self.scroll_offset = self.scroll_offset.saturating_add(amount);
118 self.selection = None;
121 }
122
123 pub fn scroll_down(&mut self, amount: u16) {
126 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
127 if self.scroll_offset == 0 {
128 self.is_user_scrolling = false;
130 }
131 self.selection = None;
132 }
133
134 pub fn resume_auto_scroll(&mut self) {
136 self.is_user_scrolling = false;
137 self.scroll_offset = 0;
138 }
139
140 pub fn is_manually_scrolling(&self) -> bool {
142 self.is_user_scrolling
143 }
144
145 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
148 let (_, area_y, _, area_height) = self.last_chat_area?;
149
150 if screen_row < area_y || screen_row >= area_y + area_height {
152 return None;
153 }
154
155 let viewport_row = screen_row - area_y;
157 let content_line = viewport_row + self.last_scroll_position;
158
159 self.image_click_map
161 .iter()
162 .find(|(line, _)| *line == content_line)
163 .map(|(_, target)| target)
164 }
165
166 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
170 let (area_x, area_y, _, area_height) = self.last_chat_area?;
171 if screen_row < area_y || screen_row >= area_y + area_height {
172 return None;
173 }
174 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
175 let col = screen_col.saturating_sub(area_x) as usize;
176 Some((content_line, col))
177 }
178
179 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
183 self.selection = self
184 .screen_to_content(screen_row, screen_col)
185 .map(|p| (p, p));
186 }
187
188 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
190 if let Some((anchor, _)) = self.selection
191 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
192 {
193 self.selection = Some((anchor, cursor));
194 }
195 }
196
197 pub fn clear_selection(&mut self) {
199 self.selection = None;
200 }
201
202 pub fn selected_text(&self) -> Option<String> {
207 let (a, b) = self.selection?;
208 let (start, end) = if a <= b { (a, b) } else { (b, a) };
209 if self.last_rendered_rows.is_empty() {
210 return None;
211 }
212 let last = self.last_rendered_rows.len() - 1;
213 let (start_line, start_col) = (start.0.min(last), start.1);
214 let (end_line, end_col) = (end.0.min(last), end.1);
215
216 let mut out = String::new();
217 for line in start_line..=end_line {
218 let row = &self.last_rendered_rows[line];
219 let c0 = if line == start_line { start_col } else { 0 };
220 let c1 = if line == end_line {
221 end_col
222 } else {
223 usize::MAX
224 };
225 let mut piece = slice_by_cells(row, c0, c1).to_string();
226 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
231 while margin > 0 && piece.starts_with(' ') {
232 piece.remove(0);
233 margin -= 1;
234 }
235 out.push_str(piece.trim_end());
236 if line != end_line {
237 out.push('\n');
238 }
239 }
240 if out.is_empty() { None } else { Some(out) }
241 }
242}
243
244const SELECT_MARGIN_CELLS: usize = 2;
248
249fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
254 if width == 0 {
255 return vec![line];
256 }
257 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
258 if total <= width {
259 return vec![line];
260 }
261
262 let base = line.style;
263 let mut out: Vec<Line<'static>> = Vec::new();
264 let mut cur: Vec<Span<'static>> = Vec::new();
265 let mut cur_w = 0usize;
266 let mut on_first = true;
267
268 for span in line.spans {
269 let style = span.style;
270 let mut buf = String::new();
271 for ch in span.content.chars() {
272 let cw = ch.width().unwrap_or(0);
273 let floor = if on_first { 0 } else { indent };
276 if cur_w + cw > width && cur_w > floor {
277 if !buf.is_empty() {
278 cur.push(Span::styled(std::mem::take(&mut buf), style));
279 }
280 out.push(Line::from(std::mem::take(&mut cur)).style(base));
281 on_first = false;
282 cur.push(Span::styled(" ".repeat(indent), base));
283 cur_w = indent;
284 }
285 buf.push(ch);
286 cur_w += cw;
287 }
288 if !buf.is_empty() {
289 cur.push(Span::styled(buf, style));
290 }
291 }
292 if !cur.is_empty() {
293 out.push(Line::from(cur).style(base));
294 }
295 if out.is_empty() {
296 vec![Line::from("").style(base)]
297 } else {
298 out
299 }
300}
301
302fn byte_at_cell(s: &str, target: usize) -> usize {
306 if target == 0 {
307 return 0;
308 }
309 let mut width = 0usize;
310 for (idx, ch) in s.char_indices() {
311 if width >= target {
312 return idx;
313 }
314 width += ch.width().unwrap_or(0);
315 }
316 s.len()
317}
318
319fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
321 let start = byte_at_cell(s, c0);
322 let end = byte_at_cell(s, c1).max(start);
323 &s[start..end]
324}
325
326fn pad_to_cells(s: &str, cells: usize) -> String {
331 let w = s.width();
332 if w >= cells {
333 return s.to_string();
334 }
335 let mut out = String::with_capacity(s.len() + (cells - w));
336 out.push_str(s);
337 out.push_str(&" ".repeat(cells - w));
338 out
339}
340
341fn user_timestamp_padding(
346 role_prefix_width: usize,
347 text_width: usize,
348 timestamp_width: usize,
349 min_gap: usize,
350 content_width: usize,
351) -> usize {
352 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
353 min_gap + content_width.saturating_sub(total_used)
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 show_reasoning: bool,
418 pub blink_on: bool,
423}
424
425fn wrap_assistant_content(
434 content: &str,
435 content_width: u16,
436 role_prefix: &str,
437 role_color: ratatui::style::Color,
438 theme: &Theme,
439) -> Vec<Line<'static>> {
440 let md_width = (content_width as usize).saturating_sub(2);
442 let parsed = parse_markdown(content, theme, md_width);
443
444 let mut out: Vec<Line<'static>> = Vec::new();
445 for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
446 let preformatted = parsed_line.preformatted;
451 let base_style = parsed_line.line.style;
452
453 let continuation = if preformatted {
458 2
459 } else {
460 2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
461 };
462
463 let mut spans = if line_idx == 0 {
465 vec![Span::styled(
466 format!("{} ", role_prefix),
467 Style::new().fg(role_color).bold(),
468 )]
469 } else {
470 vec![Span::raw(" ")]
471 };
472 spans.extend(parsed_line.line.spans);
473 let new_line = Line::from(spans).style(base_style);
474
475 if preformatted {
476 out.extend(wrap_preformatted(new_line, content_width as usize, 2));
479 } else {
480 out.extend(wrap_styled_line(
481 new_line,
482 content_width as usize,
483 continuation,
484 ));
485 }
486 }
487 out
488}
489
490struct HashWrite<'a, H: Hasher>(&'a mut H);
494
495impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
496 fn write_str(&mut self, s: &str) -> std::fmt::Result {
497 self.0.write(s.as_bytes());
498 Ok(())
499 }
500}
501
502fn frame_fingerprint(
514 messages: &[ChatMessage],
515 theme_seed: u64,
516 content_width: u16,
517 show_reasoning: bool,
518 blink_on: bool,
519) -> u64 {
520 use std::fmt::Write as _;
521 let mut h = rustc_hash::FxHasher::default();
522 theme_seed.hash(&mut h);
523 content_width.hash(&mut h);
524 show_reasoning.hash(&mut h);
525 chrono::Local::now().date_naive().hash(&mut h);
528 if messages.iter().any(|m| {
532 m.actions
533 .iter()
534 .any(|a| matches!(a.result, ActionResult::Running))
535 }) {
536 blink_on.hash(&mut h);
537 }
538 messages.len().hash(&mut h);
539 for msg in messages {
540 msg.content.hash(&mut h);
541 msg.thinking.hash(&mut h);
542 msg.timestamp.timestamp().hash(&mut h);
545 msg.images
546 .as_ref()
547 .map_or(0, |imgs| imgs.len())
548 .hash(&mut h);
549 let mut hw = HashWrite(&mut h);
550 let _ = write!(
551 hw,
552 "{:?}|{:?}|{:?}|{:?}",
553 msg.role, msg.kind, msg.metadata, msg.actions
554 );
555 }
556 h.finish()
557}
558
559impl<'a> StatefulWidget for ChatWidget<'a> {
560 type State = ChatState;
561
562 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
563 let code_bg = self.theme.colors.code_background.to_color();
566 let theme_seed = {
567 let mut h = rustc_hash::FxHasher::default();
568 self.theme.colors.foreground.to_color().hash(&mut h);
569 code_bg.hash(&mut h);
570 self.theme.colors.header.to_color().hash(&mut h);
571 h.finish()
572 };
573
574 let content_width = area.width;
576 let content_area = area;
577
578 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
579
580 let frame_key = frame_fingerprint(
586 self.messages,
587 theme_seed,
588 content_width,
589 self.show_reasoning,
590 self.blink_on,
591 );
592 let memo_hit = state
595 .frame_memo
596 .as_ref()
597 .filter(|m| m.key == frame_key)
598 .map(|m| (m.lines.clone(), m.click_map.clone()));
599
600 let mut lines = if let Some((cached_lines, cached_click_map)) = memo_hit {
601 state.image_click_map = cached_click_map;
604 cached_lines
605 } else {
606 let mut lines: Vec<Line<'static>> = Vec::new();
608
609 state.image_click_map.clear();
611
612 for (idx, msg) in self.messages.iter().enumerate() {
613 if matches!(msg.role, MessageRole::Tool) {
616 continue;
617 }
618
619 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
620 if let Some(event_lines) =
621 render_context_checkpoint_event(msg, self.theme, content_width as usize)
622 {
623 lines.extend(event_lines);
624 lines.push(Line::from(""));
625 }
626 continue;
627 }
628
629 if matches!(msg.kind, ChatMessageKind::RunSummary) {
634 lines.push(Line::from(Span::styled(
635 format!(" {}", msg.content),
636 Style::new().fg(self.theme.colors.text_meta.to_color()),
637 )));
638 lines.push(Line::from(""));
639 continue;
640 }
641
642 if matches!(msg.kind, ChatMessageKind::RecoveryNudge) {
646 continue;
647 }
648
649 if matches!(msg.role, MessageRole::System) {
654 let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
655 for wrapped_line in
656 wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
657 {
658 lines.push(Line::from(Span::styled(wrapped_line, meta)));
659 }
660 lines.push(Line::from(""));
661 continue;
662 }
663
664 let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
672 && self.messages[..idx]
673 .iter()
674 .rev()
675 .find(|m| !matches!(m.role, MessageRole::Tool))
676 .is_some_and(crate::render::mergeable_into);
677 if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
678 lines.pop();
679 }
680
681 let (role_prefix, role_color) = match msg.role {
682 MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
683 MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
684 MessageRole::System | MessageRole::Tool => {
685 unreachable!("System and Tool messages handled above")
686 },
687 };
688 let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
692
693 if matches!(msg.role, MessageRole::Assistant) {
694 if let Some(ref thinking) = msg.thinking {
696 let thinking_trimmed = thinking.trim();
698 if thinking_trimmed.is_empty()
699 || thinking_trimmed == "None"
700 || thinking_trimmed == "none"
701 {
702 } else if self.show_reasoning {
704 lines.push(Line::from(vec![
706 Span::styled(
707 "● ",
708 Style::new().fg(self.theme.colors.text_disabled.to_color()),
709 ),
710 Span::styled(
711 "Thinking...",
712 Style::new()
713 .fg(self.theme.colors.text_secondary.to_color())
714 .italic()
715 .dim(),
716 ),
717 ]));
718
719 let wrapped = wrap_text_with_indent(
721 thinking,
722 content_width as usize,
723 2, 2, );
726 for wrapped_line in wrapped {
727 lines.push(Line::from(Span::styled(
728 wrapped_line,
729 Style::new()
730 .fg(self.theme.colors.text_secondary.to_color())
731 .italic()
732 .dim(),
733 )));
734 }
735
736 lines.push(Line::from(""));
738 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
739 continue;
744 }
745 }
746
747 let mut hasher = rustc_hash::FxHasher::default();
755 msg.content.hash(&mut hasher);
756 theme_seed.hash(&mut hasher);
757 content_width.hash(&mut hasher);
758 stitch_onto_prev.hash(&mut hasher);
761 let cache_key = hasher.finish();
762
763 let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
764 cached.clone()
765 } else {
766 let block = wrap_assistant_content(
767 &msg.content,
768 content_width,
769 role_prefix,
770 role_color,
771 self.theme,
772 );
773 self.wrapped_line_cache.insert(cache_key, block.clone());
774 if self.wrapped_line_cache.len()
775 > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
776 {
777 let overflow = self.wrapped_line_cache.len()
782 - crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
783 let stale: Vec<u64> = self
784 .wrapped_line_cache
785 .keys()
786 .copied()
787 .filter(|&k| k != cache_key)
788 .take(overflow)
789 .collect();
790 for k in stale {
791 self.wrapped_line_cache.remove(&k);
792 }
793 }
794 block
795 };
796 lines.extend(wrapped);
797
798 if !msg.actions.is_empty() {
800 if !msg.content.trim().is_empty() {
802 lines.push(Line::from(""));
803 }
804 render_actions(
805 &msg.actions,
806 &mut lines,
807 self.theme,
808 content_width as usize,
809 self.blink_on,
810 );
811 }
812 } else {
813 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
815 let timestamp_width = formatted_timestamp.width();
819 let min_gap = 3; let cleaned_content = &msg.content;
823
824 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
828
829 let wrapped = wrap_text_with_indent(
831 cleaned_content,
832 content_width as usize,
833 first_line_reserved, 2, );
836
837 let band_start = lines.len();
838 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
839 if line_idx == 0 {
840 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
843
844 let mut spans = vec![
845 Span::styled(
846 format!("{} ", role_prefix),
847 Style::new().fg(role_color).bold(),
848 ),
849 Span::raw(text_content.to_string()),
850 ];
851
852 let pad = user_timestamp_padding(
855 role_prefix_width,
856 text_width,
857 timestamp_width,
858 min_gap,
859 content_width as usize,
860 );
861 spans.push(Span::raw(" ".repeat(pad)));
862 spans.push(Span::styled(
863 formatted_timestamp.clone(),
864 Style::new().fg(self.theme.colors.text_meta.to_color()),
865 ));
866
867 lines.push(Line::from(spans));
868 } else {
869 lines.push(Line::from(wrapped_line.clone()));
871 }
872 }
873
874 if matches!(msg.role, MessageRole::User) {
879 let user_bg = self.theme.colors.user_message_background.to_color();
880 let cw = content_width as usize;
881 for line in &mut lines[band_start..] {
882 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
883 if used < cw {
884 line.spans.push(Span::raw(" ".repeat(cw - used)));
885 }
886 line.style = line.style.bg(user_bg);
887 }
888 }
889 }
890
891 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
898 && let Some(ref images) = msg.images
899 && !images.is_empty()
900 {
901 for (i, _) in images.iter().enumerate() {
902 let content_line = lines.len();
908 let image_number =
909 msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
910 state.image_click_map.push((
911 clamp_to_u16(content_line),
912 ImageClickTarget {
913 message_index: idx,
914 image_index: i,
915 image_number,
916 },
917 ));
918 let label = image_number
923 .map(|n| format!("[Image #{n}]"))
924 .unwrap_or_else(|| format!("[Image #{}]", i + 1));
925 lines.push(Line::from(vec![
926 Span::styled(
927 " ⎿ ",
928 Style::new().fg(self.theme.colors.info.to_color()),
929 ),
930 Span::styled(
931 label,
932 Style::new().fg(self.theme.colors.info.to_color()).italic(),
933 ),
934 ]));
935 }
936 }
937
938 lines.push(Line::from(""));
939 }
940
941 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
947
948 state.frame_memo = Some(FrameMemo {
953 key: frame_key,
954 lines: lines.clone(),
955 click_map: state.image_click_map.clone(),
956 });
957 lines
958 };
959
960 if let Some((a, b)) = state.selection
975 && !lines.is_empty()
976 {
977 let (start, end) = if a <= b { (a, b) } else { (b, a) };
978 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
979 let last_line = lines.len() - 1;
980 for (line_idx, line) in lines
981 .iter_mut()
982 .enumerate()
983 .take(end.0.min(last_line) + 1)
984 .skip(start.0)
985 {
986 let c0 = if line_idx == start.0 { start.1 } else { 0 };
987 let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
988 if c1 > c0 {
989 highlight_line_cells(line, c0, c1, sel_style);
990 }
991 }
992 }
993
994 let content_height = lines.len();
1000 let viewport_height = area.height;
1001
1002 let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
1003 state.last_scroll_position = scroll_pos;
1004
1005 let paragraph = Paragraph::new(lines)
1006 .block(Block::default())
1007 .scroll((scroll_pos, 0));
1008
1009 paragraph.render(content_area, buf);
1010 }
1011}
1012
1013fn render_context_checkpoint_event(
1014 msg: &ChatMessage,
1015 theme: &Theme,
1016 viewport_width: usize,
1017) -> Option<Vec<Line<'static>>> {
1018 if !matches!(msg.role, MessageRole::User) {
1019 return None;
1020 }
1021
1022 let metadata = msg.metadata.as_ref();
1023 let trigger = metadata
1024 .and_then(|value| value.get("trigger"))
1025 .and_then(|value| value.as_str())
1026 .unwrap_or("manual");
1027 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
1028 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
1029 let archived_messages =
1030 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
1031 let preserved_messages =
1032 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
1033 let duration_secs = metadata
1034 .and_then(|value| value.get("duration_secs"))
1035 .and_then(|value| value.as_f64());
1036 let review_status = metadata
1037 .and_then(|value| value.get("review_status"))
1038 .and_then(|value| value.as_str());
1039 let review_error = metadata
1040 .and_then(|value| value.get("review_error"))
1041 .and_then(|value| value.as_str());
1042
1043 let action_color = theme.colors.info.to_color();
1044 let mut result = match (before_tokens, after_tokens) {
1045 (Some(before), Some(after)) => {
1046 format!(
1047 "{} -> {} tokens",
1048 format_compact_count(before),
1049 format_compact_count(after)
1050 )
1051 },
1052 _ => "Context compacted".to_string(),
1053 };
1054
1055 if let Some(count) = archived_messages {
1056 result.push_str(&format!(
1057 ", archived {} {}",
1058 count,
1059 if count == 1 { "message" } else { "messages" }
1060 ));
1061 }
1062 if let Some(count) = preserved_messages {
1063 result.push_str(&format!(
1064 ", preserved {} {}",
1065 count,
1066 if count == 1 { "message" } else { "messages" }
1067 ));
1068 }
1069 if let Some(status) = review_status {
1070 match status {
1071 "reviewed" => result.push_str(", reviewed"),
1072 "draft_validated" => result.push_str(", validated draft"),
1073 _ => {},
1074 }
1075 }
1076 result = append_action_duration(result, duration_secs);
1077
1078 let mut lines = vec![Line::from(vec![
1079 Span::styled("● ", Style::new().fg(action_color).bold()),
1080 Span::styled("Compact(", Style::new().fg(action_color).bold()),
1081 Span::styled(
1082 trigger.to_string(),
1083 Style::new().fg(theme.colors.text_secondary.to_color()),
1084 ),
1085 Span::styled(")", Style::new().fg(action_color).bold()),
1086 ])];
1087 lines.extend(wrap_styled_line(
1088 Line::from(vec![
1089 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1090 Span::styled(
1091 result,
1092 Style::new().fg(theme.colors.text_secondary.to_color()),
1093 ),
1094 ]),
1095 viewport_width,
1096 4,
1097 ));
1098
1099 if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
1100 lines.extend(wrap_styled_line(
1101 Line::from(vec![
1102 Span::styled(" ", Style::new().fg(action_color)),
1103 Span::styled(
1104 format!("review: {}", compact_inline_error(error, 180)),
1105 Style::new().fg(theme.colors.warning.to_color()),
1106 ),
1107 ]),
1108 viewport_width,
1109 4,
1110 ));
1111 }
1112
1113 Some(lines)
1114}
1115
1116fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1117 value
1118 .get(key)?
1119 .as_u64()
1120 .and_then(|value| usize::try_from(value).ok())
1121}
1122
1123fn compact_inline_error(text: &str, max_chars: usize) -> String {
1124 let text = text.trim();
1125 if text.chars().count() <= max_chars {
1126 return text.to_string();
1127 }
1128 let keep = max_chars.saturating_sub(3);
1129 let mut out: String = text.chars().take(keep).collect();
1130 out.push_str("...");
1131 out
1132}
1133
1134fn expand_tabs(s: &str) -> String {
1143 const TAB_WIDTH: usize = 4;
1144 if !s.contains('\t') {
1145 return s.to_string();
1146 }
1147 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1148 let mut col = 0usize;
1149 for ch in s.chars() {
1150 if ch == '\t' {
1151 let n = TAB_WIDTH - (col % TAB_WIDTH);
1152 for _ in 0..n {
1153 out.push(' ');
1154 }
1155 col += n;
1156 } else {
1157 out.push(ch);
1158 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1159 }
1160 }
1161 out
1162}
1163
1164fn render_actions(
1165 actions: &[ActionDisplay],
1166 lines: &mut Vec<Line>,
1167 theme: &Theme,
1168 viewport_width: usize,
1169 blink_on: bool,
1170) {
1171 for (action_idx, action) in actions.iter().enumerate() {
1172 if action_idx > 0 {
1173 lines.push(Line::from(""));
1174 }
1175 if let Some(meta) = &action.metadata
1180 && let ToolMetadata::Questions {
1181 answers,
1182 remembered,
1183 } = &meta.detail
1184 && matches!(action.result, ActionResult::Success { .. })
1185 {
1186 render_question_answers(answers, *remembered, lines, theme, viewport_width);
1187 continue;
1188 }
1189 if let Some(meta) = &action.metadata
1193 && let ToolMetadata::Plan { path, body, .. } = &meta.detail
1194 && matches!(action.result, ActionResult::Success { .. })
1195 {
1196 render_plan_approved(path, body, lines, theme, viewport_width);
1197 continue;
1198 }
1199 let action_color = match action.action_type.as_str() {
1200 "Write" | "Update" => theme.colors.success.to_color(),
1201 "Delete" => theme.colors.warning.to_color(),
1202 _ => theme.colors.info.to_color(),
1203 };
1204
1205 let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1213 Style::new()
1214 .fg(theme.colors.text_disabled.to_color())
1215 .bold()
1216 } else {
1217 Style::new().fg(action_color).bold()
1218 };
1219 push_action_header(
1220 lines,
1221 action,
1222 action_color,
1223 dot_style,
1224 theme,
1225 viewport_width,
1226 );
1227
1228 match &action.result {
1229 ActionResult::Running => {},
1232 ActionResult::Success { .. } => {
1233 let result_msg = match &action.details {
1235 ActionDetails::FileContent { line_count, .. } => {
1236 let base = format!(
1237 "{} {} written",
1238 line_count,
1239 if *line_count == 1 { "line" } else { "lines" }
1240 );
1241 append_action_duration(base, action.duration_seconds)
1242 },
1243 ActionDetails::Diff { summary, .. } => summary.clone(),
1244 ActionDetails::Preview { text, .. } => text.clone(),
1245 ActionDetails::Simple => {
1249 append_action_duration(String::new(), action.duration_seconds)
1250 },
1251 };
1252
1253 for (idx, line) in result_msg.lines().enumerate() {
1254 let prefix = if idx == 0 { " ⎿ " } else { " " };
1255 lines.extend(wrap_styled_line(
1258 Line::from(vec![
1259 Span::styled(prefix, Style::new().fg(action_color)),
1260 Span::styled(
1261 line.to_string(),
1262 Style::new().fg(theme.colors.text_secondary.to_color()),
1263 ),
1264 ]),
1265 viewport_width,
1266 4,
1267 ));
1268 }
1269
1270 if let ActionDetails::FileContent {
1272 content,
1273 line_count,
1274 } = &action.details
1275 {
1276 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1277 if !preview_lines.is_empty() {
1278 lines.push(Line::from(vec![Span::styled(
1279 " ",
1280 Style::new().fg(action_color),
1281 )]));
1282
1283 let preview_content = preview_lines.join("\n");
1284 let mut parsed = parse_markdown(
1285 &format!("```\n{}\n```", preview_content),
1286 theme,
1287 viewport_width.saturating_sub(4),
1288 );
1289 for parsed_line in parsed.iter_mut() {
1290 let mut new_spans =
1291 vec![Span::styled(" ", Style::new().fg(action_color))];
1292 new_spans.append(&mut parsed_line.line.spans);
1293 parsed_line.line.spans = new_spans;
1294 }
1295 lines.extend(
1299 parsed
1300 .into_iter()
1301 .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1302 );
1303
1304 if *line_count > 10 {
1305 lines.push(Line::from(vec![
1306 Span::styled(" ", Style::new().fg(action_color)),
1307 Span::styled(
1308 format!("... ({} more lines)", line_count - 10),
1309 Style::new()
1310 .fg(theme.colors.text_disabled.to_color())
1311 .italic(),
1312 ),
1313 ]));
1314 }
1315 }
1316 }
1317
1318 if let ActionDetails::Diff { diff, .. } = &action.details {
1320 let diff_lines: Vec<&str> = diff.lines().collect();
1321 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1322
1323 if !display_lines.is_empty() {
1324 let removed_bg = theme.colors.diff_removed_bg.to_color();
1325 let added_bg = theme.colors.diff_added_bg.to_color();
1326
1327 for diff_line in &display_lines {
1328 let diff_line = expand_tabs(diff_line);
1335 match parse_diff_line(&diff_line) {
1340 DiffLineKind::Removed => {
1341 push_wrapped_diff_rows(
1342 lines,
1343 format!(" {}", diff_line),
1344 Style::new()
1345 .fg(theme.colors.error.to_color())
1346 .bg(removed_bg),
1347 viewport_width,
1348 );
1349 },
1350 DiffLineKind::Added => {
1351 push_wrapped_diff_rows(
1352 lines,
1353 format!(" {}", diff_line),
1354 Style::new()
1355 .fg(theme.colors.success.to_color())
1356 .bg(added_bg),
1357 viewport_width,
1358 );
1359 },
1360 DiffLineKind::Context => {
1361 lines.extend(wrap_preformatted(
1364 Line::from(vec![
1365 Span::styled(" ", Style::new().fg(action_color)),
1366 Span::styled(
1367 diff_line,
1368 Style::new()
1369 .fg(theme.colors.text_secondary.to_color()),
1370 ),
1371 ]),
1372 viewport_width,
1373 6,
1374 ));
1375 },
1376 }
1377 }
1378
1379 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1380 if remaining > 0 {
1381 lines.push(Line::from(vec![
1382 Span::styled(" ", Style::new().fg(action_color)),
1383 Span::styled(
1384 format!("... ({} more lines)", remaining),
1385 Style::new()
1386 .fg(theme.colors.text_disabled.to_color())
1387 .italic(),
1388 ),
1389 ]));
1390 }
1391 }
1392 }
1393 },
1394 ActionResult::Error { error } => {
1395 let error =
1396 append_action_duration(format!("Error: {}", error), action.duration_seconds);
1397 for (idx, err_line) in error.lines().enumerate() {
1401 let prefix = if idx == 0 { " ⎿ " } else { " " };
1402 lines.extend(wrap_styled_line(
1403 Line::from(vec![
1404 Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1405 Span::styled(
1406 err_line.to_string(),
1407 Style::new().fg(theme.colors.error.to_color()),
1408 ),
1409 ]),
1410 viewport_width,
1411 4,
1412 ));
1413 }
1414 },
1415 }
1416 }
1417}
1418
1419fn render_plan_approved(
1423 path: &str,
1424 body: &str,
1425 lines: &mut Vec<Line>,
1426 theme: &Theme,
1427 viewport_width: usize,
1428) {
1429 lines.push(Line::from(Span::styled(
1430 format!("● User approved the plan — {path}"),
1431 Style::new().fg(theme.colors.success.to_color()),
1432 )));
1433 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1434 let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
1437 let mut first_row = true;
1438 for mut parsed_line in parsed {
1439 let gutter = if first_row { " ⎿ " } else { " " };
1440 first_row = false;
1441 let mut spans = vec![Span::styled(gutter, gutter_style)];
1442 spans.append(&mut parsed_line.line.spans);
1443 lines.push(Line::from(spans));
1444 }
1445}
1446
1447fn render_question_answers(
1451 answers: &[QuestionAnswer],
1452 remembered: bool,
1453 lines: &mut Vec<Line>,
1454 theme: &Theme,
1455 viewport_width: usize,
1456) {
1457 let header = if remembered {
1458 "User answered the model's questions (remembered):"
1459 } else {
1460 "User answered the model's questions:"
1461 };
1462 lines.push(Line::from(Span::styled(
1463 format!("● {header}"),
1464 Style::new().fg(theme.colors.text_primary.to_color()),
1465 )));
1466
1467 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1468 let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1469 let note_style = Style::new()
1470 .fg(theme.colors.text_disabled.to_color())
1471 .italic();
1472 let wrap_width = viewport_width.saturating_sub(4);
1476 let mut first_row = true;
1477 for answer in answers {
1478 let value = if answer.selected.is_empty() {
1479 "(no selection)".to_string()
1480 } else {
1481 answer.selected.join(", ")
1482 };
1483 let entry = format!("· {} → {}", answer.question, value);
1484 let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1485 .into_iter()
1486 .map(|row| (row, text_style))
1487 .collect();
1488 if let Some(note) = &answer.note {
1489 rows.extend(
1490 wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1491 .into_iter()
1492 .map(|row| (row, note_style)),
1493 );
1494 }
1495 for (row, style) in rows {
1496 let gutter = if first_row { " ⎿ " } else { " " };
1497 first_row = false;
1498 lines.push(Line::from(vec![
1499 Span::styled(gutter, gutter_style),
1500 Span::styled(row, style),
1501 ]));
1502 }
1503 }
1504}
1505
1506const MAX_ACTION_HEADER_ROWS: usize = 4;
1510
1511fn push_action_header(
1519 lines: &mut Vec<Line>,
1520 action: &ActionDisplay,
1521 action_color: Color,
1522 dot_style: Style,
1523 theme: &Theme,
1524 viewport_width: usize,
1525) {
1526 let bold = Style::new().fg(action_color).bold();
1527 let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1528 if action.target.is_empty() {
1529 lines.push(Line::from(vec![
1530 Span::styled("● ", dot_style),
1531 Span::styled(format!("{}()", action.action_type), bold),
1532 ]));
1533 return;
1534 }
1535
1536 let open = format!("{}(", action.action_type);
1537 let first_indent = 2 + open.width();
1541 let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1542 let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1543 let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1544 rows.truncate(MAX_ACTION_HEADER_ROWS);
1545
1546 let last = rows.len().saturating_sub(1);
1547 for (i, row) in rows.into_iter().enumerate() {
1548 let mut spans = if i == 0 {
1549 vec![
1550 Span::styled("● ", dot_style),
1551 Span::styled(open.clone(), bold),
1552 Span::styled(row.trim_start().to_string(), secondary),
1553 ]
1554 } else {
1555 vec![Span::styled(row, secondary)]
1556 };
1557 if i == last {
1558 if truncated {
1559 spans.push(Span::styled(
1560 "…",
1561 Style::new().fg(theme.colors.text_disabled.to_color()),
1562 ));
1563 }
1564 spans.push(Span::styled(")", bold));
1565 }
1566 lines.push(Line::from(spans));
1567 }
1568}
1569
1570fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1574 for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1575 let padded = pad_to_cells(&line_plain_text(&row), width);
1576 lines.push(Line::from(Span::styled(padded, style)));
1577 }
1578}
1579
1580fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1581 if let Some(seconds) = duration_seconds {
1582 if !text.is_empty() {
1585 text.push_str(", ");
1586 }
1587 text.push_str("took ");
1588 text.push_str(&format_action_duration(seconds));
1589 }
1590 text
1591}
1592
1593fn format_action_duration(seconds: f64) -> String {
1594 if seconds < 1.0 {
1595 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1596 } else if seconds < 10.0 {
1597 format!("{:.1}s", seconds)
1598 } else {
1599 format!("{}s", seconds.round() as u64)
1600 }
1601}
1602
1603fn hard_break_plain_token(
1616 token: &str,
1617 out: &mut Vec<String>,
1618 current_line: &mut String,
1619 current_length: &mut usize,
1620 width: usize,
1621 continuation_indent: usize,
1622 initial_budget: usize,
1623) {
1624 let cont_budget = width.saturating_sub(continuation_indent).max(1);
1625 let mut line_budget = initial_budget.max(1);
1626
1627 if *current_length > 0 {
1631 out.push(std::mem::take(current_line));
1632 current_line.push_str(&" ".repeat(continuation_indent));
1633 *current_length = 0;
1634 line_budget = cont_budget;
1635 }
1636
1637 for ch in token.chars() {
1638 let cw = ch.width().unwrap_or(0);
1639 if *current_length + cw > line_budget && *current_length > 0 {
1642 out.push(std::mem::take(current_line));
1643 current_line.push_str(&" ".repeat(continuation_indent));
1644 *current_length = 0;
1645 line_budget = cont_budget;
1646 }
1647 current_line.push(ch);
1648 *current_length += cw;
1649 }
1650}
1651
1652fn wrap_text_with_indent(
1661 text: &str,
1662 width: usize,
1663 first_line_indent: usize,
1664 continuation_indent: usize,
1665) -> Vec<String> {
1666 let mut wrapped_lines = Vec::new();
1667
1668 for (line_idx, line) in text.lines().enumerate() {
1669 if line.is_empty() {
1670 wrapped_lines.push(String::new());
1671 continue;
1672 }
1673
1674 let current_indent = if line_idx == 0 {
1675 first_line_indent
1676 } else {
1677 continuation_indent
1678 };
1679 let available_width = width.saturating_sub(current_indent);
1680
1681 if available_width == 0 {
1682 wrapped_lines.push(" ".repeat(current_indent));
1683 continue;
1684 }
1685
1686 let words: Vec<&str> = line.split_whitespace().collect();
1687 if words.is_empty() {
1688 wrapped_lines.push(" ".repeat(current_indent));
1689 continue;
1690 }
1691
1692 let mut current_line = String::with_capacity(width);
1693 current_line.push_str(&" ".repeat(current_indent));
1694 let mut current_length = 0;
1697
1698 for (word_idx, word) in words.iter().enumerate() {
1699 let word_width = word.width();
1700
1701 if word_idx == 0 {
1702 if word_width <= available_width {
1703 current_line.push_str(word);
1705 current_length = word_width;
1706 } else {
1707 hard_break_plain_token(
1712 word,
1713 &mut wrapped_lines,
1714 &mut current_line,
1715 &mut current_length,
1716 width,
1717 continuation_indent,
1718 available_width,
1719 );
1720 }
1721 } else if current_length + 1 + word_width <= available_width {
1722 current_line.push(' ');
1725 current_line.push_str(word);
1726 current_length += 1 + word_width;
1727 } else if word_width <= available_width {
1728 wrapped_lines.push(current_line);
1730 current_line = String::with_capacity(width);
1731 current_line.push_str(&" ".repeat(continuation_indent));
1732 current_line.push_str(word);
1733 current_length = word_width;
1734 } else {
1735 hard_break_plain_token(
1738 word,
1739 &mut wrapped_lines,
1740 &mut current_line,
1741 &mut current_length,
1742 width,
1743 continuation_indent,
1744 available_width,
1745 );
1746 }
1747 }
1748
1749 if !current_line.trim().is_empty() {
1751 wrapped_lines.push(current_line);
1752 }
1753 }
1754
1755 wrapped_lines
1756}
1757
1758#[allow(clippy::too_many_arguments)]
1775fn hard_break_styled_word(
1776 fragments: &[(String, Style)],
1777 result_lines: &mut Vec<Line<'static>>,
1778 current_line_spans: &mut Vec<Span<'static>>,
1779 current_line_width: &mut usize,
1780 continuation_indent: usize,
1781 continuation_capacity: usize,
1782 mut line_capacity: usize,
1783) {
1784 for (text, style) in fragments {
1785 let mut buf = String::new();
1786 for ch in text.chars() {
1787 let cw = ch.width().unwrap_or(0);
1788 if *current_line_width + cw > line_capacity && *current_line_width > 0 {
1791 if !buf.is_empty() {
1792 current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
1793 }
1794 result_lines.push(Line::from(std::mem::take(current_line_spans)));
1795 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1796 *current_line_width = 0;
1797 line_capacity = continuation_capacity.max(1);
1798 }
1799 buf.push(ch);
1800 *current_line_width += cw;
1801 }
1802 if !buf.is_empty() {
1803 current_line_spans.push(Span::styled(buf, *style));
1804 }
1805 }
1806}
1807
1808fn wrap_styled_line(
1819 line: Line<'static>,
1820 width: usize,
1821 continuation_indent: usize,
1822) -> Vec<Line<'static>> {
1823 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1828
1829 if total_width <= width {
1831 return vec![line];
1832 }
1833
1834 let mut result_lines = Vec::new();
1836 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
1837 let mut current_line_width = 0usize;
1838 let available_width = width.saturating_sub(continuation_indent);
1839
1840 let leading_indent: usize = {
1848 let mut n = 0;
1849 for span in &line.spans {
1850 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1851 n += spaces;
1852 if spaces < span.content.len() {
1853 break; }
1855 }
1856 n
1857 };
1858
1859 let mut words: Vec<Vec<(String, Style)>> = Vec::new();
1864 let mut current_word: Vec<(String, Style)> = Vec::new();
1865 for span in &line.spans {
1866 let mut frag = String::new();
1867 for ch in span.content.chars() {
1868 if ch.is_whitespace() {
1869 if !frag.is_empty() {
1870 current_word.push((std::mem::take(&mut frag), span.style));
1871 }
1872 if !current_word.is_empty() {
1873 words.push(std::mem::take(&mut current_word));
1874 }
1875 } else {
1876 frag.push(ch);
1877 }
1878 }
1879 if !frag.is_empty() {
1880 current_word.push((frag, span.style));
1881 }
1882 }
1883 if !current_word.is_empty() {
1884 words.push(current_word);
1885 }
1886
1887 fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
1888 for (text, style) in word {
1889 spans.push(Span::styled(text, style));
1890 }
1891 }
1892
1893 for word in words {
1894 let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
1895
1896 if current_line_width == 0 && result_lines.is_empty() {
1897 if leading_indent > 0 {
1901 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1902 current_line_width += leading_indent;
1903 }
1904 if word_width <= available_width {
1905 current_line_width += word_width;
1906 emit_word(&mut current_line_spans, word);
1907 } else {
1908 hard_break_styled_word(
1914 &word,
1915 &mut result_lines,
1916 &mut current_line_spans,
1917 &mut current_line_width,
1918 continuation_indent,
1919 available_width,
1920 width,
1921 );
1922 }
1923 continue;
1924 }
1925
1926 let sep = usize::from(current_line_width > 0);
1931 if current_line_width + sep + word_width <= available_width {
1932 if sep == 1 {
1934 current_line_spans.push(Span::raw(" "));
1935 }
1936 current_line_width += sep + word_width;
1937 emit_word(&mut current_line_spans, word);
1938 } else if word_width <= available_width {
1939 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1941 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1942 current_line_width = word_width;
1943 emit_word(&mut current_line_spans, word);
1944 } else {
1945 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1949 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1950 current_line_width = 0;
1951 hard_break_styled_word(
1952 &word,
1953 &mut result_lines,
1954 &mut current_line_spans,
1955 &mut current_line_width,
1956 continuation_indent,
1957 available_width,
1958 available_width,
1959 );
1960 }
1961 }
1962
1963 if !current_line_spans.is_empty() {
1965 result_lines.push(Line::from(current_line_spans));
1966 }
1967
1968 if result_lines.is_empty() {
1969 vec![line]
1970 } else {
1971 result_lines
1972 }
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977 use super::*;
1978
1979 #[test]
1980 fn question_answers_render_as_question_arrow_answer_block() {
1981 use crate::domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
1982
1983 let theme = Theme::dark();
1984 let answers = vec![
1985 QuestionAnswer {
1986 header: "Snack".to_string(),
1987 question: "Which snack fuels your next coding session?".to_string(),
1988 selected: vec!["Coffee (Recommended)".to_string()],
1989 note: None,
1990 },
1991 QuestionAnswer {
1992 header: "Powers".to_string(),
1993 question: "Which superpowers would you take?".to_string(),
1994 selected: vec![
1995 "Read any codebase instantly".to_string(),
1996 "Bugs reproduce on demand".to_string(),
1997 ],
1998 note: Some("only on weekdays".to_string()),
1999 },
2000 ];
2001 let action = ActionDisplay {
2002 action_type: "ask_user_question".to_string(),
2003 target: String::new(),
2004 result: ActionResult::Success {
2005 output: String::new(),
2006 images: None,
2007 },
2008 details: ActionDetails::Simple,
2009 duration_seconds: Some(93.0),
2010 metadata: Some(ToolRunMetadata {
2011 detail: ToolMetadata::Questions {
2012 answers,
2013 remembered: false,
2014 },
2015 ..Default::default()
2016 }),
2017 };
2018
2019 let mut lines: Vec<Line> = Vec::new();
2020 render_actions(&[action], &mut lines, &theme, 120, true);
2021 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2022 let all = rows.join("\n");
2023
2024 assert_eq!(rows[0], "● User answered the model's questions:");
2025 assert!(
2026 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
2027 "got {:?}",
2028 rows[1]
2029 );
2030 assert!(
2031 all.contains(
2032 "· Which superpowers would you take? → Read any codebase instantly, \
2033 Bugs reproduce on demand"
2034 ),
2035 "got {all}"
2036 );
2037 assert!(all.contains("(note: only on weekdays)"), "got {all}");
2038 assert!(!all.contains("ask_user_question("), "got {all}");
2040 assert!(!all.contains("took"), "got {all}");
2041 }
2042
2043 #[test]
2044 fn diff_background_fills_full_width_with_tabs() {
2045 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
2050 use ratatui::Terminal;
2051 use ratatui::backend::TestBackend;
2052
2053 let theme = Theme::dark();
2054 let added_bg = theme.colors.diff_added_bg.to_color();
2055 let removed_bg = theme.colors.diff_removed_bg.to_color();
2056 let diff = format!(
2058 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
2059 m = DIFF_REMOVED_MARKER,
2060 p = DIFF_ADDED_MARKER
2061 );
2062 let action = ActionDisplay {
2063 action_type: "Update".to_string(),
2064 target: "engine.ts".to_string(),
2065 result: ActionResult::Success {
2066 output: String::new(),
2067 images: None,
2068 },
2069 details: ActionDetails::Diff {
2070 summary: "ok".to_string(),
2071 diff,
2072 },
2073 duration_seconds: Some(0.3),
2074 metadata: None,
2075 };
2076
2077 let width: u16 = 60;
2078 let mut lines: Vec<Line> = Vec::new();
2079 render_actions(&[action], &mut lines, &theme, width as usize, true);
2080 let h = lines.len() as u16;
2081 let backend = TestBackend::new(width, h);
2082 let mut term = Terminal::new(backend).unwrap();
2083 term.draw(|f| {
2084 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
2085 })
2086 .unwrap();
2087 let buf = term.backend().buffer();
2088
2089 for y in 0..h {
2090 let is_diff_row = (0..width).any(|x| {
2091 let bg = buf[(x, y)].bg;
2092 bg == added_bg || bg == removed_bg
2093 });
2094 if !is_diff_row {
2095 continue;
2096 }
2097 for x in 0..width {
2098 let bg = buf[(x, y)].bg;
2099 assert!(
2100 bg == added_bg || bg == removed_bg,
2101 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
2102 );
2103 }
2104 }
2105 }
2106
2107 fn assert_rows_fit(lines: &[Line], width: usize) {
2110 for (i, line) in lines.iter().enumerate() {
2111 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
2112 assert!(
2113 w <= width,
2114 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
2115 line_plain_text(line)
2116 );
2117 }
2118 }
2119
2120 #[test]
2121 fn action_header_and_error_wrap_instead_of_clipping() {
2122 let theme = Theme::dark();
2126 let action = ActionDisplay {
2127 action_type: "Error".to_string(),
2128 target: "Backend error".to_string(),
2129 result: ActionResult::Error {
2130 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(),
2131 },
2132 details: ActionDetails::Simple,
2133 duration_seconds: None,
2134 metadata: None,
2135 };
2136
2137 let width = 60usize;
2138 let mut lines: Vec<Line> = Vec::new();
2139 render_actions(&[action], &mut lines, &theme, width, true);
2140
2141 assert_rows_fit(&lines, width);
2142 let rendered = lines
2143 .iter()
2144 .map(line_plain_text)
2145 .collect::<Vec<_>>()
2146 .join("\n");
2147 assert!(rendered.contains("invalid_request_error"));
2150 assert!(
2151 lines.len() > 2,
2152 "a 140-cell error at width 60 must span multiple rows"
2153 );
2154 }
2155
2156 #[test]
2157 fn action_header_wraps_long_command_and_keeps_closing_paren() {
2158 let theme = Theme::dark();
2159 let action = ActionDisplay {
2160 action_type: "Bash".to_string(),
2161 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
2162 that keeps going well past the sixty cell viewport edge"
2163 .to_string(),
2164 result: ActionResult::Success {
2165 output: String::new(),
2166 images: None,
2167 },
2168 details: ActionDetails::Simple,
2169 duration_seconds: Some(0.1),
2170 metadata: None,
2171 };
2172
2173 let width = 60usize;
2174 let mut lines: Vec<Line> = Vec::new();
2175 render_actions(&[action], &mut lines, &theme, width, true);
2176
2177 assert_rows_fit(&lines, width);
2178 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2179 assert!(rows[0].starts_with("● Bash("));
2180 assert!(
2181 rows.len() >= 2,
2182 "the long command must wrap the header across rows"
2183 );
2184 let last_target_row = rows
2185 .iter()
2186 .rfind(|r| r.trim_end().ends_with(')'))
2187 .expect("wrapped header must still close its paren");
2188 assert!(last_target_row.trim_end().ends_with(')'));
2189 }
2190
2191 #[test]
2192 fn action_header_caps_rows_and_marks_truncation() {
2193 let theme = Theme::dark();
2196 let action = ActionDisplay {
2197 action_type: "Bash".to_string(),
2198 target: "word ".repeat(400),
2199 result: ActionResult::Success {
2200 output: String::new(),
2201 images: None,
2202 },
2203 details: ActionDetails::Simple,
2204 duration_seconds: None,
2205 metadata: None,
2206 };
2207
2208 let width = 60usize;
2209 let mut lines: Vec<Line> = Vec::new();
2210 render_actions(&[action], &mut lines, &theme, width, true);
2211
2212 assert_rows_fit(&lines, width);
2213 let header_rows: Vec<String> = lines
2214 .iter()
2215 .map(line_plain_text)
2216 .take_while(|r| !r.trim_start().starts_with('⎿'))
2217 .collect();
2218 assert_eq!(
2219 header_rows.len(),
2220 MAX_ACTION_HEADER_ROWS,
2221 "header must cap at MAX_ACTION_HEADER_ROWS rows"
2222 );
2223 assert!(
2224 header_rows.last().unwrap().trim_end().ends_with("…)"),
2225 "capped header must end with …) — got {:?}",
2226 header_rows.last().unwrap()
2227 );
2228 }
2229
2230 #[test]
2231 fn action_header_preserves_multiline_command_rows() {
2232 let theme = Theme::dark();
2236 let action = ActionDisplay {
2237 action_type: "Bash".to_string(),
2238 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
2239 result: ActionResult::Success {
2240 output: String::new(),
2241 images: None,
2242 },
2243 details: ActionDetails::Simple,
2244 duration_seconds: None,
2245 metadata: None,
2246 };
2247
2248 let mut lines: Vec<Line> = Vec::new();
2249 render_actions(&[action], &mut lines, &theme, 80, true);
2250
2251 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2252 assert!(rows[0].contains("python3 - << 'PY'"));
2253 assert!(rows[1].contains("from PIL import Image"));
2254 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
2255 }
2256
2257 #[test]
2258 fn action_result_summary_wraps_instead_of_clipping() {
2259 let theme = Theme::dark();
2260 let action = ActionDisplay {
2261 action_type: "Tasks".to_string(),
2262 target: "update 3 steps".to_string(),
2263 result: ActionResult::Success {
2264 output: String::new(),
2265 images: None,
2266 },
2267 details: ActionDetails::Preview {
2268 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2269 placeholders kept intentionally until real data available. \
2270 Task 2 and 6 deferred., to revisit later"
2271 .to_string(),
2272 line_count: None,
2273 },
2274 duration_seconds: None,
2275 metadata: None,
2276 };
2277
2278 let width = 60usize;
2279 let mut lines: Vec<Line> = Vec::new();
2280 render_actions(&[action], &mut lines, &theme, width, true);
2281
2282 assert_rows_fit(&lines, width);
2283 let rendered = lines
2284 .iter()
2285 .map(line_plain_text)
2286 .collect::<Vec<_>>()
2287 .join("\n");
2288 assert!(
2289 rendered.contains("revisit later"),
2290 "the summary's tail must survive the wrap instead of being clipped"
2291 );
2292 }
2293
2294 #[test]
2295 fn wrapped_line_cache_hit_matches_cache_miss() {
2296 use ratatui::Terminal;
2303 use ratatui::backend::TestBackend;
2304
2305 let theme = Theme::dark();
2306 let messages = vec![
2307 ChatMessage::assistant(
2308 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2309 across this narrow viewport more than once.\n\n\
2310 - a list item that also keeps going past the edge so it wraps too\n\
2311 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2312 ),
2313 ChatMessage::assistant("Short follow-up paragraph."),
2314 ];
2315
2316 let (width, height): (u16, u16) = (40, 40);
2317 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2318 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2319 let mut state = ChatState::new();
2320 term.draw(|f| {
2321 let widget = ChatWidget {
2322 messages: &messages,
2323 theme: &theme,
2324 wrapped_line_cache: cache,
2325 show_reasoning: true,
2326 blink_on: true,
2327 };
2328 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2329 })
2330 .unwrap();
2331 term.backend().buffer().clone()
2332 };
2333
2334 let mut shared = FxHashMap::default();
2335 let miss = render_once(&mut shared);
2336 assert!(!shared.is_empty(), "first render must populate the cache");
2337 let hit = render_once(&mut shared);
2338 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2339
2340 let mut cold_cache = FxHashMap::default();
2341 let cold = render_once(&mut cold_cache);
2342 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2343 }
2344
2345 #[test]
2346 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2347 use ratatui::Terminal;
2351 use ratatui::backend::TestBackend;
2352
2353 let theme = Theme::dark();
2354 let messages = vec![ChatMessage::system(
2355 "Heads up: this model reports no vision capability",
2356 )];
2357 let (width, height): (u16, u16) = (60, 10);
2358 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2359 let mut state = ChatState::new();
2360 let mut cache = FxHashMap::default();
2361 term.draw(|f| {
2362 let widget = ChatWidget {
2363 messages: &messages,
2364 theme: &theme,
2365 wrapped_line_cache: &mut cache,
2366 show_reasoning: true,
2367 blink_on: true,
2368 };
2369 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2370 })
2371 .unwrap();
2372 let buf = term.backend().buffer();
2373 let rows: Vec<String> = (0..height)
2374 .map(|y| {
2375 (0..width)
2376 .map(|x| buf[(x, y)].symbol().to_string())
2377 .collect::<String>()
2378 })
2379 .collect();
2380 let all = rows.join("\n");
2381 assert!(
2382 !all.contains('●'),
2383 "no role bullet on system notices: {all}"
2384 );
2385 assert!(
2386 !all.contains("Today at"),
2387 "no timestamp on system notices: {all}"
2388 );
2389 let row = rows
2390 .iter()
2391 .position(|r| r.contains("Heads up"))
2392 .expect("notice rendered");
2393 assert!(
2394 rows[row].starts_with(" Heads up"),
2395 "2-space indent, nothing in the gutter: {:?}",
2396 rows[row]
2397 );
2398 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2400 buf[(col as u16, row as u16)].fg,
2401 theme.colors.text_meta.to_color(),
2402 "notice text uses the muted meta gray"
2403 );
2404 }
2405
2406 #[test]
2407 fn byte_at_cell_clamps_and_respects_cjk() {
2408 assert_eq!(byte_at_cell("hello", 0), 0);
2409 assert_eq!(byte_at_cell("hello", 3), 3);
2410 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2413 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2416 }
2417
2418 #[test]
2419 fn slice_by_cells_extracts_display_range() {
2420 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2421 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2422 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2423 }
2424
2425 #[test]
2426 fn pad_to_cells_fills_to_display_width() {
2427 assert_eq!(pad_to_cells("ab", 5), "ab ");
2428 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2430 assert_eq!(pad_to_cells("你好", 3), "你好");
2432 assert_eq!(pad_to_cells("", 0), "");
2433 }
2434
2435 #[test]
2436 fn user_timestamp_padding_aligns_on_display_cells() {
2437 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
2439 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
2442 assert_eq!(4 + 10 + pad + 8, 40);
2443 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
2445 }
2446
2447 #[test]
2448 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2449 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2452 let wrapped = wrap_preformatted(line, 10, 2);
2453 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2454 let first: String = wrapped[0]
2455 .spans
2456 .iter()
2457 .map(|s| s.content.as_ref())
2458 .collect();
2459 assert!(
2460 first.starts_with(" aaaa"),
2461 "indentation must be preserved, got {first:?}"
2462 );
2463 let second: String = wrapped[1]
2464 .spans
2465 .iter()
2466 .map(|s| s.content.as_ref())
2467 .collect();
2468 assert!(
2469 second.starts_with(" "),
2470 "continuation should get the hanging indent, got {second:?}"
2471 );
2472 }
2473
2474 #[test]
2475 fn wrap_preformatted_short_line_unchanged() {
2476 let line = Line::from(vec![Span::raw(" short")]);
2477 let wrapped = wrap_preformatted(line, 40, 2);
2478 assert_eq!(wrapped.len(), 1);
2479 let text: String = wrapped[0]
2480 .spans
2481 .iter()
2482 .map(|s| s.content.as_ref())
2483 .collect();
2484 assert_eq!(text, " short");
2485 }
2486
2487 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2491 let mut st = ChatState::new();
2492 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2493 st.selection = Some(sel);
2494 st
2495 }
2496
2497 #[test]
2498 fn selected_text_single_line() {
2499 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2500 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2501 }
2502
2503 #[test]
2504 fn selected_text_spans_multiple_rows() {
2505 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2506 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2509 }
2510
2511 #[test]
2512 fn selected_text_strips_margin_but_keeps_code_indentation() {
2513 let st = state_with_rows(
2516 &[" fn main() {", " let x = 1;", " }"],
2517 ((0, 0), (2, 3)),
2518 );
2519 assert_eq!(
2520 st.selected_text().as_deref(),
2521 Some("fn main() {\n let x = 1;\n}")
2522 );
2523 }
2524
2525 #[test]
2526 fn selected_text_normalizes_reversed_drag() {
2527 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2529 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2530 }
2531
2532 #[test]
2533 fn selected_text_empty_selection_is_none() {
2534 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2536 assert_eq!(st.selected_text(), None);
2537 }
2538
2539 #[test]
2540 fn highlight_line_cells_splits_spans_on_selection() {
2541 let mut line = Line::from(vec![Span::raw("abcdef")]);
2542 highlight_line_cells(
2543 &mut line,
2544 2,
2545 4,
2546 Style::new().add_modifier(Modifier::REVERSED),
2547 );
2548 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2550 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2551 assert!(
2552 line.spans[1]
2553 .style
2554 .add_modifier
2555 .contains(Modifier::REVERSED)
2556 );
2557 assert!(
2558 !line.spans[0]
2559 .style
2560 .add_modifier
2561 .contains(Modifier::REVERSED)
2562 );
2563 }
2564
2565 #[test]
2566 fn context_checkpoint_renders_as_compact_event() {
2567 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2568 msg.kind = ChatMessageKind::ContextCheckpoint;
2569 msg.metadata = Some(serde_json::json!({
2570 "trigger": "manual",
2571 "before_tokens": 43_800,
2572 "after_tokens": 9_200,
2573 "archived_message_count": 18,
2574 "preserved_message_count": 4,
2575 "duration_secs": 2.4,
2576 "review_status": "reviewed",
2577 }));
2578
2579 let lines =
2580 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2581 let rendered = lines
2582 .iter()
2583 .map(|line| {
2584 line.spans
2585 .iter()
2586 .map(|span| span.content.as_ref())
2587 .collect::<String>()
2588 })
2589 .collect::<Vec<_>>()
2590 .join("\n");
2591
2592 assert!(rendered.contains("Compact(manual)"));
2593 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2594 assert!(rendered.contains("archived 18 messages"));
2595 assert!(rendered.contains("preserved 4 messages"));
2596 assert!(rendered.contains("reviewed"));
2597 assert!(!rendered.contains("full checkpoint summary"));
2598 }
2599
2600 #[test]
2601 fn context_checkpoint_renders_validated_draft() {
2602 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2603 msg.kind = ChatMessageKind::ContextCheckpoint;
2604 msg.metadata = Some(serde_json::json!({
2605 "trigger": "auto_threshold",
2606 "before_tokens": 43_800,
2607 "after_tokens": 9_200,
2608 "archived_message_count": 18,
2609 "preserved_message_count": 4,
2610 "duration_secs": 2.4,
2611 "review_status": "draft_validated",
2612 "review_error": "provider overloaded",
2613 }));
2614
2615 let lines =
2616 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2617 let rendered = lines
2618 .iter()
2619 .map(|line| {
2620 line.spans
2621 .iter()
2622 .map(|span| span.content.as_ref())
2623 .collect::<String>()
2624 })
2625 .collect::<Vec<_>>()
2626 .join("\n");
2627
2628 assert!(rendered.contains("Compact(auto_threshold)"));
2629 assert!(rendered.contains("validated draft"));
2630 assert!(rendered.contains("review: provider overloaded"));
2631 }
2632
2633 #[test]
2639 fn wrap_styled_line_uses_display_width_for_cjk() {
2640 let line = Line::from(Span::raw("你好世界".to_string()));
2644 let wrapped = wrap_styled_line(line, 10, 2);
2645 assert_eq!(
2646 wrapped.len(),
2647 1,
2648 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
2649 wrapped.len()
2650 );
2651 }
2652
2653 #[test]
2656 fn wrap_styled_line_ascii_wraps_when_too_long() {
2657 let line = Line::from(Span::raw(
2658 "the quick brown fox jumps over the lazy dog".to_string(),
2659 ));
2660 let wrapped = wrap_styled_line(line, 15, 2);
2661 assert!(
2662 wrapped.len() >= 2,
2663 "long ASCII input should wrap to multiple lines; got {}",
2664 wrapped.len()
2665 );
2666 }
2667
2668 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
2669 wrapped[0]
2670 .spans
2671 .iter()
2672 .map(|s| s.content.as_ref())
2673 .collect()
2674 }
2675
2676 #[test]
2682 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
2683 let line = Line::from(vec![
2684 Span::raw(" "), Span::raw(
2686 "No source files, no config, no docs, no build system and more words to wrap"
2687 .to_string(),
2688 ),
2689 ]);
2690 let wrapped = wrap_styled_line(line, 30, 2);
2691 assert!(wrapped.len() >= 2, "should wrap");
2692 let first = first_segment_text(&wrapped);
2693 assert!(
2694 first.starts_with(" ") && first.trim_start().starts_with("No source"),
2695 "first wrapped segment must keep the 2-space gutter; got {first:?}"
2696 );
2697 }
2698
2699 #[test]
2705 fn wrap_styled_line_hangs_list_continuation_under_marker() {
2706 let line = Line::from(vec![
2707 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2711 ]);
2712 let wrapped = wrap_styled_line(line, 24, 6);
2713 assert!(wrapped.len() >= 2, "should wrap");
2714 assert!(
2715 first_segment_text(&wrapped).starts_with(" • "),
2716 "first segment keeps gutter + nesting + marker"
2717 );
2718 for cont in &wrapped[1..] {
2719 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2720 assert!(
2721 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2722 "continuation hangs under the item text at col 6; got {t:?}"
2723 );
2724 }
2725 }
2726
2727 #[test]
2730 fn wrap_styled_line_keeps_bullet_at_column_zero() {
2731 let line = Line::from(vec![
2732 Span::raw("● "),
2733 Span::raw(
2734 "a fairly long first line of a message that definitely needs to wrap".to_string(),
2735 ),
2736 ]);
2737 let wrapped = wrap_styled_line(line, 25, 2);
2738 assert!(wrapped.len() >= 2, "should wrap");
2739 assert!(
2740 first_segment_text(&wrapped).starts_with('●'),
2741 "bullet must stay at column 0"
2742 );
2743 }
2744
2745 #[test]
2751 fn wrap_text_with_indent_uses_display_width_for_cjk() {
2752 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2755 assert_eq!(
2756 wrapped.len(),
2757 1,
2758 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2759 wrapped.len(),
2760 wrapped
2761 );
2762 assert_eq!(wrapped[0].trim_start(), "你好世界");
2763 }
2764
2765 #[test]
2768 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2769 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2773 assert!(
2774 wrapped.len() >= 2,
2775 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2776 wrapped.len(),
2777 wrapped
2778 );
2779 }
2780
2781 #[test]
2782 fn clamp_to_u16_saturates_past_u16_max() {
2783 assert_eq!(clamp_to_u16(0), 0);
2786 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2787 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2788 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2789 }
2790
2791 #[test]
2792 fn wrap_text_with_indent_hard_breaks_overlong_token() {
2793 let token = "x".repeat(100);
2797 let width = 20;
2798 let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2799 assert!(
2800 wrapped.len() >= 5,
2801 "a 100-cell token at width 20 must span many rows; got {}",
2802 wrapped.len()
2803 );
2804 for line in &wrapped {
2805 assert!(
2806 line.chars().count() <= width,
2807 "no wrapped row may exceed the width; got {:?} ({} cells)",
2808 line,
2809 line.chars().count()
2810 );
2811 }
2812 let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2814 assert_eq!(
2815 joined, token,
2816 "hard-break must preserve the token's content"
2817 );
2818 }
2819
2820 #[test]
2821 fn wrap_styled_line_hard_breaks_overlong_token() {
2822 let token = "y".repeat(90);
2824 let style = Style::new().fg(ratatui::style::Color::Red);
2825 let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
2826 let width = 24;
2827 let wrapped = wrap_styled_line(line, width, 2);
2828 assert!(
2829 wrapped.len() >= 4,
2830 "must hard-break across rows; got {}",
2831 wrapped.len()
2832 );
2833
2834 let mut reconstructed = String::new();
2835 for l in &wrapped {
2836 let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2837 assert!(
2838 row_cells <= width,
2839 "row exceeds width: {row_cells} > {width}"
2840 );
2841 for s in &l.spans {
2842 if s.content.trim().is_empty() {
2845 continue;
2846 }
2847 assert_eq!(
2848 s.style.fg,
2849 Some(ratatui::style::Color::Red),
2850 "hard-break must preserve the span style"
2851 );
2852 reconstructed.push_str(s.content.as_ref());
2853 }
2854 }
2855 assert_eq!(reconstructed, token, "hard-break must preserve the token");
2856 }
2857
2858 #[test]
2862 fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
2863 let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
2864 let line = Line::from(vec![
2865 Span::raw(" "),
2866 Span::raw("some filler words long enough to force a wrap here "),
2867 Span::styled("underlined-link-text", underlined),
2868 Span::raw(" and a bit more trailing filler after the link"),
2869 ]);
2870 let wrapped = wrap_styled_line(line, 30, 2);
2871 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2872 for l in &wrapped {
2873 for s in &l.spans {
2874 if s.content.chars().all(|c| c == ' ') {
2875 assert_eq!(
2876 s.style,
2877 Style::default(),
2878 "whitespace span {:?} must be unstyled",
2879 s.content
2880 );
2881 }
2882 }
2883 }
2884 }
2885
2886 #[test]
2890 fn wrap_styled_line_no_phantom_space_at_span_boundary() {
2891 let dim = Style::new().fg(ratatui::style::Color::DarkGray);
2892 let line = Line::from(vec![
2893 Span::raw(" "),
2894 Span::raw("filler text that pushes the line well past the width limit "),
2895 Span::styled("(https://example.com)".to_string(), dim),
2896 Span::raw("."),
2897 ]);
2898 let wrapped = wrap_styled_line(line, 30, 2);
2899 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2900 let text: String = wrapped
2901 .iter()
2902 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2903 .collect();
2904 assert!(
2905 text.contains("(https://example.com)."),
2906 "period must stay glued to the URL suffix; got {text:?}"
2907 );
2908 assert!(
2909 !text.contains("(https://example.com) ."),
2910 "no phantom space before the period; got {text:?}"
2911 );
2912 }
2913
2914 #[test]
2918 fn wrap_styled_line_keeps_mid_word_style_change_glued() {
2919 let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
2920 let line = Line::from(vec![
2921 Span::raw(" "),
2922 Span::raw("leading filler words to force wrapping "),
2923 Span::styled("bold", bold),
2924 Span::raw("suffix"),
2925 Span::raw(" trailing filler words to force more wrapping"),
2926 ]);
2927 let wrapped = wrap_styled_line(line, 30, 2);
2928 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2929 let rows: Vec<String> = wrapped
2930 .iter()
2931 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
2932 .collect();
2933 assert_eq!(
2934 rows.iter().filter(|r| r.contains("boldsuffix")).count(),
2935 1,
2936 "glued token must land whole on exactly one row; rows: {rows:?}"
2937 );
2938 for l in &wrapped {
2939 for s in &l.spans {
2940 if s.content.as_ref() == "bold" {
2941 assert_eq!(s.style, bold, "bold fragment keeps its modifier");
2942 }
2943 if s.content.as_ref() == "suffix" {
2944 assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
2945 }
2946 }
2947 }
2948 }
2949
2950 #[test]
2954 fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
2955 let red = Style::new().fg(ratatui::style::Color::Red);
2956 let blue = Style::new().fg(ratatui::style::Color::Blue);
2957 let line = Line::from(vec![
2958 Span::raw(" "),
2959 Span::styled("a".repeat(40), red),
2960 Span::styled("b".repeat(40), blue),
2961 ]);
2962 let width = 24;
2963 let wrapped = wrap_styled_line(line, width, 2);
2964 assert!(
2965 wrapped.len() >= 4,
2966 "80-cell token at width 24 must span >= 4 rows; got {}",
2967 wrapped.len()
2968 );
2969 let mut reconstructed = String::new();
2970 for l in &wrapped {
2971 let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
2972 assert!(
2973 row_cells <= width,
2974 "row exceeds width: {row_cells} > {width}"
2975 );
2976 for s in &l.spans {
2977 if s.content.trim().is_empty() {
2978 continue;
2979 }
2980 let expected = if s.content.contains('a') { red } else { blue };
2981 assert!(
2982 !(s.content.contains('a') && s.content.contains('b')),
2983 "fragments must not merge across the style boundary"
2984 );
2985 assert_eq!(s.style, expected, "fragment style preserved across break");
2986 reconstructed.push_str(s.content.as_ref());
2987 }
2988 }
2989 assert_eq!(
2990 reconstructed,
2991 format!("{}{}", "a".repeat(40), "b".repeat(40)),
2992 "hard-break must preserve the whole glued token"
2993 );
2994 }
2995
2996 #[test]
2999 fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
3000 let line = Line::from(vec![
3001 Span::raw(" "),
3002 Span::raw("filler words that push this line past the wrap width "),
3003 Span::raw("foo"),
3004 Span::raw(" "),
3005 Span::raw("bar"),
3006 ]);
3007 let wrapped = wrap_styled_line(line, 30, 2);
3008 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3009 let text: String = wrapped
3010 .iter()
3011 .map(|l| {
3012 l.spans
3013 .iter()
3014 .map(|s| s.content.as_ref())
3015 .collect::<String>()
3016 })
3017 .collect::<Vec<_>>()
3018 .join("\n");
3019 assert!(
3020 text.contains("foo bar") || text.contains("foo\n bar"),
3021 "whitespace-only span must keep the words apart; got {text:?}"
3022 );
3023 assert!(
3024 !text.contains("foobar"),
3025 "words must not glue; got {text:?}"
3026 );
3027 }
3028
3029 #[test]
3030 fn frame_memo_hit_matches_miss() {
3031 use ratatui::Terminal;
3037 use ratatui::backend::TestBackend;
3038
3039 let theme = Theme::dark();
3040 let messages = vec![
3041 ChatMessage::assistant(
3042 "# Heading\n\nSome **bold** prose long enough that it wraps across \
3043 this narrow viewport more than once.\n\n- a list item that also \
3044 runs past the edge so it wraps\n- second item",
3045 ),
3046 ChatMessage::assistant("Short follow-up."),
3047 ];
3048
3049 let (width, height): (u16, u16) = (34, 30);
3050 let mut cache = FxHashMap::default();
3051 let mut state = ChatState::new();
3052
3053 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
3054 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
3055 term.draw(|f| {
3056 let widget = ChatWidget {
3057 messages: &messages,
3058 theme: &theme,
3059 wrapped_line_cache: cache,
3060 show_reasoning: true,
3061 blink_on: true,
3062 };
3063 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
3064 })
3065 .unwrap();
3066 term.backend().buffer().clone()
3067 };
3068
3069 let miss = render(&mut state, &mut cache);
3070 assert!(
3071 state.frame_memo.is_some(),
3072 "first render must populate the frame memo"
3073 );
3074 let hit = render(&mut state, &mut cache);
3075 assert_eq!(
3076 miss, hit,
3077 "frame-memo hit must render identically to the miss"
3078 );
3079 assert!(
3083 !state.last_rendered_rows.is_empty(),
3084 "memo hit must preserve last_rendered_rows from the miss"
3085 );
3086 }
3087
3088 #[test]
3089 fn append_action_duration_handles_empty_base() {
3090 assert_eq!(
3093 append_action_duration(String::new(), Some(0.035)),
3094 "took 35ms"
3095 );
3096 assert_eq!(
3098 append_action_duration("3 lines read".to_string(), Some(1.25)),
3099 "3 lines read, took 1.2s"
3100 );
3101 assert_eq!(append_action_duration(String::new(), None), "");
3103 }
3104}