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
1890fn wrap_styled_line(
1901 line: Line<'static>,
1902 width: usize,
1903 continuation_indent: usize,
1904) -> Vec<Line<'static>> {
1905 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1910
1911 if total_width <= width {
1913 return vec![line];
1914 }
1915
1916 let mut result_lines = Vec::new();
1918 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
1919 let mut current_line_width = 0usize;
1920 let available_width = width.saturating_sub(continuation_indent);
1921
1922 let leading_indent: usize = {
1930 let mut n = 0;
1931 for span in &line.spans {
1932 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1933 n += spaces;
1934 if spaces < span.content.len() {
1935 break; }
1937 }
1938 n
1939 };
1940
1941 let mut words: Vec<Vec<(String, Style)>> = Vec::new();
1946 let mut current_word: Vec<(String, Style)> = Vec::new();
1947 for span in &line.spans {
1948 let mut frag = String::new();
1949 for ch in span.content.chars() {
1950 if ch.is_whitespace() {
1951 if !frag.is_empty() {
1952 current_word.push((std::mem::take(&mut frag), span.style));
1953 }
1954 if !current_word.is_empty() {
1955 words.push(std::mem::take(&mut current_word));
1956 }
1957 } else {
1958 frag.push(ch);
1959 }
1960 }
1961 if !frag.is_empty() {
1962 current_word.push((frag, span.style));
1963 }
1964 }
1965 if !current_word.is_empty() {
1966 words.push(current_word);
1967 }
1968
1969 fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
1970 for (text, style) in word {
1971 spans.push(Span::styled(text, style));
1972 }
1973 }
1974
1975 for word in words {
1976 let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
1977
1978 if current_line_width == 0 && result_lines.is_empty() {
1979 if leading_indent > 0 {
1983 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1984 current_line_width += leading_indent;
1985 }
1986 if word_width <= available_width {
1987 current_line_width += word_width;
1988 emit_word(&mut current_line_spans, word);
1989 } else {
1990 hard_break_styled_word(
1996 &word,
1997 &mut result_lines,
1998 &mut current_line_spans,
1999 &mut current_line_width,
2000 continuation_indent,
2001 available_width,
2002 width,
2003 );
2004 }
2005 continue;
2006 }
2007
2008 let sep = usize::from(current_line_width > 0);
2013 if current_line_width + sep + word_width <= available_width {
2014 if sep == 1 {
2016 current_line_spans.push(Span::raw(" "));
2017 }
2018 current_line_width += sep + word_width;
2019 emit_word(&mut current_line_spans, word);
2020 } else if word_width <= available_width {
2021 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
2023 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
2024 current_line_width = word_width;
2025 emit_word(&mut current_line_spans, word);
2026 } else {
2027 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
2031 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
2032 current_line_width = 0;
2033 hard_break_styled_word(
2034 &word,
2035 &mut result_lines,
2036 &mut current_line_spans,
2037 &mut current_line_width,
2038 continuation_indent,
2039 available_width,
2040 available_width,
2041 );
2042 }
2043 }
2044
2045 if !current_line_spans.is_empty() {
2047 result_lines.push(Line::from(current_line_spans));
2048 }
2049
2050 if result_lines.is_empty() {
2051 vec![line]
2052 } else {
2053 result_lines
2054 }
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059 use super::*;
2060
2061 #[test]
2062 fn question_answers_render_as_question_arrow_answer_block() {
2063 use crate::domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
2064
2065 let theme = Theme::dark();
2066 let answers = vec![
2067 QuestionAnswer {
2068 header: "Snack".to_string(),
2069 question: "Which snack fuels your next coding session?".to_string(),
2070 selected: vec!["Coffee (Recommended)".to_string()],
2071 note: None,
2072 },
2073 QuestionAnswer {
2074 header: "Powers".to_string(),
2075 question: "Which superpowers would you take?".to_string(),
2076 selected: vec![
2077 "Read any codebase instantly".to_string(),
2078 "Bugs reproduce on demand".to_string(),
2079 ],
2080 note: Some("only on weekdays".to_string()),
2081 },
2082 ];
2083 let action = ActionDisplay {
2084 action_type: "ask_user_question".to_string(),
2085 target: String::new(),
2086 result: ActionResult::Success {
2087 output: String::new(),
2088 images: None,
2089 },
2090 details: ActionDetails::Simple,
2091 duration_seconds: Some(93.0),
2092 metadata: Some(ToolRunMetadata {
2093 detail: ToolMetadata::Questions {
2094 answers,
2095 remembered: false,
2096 },
2097 ..Default::default()
2098 }),
2099 };
2100
2101 let mut lines: Vec<Line> = Vec::new();
2102 render_actions(&[action], &mut lines, &theme, 120, true);
2103 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2104 let all = rows.join("\n");
2105
2106 assert_eq!(rows[0], "● User answered the model's questions:");
2107 assert!(
2108 rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
2109 "got {:?}",
2110 rows[1]
2111 );
2112 assert!(
2113 all.contains(
2114 "· Which superpowers would you take? → Read any codebase instantly, \
2115 Bugs reproduce on demand"
2116 ),
2117 "got {all}"
2118 );
2119 assert!(all.contains("(note: only on weekdays)"), "got {all}");
2120 assert!(!all.contains("ask_user_question("), "got {all}");
2122 assert!(!all.contains("took"), "got {all}");
2123 }
2124
2125 #[test]
2126 fn diff_background_fills_full_width_with_tabs() {
2127 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
2132 use ratatui::Terminal;
2133 use ratatui::backend::TestBackend;
2134
2135 let theme = Theme::dark();
2136 let added_bg = theme.colors.diff_added_bg.to_color();
2137 let removed_bg = theme.colors.diff_removed_bg.to_color();
2138 let diff = format!(
2140 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
2141 m = DIFF_REMOVED_MARKER,
2142 p = DIFF_ADDED_MARKER
2143 );
2144 let action = ActionDisplay {
2145 action_type: "Update".to_string(),
2146 target: "engine.ts".to_string(),
2147 result: ActionResult::Success {
2148 output: String::new(),
2149 images: None,
2150 },
2151 details: ActionDetails::Diff {
2152 summary: "ok".to_string(),
2153 diff,
2154 },
2155 duration_seconds: Some(0.3),
2156 metadata: None,
2157 };
2158
2159 let width: u16 = 60;
2160 let mut lines: Vec<Line> = Vec::new();
2161 render_actions(&[action], &mut lines, &theme, width as usize, true);
2162 let h = lines.len() as u16;
2163 let backend = TestBackend::new(width, h);
2164 let mut term = Terminal::new(backend).unwrap();
2165 term.draw(|f| {
2166 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
2167 })
2168 .unwrap();
2169 let buf = term.backend().buffer();
2170
2171 for y in 0..h {
2172 let is_diff_row = (0..width).any(|x| {
2173 let bg = buf[(x, y)].bg;
2174 bg == added_bg || bg == removed_bg
2175 });
2176 if !is_diff_row {
2177 continue;
2178 }
2179 for x in 0..width {
2180 let bg = buf[(x, y)].bg;
2181 assert!(
2182 bg == added_bg || bg == removed_bg,
2183 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
2184 );
2185 }
2186 }
2187 }
2188
2189 fn assert_rows_fit(lines: &[Line], width: usize) {
2192 for (i, line) in lines.iter().enumerate() {
2193 let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
2194 assert!(
2195 w <= width,
2196 "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
2197 line_plain_text(line)
2198 );
2199 }
2200 }
2201
2202 #[test]
2203 fn action_header_and_error_wrap_instead_of_clipping() {
2204 let theme = Theme::dark();
2208 let action = ActionDisplay {
2209 action_type: "Error".to_string(),
2210 target: "Backend error".to_string(),
2211 result: ActionResult::Error {
2212 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(),
2213 },
2214 details: ActionDetails::Simple,
2215 duration_seconds: None,
2216 metadata: None,
2217 };
2218
2219 let width = 60usize;
2220 let mut lines: Vec<Line> = Vec::new();
2221 render_actions(&[action], &mut lines, &theme, width, true);
2222
2223 assert_rows_fit(&lines, width);
2224 let rendered = lines
2225 .iter()
2226 .map(line_plain_text)
2227 .collect::<Vec<_>>()
2228 .join("\n");
2229 assert!(rendered.contains("invalid_request_error"));
2232 assert!(
2233 lines.len() > 2,
2234 "a 140-cell error at width 60 must span multiple rows"
2235 );
2236 }
2237
2238 #[test]
2239 fn action_header_wraps_long_command_and_keeps_closing_paren() {
2240 let theme = Theme::dark();
2241 let action = ActionDisplay {
2242 action_type: "Bash".to_string(),
2243 target: "python3 -c 'print(1)' && echo a-very-long-command-line \
2244 that keeps going well past the sixty cell viewport edge"
2245 .to_string(),
2246 result: ActionResult::Success {
2247 output: String::new(),
2248 images: None,
2249 },
2250 details: ActionDetails::Simple,
2251 duration_seconds: Some(0.1),
2252 metadata: None,
2253 };
2254
2255 let width = 60usize;
2256 let mut lines: Vec<Line> = Vec::new();
2257 render_actions(&[action], &mut lines, &theme, width, true);
2258
2259 assert_rows_fit(&lines, width);
2260 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2261 assert!(rows[0].starts_with("● Bash("));
2262 assert!(
2263 rows.len() >= 2,
2264 "the long command must wrap the header across rows"
2265 );
2266 let last_target_row = rows
2267 .iter()
2268 .rfind(|r| r.trim_end().ends_with(')'))
2269 .expect("wrapped header must still close its paren");
2270 assert!(last_target_row.trim_end().ends_with(')'));
2271 }
2272
2273 #[test]
2274 fn action_header_caps_rows_and_marks_truncation() {
2275 let theme = Theme::dark();
2278 let action = ActionDisplay {
2279 action_type: "Bash".to_string(),
2280 target: "word ".repeat(400),
2281 result: ActionResult::Success {
2282 output: String::new(),
2283 images: None,
2284 },
2285 details: ActionDetails::Simple,
2286 duration_seconds: None,
2287 metadata: None,
2288 };
2289
2290 let width = 60usize;
2291 let mut lines: Vec<Line> = Vec::new();
2292 render_actions(&[action], &mut lines, &theme, width, true);
2293
2294 assert_rows_fit(&lines, width);
2295 let header_rows: Vec<String> = lines
2296 .iter()
2297 .map(line_plain_text)
2298 .take_while(|r| !r.trim_start().starts_with('⎿'))
2299 .collect();
2300 assert_eq!(
2301 header_rows.len(),
2302 MAX_ACTION_HEADER_ROWS,
2303 "header must cap at MAX_ACTION_HEADER_ROWS rows"
2304 );
2305 assert!(
2306 header_rows.last().unwrap().trim_end().ends_with("…)"),
2307 "capped header must end with …) — got {:?}",
2308 header_rows.last().unwrap()
2309 );
2310 }
2311
2312 #[test]
2313 fn action_header_preserves_multiline_command_rows() {
2314 let theme = Theme::dark();
2318 let action = ActionDisplay {
2319 action_type: "Bash".to_string(),
2320 target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
2321 result: ActionResult::Success {
2322 output: String::new(),
2323 images: None,
2324 },
2325 details: ActionDetails::Simple,
2326 duration_seconds: None,
2327 metadata: None,
2328 };
2329
2330 let mut lines: Vec<Line> = Vec::new();
2331 render_actions(&[action], &mut lines, &theme, 80, true);
2332
2333 let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2334 assert!(rows[0].contains("python3 - << 'PY'"));
2335 assert!(rows[1].contains("from PIL import Image"));
2336 assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
2337 }
2338
2339 #[test]
2340 fn action_result_summary_wraps_instead_of_clipping() {
2341 let theme = Theme::dark();
2342 let action = ActionDisplay {
2343 action_type: "Tasks".to_string(),
2344 target: "update 3 steps".to_string(),
2345 result: ActionResult::Success {
2346 output: String::new(),
2347 images: None,
2348 },
2349 details: ActionDetails::Preview {
2350 text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2351 placeholders kept intentionally until real data available. \
2352 Task 2 and 6 deferred., to revisit later"
2353 .to_string(),
2354 line_count: None,
2355 },
2356 duration_seconds: None,
2357 metadata: None,
2358 };
2359
2360 let width = 60usize;
2361 let mut lines: Vec<Line> = Vec::new();
2362 render_actions(&[action], &mut lines, &theme, width, true);
2363
2364 assert_rows_fit(&lines, width);
2365 let rendered = lines
2366 .iter()
2367 .map(line_plain_text)
2368 .collect::<Vec<_>>()
2369 .join("\n");
2370 assert!(
2371 rendered.contains("revisit later"),
2372 "the summary's tail must survive the wrap instead of being clipped"
2373 );
2374 }
2375
2376 #[test]
2377 fn wrapped_line_cache_hit_matches_cache_miss() {
2378 use ratatui::Terminal;
2385 use ratatui::backend::TestBackend;
2386
2387 let theme = Theme::dark();
2388 let messages = vec![
2389 ChatMessage::assistant(
2390 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2391 across this narrow viewport more than once.\n\n\
2392 - a list item that also keeps going past the edge so it wraps too\n\
2393 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2394 ),
2395 ChatMessage::assistant("Short follow-up paragraph."),
2396 ];
2397
2398 let (width, height): (u16, u16) = (40, 40);
2399 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2400 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2401 let mut state = ChatState::new();
2402 term.draw(|f| {
2403 let widget = ChatWidget {
2404 messages: &messages,
2405 content_key: test_content_key(&messages),
2406 theme: &theme,
2407 wrapped_line_cache: cache,
2408 show_reasoning: true,
2409 blink_on: true,
2410 };
2411 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2412 })
2413 .unwrap();
2414 term.backend().buffer().clone()
2415 };
2416
2417 let mut shared = FxHashMap::default();
2418 let miss = render_once(&mut shared);
2419 assert!(!shared.is_empty(), "first render must populate the cache");
2420 let hit = render_once(&mut shared);
2421 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2422
2423 let mut cold_cache = FxHashMap::default();
2424 let cold = render_once(&mut cold_cache);
2425 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2426 }
2427
2428 #[test]
2429 fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2430 use ratatui::Terminal;
2434 use ratatui::backend::TestBackend;
2435
2436 let theme = Theme::dark();
2437 let messages = vec![ChatMessage::system(
2438 "Heads up: this model reports no vision capability",
2439 )];
2440 let (width, height): (u16, u16) = (60, 10);
2441 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2442 let mut state = ChatState::new();
2443 let mut cache = FxHashMap::default();
2444 term.draw(|f| {
2445 let widget = ChatWidget {
2446 messages: &messages,
2447 content_key: test_content_key(&messages),
2448 theme: &theme,
2449 wrapped_line_cache: &mut cache,
2450 show_reasoning: true,
2451 blink_on: true,
2452 };
2453 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2454 })
2455 .unwrap();
2456 let buf = term.backend().buffer();
2457 let rows: Vec<String> = (0..height)
2458 .map(|y| {
2459 (0..width)
2460 .map(|x| buf[(x, y)].symbol().to_string())
2461 .collect::<String>()
2462 })
2463 .collect();
2464 let all = rows.join("\n");
2465 assert!(
2466 !all.contains('●'),
2467 "no role bullet on system notices: {all}"
2468 );
2469 assert!(
2470 !all.contains("Today at"),
2471 "no timestamp on system notices: {all}"
2472 );
2473 let row = rows
2474 .iter()
2475 .position(|r| r.contains("Heads up"))
2476 .expect("notice rendered");
2477 assert!(
2478 rows[row].starts_with(" Heads up"),
2479 "2-space indent, nothing in the gutter: {:?}",
2480 rows[row]
2481 );
2482 let col = rows[row].find("Heads up").unwrap(); assert_eq!(
2484 buf[(col as u16, row as u16)].fg,
2485 theme.colors.text_meta.to_color(),
2486 "notice text uses the muted meta gray"
2487 );
2488 }
2489
2490 #[test]
2491 fn byte_at_cell_clamps_and_respects_cjk() {
2492 assert_eq!(byte_at_cell("hello", 0), 0);
2493 assert_eq!(byte_at_cell("hello", 3), 3);
2494 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
2497 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
2500 }
2501
2502 #[test]
2503 fn slice_by_cells_extracts_display_range() {
2504 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2505 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2506 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2507 }
2508
2509 #[test]
2510 fn pad_to_cells_fills_to_display_width() {
2511 assert_eq!(pad_to_cells("ab", 5), "ab ");
2512 assert_eq!(pad_to_cells("你好", 6), "你好 ");
2514 assert_eq!(pad_to_cells("你好", 3), "你好");
2516 assert_eq!(pad_to_cells("", 0), "");
2517 }
2518
2519 #[test]
2520 fn user_timestamp_padding_aligns_on_display_cells() {
2521 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
2523 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
2526 assert_eq!(4 + 10 + pad + 8, 40);
2527 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
2529 }
2530
2531 #[test]
2532 fn wrap_preformatted_hard_wraps_preserving_spaces() {
2533 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
2536 let wrapped = wrap_preformatted(line, 10, 2);
2537 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2538 let first: String = wrapped[0]
2539 .spans
2540 .iter()
2541 .map(|s| s.content.as_ref())
2542 .collect();
2543 assert!(
2544 first.starts_with(" aaaa"),
2545 "indentation must be preserved, got {first:?}"
2546 );
2547 let second: String = wrapped[1]
2548 .spans
2549 .iter()
2550 .map(|s| s.content.as_ref())
2551 .collect();
2552 assert!(
2553 second.starts_with(" "),
2554 "continuation should get the hanging indent, got {second:?}"
2555 );
2556 }
2557
2558 #[test]
2559 fn wrap_preformatted_short_line_unchanged() {
2560 let line = Line::from(vec![Span::raw(" short")]);
2561 let wrapped = wrap_preformatted(line, 40, 2);
2562 assert_eq!(wrapped.len(), 1);
2563 let text: String = wrapped[0]
2564 .spans
2565 .iter()
2566 .map(|s| s.content.as_ref())
2567 .collect();
2568 assert_eq!(text, " short");
2569 }
2570
2571 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2575 let mut st = ChatState::new();
2576 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2577 st.selection = Some(sel);
2578 st
2579 }
2580
2581 #[test]
2582 fn selected_text_single_line() {
2583 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2584 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2585 }
2586
2587 #[test]
2588 fn selected_text_spans_multiple_rows() {
2589 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
2590 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2593 }
2594
2595 #[test]
2596 fn selected_text_strips_margin_but_keeps_code_indentation() {
2597 let st = state_with_rows(
2600 &[" fn main() {", " let x = 1;", " }"],
2601 ((0, 0), (2, 3)),
2602 );
2603 assert_eq!(
2604 st.selected_text().as_deref(),
2605 Some("fn main() {\n let x = 1;\n}")
2606 );
2607 }
2608
2609 #[test]
2610 fn selected_text_normalizes_reversed_drag() {
2611 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2613 assert_eq!(st.selected_text().as_deref(), Some("hello"));
2614 }
2615
2616 #[test]
2617 fn selected_text_empty_selection_is_none() {
2618 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2620 assert_eq!(st.selected_text(), None);
2621 }
2622
2623 #[test]
2624 fn highlight_line_cells_splits_spans_on_selection() {
2625 let mut line = Line::from(vec![Span::raw("abcdef")]);
2626 highlight_line_cells(
2627 &mut line,
2628 2,
2629 4,
2630 Style::new().add_modifier(Modifier::REVERSED),
2631 );
2632 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2634 assert_eq!(texts, vec!["ab", "cd", "ef"]);
2635 assert!(
2636 line.spans[1]
2637 .style
2638 .add_modifier
2639 .contains(Modifier::REVERSED)
2640 );
2641 assert!(
2642 !line.spans[0]
2643 .style
2644 .add_modifier
2645 .contains(Modifier::REVERSED)
2646 );
2647 }
2648
2649 #[test]
2650 fn context_checkpoint_renders_as_compact_event() {
2651 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2652 msg.kind = ChatMessageKind::ContextCheckpoint;
2653 msg.metadata = Some(serde_json::json!({
2654 "trigger": "manual",
2655 "before_tokens": 43_800,
2656 "after_tokens": 9_200,
2657 "archived_message_count": 18,
2658 "preserved_message_count": 4,
2659 "duration_secs": 2.4,
2660 "review_status": "reviewed",
2661 }));
2662
2663 let lines =
2664 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2665 let rendered = lines
2666 .iter()
2667 .map(|line| {
2668 line.spans
2669 .iter()
2670 .map(|span| span.content.as_ref())
2671 .collect::<String>()
2672 })
2673 .collect::<Vec<_>>()
2674 .join("\n");
2675
2676 assert!(rendered.contains("Compact(manual)"));
2677 assert!(rendered.contains("43.8k -> 9.2k tokens"));
2678 assert!(rendered.contains("archived 18 messages"));
2679 assert!(rendered.contains("preserved 4 messages"));
2680 assert!(rendered.contains("reviewed"));
2681 assert!(!rendered.contains("full checkpoint summary"));
2682 }
2683
2684 #[test]
2685 fn context_checkpoint_renders_validated_draft() {
2686 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2687 msg.kind = ChatMessageKind::ContextCheckpoint;
2688 msg.metadata = Some(serde_json::json!({
2689 "trigger": "auto_threshold",
2690 "before_tokens": 43_800,
2691 "after_tokens": 9_200,
2692 "archived_message_count": 18,
2693 "preserved_message_count": 4,
2694 "duration_secs": 2.4,
2695 "review_status": "draft_validated",
2696 "review_error": "provider overloaded",
2697 }));
2698
2699 let lines =
2700 render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2701 let rendered = lines
2702 .iter()
2703 .map(|line| {
2704 line.spans
2705 .iter()
2706 .map(|span| span.content.as_ref())
2707 .collect::<String>()
2708 })
2709 .collect::<Vec<_>>()
2710 .join("\n");
2711
2712 assert!(rendered.contains("Compact(auto_threshold)"));
2713 assert!(rendered.contains("validated draft"));
2714 assert!(rendered.contains("review: provider overloaded"));
2715 }
2716
2717 #[test]
2723 fn wrap_styled_line_uses_display_width_for_cjk() {
2724 let line = Line::from(Span::raw("你好世界".to_string()));
2728 let wrapped = wrap_styled_line(line, 10, 2);
2729 assert_eq!(
2730 wrapped.len(),
2731 1,
2732 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
2733 wrapped.len()
2734 );
2735 }
2736
2737 #[test]
2740 fn wrap_styled_line_ascii_wraps_when_too_long() {
2741 let line = Line::from(Span::raw(
2742 "the quick brown fox jumps over the lazy dog".to_string(),
2743 ));
2744 let wrapped = wrap_styled_line(line, 15, 2);
2745 assert!(
2746 wrapped.len() >= 2,
2747 "long ASCII input should wrap to multiple lines; got {}",
2748 wrapped.len()
2749 );
2750 }
2751
2752 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
2753 wrapped[0]
2754 .spans
2755 .iter()
2756 .map(|s| s.content.as_ref())
2757 .collect()
2758 }
2759
2760 #[test]
2766 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
2767 let line = Line::from(vec![
2768 Span::raw(" "), Span::raw(
2770 "No source files, no config, no docs, no build system and more words to wrap"
2771 .to_string(),
2772 ),
2773 ]);
2774 let wrapped = wrap_styled_line(line, 30, 2);
2775 assert!(wrapped.len() >= 2, "should wrap");
2776 let first = first_segment_text(&wrapped);
2777 assert!(
2778 first.starts_with(" ") && first.trim_start().starts_with("No source"),
2779 "first wrapped segment must keep the 2-space gutter; got {first:?}"
2780 );
2781 }
2782
2783 #[test]
2789 fn wrap_styled_line_hangs_list_continuation_under_marker() {
2790 let line = Line::from(vec![
2791 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2795 ]);
2796 let wrapped = wrap_styled_line(line, 24, 6);
2797 assert!(wrapped.len() >= 2, "should wrap");
2798 assert!(
2799 first_segment_text(&wrapped).starts_with(" • "),
2800 "first segment keeps gutter + nesting + marker"
2801 );
2802 for cont in &wrapped[1..] {
2803 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2804 assert!(
2805 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2806 "continuation hangs under the item text at col 6; got {t:?}"
2807 );
2808 }
2809 }
2810
2811 #[test]
2814 fn wrap_styled_line_keeps_bullet_at_column_zero() {
2815 let line = Line::from(vec![
2816 Span::raw("● "),
2817 Span::raw(
2818 "a fairly long first line of a message that definitely needs to wrap".to_string(),
2819 ),
2820 ]);
2821 let wrapped = wrap_styled_line(line, 25, 2);
2822 assert!(wrapped.len() >= 2, "should wrap");
2823 assert!(
2824 first_segment_text(&wrapped).starts_with('●'),
2825 "bullet must stay at column 0"
2826 );
2827 }
2828
2829 #[test]
2835 fn wrap_text_with_indent_uses_display_width_for_cjk() {
2836 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2839 assert_eq!(
2840 wrapped.len(),
2841 1,
2842 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2843 wrapped.len(),
2844 wrapped
2845 );
2846 assert_eq!(wrapped[0].trim_start(), "你好世界");
2847 }
2848
2849 #[test]
2852 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2853 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2857 assert!(
2858 wrapped.len() >= 2,
2859 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2860 wrapped.len(),
2861 wrapped
2862 );
2863 }
2864
2865 #[test]
2866 fn clamp_to_u16_saturates_past_u16_max() {
2867 assert_eq!(clamp_to_u16(0), 0);
2870 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2871 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2872 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2873 }
2874
2875 #[test]
2876 fn wrap_text_with_indent_hard_breaks_overlong_token() {
2877 let token = "x".repeat(100);
2881 let width = 20;
2882 let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2883 assert!(
2884 wrapped.len() >= 5,
2885 "a 100-cell token at width 20 must span many rows; got {}",
2886 wrapped.len()
2887 );
2888 for line in &wrapped {
2889 assert!(
2890 line.chars().count() <= width,
2891 "no wrapped row may exceed the width; got {:?} ({} cells)",
2892 line,
2893 line.chars().count()
2894 );
2895 }
2896 let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2898 assert_eq!(
2899 joined, token,
2900 "hard-break must preserve the token's content"
2901 );
2902 }
2903
2904 #[test]
2905 fn wrap_styled_line_hard_breaks_overlong_token() {
2906 let token = "y".repeat(90);
2908 let style = Style::new().fg(ratatui::style::Color::Red);
2909 let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
2910 let width = 24;
2911 let wrapped = wrap_styled_line(line, width, 2);
2912 assert!(
2913 wrapped.len() >= 4,
2914 "must hard-break across rows; got {}",
2915 wrapped.len()
2916 );
2917
2918 let mut reconstructed = String::new();
2919 for l in &wrapped {
2920 let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2921 assert!(
2922 row_cells <= width,
2923 "row exceeds width: {row_cells} > {width}"
2924 );
2925 for s in &l.spans {
2926 if s.content.trim().is_empty() {
2929 continue;
2930 }
2931 assert_eq!(
2932 s.style.fg,
2933 Some(ratatui::style::Color::Red),
2934 "hard-break must preserve the span style"
2935 );
2936 reconstructed.push_str(s.content.as_ref());
2937 }
2938 }
2939 assert_eq!(reconstructed, token, "hard-break must preserve the token");
2940 }
2941
2942 #[test]
2946 fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
2947 let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
2948 let line = Line::from(vec![
2949 Span::raw(" "),
2950 Span::raw("some filler words long enough to force a wrap here "),
2951 Span::styled("underlined-link-text", underlined),
2952 Span::raw(" and a bit more trailing filler after the link"),
2953 ]);
2954 let wrapped = wrap_styled_line(line, 30, 2);
2955 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2956 for l in &wrapped {
2957 for s in &l.spans {
2958 if s.content.chars().all(|c| c == ' ') {
2959 assert_eq!(
2960 s.style,
2961 Style::default(),
2962 "whitespace span {:?} must be unstyled",
2963 s.content
2964 );
2965 }
2966 }
2967 }
2968 }
2969
2970 #[test]
2974 fn wrap_styled_line_no_phantom_space_at_span_boundary() {
2975 let dim = Style::new().fg(ratatui::style::Color::DarkGray);
2976 let line = Line::from(vec![
2977 Span::raw(" "),
2978 Span::raw("filler text that pushes the line well past the width limit "),
2979 Span::styled("(https://example.com)".to_string(), dim),
2980 Span::raw("."),
2981 ]);
2982 let wrapped = wrap_styled_line(line, 30, 2);
2983 assert!(wrapped.len() >= 2, "fixture must actually wrap");
2984 let text: String = wrapped
2985 .iter()
2986 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2987 .collect();
2988 assert!(
2989 text.contains("(https://example.com)."),
2990 "period must stay glued to the URL suffix; got {text:?}"
2991 );
2992 assert!(
2993 !text.contains("(https://example.com) ."),
2994 "no phantom space before the period; got {text:?}"
2995 );
2996 }
2997
2998 #[test]
3002 fn wrap_styled_line_keeps_mid_word_style_change_glued() {
3003 let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
3004 let line = Line::from(vec![
3005 Span::raw(" "),
3006 Span::raw("leading filler words to force wrapping "),
3007 Span::styled("bold", bold),
3008 Span::raw("suffix"),
3009 Span::raw(" trailing filler words to force more wrapping"),
3010 ]);
3011 let wrapped = wrap_styled_line(line, 30, 2);
3012 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3013 let rows: Vec<String> = wrapped
3014 .iter()
3015 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
3016 .collect();
3017 assert_eq!(
3018 rows.iter().filter(|r| r.contains("boldsuffix")).count(),
3019 1,
3020 "glued token must land whole on exactly one row; rows: {rows:?}"
3021 );
3022 for l in &wrapped {
3023 for s in &l.spans {
3024 if s.content.as_ref() == "bold" {
3025 assert_eq!(s.style, bold, "bold fragment keeps its modifier");
3026 }
3027 if s.content.as_ref() == "suffix" {
3028 assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
3029 }
3030 }
3031 }
3032 }
3033
3034 #[test]
3038 fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
3039 let red = Style::new().fg(ratatui::style::Color::Red);
3040 let blue = Style::new().fg(ratatui::style::Color::Blue);
3041 let line = Line::from(vec![
3042 Span::raw(" "),
3043 Span::styled("a".repeat(40), red),
3044 Span::styled("b".repeat(40), blue),
3045 ]);
3046 let width = 24;
3047 let wrapped = wrap_styled_line(line, width, 2);
3048 assert!(
3049 wrapped.len() >= 4,
3050 "80-cell token at width 24 must span >= 4 rows; got {}",
3051 wrapped.len()
3052 );
3053 let mut reconstructed = String::new();
3054 for l in &wrapped {
3055 let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
3056 assert!(
3057 row_cells <= width,
3058 "row exceeds width: {row_cells} > {width}"
3059 );
3060 for s in &l.spans {
3061 if s.content.trim().is_empty() {
3062 continue;
3063 }
3064 let expected = if s.content.contains('a') { red } else { blue };
3065 assert!(
3066 !(s.content.contains('a') && s.content.contains('b')),
3067 "fragments must not merge across the style boundary"
3068 );
3069 assert_eq!(s.style, expected, "fragment style preserved across break");
3070 reconstructed.push_str(s.content.as_ref());
3071 }
3072 }
3073 assert_eq!(
3074 reconstructed,
3075 format!("{}{}", "a".repeat(40), "b".repeat(40)),
3076 "hard-break must preserve the whole glued token"
3077 );
3078 }
3079
3080 #[test]
3083 fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
3084 let line = Line::from(vec![
3085 Span::raw(" "),
3086 Span::raw("filler words that push this line past the wrap width "),
3087 Span::raw("foo"),
3088 Span::raw(" "),
3089 Span::raw("bar"),
3090 ]);
3091 let wrapped = wrap_styled_line(line, 30, 2);
3092 assert!(wrapped.len() >= 2, "fixture must actually wrap");
3093 let text: String = wrapped
3094 .iter()
3095 .map(|l| {
3096 l.spans
3097 .iter()
3098 .map(|s| s.content.as_ref())
3099 .collect::<String>()
3100 })
3101 .collect::<Vec<_>>()
3102 .join("\n");
3103 assert!(
3104 text.contains("foo bar") || text.contains("foo\n bar"),
3105 "whitespace-only span must keep the words apart; got {text:?}"
3106 );
3107 assert!(
3108 !text.contains("foobar"),
3109 "words must not glue; got {text:?}"
3110 );
3111 }
3112
3113 #[test]
3114 fn frame_memo_hit_matches_miss() {
3115 use ratatui::Terminal;
3121 use ratatui::backend::TestBackend;
3122
3123 let theme = Theme::dark();
3124 let messages = vec![
3125 ChatMessage::assistant(
3126 "# Heading\n\nSome **bold** prose long enough that it wraps across \
3127 this narrow viewport more than once.\n\n- a list item that also \
3128 runs past the edge so it wraps\n- second item",
3129 ),
3130 ChatMessage::assistant("Short follow-up."),
3131 ];
3132
3133 let (width, height): (u16, u16) = (34, 30);
3134 let mut cache = FxHashMap::default();
3135 let mut state = ChatState::new();
3136
3137 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
3138 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
3139 term.draw(|f| {
3140 let widget = ChatWidget {
3141 messages: &messages,
3142 content_key: test_content_key(&messages),
3143 theme: &theme,
3144 wrapped_line_cache: cache,
3145 show_reasoning: true,
3146 blink_on: true,
3147 };
3148 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
3149 })
3150 .unwrap();
3151 term.backend().buffer().clone()
3152 };
3153
3154 let miss = render(&mut state, &mut cache);
3155 assert!(
3156 state.frame_memo.is_some(),
3157 "first render must populate the frame memo"
3158 );
3159 let hit = render(&mut state, &mut cache);
3160 assert_eq!(
3161 miss, hit,
3162 "frame-memo hit must render identically to the miss"
3163 );
3164 assert!(
3168 !state.last_rendered_rows.is_empty(),
3169 "memo hit must preserve last_rendered_rows from the miss"
3170 );
3171 }
3172
3173 #[test]
3174 fn append_action_duration_handles_empty_base() {
3175 assert_eq!(
3178 append_action_duration(String::new(), Some(0.035)),
3179 "took 35ms"
3180 );
3181 assert_eq!(
3183 append_action_duration("3 lines read".to_string(), Some(1.25)),
3184 "3 lines read, took 1.2s"
3185 );
3186 assert_eq!(append_action_duration(String::new(), None), "");
3188 }
3189}