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 #[cfg(debug_assertions)]
69 debug_key_check: Option<(u64, u64)>,
70}
71
72#[derive(Debug, Clone)]
79struct FrameMemo {
80 key: u64,
82 lines: Vec<Line<'static>>,
84 click_map: Vec<(u16, ImageClickTarget)>,
86}
87
88impl ChatState {
89 pub fn new() -> Self {
91 Self {
92 scroll_offset: 0,
93 is_user_scrolling: false,
94 image_click_map: Vec::new(),
95 last_scroll_position: 0,
96 last_chat_area: None,
97 selection: None,
98 last_rendered_rows: Vec::new(),
99 frame_memo: None,
100 #[cfg(debug_assertions)]
101 debug_key_check: None,
102 }
103 }
104
105 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
108 let max_scroll = content_height.saturating_sub(viewport_height);
109 if self.is_user_scrolling {
110 let capped_offset = self.scroll_offset.min(max_scroll);
113 max_scroll.saturating_sub(capped_offset)
114 } else {
115 max_scroll
117 }
118 }
119
120 pub fn scroll_up(&mut self, amount: u16) {
122 self.is_user_scrolling = true;
123 self.scroll_offset = self.scroll_offset.saturating_add(amount);
124 self.selection = None;
127 }
128
129 pub fn scroll_down(&mut self, amount: u16) {
132 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
133 if self.scroll_offset == 0 {
134 self.is_user_scrolling = false;
136 }
137 self.selection = None;
138 }
139
140 pub fn resume_auto_scroll(&mut self) {
142 self.is_user_scrolling = false;
143 self.scroll_offset = 0;
144 }
145
146 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
149 let (_, area_y, _, area_height) = self.last_chat_area?;
150
151 if screen_row < area_y || screen_row >= area_y + area_height {
153 return None;
154 }
155
156 let viewport_row = screen_row - area_y;
158 let content_line = viewport_row + self.last_scroll_position;
159
160 self.image_click_map
162 .iter()
163 .find(|(line, _)| *line == content_line)
164 .map(|(_, target)| target)
165 }
166
167 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
171 let (area_x, area_y, _, area_height) = self.last_chat_area?;
172 if screen_row < area_y || screen_row >= area_y + area_height {
173 return None;
174 }
175 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
176 let col = screen_col.saturating_sub(area_x) as usize;
177 Some((content_line, col))
178 }
179
180 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
184 self.selection = self
185 .screen_to_content(screen_row, screen_col)
186 .map(|p| (p, p));
187 }
188
189 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
191 if let Some((anchor, _)) = self.selection
192 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
193 {
194 self.selection = Some((anchor, cursor));
195 }
196 }
197
198 pub fn selected_text(&self) -> Option<String> {
203 let (a, b) = self.selection?;
204 let (start, end) = if a <= b { (a, b) } else { (b, a) };
205 if self.last_rendered_rows.is_empty() {
206 return None;
207 }
208 let last = self.last_rendered_rows.len() - 1;
209 let (start_line, start_col) = (start.0.min(last), start.1);
210 let (end_line, end_col) = (end.0.min(last), end.1);
211
212 let mut out = String::new();
213 for line in start_line..=end_line {
214 let row = &self.last_rendered_rows[line];
215 let c0 = if line == start_line { start_col } else { 0 };
216 let c1 = if line == end_line {
217 end_col
218 } else {
219 usize::MAX
220 };
221 let mut piece = slice_by_cells(row, c0, c1).to_string();
222 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
227 while margin > 0 && piece.starts_with(' ') {
228 piece.remove(0);
229 margin -= 1;
230 }
231 out.push_str(piece.trim_end());
232 if line != end_line {
233 out.push('\n');
234 }
235 }
236 if out.is_empty() { None } else { Some(out) }
237 }
238}
239
240const SELECT_MARGIN_CELLS: usize = 2;
244
245fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
250 if width == 0 {
251 return vec![line];
252 }
253 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
254 if total <= width {
255 return vec![line];
256 }
257
258 let base = line.style;
259 let mut out: Vec<Line<'static>> = Vec::new();
260 let mut cur: Vec<Span<'static>> = Vec::new();
261 let mut cur_w = 0usize;
262 let mut on_first = true;
263
264 for span in line.spans {
265 let style = span.style;
266 let mut buf = String::new();
267 for ch in span.content.chars() {
268 let cw = ch.width().unwrap_or(0);
269 let floor = if on_first { 0 } else { indent };
272 if cur_w + cw > width && cur_w > floor {
273 if !buf.is_empty() {
274 cur.push(Span::styled(std::mem::take(&mut buf), style));
275 }
276 out.push(Line::from(std::mem::take(&mut cur)).style(base));
277 on_first = false;
278 cur.push(Span::styled(" ".repeat(indent), base));
279 cur_w = indent;
280 }
281 buf.push(ch);
282 cur_w += cw;
283 }
284 if !buf.is_empty() {
285 cur.push(Span::styled(buf, style));
286 }
287 }
288 if !cur.is_empty() {
289 out.push(Line::from(cur).style(base));
290 }
291 if out.is_empty() {
292 vec![Line::from("").style(base)]
293 } else {
294 out
295 }
296}
297
298fn byte_at_cell(s: &str, target: usize) -> usize {
302 if target == 0 {
303 return 0;
304 }
305 let mut width = 0usize;
306 for (idx, ch) in s.char_indices() {
307 if width >= target {
308 return idx;
309 }
310 width += ch.width().unwrap_or(0);
311 }
312 s.len()
313}
314
315fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
317 let start = byte_at_cell(s, c0);
318 let end = byte_at_cell(s, c1).max(start);
319 &s[start..end]
320}
321
322fn pad_to_cells(s: &str, cells: usize) -> String {
327 let w = s.width();
328 if w >= cells {
329 return s.to_string();
330 }
331 let mut out = String::with_capacity(s.len() + (cells - w));
332 out.push_str(s);
333 out.push_str(&" ".repeat(cells - w));
334 out
335}
336
337fn user_timestamp_padding(
342 role_prefix_width: usize,
343 text_width: usize,
344 timestamp_width: usize,
345 min_gap: usize,
346 content_width: usize,
347) -> usize {
348 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
349 min_gap + content_width.saturating_sub(total_used)
350}
351
352fn line_plain_text(line: &Line) -> String {
354 line.spans.iter().map(|s| s.content.as_ref()).collect()
355}
356
357fn clamp_to_u16(n: usize) -> u16 {
363 u16::try_from(n).unwrap_or(u16::MAX)
364}
365
366fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
370 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
371 let mut width = 0usize;
372 for span in line.spans.drain(..) {
373 let span_w = span.content.width();
374 let (span_start, span_end) = (width, width + span_w);
375 width = span_end;
376
377 let ov0 = c0.max(span_start);
378 let ov1 = c1.min(span_end);
379 if ov1 <= ov0 {
380 new_spans.push(span); continue;
382 }
383
384 let s = span.content.as_ref();
385 let b0 = byte_at_cell(s, ov0 - span_start);
386 let b1 = byte_at_cell(s, ov1 - span_start);
387 if b0 > 0 {
388 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
389 }
390 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
391 if b1 < s.len() {
392 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
393 }
394 }
395 line.spans = new_spans;
396}
397
398impl Default for ChatState {
399 fn default() -> Self {
400 Self::new()
401 }
402}
403
404pub struct ChatWidget<'a> {
406 pub messages: &'a [ChatMessage],
407 pub theme: &'a Theme,
408 pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
413 pub content_key: u64,
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
502pub(crate) fn frame_key(
517 content_key: u64,
518 theme_seed: u64,
519 content_width: u16,
520 show_reasoning: bool,
521) -> u64 {
522 let mut h = rustc_hash::FxHasher::default();
523 content_key.hash(&mut h);
524 theme_seed.hash(&mut h);
525 content_width.hash(&mut h);
526 show_reasoning.hash(&mut h);
527 chrono::Local::now().date_naive().hash(&mut h);
530 h.finish()
531}
532
533#[cfg(test)]
537pub(crate) fn test_content_key(messages: &[ChatMessage]) -> u64 {
538 let mut h = rustc_hash::FxHasher::default();
539 messages.len().hash(&mut h);
540 for msg in messages {
541 msg.content.hash(&mut h);
542 msg.thinking.hash(&mut h);
543 std::mem::discriminant(&msg.kind).hash(&mut h);
544 msg.actions.len().hash(&mut h);
545 }
546 h.finish()
547}
548
549#[cfg(debug_assertions)]
558pub(crate) fn frame_fingerprint(
559 messages: &[ChatMessage],
560 theme_seed: u64,
561 content_width: u16,
562 show_reasoning: bool,
563 blink_on: bool,
564) -> u64 {
565 use std::fmt::Write as _;
566 let mut h = rustc_hash::FxHasher::default();
567 theme_seed.hash(&mut h);
568 content_width.hash(&mut h);
569 show_reasoning.hash(&mut h);
570 if messages.iter().any(|m| {
571 m.actions
572 .iter()
573 .any(|a| matches!(a.result, ActionResult::Running))
574 }) {
575 blink_on.hash(&mut h);
576 }
577 messages.len().hash(&mut h);
578 for msg in messages {
579 msg.content.hash(&mut h);
580 msg.thinking.hash(&mut h);
581 msg.timestamp.timestamp().hash(&mut h);
584 msg.images
585 .as_ref()
586 .map_or(0, |imgs| imgs.len())
587 .hash(&mut h);
588 let mut hw = HashWrite(&mut h);
589 let _ = write!(
590 hw,
591 "{:?}|{:?}|{:?}|{:?}",
592 msg.role, msg.kind, msg.metadata, msg.actions
593 );
594 }
595 h.finish()
596}
597
598impl<'a> StatefulWidget for ChatWidget<'a> {
599 type State = ChatState;
600
601 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
602 let code_bg = self.theme.colors.code_background.to_color();
605 let theme_seed = {
606 let mut h = rustc_hash::FxHasher::default();
607 self.theme.colors.foreground.to_color().hash(&mut h);
608 code_bg.hash(&mut h);
609 self.theme.colors.header.to_color().hash(&mut h);
610 h.finish()
611 };
612
613 let content_width = area.width;
615 let content_area = area;
616
617 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
618
619 let frame_key = frame_key(
625 self.content_key,
626 theme_seed,
627 content_width,
628 self.show_reasoning,
629 );
630 #[cfg(debug_assertions)]
634 {
635 let content_hash = frame_fingerprint(
636 self.messages,
637 theme_seed,
638 content_width,
639 self.show_reasoning,
640 self.blink_on,
641 );
642 if let Some((last_key, last_hash)) = state.debug_key_check {
643 debug_assert!(
644 last_hash == content_hash || last_key != frame_key,
645 "chat frame content changed without a new memo key — a mutation \
646 bypassed ConversationHistory::messages_mut (stale transcript risk)",
647 );
648 }
649 state.debug_key_check = Some((frame_key, content_hash));
650 }
651 let memo = state.frame_memo.take().filter(|m| m.key == frame_key);
659
660 let memo = if let Some(memo) = memo {
661 state.image_click_map = memo.click_map.clone();
663 memo
664 } else {
665 let mut lines: Vec<Line<'static>> = Vec::new();
667
668 state.image_click_map.clear();
670
671 for (idx, msg) in self.messages.iter().enumerate() {
672 if matches!(msg.role, MessageRole::Tool) {
675 continue;
676 }
677
678 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
679 if let Some(event_lines) =
680 render_context_checkpoint_event(msg, self.theme, content_width as usize)
681 {
682 lines.extend(event_lines);
683 lines.push(Line::from(""));
684 }
685 continue;
686 }
687
688 if matches!(msg.kind, ChatMessageKind::RunSummary) {
693 lines.push(Line::from(Span::styled(
694 format!(" {}", msg.content),
695 Style::new().fg(self.theme.colors.text_meta.to_color()),
696 )));
697 lines.push(Line::from(""));
698 continue;
699 }
700
701 if matches!(
707 msg.kind,
708 ChatMessageKind::RecoveryNudge | ChatMessageKind::ContextMarker
709 ) {
710 continue;
711 }
712
713 if matches!(msg.role, MessageRole::System) {
718 let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
719 for wrapped_line in
720 wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
721 {
722 lines.push(Line::from(Span::styled(wrapped_line, meta)));
723 }
724 lines.push(Line::from(""));
725 continue;
726 }
727
728 let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
736 && self.messages[..idx]
737 .iter()
738 .rev()
739 .find(|m| !matches!(m.role, MessageRole::Tool))
740 .is_some_and(crate::render::mergeable_into);
741 if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
742 lines.pop();
743 }
744
745 let (role_prefix, role_color) = match msg.role {
746 MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
747 MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
748 MessageRole::System | MessageRole::Tool => {
749 unreachable!("System and Tool messages handled above")
750 },
751 };
752 let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
756
757 if matches!(msg.role, MessageRole::Assistant) {
758 if let Some(ref thinking) = msg.thinking {
760 let thinking_trimmed = thinking.trim();
762 if thinking_trimmed.is_empty()
763 || thinking_trimmed == "None"
764 || thinking_trimmed == "none"
765 {
766 } else if self.show_reasoning {
768 lines.push(Line::from(vec![
770 Span::styled(
771 "● ",
772 Style::new().fg(self.theme.colors.text_disabled.to_color()),
773 ),
774 Span::styled(
775 "Thinking...",
776 Style::new()
777 .fg(self.theme.colors.text_secondary.to_color())
778 .italic()
779 .dim(),
780 ),
781 ]));
782
783 let wrapped = wrap_text_with_indent(
785 thinking,
786 content_width as usize,
787 2, 2, );
790 for wrapped_line in wrapped {
791 lines.push(Line::from(Span::styled(
792 wrapped_line,
793 Style::new()
794 .fg(self.theme.colors.text_secondary.to_color())
795 .italic()
796 .dim(),
797 )));
798 }
799
800 lines.push(Line::from(""));
802 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
803 continue;
808 }
809 }
810
811 let mut hasher = rustc_hash::FxHasher::default();
819 msg.content.hash(&mut hasher);
820 theme_seed.hash(&mut hasher);
821 content_width.hash(&mut hasher);
822 stitch_onto_prev.hash(&mut hasher);
825 let cache_key = hasher.finish();
826
827 let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
828 cached.clone()
829 } else {
830 let block = wrap_assistant_content(
831 &msg.content,
832 content_width,
833 role_prefix,
834 role_color,
835 self.theme,
836 );
837 self.wrapped_line_cache.insert(cache_key, block.clone());
838 if self.wrapped_line_cache.len()
839 > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
840 {
841 let overflow = self.wrapped_line_cache.len()
846 - crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
847 let stale: Vec<u64> = self
848 .wrapped_line_cache
849 .keys()
850 .copied()
851 .filter(|&k| k != cache_key)
852 .take(overflow)
853 .collect();
854 for k in stale {
855 self.wrapped_line_cache.remove(&k);
856 }
857 }
858 block
859 };
860 lines.extend(wrapped);
861
862 if !msg.actions.is_empty() {
864 if !msg.content.trim().is_empty() {
866 lines.push(Line::from(""));
867 }
868 render_actions(
869 &msg.actions,
870 &mut lines,
871 self.theme,
872 content_width as usize,
873 self.blink_on,
874 );
875 }
876 } else {
877 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
879 let timestamp_width = formatted_timestamp.width();
883 let min_gap = 3; let cleaned_content = &msg.content;
887
888 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
892
893 let wrapped = wrap_text_with_indent(
895 cleaned_content,
896 content_width as usize,
897 first_line_reserved, 2, );
900
901 let band_start = lines.len();
902 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
903 if line_idx == 0 {
904 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
907
908 let mut spans = vec![
909 Span::styled(
910 format!("{} ", role_prefix),
911 Style::new().fg(role_color).bold(),
912 ),
913 Span::raw(text_content.to_string()),
914 ];
915
916 let pad = user_timestamp_padding(
919 role_prefix_width,
920 text_width,
921 timestamp_width,
922 min_gap,
923 content_width as usize,
924 );
925 spans.push(Span::raw(" ".repeat(pad)));
926 spans.push(Span::styled(
927 formatted_timestamp.clone(),
928 Style::new().fg(self.theme.colors.text_meta.to_color()),
929 ));
930
931 lines.push(Line::from(spans));
932 } else {
933 lines.push(Line::from(wrapped_line.clone()));
935 }
936 }
937
938 if matches!(msg.role, MessageRole::User) {
943 let user_bg = self.theme.colors.user_message_background.to_color();
944 let cw = content_width as usize;
945 for line in &mut lines[band_start..] {
946 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
947 if used < cw {
948 line.spans.push(Span::raw(" ".repeat(cw - used)));
949 }
950 line.style = line.style.bg(user_bg);
951 }
952 }
953 }
954
955 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
962 && let Some(ref images) = msg.images
963 && !images.is_empty()
964 {
965 for (i, _) in images.iter().enumerate() {
966 let content_line = lines.len();
972 let image_number =
973 msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
974 state.image_click_map.push((
975 clamp_to_u16(content_line),
976 ImageClickTarget {
977 message_index: idx,
978 image_index: i,
979 image_number,
980 },
981 ));
982 let label = image_number
987 .map(|n| format!("[Image #{n}]"))
988 .unwrap_or_else(|| format!("[Image #{}]", i + 1));
989 lines.push(Line::from(vec![
990 Span::styled(
991 " ⎿ ",
992 Style::new().fg(self.theme.colors.info.to_color()),
993 ),
994 Span::styled(
995 label,
996 Style::new().fg(self.theme.colors.info.to_color()).italic(),
997 ),
998 ]));
999 }
1000 }
1001
1002 lines.push(Line::from(""));
1003 }
1004
1005 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
1011
1012 FrameMemo {
1018 key: frame_key,
1019 lines,
1020 click_map: state.image_click_map.clone(),
1021 }
1022 };
1023
1024 let content_height = memo.lines.len();
1046 let viewport_height = area.height;
1047
1048 let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
1049 state.last_scroll_position = scroll_pos;
1050
1051 let first = (scroll_pos as usize).min(content_height);
1055 let last = first
1056 .saturating_add(viewport_height as usize)
1057 .min(content_height);
1058 let mut lines: Vec<Line<'static>> = memo.lines[first..last].to_vec();
1059
1060 if let Some((a, b)) = state.selection
1064 && !lines.is_empty()
1065 {
1066 let (start, end) = if a <= b { (a, b) } else { (b, a) };
1067 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
1068 for (offset, line) in lines.iter_mut().enumerate() {
1069 let content_idx = first + offset;
1070 if content_idx < start.0 || content_idx > end.0 {
1071 continue;
1072 }
1073 let c0 = if content_idx == start.0 { start.1 } else { 0 };
1074 let c1 = if content_idx == end.0 {
1075 end.1
1076 } else {
1077 usize::MAX
1078 };
1079 if c1 > c0 {
1080 highlight_line_cells(line, c0, c1, sel_style);
1081 }
1082 }
1083 }
1084
1085 let paragraph = Paragraph::new(lines).block(Block::default()).scroll((0, 0));
1087
1088 paragraph.render(content_area, buf);
1089
1090 state.frame_memo = Some(memo);
1092 }
1093}
1094
1095fn render_context_checkpoint_event(
1096 msg: &ChatMessage,
1097 theme: &Theme,
1098 viewport_width: usize,
1099) -> Option<Vec<Line<'static>>> {
1100 if !matches!(msg.role, MessageRole::User) {
1101 return None;
1102 }
1103
1104 let metadata = msg.metadata.as_ref();
1105 let trigger = metadata
1106 .and_then(|value| value.get("trigger"))
1107 .and_then(|value| value.as_str())
1108 .unwrap_or("manual");
1109 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
1110 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
1111 let archived_messages =
1112 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
1113 let preserved_messages =
1114 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
1115 let duration_secs = metadata
1116 .and_then(|value| value.get("duration_secs"))
1117 .and_then(|value| value.as_f64());
1118 let review_status = metadata
1119 .and_then(|value| value.get("review_status"))
1120 .and_then(|value| value.as_str());
1121 let review_error = metadata
1122 .and_then(|value| value.get("review_error"))
1123 .and_then(|value| value.as_str());
1124
1125 let action_color = theme.colors.info.to_color();
1126 let mut result = match (before_tokens, after_tokens) {
1127 (Some(before), Some(after)) => {
1128 format!(
1129 "{} -> {} tokens",
1130 format_compact_count(before),
1131 format_compact_count(after)
1132 )
1133 },
1134 _ => "Context compacted".to_string(),
1135 };
1136
1137 if let Some(count) = archived_messages {
1138 result.push_str(&format!(
1139 ", archived {} {}",
1140 count,
1141 if count == 1 { "message" } else { "messages" }
1142 ));
1143 }
1144 if let Some(count) = preserved_messages {
1145 result.push_str(&format!(
1146 ", preserved {} {}",
1147 count,
1148 if count == 1 { "message" } else { "messages" }
1149 ));
1150 }
1151 if let Some(status) = review_status {
1152 match status {
1153 "reviewed" => result.push_str(", reviewed"),
1154 "draft_validated" => result.push_str(", validated draft"),
1155 _ => {},
1156 }
1157 }
1158 result = append_action_duration(result, duration_secs);
1159
1160 let mut lines = vec![Line::from(vec![
1161 Span::styled("● ", Style::new().fg(action_color).bold()),
1162 Span::styled("Compact(", Style::new().fg(action_color).bold()),
1163 Span::styled(
1164 trigger.to_string(),
1165 Style::new().fg(theme.colors.text_secondary.to_color()),
1166 ),
1167 Span::styled(")", Style::new().fg(action_color).bold()),
1168 ])];
1169 lines.extend(wrap_styled_line(
1170 Line::from(vec![
1171 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1172 Span::styled(
1173 result,
1174 Style::new().fg(theme.colors.text_secondary.to_color()),
1175 ),
1176 ]),
1177 viewport_width,
1178 4,
1179 ));
1180
1181 if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
1182 lines.extend(wrap_styled_line(
1183 Line::from(vec![
1184 Span::styled(" ", Style::new().fg(action_color)),
1185 Span::styled(
1186 format!("review: {}", compact_inline_error(error, 180)),
1187 Style::new().fg(theme.colors.warning.to_color()),
1188 ),
1189 ]),
1190 viewport_width,
1191 4,
1192 ));
1193 }
1194
1195 Some(lines)
1196}
1197
1198fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1199 value
1200 .get(key)?
1201 .as_u64()
1202 .and_then(|value| usize::try_from(value).ok())
1203}
1204
1205fn compact_inline_error(text: &str, max_chars: usize) -> String {
1206 let text = text.trim();
1207 if text.chars().count() <= max_chars {
1208 return text.to_string();
1209 }
1210 let keep = max_chars.saturating_sub(3);
1211 let mut out: String = text.chars().take(keep).collect();
1212 out.push_str("...");
1213 out
1214}
1215
1216fn expand_tabs(s: &str) -> String {
1225 const TAB_WIDTH: usize = 4;
1226 if !s.contains('\t') {
1227 return s.to_string();
1228 }
1229 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1230 let mut col = 0usize;
1231 for ch in s.chars() {
1232 if ch == '\t' {
1233 let n = TAB_WIDTH - (col % TAB_WIDTH);
1234 for _ in 0..n {
1235 out.push(' ');
1236 }
1237 col += n;
1238 } else {
1239 out.push(ch);
1240 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1241 }
1242 }
1243 out
1244}
1245
1246fn render_actions(
1247 actions: &[ActionDisplay],
1248 lines: &mut Vec<Line>,
1249 theme: &Theme,
1250 viewport_width: usize,
1251 blink_on: bool,
1252) {
1253 for (action_idx, action) in actions.iter().enumerate() {
1254 if action_idx > 0 {
1255 lines.push(Line::from(""));
1256 }
1257 if let Some(meta) = &action.metadata
1262 && let ToolMetadata::Questions {
1263 answers,
1264 remembered,
1265 } = &meta.detail
1266 && matches!(action.result, ActionResult::Success { .. })
1267 {
1268 render_question_answers(answers, *remembered, lines, theme, viewport_width);
1269 continue;
1270 }
1271 if let Some(meta) = &action.metadata
1275 && let ToolMetadata::Plan { path, body, .. } = &meta.detail
1276 && matches!(action.result, ActionResult::Success { .. })
1277 {
1278 render_plan_approved(path, body, lines, theme, viewport_width);
1279 continue;
1280 }
1281 let action_color = match action.action_type.as_str() {
1282 "Write" | "Update" => theme.colors.success.to_color(),
1283 "Delete" => theme.colors.warning.to_color(),
1284 _ => theme.colors.info.to_color(),
1285 };
1286
1287 let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1295 Style::new()
1296 .fg(theme.colors.text_disabled.to_color())
1297 .bold()
1298 } else {
1299 Style::new().fg(action_color).bold()
1300 };
1301 push_action_header(
1302 lines,
1303 action,
1304 action_color,
1305 dot_style,
1306 theme,
1307 viewport_width,
1308 );
1309
1310 match &action.result {
1311 ActionResult::Running => {},
1314 ActionResult::Success { .. } => {
1315 let result_msg = match &action.details {
1317 ActionDetails::FileContent { line_count, .. } => {
1318 let base = format!(
1319 "{} {} written",
1320 line_count,
1321 if *line_count == 1 { "line" } else { "lines" }
1322 );
1323 append_action_duration(base, action.duration_seconds)
1324 },
1325 ActionDetails::Diff { summary, .. } => summary.clone(),
1326 ActionDetails::Preview { text, .. } => text.clone(),
1327 ActionDetails::Simple => {
1331 append_action_duration(String::new(), action.duration_seconds)
1332 },
1333 };
1334
1335 for (idx, line) in result_msg.lines().enumerate() {
1336 let prefix = if idx == 0 { " ⎿ " } else { " " };
1337 lines.extend(wrap_styled_line(
1340 Line::from(vec![
1341 Span::styled(prefix, Style::new().fg(action_color)),
1342 Span::styled(
1343 line.to_string(),
1344 Style::new().fg(theme.colors.text_secondary.to_color()),
1345 ),
1346 ]),
1347 viewport_width,
1348 4,
1349 ));
1350 }
1351
1352 if let ActionDetails::FileContent {
1354 content,
1355 line_count,
1356 } = &action.details
1357 {
1358 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1359 if !preview_lines.is_empty() {
1360 lines.push(Line::from(vec![Span::styled(
1361 " ",
1362 Style::new().fg(action_color),
1363 )]));
1364
1365 let preview_content = preview_lines.join("\n");
1366 let mut parsed = parse_markdown(
1367 &format!("```\n{}\n```", preview_content),
1368 theme,
1369 viewport_width.saturating_sub(4),
1370 );
1371 for parsed_line in parsed.iter_mut() {
1372 let mut new_spans =
1373 vec![Span::styled(" ", Style::new().fg(action_color))];
1374 new_spans.append(&mut parsed_line.line.spans);
1375 parsed_line.line.spans = new_spans;
1376 }
1377 lines.extend(
1381 parsed
1382 .into_iter()
1383 .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1384 );
1385
1386 if *line_count > 10 {
1387 lines.push(Line::from(vec![
1388 Span::styled(" ", Style::new().fg(action_color)),
1389 Span::styled(
1390 format!("... ({} more lines)", line_count - 10),
1391 Style::new()
1392 .fg(theme.colors.text_disabled.to_color())
1393 .italic(),
1394 ),
1395 ]));
1396 }
1397 }
1398 }
1399
1400 if let ActionDetails::Diff { diff, .. } = &action.details {
1402 let diff_lines: Vec<&str> = diff.lines().collect();
1403 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1404
1405 if !display_lines.is_empty() {
1406 let removed_bg = theme.colors.diff_removed_bg.to_color();
1407 let added_bg = theme.colors.diff_added_bg.to_color();
1408
1409 for diff_line in &display_lines {
1410 let diff_line = expand_tabs(diff_line);
1417 match parse_diff_line(&diff_line) {
1422 DiffLineKind::Removed => {
1423 push_wrapped_diff_rows(
1424 lines,
1425 format!(" {}", diff_line),
1426 Style::new()
1427 .fg(theme.colors.error.to_color())
1428 .bg(removed_bg),
1429 viewport_width,
1430 );
1431 },
1432 DiffLineKind::Added => {
1433 push_wrapped_diff_rows(
1434 lines,
1435 format!(" {}", diff_line),
1436 Style::new()
1437 .fg(theme.colors.success.to_color())
1438 .bg(added_bg),
1439 viewport_width,
1440 );
1441 },
1442 DiffLineKind::Context => {
1443 lines.extend(wrap_preformatted(
1446 Line::from(vec![
1447 Span::styled(" ", Style::new().fg(action_color)),
1448 Span::styled(
1449 diff_line,
1450 Style::new()
1451 .fg(theme.colors.text_secondary.to_color()),
1452 ),
1453 ]),
1454 viewport_width,
1455 6,
1456 ));
1457 },
1458 }
1459 }
1460
1461 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1462 if remaining > 0 {
1463 lines.push(Line::from(vec![
1464 Span::styled(" ", Style::new().fg(action_color)),
1465 Span::styled(
1466 format!("... ({} more lines)", remaining),
1467 Style::new()
1468 .fg(theme.colors.text_disabled.to_color())
1469 .italic(),
1470 ),
1471 ]));
1472 }
1473 }
1474 }
1475 },
1476 ActionResult::Error { error } => {
1477 let error =
1478 append_action_duration(format!("Error: {}", error), action.duration_seconds);
1479 for (idx, err_line) in error.lines().enumerate() {
1483 let prefix = if idx == 0 { " ⎿ " } else { " " };
1484 lines.extend(wrap_styled_line(
1485 Line::from(vec![
1486 Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1487 Span::styled(
1488 err_line.to_string(),
1489 Style::new().fg(theme.colors.error.to_color()),
1490 ),
1491 ]),
1492 viewport_width,
1493 4,
1494 ));
1495 }
1496 },
1497 }
1498 }
1499}
1500
1501fn render_plan_approved(
1505 path: &str,
1506 body: &str,
1507 lines: &mut Vec<Line>,
1508 theme: &Theme,
1509 viewport_width: usize,
1510) {
1511 lines.push(Line::from(Span::styled(
1512 format!("● User approved the plan — {path}"),
1513 Style::new().fg(theme.colors.success.to_color()),
1514 )));
1515 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1516 let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
1519 let mut first_row = true;
1520 for mut parsed_line in parsed {
1521 let gutter = if first_row { " ⎿ " } else { " " };
1522 first_row = false;
1523 let mut spans = vec![Span::styled(gutter, gutter_style)];
1524 spans.append(&mut parsed_line.line.spans);
1525 lines.push(Line::from(spans));
1526 }
1527}
1528
1529fn render_question_answers(
1533 answers: &[QuestionAnswer],
1534 remembered: bool,
1535 lines: &mut Vec<Line>,
1536 theme: &Theme,
1537 viewport_width: usize,
1538) {
1539 let header = if remembered {
1540 "User answered the model's questions (remembered):"
1541 } else {
1542 "User answered the model's questions:"
1543 };
1544 lines.push(Line::from(Span::styled(
1545 format!("● {header}"),
1546 Style::new().fg(theme.colors.text_primary.to_color()),
1547 )));
1548
1549 let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1550 let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1551 let note_style = Style::new()
1552 .fg(theme.colors.text_disabled.to_color())
1553 .italic();
1554 let wrap_width = viewport_width.saturating_sub(4);
1558 let mut first_row = true;
1559 for answer in answers {
1560 let value = if answer.selected.is_empty() {
1561 "(no selection)".to_string()
1562 } else {
1563 answer.selected.join(", ")
1564 };
1565 let entry = format!("· {} → {}", answer.question, value);
1566 let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1567 .into_iter()
1568 .map(|row| (row, text_style))
1569 .collect();
1570 if let Some(note) = &answer.note {
1571 rows.extend(
1572 wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1573 .into_iter()
1574 .map(|row| (row, note_style)),
1575 );
1576 }
1577 for (row, style) in rows {
1578 let gutter = if first_row { " ⎿ " } else { " " };
1579 first_row = false;
1580 lines.push(Line::from(vec![
1581 Span::styled(gutter, gutter_style),
1582 Span::styled(row, style),
1583 ]));
1584 }
1585 }
1586}
1587
1588const MAX_ACTION_HEADER_ROWS: usize = 4;
1592
1593fn push_action_header(
1601 lines: &mut Vec<Line>,
1602 action: &ActionDisplay,
1603 action_color: Color,
1604 dot_style: Style,
1605 theme: &Theme,
1606 viewport_width: usize,
1607) {
1608 let bold = Style::new().fg(action_color).bold();
1609 let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1610 if action.target.is_empty() {
1611 lines.push(Line::from(vec![
1612 Span::styled("● ", dot_style),
1613 Span::styled(format!("{}()", action.action_type), bold),
1614 ]));
1615 return;
1616 }
1617
1618 let open = format!("{}(", action.action_type);
1619 let first_indent = 2 + open.width();
1623 let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1624 let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1625 let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1626 rows.truncate(MAX_ACTION_HEADER_ROWS);
1627
1628 let last = rows.len().saturating_sub(1);
1629 for (i, row) in rows.into_iter().enumerate() {
1630 let mut spans = if i == 0 {
1631 vec![
1632 Span::styled("● ", dot_style),
1633 Span::styled(open.clone(), bold),
1634 Span::styled(row.trim_start().to_string(), secondary),
1635 ]
1636 } else {
1637 vec![Span::styled(row, secondary)]
1638 };
1639 if i == last {
1640 if truncated {
1641 spans.push(Span::styled(
1642 "…",
1643 Style::new().fg(theme.colors.text_disabled.to_color()),
1644 ));
1645 }
1646 spans.push(Span::styled(")", bold));
1647 }
1648 lines.push(Line::from(spans));
1649 }
1650}
1651
1652fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1656 for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1657 let padded = pad_to_cells(&line_plain_text(&row), width);
1658 lines.push(Line::from(Span::styled(padded, style)));
1659 }
1660}
1661
1662fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1663 if let Some(seconds) = duration_seconds {
1664 if !text.is_empty() {
1667 text.push_str(", ");
1668 }
1669 text.push_str("took ");
1670 text.push_str(&format_action_duration(seconds));
1671 }
1672 text
1673}
1674
1675fn format_action_duration(seconds: f64) -> String {
1676 if seconds < 1.0 {
1677 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1678 } else if seconds < 10.0 {
1679 format!("{:.1}s", seconds)
1680 } else {
1681 format!("{}s", seconds.round() as u64)
1682 }
1683}
1684
1685fn hard_break_plain_token(
1698 token: &str,
1699 out: &mut Vec<String>,
1700 current_line: &mut String,
1701 current_length: &mut usize,
1702 width: usize,
1703 continuation_indent: usize,
1704 initial_budget: usize,
1705) {
1706 let cont_budget = width.saturating_sub(continuation_indent).max(1);
1707 let mut line_budget = initial_budget.max(1);
1708
1709 if *current_length > 0 {
1713 out.push(std::mem::take(current_line));
1714 current_line.push_str(&" ".repeat(continuation_indent));
1715 *current_length = 0;
1716 line_budget = cont_budget;
1717 }
1718
1719 for ch in token.chars() {
1720 let cw = ch.width().unwrap_or(0);
1721 if *current_length + cw > line_budget && *current_length > 0 {
1724 out.push(std::mem::take(current_line));
1725 current_line.push_str(&" ".repeat(continuation_indent));
1726 *current_length = 0;
1727 line_budget = cont_budget;
1728 }
1729 current_line.push(ch);
1730 *current_length += cw;
1731 }
1732}
1733
1734fn wrap_text_with_indent(
1743 text: &str,
1744 width: usize,
1745 first_line_indent: usize,
1746 continuation_indent: usize,
1747) -> Vec<String> {
1748 let mut wrapped_lines = Vec::new();
1749
1750 for (line_idx, line) in text.lines().enumerate() {
1751 if line.is_empty() {
1752 wrapped_lines.push(String::new());
1753 continue;
1754 }
1755
1756 let current_indent = if line_idx == 0 {
1757 first_line_indent
1758 } else {
1759 continuation_indent
1760 };
1761 let available_width = width.saturating_sub(current_indent);
1762
1763 if available_width == 0 {
1764 wrapped_lines.push(" ".repeat(current_indent));
1765 continue;
1766 }
1767
1768 let words: Vec<&str> = line.split_whitespace().collect();
1769 if words.is_empty() {
1770 wrapped_lines.push(" ".repeat(current_indent));
1771 continue;
1772 }
1773
1774 let mut current_line = String::with_capacity(width);
1775 current_line.push_str(&" ".repeat(current_indent));
1776 let mut current_length = 0;
1779
1780 for (word_idx, word) in words.iter().enumerate() {
1781 let word_width = word.width();
1782
1783 if word_idx == 0 {
1784 if word_width <= available_width {
1785 current_line.push_str(word);
1787 current_length = word_width;
1788 } else {
1789 hard_break_plain_token(
1794 word,
1795 &mut wrapped_lines,
1796 &mut current_line,
1797 &mut current_length,
1798 width,
1799 continuation_indent,
1800 available_width,
1801 );
1802 }
1803 } else if current_length + 1 + word_width <= available_width {
1804 current_line.push(' ');
1807 current_line.push_str(word);
1808 current_length += 1 + word_width;
1809 } else if word_width <= available_width {
1810 wrapped_lines.push(current_line);
1812 current_line = String::with_capacity(width);
1813 current_line.push_str(&" ".repeat(continuation_indent));
1814 current_line.push_str(word);
1815 current_length = word_width;
1816 } else {
1817 hard_break_plain_token(
1820 word,
1821 &mut wrapped_lines,
1822 &mut current_line,
1823 &mut current_length,
1824 width,
1825 continuation_indent,
1826 available_width,
1827 );
1828 }
1829 }
1830
1831 if !current_line.trim().is_empty() {
1833 wrapped_lines.push(current_line);
1834 }
1835 }
1836
1837 wrapped_lines
1838}
1839
1840#[allow(clippy::too_many_arguments)]
1857fn hard_break_styled_word(
1858 fragments: &[(String, Style)],
1859 result_lines: &mut Vec<Line<'static>>,
1860 current_line_spans: &mut Vec<Span<'static>>,
1861 current_line_width: &mut usize,
1862 continuation_indent: usize,
1863 continuation_capacity: usize,
1864 mut line_capacity: usize,
1865) {
1866 for (text, style) in fragments {
1867 let mut buf = String::new();
1868 for ch in text.chars() {
1869 let cw = ch.width().unwrap_or(0);
1870 if *current_line_width + cw > line_capacity && *current_line_width > 0 {
1873 if !buf.is_empty() {
1874 current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
1875 }
1876 result_lines.push(Line::from(std::mem::take(current_line_spans)));
1877 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1878 *current_line_width = 0;
1879 line_capacity = continuation_capacity.max(1);
1880 }
1881 buf.push(ch);
1882 *current_line_width += cw;
1883 }
1884 if !buf.is_empty() {
1885 current_line_spans.push(Span::styled(buf, *style));
1886 }
1887 }
1888}
1889
1890pub(crate) fn wrap_styled_line(
1906 line: Line<'static>,
1907 width: usize,
1908 continuation_indent: usize,
1909) -> Vec<Line<'static>> {
1910 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1915
1916 if total_width <= width {
1918 return vec![line];
1919 }
1920
1921 let mut result_lines = Vec::new();
1923 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
1924 let mut current_line_width = 0usize;
1925 let available_width = width.saturating_sub(continuation_indent);
1926
1927 let leading_indent: usize = {
1935 let mut n = 0;
1936 for span in &line.spans {
1937 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1938 n += spaces;
1939 if spaces < span.content.len() {
1940 break; }
1942 }
1943 n
1944 };
1945
1946 struct Word {
1952 fragments: Vec<(String, Style)>,
1953 separator: Style,
1957 }
1958 let mut words: Vec<Word> = Vec::new();
1959 let mut current_word: Vec<(String, Style)> = Vec::new();
1960 let mut separator = Style::default();
1963 for span in &line.spans {
1964 let mut frag = String::new();
1965 for ch in span.content.chars() {
1966 if ch.is_whitespace() {
1967 if !frag.is_empty() {
1968 current_word.push((std::mem::take(&mut frag), span.style));
1969 }
1970 if !current_word.is_empty() {
1971 words.push(Word {
1972 fragments: std::mem::take(&mut current_word),
1973 separator,
1974 });
1975 }
1976 separator = span.style;
1980 } else {
1981 frag.push(ch);
1982 }
1983 }
1984 if !frag.is_empty() {
1985 current_word.push((frag, span.style));
1986 }
1987 }
1988 if !current_word.is_empty() {
1989 words.push(Word {
1990 fragments: current_word,
1991 separator,
1992 });
1993 }
1994
1995 fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
1996 for (text, style) in word {
1997 spans.push(Span::styled(text, style));
1998 }
1999 }
2000
2001 for Word {
2002 fragments: word,
2003 separator,
2004 } in words
2005 {
2006 let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
2007
2008 if current_line_width == 0 && result_lines.is_empty() {
2009 if leading_indent > 0 {
2013 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
2014 current_line_width += leading_indent;
2015 }
2016 if word_width <= available_width {
2017 current_line_width += word_width;
2018 emit_word(&mut current_line_spans, word);
2019 } else {
2020 hard_break_styled_word(
2026 &word,
2027 &mut result_lines,
2028 &mut current_line_spans,
2029 &mut current_line_width,
2030 continuation_indent,
2031 available_width,
2032 width,
2033 );
2034 }
2035 continue;
2036 }
2037
2038 let sep = usize::from(current_line_width > 0);
2044 if current_line_width + sep + word_width <= available_width {
2045 if sep == 1 {
2047 current_line_spans.push(Span::styled(" ", separator));
2048 }
2049 current_line_width += sep + word_width;
2050 emit_word(&mut current_line_spans, word);
2051 } else if word_width <= available_width {
2052 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
2054 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
2055 current_line_width = word_width;
2056 emit_word(&mut current_line_spans, word);
2057 } else {
2058 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
2062 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
2063 current_line_width = 0;
2064 hard_break_styled_word(
2065 &word,
2066 &mut result_lines,
2067 &mut current_line_spans,
2068 &mut current_line_width,
2069 continuation_indent,
2070 available_width,
2071 available_width,
2072 );
2073 }
2074 }
2075
2076 if !current_line_spans.is_empty() {
2078 result_lines.push(Line::from(current_line_spans));
2079 }
2080
2081 if result_lines.is_empty() {
2082 vec![line]
2083 } else {
2084 result_lines
2085 }
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090 use super::*;
2091
2092 #[test]
2093 fn question_answers_render_as_question_arrow_answer_block() {
2094 use crate::domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
2095
2096 let theme = Theme::dark();
2097 let answers = vec![
2098 QuestionAnswer {
2099 header: "Snack".to_string(),
2100 question: "Which snack fuels your next coding session?".to_string(),
2101 selected: vec!["Coffee (Recommended)".to_string()],
2102 note: None,
2103 },
2104 QuestionAnswer {
2105 header: "Powers".to_string(),
2106 question: "Which superpowers would you take?".to_string(),
2107 selected: vec![
2108 "Read any codebase instantly".to_string(),
2109 "Bugs reproduce on demand".to_string(),
2110 ],
2111 note: Some("only on weekdays".to_string()),
2112 },
2113 ];
2114 let action = ActionDisplay {
2115 action_type: "ask_user_question".to_string(),
2116 target: String::new(),
2117 result: ActionResult::Success {
2118 output: String::new(),
2119 images: None,
2120 },
2121 details: ActionDetails::Simple,
2122 duration_seconds: Some(93.0),
2123 metadata: Some(ToolRunMetadata {
2124 detail: ToolMetadata::Questions {
2125 answers,
2126 remembered: false,
2127 },
2128 ..Default::default()
2129 }),
2130 };
2131
2132 let mut lines: Vec<Line> = Vec::new();
2133 render_actions(&[action], &mut lines, &theme, 120, true);
2134 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2135 let all = rows.join("\n");
2136
2137 assert_eq!(rows[0], "● User answered the model's questions:");
2138 assert!(
2139 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
2140 "got {:?}",
2141 rows[1]
2142 );
2143 assert!(
2144 all.contains(
2145 "· Which superpowers would you take? → Read any codebase instantly, \
2146 Bugs reproduce on demand"
2147 ),
2148 "got {all}"
2149 );
2150 assert!(all.contains("(note: only on weekdays)"), "got {all}");
2151 assert!(!all.contains("ask_user_question("), "got {all}");
2153 assert!(!all.contains("took"), "got {all}");
2154 }
2155
2156 #[test]
2157 fn diff_background_fills_full_width_with_tabs() {
2158 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
2163 use ratatui::Terminal;
2164 use ratatui::backend::TestBackend;
2165
2166 let theme = Theme::dark();
2167 let added_bg = theme.colors.diff_added_bg.to_color();
2168 let removed_bg = theme.colors.diff_removed_bg.to_color();
2169 let diff = format!(
2171 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
2172 m = DIFF_REMOVED_MARKER,
2173 p = DIFF_ADDED_MARKER
2174 );
2175 let action = ActionDisplay {
2176 action_type: "Update".to_string(),
2177 target: "engine.ts".to_string(),
2178 result: ActionResult::Success {
2179 output: String::new(),
2180 images: None,
2181 },
2182 details: ActionDetails::Diff {
2183 summary: "ok".to_string(),
2184 diff,
2185 },
2186 duration_seconds: Some(0.3),
2187 metadata: None,
2188 };
2189
2190 let width: u16 = 60;
2191 let mut lines: Vec<Line> = Vec::new();
2192 render_actions(&[action], &mut lines, &theme, width as usize, true);
2193 let h = lines.len() as u16;
2194 let backend = TestBackend::new(width, h);
2195 let mut term = Terminal::new(backend).unwrap();
2196 term.draw(|f| {
2197 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
2198 })
2199 .unwrap();
2200 let buf = term.backend().buffer();
2201
2202 for y in 0..h {
2203 let is_diff_row = (0..width).any(|x| {
2204 let bg = buf[(x, y)].bg;
2205 bg == added_bg || bg == removed_bg
2206 });
2207 if !is_diff_row {
2208 continue;
2209 }
2210 for x in 0..width {
2211 let bg = buf[(x, y)].bg;
2212 assert!(
2213 bg == added_bg || bg == removed_bg,
2214 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
2215 );
2216 }
2217 }
2218 }
2219
2220 fn assert_rows_fit(lines: &[Line], width: usize) {
2223 for (i, line) in lines.iter().enumerate() {
2224 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
2225 assert!(
2226 w <= width,
2227 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
2228 line_plain_text(line)
2229 );
2230 }
2231 }
2232
2233 #[test]
2234 fn action_header_and_error_wrap_instead_of_clipping() {
2235 let theme = Theme::dark();
2239 let action = ActionDisplay {
2240 action_type: "Error".to_string(),
2241 target: "Backend error".to_string(),
2242 result: ActionResult::Error {
2243 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(),
2244 },
2245 details: ActionDetails::Simple,
2246 duration_seconds: None,
2247 metadata: None,
2248 };
2249
2250 let width = 60usize;
2251 let mut lines: Vec<Line> = Vec::new();
2252 render_actions(&[action], &mut lines, &theme, width, true);
2253
2254 assert_rows_fit(&lines, width);
2255 let rendered = lines
2256 .iter()
2257 .map(line_plain_text)
2258 .collect::<Vec<_>>()
2259 .join("\n");
2260 assert!(rendered.contains("invalid_request_error"));
2263 assert!(
2264 lines.len() > 2,
2265 "a 140-cell error at width 60 must span multiple rows"
2266 );
2267 }
2268
2269 #[test]
2270 fn action_header_wraps_long_command_and_keeps_closing_paren() {
2271 let theme = Theme::dark();
2272 let action = ActionDisplay {
2273 action_type: "Bash".to_string(),
2274 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
2275 that keeps going well past the sixty cell viewport edge"
2276 .to_string(),
2277 result: ActionResult::Success {
2278 output: String::new(),
2279 images: None,
2280 },
2281 details: ActionDetails::Simple,
2282 duration_seconds: Some(0.1),
2283 metadata: None,
2284 };
2285
2286 let width = 60usize;
2287 let mut lines: Vec<Line> = Vec::new();
2288 render_actions(&[action], &mut lines, &theme, width, true);
2289
2290 assert_rows_fit(&lines, width);
2291 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2292 assert!(rows[0].starts_with("● Bash("));
2293 assert!(
2294 rows.len() >= 2,
2295 "the long command must wrap the header across rows"
2296 );
2297 let last_target_row = rows
2298 .iter()
2299 .rfind(|r| r.trim_end().ends_with(')'))
2300 .expect("wrapped header must still close its paren");
2301 assert!(last_target_row.trim_end().ends_with(')'));
2302 }
2303
2304 #[test]
2305 fn action_header_caps_rows_and_marks_truncation() {
2306 let theme = Theme::dark();
2309 let action = ActionDisplay {
2310 action_type: "Bash".to_string(),
2311 target: "word ".repeat(400),
2312 result: ActionResult::Success {
2313 output: String::new(),
2314 images: None,
2315 },
2316 details: ActionDetails::Simple,
2317 duration_seconds: None,
2318 metadata: None,
2319 };
2320
2321 let width = 60usize;
2322 let mut lines: Vec<Line> = Vec::new();
2323 render_actions(&[action], &mut lines, &theme, width, true);
2324
2325 assert_rows_fit(&lines, width);
2326 let header_rows: Vec<String> = lines
2327 .iter()
2328 .map(line_plain_text)
2329 .take_while(|r| !r.trim_start().starts_with('⎿'))
2330 .collect();
2331 assert_eq!(
2332 header_rows.len(),
2333 MAX_ACTION_HEADER_ROWS,
2334 "header must cap at MAX_ACTION_HEADER_ROWS rows"
2335 );
2336 assert!(
2337 header_rows.last().unwrap().trim_end().ends_with("…)"),
2338 "capped header must end with …) — got {:?}",
2339 header_rows.last().unwrap()
2340 );
2341 }
2342
2343 #[test]
2344 fn action_header_preserves_multiline_command_rows() {
2345 let theme = Theme::dark();
2349 let action = ActionDisplay {
2350 action_type: "Bash".to_string(),
2351 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
2352 result: ActionResult::Success {
2353 output: String::new(),
2354 images: None,
2355 },
2356 details: ActionDetails::Simple,
2357 duration_seconds: None,
2358 metadata: None,
2359 };
2360
2361 let mut lines: Vec<Line> = Vec::new();
2362 render_actions(&[action], &mut lines, &theme, 80, true);
2363
2364 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2365 assert!(rows[0].contains("python3 - << 'PY'"));
2366 assert!(rows[1].contains("from PIL import Image"));
2367 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
2368 }
2369
2370 #[test]
2371 fn action_result_summary_wraps_instead_of_clipping() {
2372 let theme = Theme::dark();
2373 let action = ActionDisplay {
2374 action_type: "Tasks".to_string(),
2375 target: "update 3 steps".to_string(),
2376 result: ActionResult::Success {
2377 output: String::new(),
2378 images: None,
2379 },
2380 details: ActionDetails::Preview {
2381 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2382 placeholders kept intentionally until real data available. \
2383 Task 2 and 6 deferred., to revisit later"
2384 .to_string(),
2385 line_count: None,
2386 },
2387 duration_seconds: None,
2388 metadata: None,
2389 };
2390
2391 let width = 60usize;
2392 let mut lines: Vec<Line> = Vec::new();
2393 render_actions(&[action], &mut lines, &theme, width, true);
2394
2395 assert_rows_fit(&lines, width);
2396 let rendered = lines
2397 .iter()
2398 .map(line_plain_text)
2399 .collect::<Vec<_>>()
2400 .join("\n");
2401 assert!(
2402 rendered.contains("revisit later"),
2403 "the summary's tail must survive the wrap instead of being clipped"
2404 );
2405 }
2406
2407 #[test]
2408 fn wrapped_line_cache_hit_matches_cache_miss() {
2409 use ratatui::Terminal;
2416 use ratatui::backend::TestBackend;
2417
2418 let theme = Theme::dark();
2419 let messages = vec![
2420 ChatMessage::assistant(
2421 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2422 across this narrow viewport more than once.\n\n\
2423 - a list item that also keeps going past the edge so it wraps too\n\
2424 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2425 ),
2426 ChatMessage::assistant("Short follow-up paragraph."),
2427 ];
2428
2429 let (width, height): (u16, u16) = (40, 40);
2430 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2431 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2432 let mut state = ChatState::new();
2433 term.draw(|f| {
2434 let widget = ChatWidget {
2435 messages: &messages,
2436 content_key: test_content_key(&messages),
2437 theme: &theme,
2438 wrapped_line_cache: cache,
2439 show_reasoning: true,
2440 blink_on: true,
2441 };
2442 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2443 })
2444 .unwrap();
2445 term.backend().buffer().clone()
2446 };
2447
2448 let mut shared = FxHashMap::default();
2449 let miss = render_once(&mut shared);
2450 assert!(!shared.is_empty(), "first render must populate the cache");
2451 let hit = render_once(&mut shared);
2452 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2453
2454 let mut cold_cache = FxHashMap::default();
2455 let cold = render_once(&mut cold_cache);
2456 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2457 }
2458
2459 #[test]
2460 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2461 use ratatui::Terminal;
2465 use ratatui::backend::TestBackend;
2466
2467 let theme = Theme::dark();
2468 let messages = vec![ChatMessage::system(
2469 "Heads up: this model reports no vision capability",
2470 )];
2471 let (width, height): (u16, u16) = (60, 10);
2472 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2473 let mut state = ChatState::new();
2474 let mut cache = FxHashMap::default();
2475 term.draw(|f| {
2476 let widget = ChatWidget {
2477 messages: &messages,
2478 content_key: test_content_key(&messages),
2479 theme: &theme,
2480 wrapped_line_cache: &mut cache,
2481 show_reasoning: true,
2482 blink_on: true,
2483 };
2484 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2485 })
2486 .unwrap();
2487 let buf = term.backend().buffer();
2488 let rows: Vec<String> = (0..height)
2489 .map(|y| {
2490 (0..width)
2491 .map(|x| buf[(x, y)].symbol().to_string())
2492 .collect::<String>()
2493 })
2494 .collect();
2495 let all = rows.join("\n");
2496 assert!(
2497 !all.contains('●'),
2498 "no role bullet on system notices: {all}"
2499 );
2500 assert!(
2501 !all.contains("Today at"),
2502 "no timestamp on system notices: {all}"
2503 );
2504 let row = rows
2505 .iter()
2506 .position(|r| r.contains("Heads up"))
2507 .expect("notice rendered");
2508 assert!(
2509 rows[row].starts_with(" Heads up"),
2510 "2-space indent, nothing in the gutter: {:?}",
2511 rows[row]
2512 );
2513 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2515 buf[(col as u16, row as u16)].fg,
2516 theme.colors.text_meta.to_color(),
2517 "notice text uses the muted meta gray"
2518 );
2519 }
2520
2521 #[test]
2522 fn byte_at_cell_clamps_and_respects_cjk() {
2523 assert_eq!(byte_at_cell("hello", 0), 0);
2524 assert_eq!(byte_at_cell("hello", 3), 3);
2525 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2528 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2531 }
2532
2533 #[test]
2534 fn slice_by_cells_extracts_display_range() {
2535 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2536 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2537 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2538 }
2539
2540 #[test]
2541 fn pad_to_cells_fills_to_display_width() {
2542 assert_eq!(pad_to_cells("ab", 5), "ab ");
2543 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2545 assert_eq!(pad_to_cells("你好", 3), "你好");
2547 assert_eq!(pad_to_cells("", 0), "");
2548 }
2549
2550 #[test]
2551 fn user_timestamp_padding_aligns_on_display_cells() {
2552 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
2554 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
2557 assert_eq!(4 + 10 + pad + 8, 40);
2558 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
2560 }
2561
2562 #[test]
2563 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2564 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2567 let wrapped = wrap_preformatted(line, 10, 2);
2568 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2569 let first: String = wrapped[0]
2570 .spans
2571 .iter()
2572 .map(|s| s.content.as_ref())
2573 .collect();
2574 assert!(
2575 first.starts_with(" aaaa"),
2576 "indentation must be preserved, got {first:?}"
2577 );
2578 let second: String = wrapped[1]
2579 .spans
2580 .iter()
2581 .map(|s| s.content.as_ref())
2582 .collect();
2583 assert!(
2584 second.starts_with(" "),
2585 "continuation should get the hanging indent, got {second:?}"
2586 );
2587 }
2588
2589 #[test]
2590 fn wrap_preformatted_short_line_unchanged() {
2591 let line = Line::from(vec![Span::raw(" short")]);
2592 let wrapped = wrap_preformatted(line, 40, 2);
2593 assert_eq!(wrapped.len(), 1);
2594 let text: String = wrapped[0]
2595 .spans
2596 .iter()
2597 .map(|s| s.content.as_ref())
2598 .collect();
2599 assert_eq!(text, " short");
2600 }
2601
2602 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2606 let mut st = ChatState::new();
2607 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2608 st.selection = Some(sel);
2609 st
2610 }
2611
2612 #[test]
2613 fn selected_text_single_line() {
2614 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2615 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2616 }
2617
2618 #[test]
2619 fn selected_text_spans_multiple_rows() {
2620 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2621 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2624 }
2625
2626 #[test]
2627 fn selected_text_strips_margin_but_keeps_code_indentation() {
2628 let st = state_with_rows(
2631 &[" fn main() {", " let x = 1;", " }"],
2632 ((0, 0), (2, 3)),
2633 );
2634 assert_eq!(
2635 st.selected_text().as_deref(),
2636 Some("fn main() {\n let x = 1;\n}")
2637 );
2638 }
2639
2640 #[test]
2641 fn selected_text_normalizes_reversed_drag() {
2642 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2644 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2645 }
2646
2647 #[test]
2648 fn selected_text_empty_selection_is_none() {
2649 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2651 assert_eq!(st.selected_text(), None);
2652 }
2653
2654 #[test]
2655 fn highlight_line_cells_splits_spans_on_selection() {
2656 let mut line = Line::from(vec![Span::raw("abcdef")]);
2657 highlight_line_cells(
2658 &mut line,
2659 2,
2660 4,
2661 Style::new().add_modifier(Modifier::REVERSED),
2662 );
2663 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2665 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2666 assert!(
2667 line.spans[1]
2668 .style
2669 .add_modifier
2670 .contains(Modifier::REVERSED)
2671 );
2672 assert!(
2673 !line.spans[0]
2674 .style
2675 .add_modifier
2676 .contains(Modifier::REVERSED)
2677 );
2678 }
2679
2680 #[test]
2681 fn context_checkpoint_renders_as_compact_event() {
2682 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2683 msg.kind = ChatMessageKind::ContextCheckpoint;
2684 msg.metadata = Some(serde_json::json!({
2685 "trigger": "manual",
2686 "before_tokens": 43_800,
2687 "after_tokens": 9_200,
2688 "archived_message_count": 18,
2689 "preserved_message_count": 4,
2690 "duration_secs": 2.4,
2691 "review_status": "reviewed",
2692 }));
2693
2694 let lines =
2695 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2696 let rendered = lines
2697 .iter()
2698 .map(|line| {
2699 line.spans
2700 .iter()
2701 .map(|span| span.content.as_ref())
2702 .collect::<String>()
2703 })
2704 .collect::<Vec<_>>()
2705 .join("\n");
2706
2707 assert!(rendered.contains("Compact(manual)"));
2708 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2709 assert!(rendered.contains("archived 18 messages"));
2710 assert!(rendered.contains("preserved 4 messages"));
2711 assert!(rendered.contains("reviewed"));
2712 assert!(!rendered.contains("full checkpoint summary"));
2713 }
2714
2715 #[test]
2716 fn context_checkpoint_renders_validated_draft() {
2717 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2718 msg.kind = ChatMessageKind::ContextCheckpoint;
2719 msg.metadata = Some(serde_json::json!({
2720 "trigger": "auto_threshold",
2721 "before_tokens": 43_800,
2722 "after_tokens": 9_200,
2723 "archived_message_count": 18,
2724 "preserved_message_count": 4,
2725 "duration_secs": 2.4,
2726 "review_status": "draft_validated",
2727 "review_error": "provider overloaded",
2728 }));
2729
2730 let lines =
2731 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2732 let rendered = lines
2733 .iter()
2734 .map(|line| {
2735 line.spans
2736 .iter()
2737 .map(|span| span.content.as_ref())
2738 .collect::<String>()
2739 })
2740 .collect::<Vec<_>>()
2741 .join("\n");
2742
2743 assert!(rendered.contains("Compact(auto_threshold)"));
2744 assert!(rendered.contains("validated draft"));
2745 assert!(rendered.contains("review: provider overloaded"));
2746 }
2747
2748 #[test]
2754 fn wrap_styled_line_uses_display_width_for_cjk() {
2755 let line = Line::from(Span::raw("你好世界".to_string()));
2759 let wrapped = wrap_styled_line(line, 10, 2);
2760 assert_eq!(
2761 wrapped.len(),
2762 1,
2763 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
2764 wrapped.len()
2765 );
2766 }
2767
2768 #[test]
2771 fn wrap_styled_line_ascii_wraps_when_too_long() {
2772 let line = Line::from(Span::raw(
2773 "the quick brown fox jumps over the lazy dog".to_string(),
2774 ));
2775 let wrapped = wrap_styled_line(line, 15, 2);
2776 assert!(
2777 wrapped.len() >= 2,
2778 "long ASCII input should wrap to multiple lines; got {}",
2779 wrapped.len()
2780 );
2781 }
2782
2783 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
2784 wrapped[0]
2785 .spans
2786 .iter()
2787 .map(|s| s.content.as_ref())
2788 .collect()
2789 }
2790
2791 #[test]
2797 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
2798 let line = Line::from(vec![
2799 Span::raw(" "), Span::raw(
2801 "No source files, no config, no docs, no build system and more words to wrap"
2802 .to_string(),
2803 ),
2804 ]);
2805 let wrapped = wrap_styled_line(line, 30, 2);
2806 assert!(wrapped.len() >= 2, "should wrap");
2807 let first = first_segment_text(&wrapped);
2808 assert!(
2809 first.starts_with(" ") && first.trim_start().starts_with("No source"),
2810 "first wrapped segment must keep the 2-space gutter; got {first:?}"
2811 );
2812 }
2813
2814 #[test]
2820 fn wrap_styled_line_keeps_inline_code_background_across_its_spaces() {
2821 let code = Style::default().bg(ratatui::style::Color::Rgb(40, 40, 40));
2822 let line = Line::from(vec![
2823 Span::raw("read_image_bytes bails with ".to_string()),
2824 Span::styled("No image data found in clipboard".to_string(), code),
2825 Span::raw(" and the effect routes it onward".to_string()),
2826 ]);
2827 let wrapped = wrap_styled_line(line, 40, 2);
2828 assert!(wrapped.len() >= 2, "should wrap");
2829
2830 let spans: Vec<_> = wrapped.iter().flat_map(|l| l.spans.iter()).collect();
2833 let interior_gaps = spans
2834 .windows(3)
2835 .filter(|w| {
2836 w[1].content.as_ref() == " " && w[0].style.bg.is_some() && w[2].style.bg.is_some()
2837 })
2838 .count();
2839 assert!(
2840 interior_gaps >= 3,
2841 "the 5-word code span should keep its background on interior gaps; got \
2842 {interior_gaps} in {:?}",
2843 spans
2844 .iter()
2845 .map(|s| (s.content.as_ref(), s.style.bg))
2846 .collect::<Vec<_>>()
2847 );
2848 assert!(
2849 spans.windows(2).all(|w| {
2850 !(w[0].content.as_ref() == " "
2851 && w[0].style.bg.is_some()
2852 && w[1].style.bg.is_none())
2853 }),
2854 "no highlighted space may leak onto the plain prose that follows"
2855 );
2856 }
2857
2858 #[test]
2864 fn wrap_styled_line_hangs_list_continuation_under_marker() {
2865 let line = Line::from(vec![
2866 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2870 ]);
2871 let wrapped = wrap_styled_line(line, 24, 6);
2872 assert!(wrapped.len() >= 2, "should wrap");
2873 assert!(
2874 first_segment_text(&wrapped).starts_with(" • "),
2875 "first segment keeps gutter + nesting + marker"
2876 );
2877 for cont in &wrapped[1..] {
2878 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2879 assert!(
2880 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2881 "continuation hangs under the item text at col 6; got {t:?}"
2882 );
2883 }
2884 }
2885
2886 #[test]
2889 fn wrap_styled_line_keeps_bullet_at_column_zero() {
2890 let line = Line::from(vec![
2891 Span::raw("● "),
2892 Span::raw(
2893 "a fairly long first line of a message that definitely needs to wrap".to_string(),
2894 ),
2895 ]);
2896 let wrapped = wrap_styled_line(line, 25, 2);
2897 assert!(wrapped.len() >= 2, "should wrap");
2898 assert!(
2899 first_segment_text(&wrapped).starts_with('●'),
2900 "bullet must stay at column 0"
2901 );
2902 }
2903
2904 #[test]
2910 fn wrap_text_with_indent_uses_display_width_for_cjk() {
2911 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2914 assert_eq!(
2915 wrapped.len(),
2916 1,
2917 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2918 wrapped.len(),
2919 wrapped
2920 );
2921 assert_eq!(wrapped[0].trim_start(), "你好世界");
2922 }
2923
2924 #[test]
2927 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2928 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2932 assert!(
2933 wrapped.len() >= 2,
2934 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2935 wrapped.len(),
2936 wrapped
2937 );
2938 }
2939
2940 #[test]
2941 fn clamp_to_u16_saturates_past_u16_max() {
2942 assert_eq!(clamp_to_u16(0), 0);
2945 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2946 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2947 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2948 }
2949
2950 #[test]
2951 fn wrap_text_with_indent_hard_breaks_overlong_token() {
2952 let token = "x".repeat(100);
2956 let width = 20;
2957 let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2958 assert!(
2959 wrapped.len() >= 5,
2960 "a 100-cell token at width 20 must span many rows; got {}",
2961 wrapped.len()
2962 );
2963 for line in &wrapped {
2964 assert!(
2965 line.chars().count() <= width,
2966 "no wrapped row may exceed the width; got {:?} ({} cells)",
2967 line,
2968 line.chars().count()
2969 );
2970 }
2971 let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2973 assert_eq!(
2974 joined, token,
2975 "hard-break must preserve the token's content"
2976 );
2977 }
2978
2979 #[test]
2980 fn wrap_styled_line_hard_breaks_overlong_token() {
2981 let token = "y".repeat(90);
2983 let style = Style::new().fg(ratatui::style::Color::Red);
2984 let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
2985 let width = 24;
2986 let wrapped = wrap_styled_line(line, width, 2);
2987 assert!(
2988 wrapped.len() >= 4,
2989 "must hard-break across rows; got {}",
2990 wrapped.len()
2991 );
2992
2993 let mut reconstructed = String::new();
2994 for l in &wrapped {
2995 let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2996 assert!(
2997 row_cells <= width,
2998 "row exceeds width: {row_cells} > {width}"
2999 );
3000 for s in &l.spans {
3001 if s.content.trim().is_empty() {
3004 continue;
3005 }
3006 assert_eq!(
3007 s.style.fg,
3008 Some(ratatui::style::Color::Red),
3009 "hard-break must preserve the span style"
3010 );
3011 reconstructed.push_str(s.content.as_ref());
3012 }
3013 }
3014 assert_eq!(reconstructed, token, "hard-break must preserve the token");
3015 }
3016
3017 #[test]
3021 fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
3022 let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
3023 let line = Line::from(vec![
3024 Span::raw(" "),
3025 Span::raw("some filler words long enough to force a wrap here "),
3026 Span::styled("underlined-link-text", underlined),
3027 Span::raw(" and a bit more trailing filler after the link"),
3028 ]);
3029 let wrapped = wrap_styled_line(line, 30, 2);
3030 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3031 for l in &wrapped {
3032 for s in &l.spans {
3033 if s.content.chars().all(|c| c == ' ') {
3034 assert_eq!(
3035 s.style,
3036 Style::default(),
3037 "whitespace span {:?} must be unstyled",
3038 s.content
3039 );
3040 }
3041 }
3042 }
3043 }
3044
3045 #[test]
3049 fn wrap_styled_line_no_phantom_space_at_span_boundary() {
3050 let dim = Style::new().fg(ratatui::style::Color::DarkGray);
3051 let line = Line::from(vec![
3052 Span::raw(" "),
3053 Span::raw("filler text that pushes the line well past the width limit "),
3054 Span::styled("(https://example.com)".to_string(), dim),
3055 Span::raw("."),
3056 ]);
3057 let wrapped = wrap_styled_line(line, 30, 2);
3058 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3059 let text: String = wrapped
3060 .iter()
3061 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3062 .collect();
3063 assert!(
3064 text.contains("(https://example.com)."),
3065 "period must stay glued to the URL suffix; got {text:?}"
3066 );
3067 assert!(
3068 !text.contains("(https://example.com) ."),
3069 "no phantom space before the period; got {text:?}"
3070 );
3071 }
3072
3073 #[test]
3077 fn wrap_styled_line_keeps_mid_word_style_change_glued() {
3078 let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
3079 let line = Line::from(vec![
3080 Span::raw(" "),
3081 Span::raw("leading filler words to force wrapping "),
3082 Span::styled("bold", bold),
3083 Span::raw("suffix"),
3084 Span::raw(" trailing filler words to force more wrapping"),
3085 ]);
3086 let wrapped = wrap_styled_line(line, 30, 2);
3087 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3088 let rows: Vec<String> = wrapped
3089 .iter()
3090 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
3091 .collect();
3092 assert_eq!(
3093 rows.iter().filter(|r| r.contains("boldsuffix")).count(),
3094 1,
3095 "glued token must land whole on exactly one row; rows: {rows:?}"
3096 );
3097 for l in &wrapped {
3098 for s in &l.spans {
3099 if s.content.as_ref() == "bold" {
3100 assert_eq!(s.style, bold, "bold fragment keeps its modifier");
3101 }
3102 if s.content.as_ref() == "suffix" {
3103 assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
3104 }
3105 }
3106 }
3107 }
3108
3109 #[test]
3113 fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
3114 let red = Style::new().fg(ratatui::style::Color::Red);
3115 let blue = Style::new().fg(ratatui::style::Color::Blue);
3116 let line = Line::from(vec![
3117 Span::raw(" "),
3118 Span::styled("a".repeat(40), red),
3119 Span::styled("b".repeat(40), blue),
3120 ]);
3121 let width = 24;
3122 let wrapped = wrap_styled_line(line, width, 2);
3123 assert!(
3124 wrapped.len() >= 4,
3125 "80-cell token at width 24 must span >= 4 rows; got {}",
3126 wrapped.len()
3127 );
3128 let mut reconstructed = String::new();
3129 for l in &wrapped {
3130 let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
3131 assert!(
3132 row_cells <= width,
3133 "row exceeds width: {row_cells} > {width}"
3134 );
3135 for s in &l.spans {
3136 if s.content.trim().is_empty() {
3137 continue;
3138 }
3139 let expected = if s.content.contains('a') { red } else { blue };
3140 assert!(
3141 !(s.content.contains('a') && s.content.contains('b')),
3142 "fragments must not merge across the style boundary"
3143 );
3144 assert_eq!(s.style, expected, "fragment style preserved across break");
3145 reconstructed.push_str(s.content.as_ref());
3146 }
3147 }
3148 assert_eq!(
3149 reconstructed,
3150 format!("{}{}", "a".repeat(40), "b".repeat(40)),
3151 "hard-break must preserve the whole glued token"
3152 );
3153 }
3154
3155 #[test]
3158 fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
3159 let line = Line::from(vec![
3160 Span::raw(" "),
3161 Span::raw("filler words that push this line past the wrap width "),
3162 Span::raw("foo"),
3163 Span::raw(" "),
3164 Span::raw("bar"),
3165 ]);
3166 let wrapped = wrap_styled_line(line, 30, 2);
3167 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3168 let text: String = wrapped
3169 .iter()
3170 .map(|l| {
3171 l.spans
3172 .iter()
3173 .map(|s| s.content.as_ref())
3174 .collect::<String>()
3175 })
3176 .collect::<Vec<_>>()
3177 .join("\n");
3178 assert!(
3179 text.contains("foo bar") || text.contains("foo\n bar"),
3180 "whitespace-only span must keep the words apart; got {text:?}"
3181 );
3182 assert!(
3183 !text.contains("foobar"),
3184 "words must not glue; got {text:?}"
3185 );
3186 }
3187
3188 #[test]
3189 fn frame_memo_hit_matches_miss() {
3190 use ratatui::Terminal;
3196 use ratatui::backend::TestBackend;
3197
3198 let theme = Theme::dark();
3199 let messages = vec![
3200 ChatMessage::assistant(
3201 "# Heading\n\nSome **bold** prose long enough that it wraps across \
3202 this narrow viewport more than once.\n\n- a list item that also \
3203 runs past the edge so it wraps\n- second item",
3204 ),
3205 ChatMessage::assistant("Short follow-up."),
3206 ];
3207
3208 let (width, height): (u16, u16) = (34, 30);
3209 let mut cache = FxHashMap::default();
3210 let mut state = ChatState::new();
3211
3212 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
3213 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
3214 term.draw(|f| {
3215 let widget = ChatWidget {
3216 messages: &messages,
3217 content_key: test_content_key(&messages),
3218 theme: &theme,
3219 wrapped_line_cache: cache,
3220 show_reasoning: true,
3221 blink_on: true,
3222 };
3223 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
3224 })
3225 .unwrap();
3226 term.backend().buffer().clone()
3227 };
3228
3229 let miss = render(&mut state, &mut cache);
3230 assert!(
3231 state.frame_memo.is_some(),
3232 "first render must populate the frame memo"
3233 );
3234 let hit = render(&mut state, &mut cache);
3235 assert_eq!(
3236 miss, hit,
3237 "frame-memo hit must render identically to the miss"
3238 );
3239 assert!(
3243 !state.last_rendered_rows.is_empty(),
3244 "memo hit must preserve last_rendered_rows from the miss"
3245 );
3246 }
3247
3248 #[test]
3249 fn append_action_duration_handles_empty_base() {
3250 assert_eq!(
3253 append_action_duration(String::new(), Some(0.035)),
3254 "took 35ms"
3255 );
3256 assert_eq!(
3258 append_action_duration("3 lines read".to_string(), Some(1.25)),
3259 "3 lines read, took 1.2s"
3260 );
3261 assert_eq!(append_action_duration(String::new(), None), "");
3263 }
3264}