1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{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::{ActionDetails, ActionDisplay, ActionResult, format_compact_count};
14use crate::models::ChatMessageKind;
15use crate::models::{ChatMessage, MessageRole};
16use crate::render::diff::{DiffLineKind, parse_diff_line};
17use crate::render::markdown::parse_markdown;
18use crate::render::theme::Theme;
19use crate::utils::format_relative_timestamp;
20
21#[derive(Debug, Clone)]
23pub struct ImageClickTarget {
24 pub message_index: usize,
26 pub image_index: usize,
28}
29
30#[derive(Debug, Clone)]
32pub struct ChatState {
33 scroll_offset: u16,
35 is_user_scrolling: bool,
37 pub image_click_map: Vec<(u16, ImageClickTarget)>,
39 pub last_scroll_position: u16,
41 pub last_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
46 last_rendered_rows: Vec<String>,
50 frame_memo: Option<FrameMemo>,
57}
58
59#[derive(Debug, Clone)]
66struct FrameMemo {
67 key: u64,
69 lines: Vec<Line<'static>>,
71 click_map: Vec<(u16, ImageClickTarget)>,
73}
74
75impl ChatState {
76 pub fn new() -> Self {
78 Self {
79 scroll_offset: 0,
80 is_user_scrolling: false,
81 image_click_map: Vec::new(),
82 last_scroll_position: 0,
83 last_chat_area: None,
84 selection: None,
85 last_rendered_rows: Vec::new(),
86 frame_memo: None,
87 }
88 }
89
90 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
93 let max_scroll = content_height.saturating_sub(viewport_height);
94 if self.is_user_scrolling {
95 let capped_offset = self.scroll_offset.min(max_scroll);
98 max_scroll.saturating_sub(capped_offset)
99 } else {
100 max_scroll
102 }
103 }
104
105 pub fn scroll_up(&mut self, amount: u16) {
107 self.is_user_scrolling = true;
108 self.scroll_offset = self.scroll_offset.saturating_add(amount);
109 self.selection = None;
112 }
113
114 pub fn scroll_down(&mut self, amount: u16) {
117 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
118 if self.scroll_offset == 0 {
119 self.is_user_scrolling = false;
121 }
122 self.selection = None;
123 }
124
125 pub fn resume_auto_scroll(&mut self) {
127 self.is_user_scrolling = false;
128 self.scroll_offset = 0;
129 }
130
131 pub fn is_manually_scrolling(&self) -> bool {
133 self.is_user_scrolling
134 }
135
136 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
139 let (_, area_y, _, area_height) = self.last_chat_area?;
140
141 if screen_row < area_y || screen_row >= area_y + area_height {
143 return None;
144 }
145
146 let viewport_row = screen_row - area_y;
148 let content_line = viewport_row + self.last_scroll_position;
149
150 self.image_click_map
152 .iter()
153 .find(|(line, _)| *line == content_line)
154 .map(|(_, target)| target)
155 }
156
157 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
161 let (area_x, area_y, _, area_height) = self.last_chat_area?;
162 if screen_row < area_y || screen_row >= area_y + area_height {
163 return None;
164 }
165 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
166 let col = screen_col.saturating_sub(area_x) as usize;
167 Some((content_line, col))
168 }
169
170 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
174 self.selection = self
175 .screen_to_content(screen_row, screen_col)
176 .map(|p| (p, p));
177 }
178
179 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
181 if let Some((anchor, _)) = self.selection
182 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
183 {
184 self.selection = Some((anchor, cursor));
185 }
186 }
187
188 pub fn clear_selection(&mut self) {
190 self.selection = None;
191 }
192
193 pub fn selected_text(&self) -> Option<String> {
198 let (a, b) = self.selection?;
199 let (start, end) = if a <= b { (a, b) } else { (b, a) };
200 if self.last_rendered_rows.is_empty() {
201 return None;
202 }
203 let last = self.last_rendered_rows.len() - 1;
204 let (start_line, start_col) = (start.0.min(last), start.1);
205 let (end_line, end_col) = (end.0.min(last), end.1);
206
207 let mut out = String::new();
208 for line in start_line..=end_line {
209 let row = &self.last_rendered_rows[line];
210 let c0 = if line == start_line { start_col } else { 0 };
211 let c1 = if line == end_line {
212 end_col
213 } else {
214 usize::MAX
215 };
216 let mut piece = slice_by_cells(row, c0, c1).to_string();
217 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
222 while margin > 0 && piece.starts_with(' ') {
223 piece.remove(0);
224 margin -= 1;
225 }
226 out.push_str(piece.trim_end());
227 if line != end_line {
228 out.push('\n');
229 }
230 }
231 if out.is_empty() { None } else { Some(out) }
232 }
233}
234
235const SELECT_MARGIN_CELLS: usize = 2;
239
240fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
245 if width == 0 {
246 return vec![line];
247 }
248 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
249 if total <= width {
250 return vec![line];
251 }
252
253 let base = line.style;
254 let mut out: Vec<Line<'static>> = Vec::new();
255 let mut cur: Vec<Span<'static>> = Vec::new();
256 let mut cur_w = 0usize;
257 let mut on_first = true;
258
259 for span in line.spans {
260 let style = span.style;
261 let mut buf = String::new();
262 for ch in span.content.chars() {
263 let cw = ch.width().unwrap_or(0);
264 let floor = if on_first { 0 } else { indent };
267 if cur_w + cw > width && cur_w > floor {
268 if !buf.is_empty() {
269 cur.push(Span::styled(std::mem::take(&mut buf), style));
270 }
271 out.push(Line::from(std::mem::take(&mut cur)).style(base));
272 on_first = false;
273 cur.push(Span::styled(" ".repeat(indent), base));
274 cur_w = indent;
275 }
276 buf.push(ch);
277 cur_w += cw;
278 }
279 if !buf.is_empty() {
280 cur.push(Span::styled(buf, style));
281 }
282 }
283 if !cur.is_empty() {
284 out.push(Line::from(cur).style(base));
285 }
286 if out.is_empty() {
287 vec![Line::from("").style(base)]
288 } else {
289 out
290 }
291}
292
293fn byte_at_cell(s: &str, target: usize) -> usize {
297 if target == 0 {
298 return 0;
299 }
300 let mut width = 0usize;
301 for (idx, ch) in s.char_indices() {
302 if width >= target {
303 return idx;
304 }
305 width += ch.width().unwrap_or(0);
306 }
307 s.len()
308}
309
310fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
312 let start = byte_at_cell(s, c0);
313 let end = byte_at_cell(s, c1).max(start);
314 &s[start..end]
315}
316
317fn pad_to_cells(s: &str, cells: usize) -> String {
322 let w = s.width();
323 if w >= cells {
324 return s.to_string();
325 }
326 let mut out = String::with_capacity(s.len() + (cells - w));
327 out.push_str(s);
328 out.push_str(&" ".repeat(cells - w));
329 out
330}
331
332fn user_timestamp_padding(
337 role_prefix_width: usize,
338 text_width: usize,
339 timestamp_width: usize,
340 min_gap: usize,
341 content_width: usize,
342) -> usize {
343 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
344 min_gap + content_width.saturating_sub(total_used)
345}
346
347fn line_plain_text(line: &Line) -> String {
349 line.spans.iter().map(|s| s.content.as_ref()).collect()
350}
351
352fn clamp_to_u16(n: usize) -> u16 {
358 u16::try_from(n).unwrap_or(u16::MAX)
359}
360
361fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
365 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
366 let mut width = 0usize;
367 for span in line.spans.drain(..) {
368 let span_w = span.content.width();
369 let (span_start, span_end) = (width, width + span_w);
370 width = span_end;
371
372 let ov0 = c0.max(span_start);
373 let ov1 = c1.min(span_end);
374 if ov1 <= ov0 {
375 new_spans.push(span); continue;
377 }
378
379 let s = span.content.as_ref();
380 let b0 = byte_at_cell(s, ov0 - span_start);
381 let b1 = byte_at_cell(s, ov1 - span_start);
382 if b0 > 0 {
383 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
384 }
385 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
386 if b1 < s.len() {
387 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
388 }
389 }
390 line.spans = new_spans;
391}
392
393impl Default for ChatState {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399pub struct ChatWidget<'a> {
401 pub messages: &'a [ChatMessage],
402 pub theme: &'a Theme,
403 pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
408 pub show_reasoning: bool,
409}
410
411fn wrap_assistant_content(
420 content: &str,
421 content_width: u16,
422 role_prefix: &str,
423 role_color: ratatui::style::Color,
424 theme: &Theme,
425) -> Vec<Line<'static>> {
426 let md_width = (content_width as usize).saturating_sub(2);
428 let parsed = parse_markdown(content, theme, md_width);
429
430 let mut out: Vec<Line<'static>> = Vec::new();
431 for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
432 let preformatted = parsed_line.preformatted;
437 let base_style = parsed_line.line.style;
438
439 let continuation = if preformatted {
444 2
445 } else {
446 2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
447 };
448
449 let mut spans = if line_idx == 0 {
451 vec![Span::styled(
452 format!("{} ", role_prefix),
453 Style::new().fg(role_color).bold(),
454 )]
455 } else {
456 vec![Span::raw(" ")]
457 };
458 spans.extend(parsed_line.line.spans);
459 let new_line = Line::from(spans).style(base_style);
460
461 if preformatted {
462 out.extend(wrap_preformatted(new_line, content_width as usize, 2));
465 } else {
466 out.extend(wrap_styled_line(
467 new_line,
468 content_width as usize,
469 continuation,
470 ));
471 }
472 }
473 out
474}
475
476struct HashWrite<'a, H: Hasher>(&'a mut H);
480
481impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
482 fn write_str(&mut self, s: &str) -> std::fmt::Result {
483 self.0.write(s.as_bytes());
484 Ok(())
485 }
486}
487
488fn frame_fingerprint(
500 messages: &[ChatMessage],
501 theme_seed: u64,
502 content_width: u16,
503 show_reasoning: bool,
504) -> u64 {
505 use std::fmt::Write as _;
506 let mut h = rustc_hash::FxHasher::default();
507 theme_seed.hash(&mut h);
508 content_width.hash(&mut h);
509 show_reasoning.hash(&mut h);
510 chrono::Local::now().date_naive().hash(&mut h);
513 messages.len().hash(&mut h);
514 for msg in messages {
515 msg.content.hash(&mut h);
516 msg.thinking.hash(&mut h);
517 msg.timestamp.timestamp().hash(&mut h);
520 msg.images
521 .as_ref()
522 .map_or(0, |imgs| imgs.len())
523 .hash(&mut h);
524 let mut hw = HashWrite(&mut h);
525 let _ = write!(
526 hw,
527 "{:?}|{:?}|{:?}|{:?}",
528 msg.role, msg.kind, msg.metadata, msg.actions
529 );
530 }
531 h.finish()
532}
533
534impl<'a> StatefulWidget for ChatWidget<'a> {
535 type State = ChatState;
536
537 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
538 let code_bg = self.theme.colors.code_background.to_color();
541 let theme_seed = {
542 let mut h = rustc_hash::FxHasher::default();
543 self.theme.colors.foreground.to_color().hash(&mut h);
544 code_bg.hash(&mut h);
545 self.theme.colors.header.to_color().hash(&mut h);
546 h.finish()
547 };
548
549 let content_width = area.width;
551 let content_area = area;
552
553 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
554
555 let frame_key = frame_fingerprint(
561 self.messages,
562 theme_seed,
563 content_width,
564 self.show_reasoning,
565 );
566 let memo_hit = state
569 .frame_memo
570 .as_ref()
571 .filter(|m| m.key == frame_key)
572 .map(|m| (m.lines.clone(), m.click_map.clone()));
573
574 let mut lines = if let Some((cached_lines, cached_click_map)) = memo_hit {
575 state.image_click_map = cached_click_map;
578 cached_lines
579 } else {
580 let mut lines: Vec<Line<'static>> = Vec::new();
582
583 state.image_click_map.clear();
585
586 for (idx, msg) in self.messages.iter().enumerate() {
587 if matches!(msg.role, MessageRole::Tool) {
590 continue;
591 }
592
593 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
594 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
595 lines.extend(event_lines);
596 lines.push(Line::from(""));
597 }
598 continue;
599 }
600
601 if matches!(msg.kind, ChatMessageKind::RunSummary) {
606 lines.push(Line::from(Span::styled(
607 format!(" {}", msg.content),
608 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
609 )));
610 lines.push(Line::from(""));
611 continue;
612 }
613
614 let (role_prefix, role_color) = match msg.role {
615 MessageRole::User => (">", ratatui::style::Color::White),
616 MessageRole::Assistant => ("●", ratatui::style::Color::White),
617 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
618 MessageRole::Tool => unreachable!("Tool messages filtered above"),
619 };
620
621 if matches!(msg.role, MessageRole::Assistant) {
622 if let Some(ref thinking) = msg.thinking {
624 let thinking_trimmed = thinking.trim();
626 if thinking_trimmed.is_empty()
627 || thinking_trimmed == "None"
628 || thinking_trimmed == "none"
629 {
630 } else if self.show_reasoning {
632 lines.push(Line::from(vec![
634 Span::styled(
635 "● ",
636 Style::new().fg(ratatui::style::Color::DarkGray),
637 ),
638 Span::styled(
639 "Thinking...",
640 Style::new()
641 .fg(self.theme.colors.text_secondary.to_color())
642 .italic()
643 .dim(),
644 ),
645 ]));
646
647 let wrapped = wrap_text_with_indent(
649 thinking,
650 content_width as usize,
651 2, 2, );
654 for wrapped_line in wrapped {
655 lines.push(Line::from(Span::styled(
656 wrapped_line,
657 Style::new()
658 .fg(self.theme.colors.text_secondary.to_color())
659 .italic()
660 .dim(),
661 )));
662 }
663
664 lines.push(Line::from(""));
666 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
667 continue;
672 }
673 }
674
675 let mut hasher = rustc_hash::FxHasher::default();
683 msg.content.hash(&mut hasher);
684 theme_seed.hash(&mut hasher);
685 content_width.hash(&mut hasher);
686 let cache_key = hasher.finish();
687
688 let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
689 cached.clone()
690 } else {
691 let block = wrap_assistant_content(
692 &msg.content,
693 content_width,
694 role_prefix,
695 role_color,
696 self.theme,
697 );
698 self.wrapped_line_cache.insert(cache_key, block.clone());
699 if self.wrapped_line_cache.len()
700 > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
701 {
702 let overflow = self.wrapped_line_cache.len()
707 - crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
708 let stale: Vec<u64> = self
709 .wrapped_line_cache
710 .keys()
711 .copied()
712 .filter(|&k| k != cache_key)
713 .take(overflow)
714 .collect();
715 for k in stale {
716 self.wrapped_line_cache.remove(&k);
717 }
718 }
719 block
720 };
721 lines.extend(wrapped);
722
723 if !msg.actions.is_empty() {
725 if !msg.content.trim().is_empty() {
727 lines.push(Line::from(""));
728 }
729 render_actions(
730 &msg.actions,
731 &mut lines,
732 self.theme,
733 content_width as usize,
734 );
735 }
736 } else {
737 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
739 let timestamp_width = formatted_timestamp.width();
743 let min_gap = 3; let cleaned_content = &msg.content;
747
748 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
752
753 let wrapped = wrap_text_with_indent(
755 cleaned_content,
756 content_width as usize,
757 first_line_reserved, 2, );
760
761 let band_start = lines.len();
762 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
763 if line_idx == 0 {
764 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
767
768 let mut spans = vec![
769 Span::styled(
770 format!("{} ", role_prefix),
771 Style::new().fg(role_color).bold(),
772 ),
773 Span::raw(text_content.to_string()),
774 ];
775
776 let pad = user_timestamp_padding(
779 role_prefix_width,
780 text_width,
781 timestamp_width,
782 min_gap,
783 content_width as usize,
784 );
785 spans.push(Span::raw(" ".repeat(pad)));
786 spans.push(Span::styled(
787 formatted_timestamp.clone(),
788 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
789 ));
790
791 lines.push(Line::from(spans));
792 } else {
793 lines.push(Line::from(wrapped_line.clone()));
795 }
796 }
797
798 if matches!(msg.role, MessageRole::User) {
804 let user_bg = self.theme.colors.user_message_background.to_color();
805 let cw = content_width as usize;
806 for line in &mut lines[band_start..] {
807 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
808 if used < cw {
809 line.spans.push(Span::raw(" ".repeat(cw - used)));
810 }
811 line.style = line.style.bg(user_bg);
812 }
813 }
814 }
815
816 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
823 && let Some(ref images) = msg.images
824 && !images.is_empty()
825 {
826 for (i, _) in images.iter().enumerate() {
827 let content_line = lines.len();
833 state.image_click_map.push((
834 clamp_to_u16(content_line),
835 ImageClickTarget {
836 message_index: idx,
837 image_index: i,
838 },
839 ));
840 lines.push(Line::from(vec![
841 Span::styled(
842 " ⎿ ",
843 Style::new().fg(self.theme.colors.info.to_color()),
844 ),
845 Span::styled(
846 format!("[Image #{}]", i + 1),
847 Style::new().fg(self.theme.colors.info.to_color()).italic(),
848 ),
849 ]));
850 }
851 }
852
853 lines.push(Line::from(""));
854 }
855
856 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
862
863 state.frame_memo = Some(FrameMemo {
868 key: frame_key,
869 lines: lines.clone(),
870 click_map: state.image_click_map.clone(),
871 });
872 lines
873 };
874
875 if let Some((a, b)) = state.selection
890 && !lines.is_empty()
891 {
892 let (start, end) = if a <= b { (a, b) } else { (b, a) };
893 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
894 let last_line = lines.len() - 1;
895 for (line_idx, line) in lines
896 .iter_mut()
897 .enumerate()
898 .take(end.0.min(last_line) + 1)
899 .skip(start.0)
900 {
901 let c0 = if line_idx == start.0 { start.1 } else { 0 };
902 let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
903 if c1 > c0 {
904 highlight_line_cells(line, c0, c1, sel_style);
905 }
906 }
907 }
908
909 let content_height = lines.len();
915 let viewport_height = area.height;
916
917 let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
918 state.last_scroll_position = scroll_pos;
919
920 let paragraph = Paragraph::new(lines)
921 .block(Block::default())
922 .scroll((scroll_pos, 0));
923
924 paragraph.render(content_area, buf);
925 }
926}
927
928fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
929 if !matches!(msg.role, MessageRole::User) {
930 return None;
931 }
932
933 let metadata = msg.metadata.as_ref();
934 let trigger = metadata
935 .and_then(|value| value.get("trigger"))
936 .and_then(|value| value.as_str())
937 .unwrap_or("manual");
938 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
939 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
940 let archived_messages =
941 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
942 let preserved_messages =
943 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
944 let duration_secs = metadata
945 .and_then(|value| value.get("duration_secs"))
946 .and_then(|value| value.as_f64());
947 let verified = metadata
948 .and_then(|value| value.get("verified"))
949 .and_then(|value| value.as_bool());
950 let verification_error = metadata
951 .and_then(|value| value.get("verification_error"))
952 .and_then(|value| value.as_str());
953
954 let action_color = theme.colors.info.to_color();
955 let mut result = match (before_tokens, after_tokens) {
956 (Some(before), Some(after)) => {
957 format!(
958 "Success, {} -> {} tokens",
959 format_compact_count(before),
960 format_compact_count(after)
961 )
962 },
963 _ => "Success".to_string(),
964 };
965
966 if let Some(count) = archived_messages {
967 result.push_str(&format!(
968 ", archived {} {}",
969 count,
970 if count == 1 { "message" } else { "messages" }
971 ));
972 }
973 if let Some(count) = preserved_messages {
974 result.push_str(&format!(
975 ", preserved {} {}",
976 count,
977 if count == 1 { "message" } else { "messages" }
978 ));
979 }
980 if let Some(verified) = verified {
981 if verified {
982 result.push_str(", verified");
983 } else {
984 result.push_str(", draft fallback");
985 }
986 }
987 result = append_action_duration(result, duration_secs);
988
989 let mut lines = vec![
990 Line::from(vec![
991 Span::styled("● ", Style::new().fg(action_color).bold()),
992 Span::styled("Compact(", Style::new().fg(action_color).bold()),
993 Span::styled(
994 trigger.to_string(),
995 Style::new().fg(theme.colors.text_secondary.to_color()),
996 ),
997 Span::styled(")", Style::new().fg(action_color).bold()),
998 ]),
999 Line::from(vec![
1000 Span::styled(" ⎿ ", Style::new().fg(action_color)),
1001 Span::styled(
1002 result,
1003 Style::new().fg(theme.colors.text_secondary.to_color()),
1004 ),
1005 ]),
1006 ];
1007
1008 if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
1009 lines.push(Line::from(vec![
1010 Span::styled(" ", Style::new().fg(action_color)),
1011 Span::styled(
1012 format!("verification: {}", compact_inline_error(error, 180)),
1013 Style::new().fg(theme.colors.warning.to_color()),
1014 ),
1015 ]));
1016 }
1017
1018 Some(lines)
1019}
1020
1021fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1022 value
1023 .get(key)?
1024 .as_u64()
1025 .and_then(|value| usize::try_from(value).ok())
1026}
1027
1028fn compact_inline_error(text: &str, max_chars: usize) -> String {
1029 let text = text.trim();
1030 if text.chars().count() <= max_chars {
1031 return text.to_string();
1032 }
1033 let keep = max_chars.saturating_sub(3);
1034 let mut out: String = text.chars().take(keep).collect();
1035 out.push_str("...");
1036 out
1037}
1038
1039fn expand_tabs(s: &str) -> String {
1048 const TAB_WIDTH: usize = 4;
1049 if !s.contains('\t') {
1050 return s.to_string();
1051 }
1052 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1053 let mut col = 0usize;
1054 for ch in s.chars() {
1055 if ch == '\t' {
1056 let n = TAB_WIDTH - (col % TAB_WIDTH);
1057 for _ in 0..n {
1058 out.push(' ');
1059 }
1060 col += n;
1061 } else {
1062 out.push(ch);
1063 col += UnicodeWidthChar::width(ch).unwrap_or(0);
1064 }
1065 }
1066 out
1067}
1068
1069fn render_actions(
1070 actions: &[ActionDisplay],
1071 lines: &mut Vec<Line>,
1072 theme: &Theme,
1073 viewport_width: usize,
1074) {
1075 for (action_idx, action) in actions.iter().enumerate() {
1076 if action_idx > 0 {
1077 lines.push(Line::from(""));
1078 }
1079 let action_color = match action.action_type.as_str() {
1080 "Write" | "Edit" => theme.colors.success.to_color(),
1081 "Delete" => theme.colors.warning.to_color(),
1082 _ => theme.colors.info.to_color(),
1083 };
1084
1085 lines.push(Line::from(vec![
1087 Span::styled("● ", Style::new().fg(action_color).bold()),
1088 Span::styled(
1089 format!("{}(", action.action_type),
1090 Style::new().fg(action_color).bold(),
1091 ),
1092 Span::styled(
1093 action.target.clone(),
1094 Style::new().fg(theme.colors.text_secondary.to_color()),
1095 ),
1096 Span::styled(")", Style::new().fg(action_color).bold()),
1097 ]));
1098
1099 match &action.result {
1100 ActionResult::Success { .. } => {
1101 let result_msg = match &action.details {
1103 ActionDetails::FileContent { line_count, .. } => {
1104 let base = format!(
1105 "Success, {} {} written",
1106 line_count,
1107 if *line_count == 1 { "line" } else { "lines" }
1108 );
1109 append_action_duration(base, action.duration_seconds)
1110 },
1111 ActionDetails::Diff { summary, .. } => summary.clone(),
1112 ActionDetails::Preview { text, .. } => text.clone(),
1113 ActionDetails::Simple => match action.action_type.as_str() {
1114 "Delete" => append_action_duration(
1115 format!("Success, deleted {}", action.target),
1116 action.duration_seconds,
1117 ),
1118 _ => append_action_duration("Success".to_string(), action.duration_seconds),
1119 },
1120 };
1121
1122 for (idx, line) in result_msg.lines().enumerate() {
1123 let prefix = if idx == 0 { " ⎿ " } else { " " };
1124 lines.push(Line::from(vec![
1125 Span::styled(prefix, Style::new().fg(action_color)),
1126 Span::styled(
1127 line.to_string(),
1128 Style::new().fg(theme.colors.text_secondary.to_color()),
1129 ),
1130 ]));
1131 }
1132
1133 if let ActionDetails::FileContent {
1135 content,
1136 line_count,
1137 } = &action.details
1138 {
1139 let preview_lines: Vec<&str> = content.lines().take(10).collect();
1140 if !preview_lines.is_empty() {
1141 lines.push(Line::from(vec![Span::styled(
1142 " ",
1143 Style::new().fg(action_color),
1144 )]));
1145
1146 let preview_content = preview_lines.join("\n");
1147 let mut parsed = parse_markdown(
1148 &format!("```\n{}\n```", preview_content),
1149 theme,
1150 viewport_width.saturating_sub(4),
1151 );
1152 for parsed_line in parsed.iter_mut() {
1153 let mut new_spans =
1154 vec![Span::styled(" ", Style::new().fg(action_color))];
1155 new_spans.append(&mut parsed_line.line.spans);
1156 parsed_line.line.spans = new_spans;
1157 }
1158 lines.extend(parsed.into_iter().map(|ml| ml.line));
1159
1160 if *line_count > 10 {
1161 lines.push(Line::from(vec![
1162 Span::styled(" ", Style::new().fg(action_color)),
1163 Span::styled(
1164 format!("... ({} more lines)", line_count - 10),
1165 Style::new()
1166 .fg(theme.colors.text_disabled.to_color())
1167 .italic(),
1168 ),
1169 ]));
1170 }
1171 }
1172 }
1173
1174 if let ActionDetails::Diff { diff, .. } = &action.details {
1176 let diff_lines: Vec<&str> = diff.lines().collect();
1177 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1178
1179 if !display_lines.is_empty() {
1180 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1181 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1182
1183 for diff_line in &display_lines {
1184 let diff_line = expand_tabs(diff_line);
1191 match parse_diff_line(&diff_line) {
1196 DiffLineKind::Removed => {
1197 let text = format!(" {}", diff_line);
1198 let padded = pad_to_cells(&text, viewport_width);
1199 lines.push(Line::from(vec![Span::styled(
1200 padded,
1201 Style::new()
1202 .fg(theme.colors.error.to_color())
1203 .bg(removed_bg),
1204 )]));
1205 },
1206 DiffLineKind::Added => {
1207 let text = format!(" {}", diff_line);
1208 let padded = pad_to_cells(&text, viewport_width);
1209 lines.push(Line::from(vec![Span::styled(
1210 padded,
1211 Style::new()
1212 .fg(theme.colors.success.to_color())
1213 .bg(added_bg),
1214 )]));
1215 },
1216 DiffLineKind::Context => {
1217 lines.push(Line::from(vec![
1218 Span::styled(" ", Style::new().fg(action_color)),
1219 Span::styled(
1220 diff_line,
1221 Style::new().fg(theme.colors.text_secondary.to_color()),
1222 ),
1223 ]));
1224 },
1225 }
1226 }
1227
1228 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1229 if remaining > 0 {
1230 lines.push(Line::from(vec![
1231 Span::styled(" ", Style::new().fg(action_color)),
1232 Span::styled(
1233 format!("... ({} more lines)", remaining),
1234 Style::new()
1235 .fg(theme.colors.text_disabled.to_color())
1236 .italic(),
1237 ),
1238 ]));
1239 }
1240 }
1241 }
1242 },
1243 ActionResult::Error { error } => {
1244 let error =
1245 append_action_duration(format!("Error: {}", error), action.duration_seconds);
1246 lines.push(Line::from(vec![
1247 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
1248 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
1249 ]));
1250 },
1251 }
1252 }
1253}
1254
1255fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1256 if let Some(seconds) = duration_seconds {
1257 text.push_str(", took ");
1258 text.push_str(&format_action_duration(seconds));
1259 }
1260 text
1261}
1262
1263fn format_action_duration(seconds: f64) -> String {
1264 if seconds < 1.0 {
1265 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1266 } else if seconds < 10.0 {
1267 format!("{:.1}s", seconds)
1268 } else {
1269 format!("{}s", seconds.round() as u64)
1270 }
1271}
1272
1273fn hard_break_plain_token(
1286 token: &str,
1287 out: &mut Vec<String>,
1288 current_line: &mut String,
1289 current_length: &mut usize,
1290 width: usize,
1291 continuation_indent: usize,
1292 initial_budget: usize,
1293) {
1294 let cont_budget = width.saturating_sub(continuation_indent).max(1);
1295 let mut line_budget = initial_budget.max(1);
1296
1297 if *current_length > 0 {
1301 out.push(std::mem::take(current_line));
1302 current_line.push_str(&" ".repeat(continuation_indent));
1303 *current_length = 0;
1304 line_budget = cont_budget;
1305 }
1306
1307 for ch in token.chars() {
1308 let cw = ch.width().unwrap_or(0);
1309 if *current_length + cw > line_budget && *current_length > 0 {
1312 out.push(std::mem::take(current_line));
1313 current_line.push_str(&" ".repeat(continuation_indent));
1314 *current_length = 0;
1315 line_budget = cont_budget;
1316 }
1317 current_line.push(ch);
1318 *current_length += cw;
1319 }
1320}
1321
1322fn wrap_text_with_indent(
1331 text: &str,
1332 width: usize,
1333 first_line_indent: usize,
1334 continuation_indent: usize,
1335) -> Vec<String> {
1336 let mut wrapped_lines = Vec::new();
1337
1338 for (line_idx, line) in text.lines().enumerate() {
1339 if line.is_empty() {
1340 wrapped_lines.push(String::new());
1341 continue;
1342 }
1343
1344 let current_indent = if line_idx == 0 {
1345 first_line_indent
1346 } else {
1347 continuation_indent
1348 };
1349 let available_width = width.saturating_sub(current_indent);
1350
1351 if available_width == 0 {
1352 wrapped_lines.push(" ".repeat(current_indent));
1353 continue;
1354 }
1355
1356 let words: Vec<&str> = line.split_whitespace().collect();
1357 if words.is_empty() {
1358 wrapped_lines.push(" ".repeat(current_indent));
1359 continue;
1360 }
1361
1362 let mut current_line = String::with_capacity(width);
1363 current_line.push_str(&" ".repeat(current_indent));
1364 let mut current_length = 0;
1367
1368 for (word_idx, word) in words.iter().enumerate() {
1369 let word_width = word.width();
1370
1371 if word_idx == 0 {
1372 if word_width <= available_width {
1373 current_line.push_str(word);
1375 current_length = word_width;
1376 } else {
1377 hard_break_plain_token(
1382 word,
1383 &mut wrapped_lines,
1384 &mut current_line,
1385 &mut current_length,
1386 width,
1387 continuation_indent,
1388 available_width,
1389 );
1390 }
1391 } else if current_length + 1 + word_width <= available_width {
1392 current_line.push(' ');
1395 current_line.push_str(word);
1396 current_length += 1 + word_width;
1397 } else if word_width <= available_width {
1398 wrapped_lines.push(current_line);
1400 current_line = String::with_capacity(width);
1401 current_line.push_str(&" ".repeat(continuation_indent));
1402 current_line.push_str(word);
1403 current_length = word_width;
1404 } else {
1405 hard_break_plain_token(
1408 word,
1409 &mut wrapped_lines,
1410 &mut current_line,
1411 &mut current_length,
1412 width,
1413 continuation_indent,
1414 available_width,
1415 );
1416 }
1417 }
1418
1419 if !current_line.trim().is_empty() {
1421 wrapped_lines.push(current_line);
1422 }
1423 }
1424
1425 wrapped_lines
1426}
1427
1428#[allow(clippy::too_many_arguments)]
1442fn hard_break_styled_token(
1443 token: &str,
1444 style: Style,
1445 result_lines: &mut Vec<Line<'static>>,
1446 current_line_spans: &mut Vec<Span<'static>>,
1447 current_line_width: &mut usize,
1448 continuation_indent: usize,
1449 continuation_capacity: usize,
1450 mut line_capacity: usize,
1451) {
1452 let mut buf = String::new();
1453 for ch in token.chars() {
1454 let cw = ch.width().unwrap_or(0);
1455 if *current_line_width + cw > line_capacity && *current_line_width > 0 {
1458 if !buf.is_empty() {
1459 current_line_spans.push(Span::styled(std::mem::take(&mut buf), style));
1460 }
1461 result_lines.push(Line::from(std::mem::take(current_line_spans)));
1462 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1463 *current_line_width = 0;
1464 line_capacity = continuation_capacity.max(1);
1465 }
1466 buf.push(ch);
1467 *current_line_width += cw;
1468 }
1469 if !buf.is_empty() {
1470 current_line_spans.push(Span::styled(buf, style));
1471 }
1472}
1473
1474fn wrap_styled_line(
1477 line: Line<'static>,
1478 width: usize,
1479 continuation_indent: usize,
1480) -> Vec<Line<'static>> {
1481 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1486
1487 if total_width <= width {
1489 return vec![line];
1490 }
1491
1492 let mut result_lines = Vec::new();
1494 let mut current_line_spans = Vec::new();
1495 let mut current_line_width = 0usize;
1496 let available_width = width.saturating_sub(continuation_indent);
1497
1498 let leading_indent: usize = {
1506 let mut n = 0;
1507 for span in &line.spans {
1508 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1509 n += spaces;
1510 if spaces < span.content.len() {
1511 break; }
1513 }
1514 n
1515 };
1516
1517 for span in line.spans.clone() {
1518 let span_text = span.content.to_string();
1519 let span_style = span.style;
1520
1521 let words: Vec<&str> = span_text.split_whitespace().collect();
1523
1524 for (word_idx, word) in words.iter().enumerate() {
1525 let word_with_space = if word_idx > 0 || current_line_width > 0 {
1526 format!(" {}", word)
1527 } else {
1528 word.to_string()
1529 };
1530
1531 let word_width = word_with_space.width();
1532
1533 if current_line_width == 0 && result_lines.is_empty() {
1534 if leading_indent > 0 {
1538 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1539 current_line_width += leading_indent;
1540 }
1541 if word_width <= available_width {
1542 current_line_spans.push(Span::styled(word_with_space, span_style));
1543 current_line_width += word_width;
1544 } else {
1545 hard_break_styled_token(
1551 word,
1552 span_style,
1553 &mut result_lines,
1554 &mut current_line_spans,
1555 &mut current_line_width,
1556 continuation_indent,
1557 available_width,
1558 width,
1559 );
1560 }
1561 } else if current_line_width + word_width <= available_width {
1562 current_line_spans.push(Span::styled(word_with_space, span_style));
1564 current_line_width += word_width;
1565 } else if word.width() <= available_width {
1566 result_lines.push(Line::from(current_line_spans));
1568 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1569 current_line_spans.push(Span::styled(word.to_string(), span_style));
1570 current_line_width = word.width();
1571 } else {
1572 result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1576 current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1577 current_line_width = 0;
1578 hard_break_styled_token(
1579 word,
1580 span_style,
1581 &mut result_lines,
1582 &mut current_line_spans,
1583 &mut current_line_width,
1584 continuation_indent,
1585 available_width,
1586 available_width,
1587 );
1588 }
1589 }
1590 }
1591
1592 if !current_line_spans.is_empty() {
1594 result_lines.push(Line::from(current_line_spans));
1595 }
1596
1597 if result_lines.is_empty() {
1598 vec![line]
1599 } else {
1600 result_lines
1601 }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607
1608 #[test]
1609 fn diff_background_fills_full_width_with_tabs() {
1610 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1615 use ratatui::Terminal;
1616 use ratatui::backend::TestBackend;
1617
1618 let theme = Theme::dark();
1619 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1620 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1621 let diff = format!(
1623 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
1624 m = DIFF_REMOVED_MARKER,
1625 p = DIFF_ADDED_MARKER
1626 );
1627 let action = ActionDisplay {
1628 action_type: "Edit".to_string(),
1629 target: "engine.ts".to_string(),
1630 result: ActionResult::Success {
1631 output: String::new(),
1632 images: None,
1633 },
1634 details: ActionDetails::Diff {
1635 summary: "ok".to_string(),
1636 diff,
1637 },
1638 duration_seconds: Some(0.3),
1639 metadata: None,
1640 };
1641
1642 let width: u16 = 60;
1643 let mut lines: Vec<Line> = Vec::new();
1644 render_actions(&[action], &mut lines, &theme, width as usize);
1645 let h = lines.len() as u16;
1646 let backend = TestBackend::new(width, h);
1647 let mut term = Terminal::new(backend).unwrap();
1648 term.draw(|f| {
1649 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1650 })
1651 .unwrap();
1652 let buf = term.backend().buffer();
1653
1654 for y in 0..h {
1655 let is_diff_row = (0..width).any(|x| {
1656 let bg = buf[(x, y)].bg;
1657 bg == added_bg || bg == removed_bg
1658 });
1659 if !is_diff_row {
1660 continue;
1661 }
1662 for x in 0..width {
1663 let bg = buf[(x, y)].bg;
1664 assert!(
1665 bg == added_bg || bg == removed_bg,
1666 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1667 );
1668 }
1669 }
1670 }
1671
1672 #[test]
1673 fn wrapped_line_cache_hit_matches_cache_miss() {
1674 use ratatui::Terminal;
1681 use ratatui::backend::TestBackend;
1682
1683 let theme = Theme::dark();
1684 let messages = vec![
1685 ChatMessage::assistant(
1686 "# Heading\n\nSome **bold** prose long enough that it has to wrap \
1687 across this narrow viewport more than once.\n\n\
1688 - a list item that also keeps going past the edge so it wraps too\n\
1689 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
1690 ),
1691 ChatMessage::assistant("Short follow-up paragraph."),
1692 ];
1693
1694 let (width, height): (u16, u16) = (40, 40);
1695 let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
1696 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
1697 let mut state = ChatState::new();
1698 term.draw(|f| {
1699 let widget = ChatWidget {
1700 messages: &messages,
1701 theme: &theme,
1702 wrapped_line_cache: cache,
1703 show_reasoning: true,
1704 };
1705 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
1706 })
1707 .unwrap();
1708 term.backend().buffer().clone()
1709 };
1710
1711 let mut shared = FxHashMap::default();
1712 let miss = render_once(&mut shared);
1713 assert!(!shared.is_empty(), "first render must populate the cache");
1714 let hit = render_once(&mut shared);
1715 assert_eq!(miss, hit, "cache hit must render identically to cache miss");
1716
1717 let mut cold_cache = FxHashMap::default();
1718 let cold = render_once(&mut cold_cache);
1719 assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
1720 }
1721
1722 #[test]
1723 fn byte_at_cell_clamps_and_respects_cjk() {
1724 assert_eq!(byte_at_cell("hello", 0), 0);
1725 assert_eq!(byte_at_cell("hello", 3), 3);
1726 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
1729 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
1732 }
1733
1734 #[test]
1735 fn slice_by_cells_extracts_display_range() {
1736 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1737 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1738 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1739 }
1740
1741 #[test]
1742 fn pad_to_cells_fills_to_display_width() {
1743 assert_eq!(pad_to_cells("ab", 5), "ab ");
1744 assert_eq!(pad_to_cells("你好", 6), "你好 ");
1746 assert_eq!(pad_to_cells("你好", 3), "你好");
1748 assert_eq!(pad_to_cells("", 0), "");
1749 }
1750
1751 #[test]
1752 fn user_timestamp_padding_aligns_on_display_cells() {
1753 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
1755 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
1758 assert_eq!(4 + 10 + pad + 8, 40);
1759 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
1761 }
1762
1763 #[test]
1764 fn wrap_preformatted_hard_wraps_preserving_spaces() {
1765 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
1768 let wrapped = wrap_preformatted(line, 10, 2);
1769 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1770 let first: String = wrapped[0]
1771 .spans
1772 .iter()
1773 .map(|s| s.content.as_ref())
1774 .collect();
1775 assert!(
1776 first.starts_with(" aaaa"),
1777 "indentation must be preserved, got {first:?}"
1778 );
1779 let second: String = wrapped[1]
1780 .spans
1781 .iter()
1782 .map(|s| s.content.as_ref())
1783 .collect();
1784 assert!(
1785 second.starts_with(" "),
1786 "continuation should get the hanging indent, got {second:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn wrap_preformatted_short_line_unchanged() {
1792 let line = Line::from(vec![Span::raw(" short")]);
1793 let wrapped = wrap_preformatted(line, 40, 2);
1794 assert_eq!(wrapped.len(), 1);
1795 let text: String = wrapped[0]
1796 .spans
1797 .iter()
1798 .map(|s| s.content.as_ref())
1799 .collect();
1800 assert_eq!(text, " short");
1801 }
1802
1803 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1807 let mut st = ChatState::new();
1808 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1809 st.selection = Some(sel);
1810 st
1811 }
1812
1813 #[test]
1814 fn selected_text_single_line() {
1815 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1816 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1817 }
1818
1819 #[test]
1820 fn selected_text_spans_multiple_rows() {
1821 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
1822 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1825 }
1826
1827 #[test]
1828 fn selected_text_strips_margin_but_keeps_code_indentation() {
1829 let st = state_with_rows(
1832 &[" fn main() {", " let x = 1;", " }"],
1833 ((0, 0), (2, 3)),
1834 );
1835 assert_eq!(
1836 st.selected_text().as_deref(),
1837 Some("fn main() {\n let x = 1;\n}")
1838 );
1839 }
1840
1841 #[test]
1842 fn selected_text_normalizes_reversed_drag() {
1843 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1845 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1846 }
1847
1848 #[test]
1849 fn selected_text_empty_selection_is_none() {
1850 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1852 assert_eq!(st.selected_text(), None);
1853 }
1854
1855 #[test]
1856 fn highlight_line_cells_splits_spans_on_selection() {
1857 let mut line = Line::from(vec![Span::raw("abcdef")]);
1858 highlight_line_cells(
1859 &mut line,
1860 2,
1861 4,
1862 Style::new().add_modifier(Modifier::REVERSED),
1863 );
1864 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1866 assert_eq!(texts, vec!["ab", "cd", "ef"]);
1867 assert!(
1868 line.spans[1]
1869 .style
1870 .add_modifier
1871 .contains(Modifier::REVERSED)
1872 );
1873 assert!(
1874 !line.spans[0]
1875 .style
1876 .add_modifier
1877 .contains(Modifier::REVERSED)
1878 );
1879 }
1880
1881 #[test]
1882 fn context_checkpoint_renders_as_compact_event() {
1883 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1884 msg.kind = ChatMessageKind::ContextCheckpoint;
1885 msg.metadata = Some(serde_json::json!({
1886 "trigger": "manual",
1887 "before_tokens": 43_800,
1888 "after_tokens": 9_200,
1889 "archived_message_count": 18,
1890 "preserved_message_count": 4,
1891 "duration_secs": 2.4,
1892 "verified": true,
1893 }));
1894
1895 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1896 let rendered = lines
1897 .iter()
1898 .map(|line| {
1899 line.spans
1900 .iter()
1901 .map(|span| span.content.as_ref())
1902 .collect::<String>()
1903 })
1904 .collect::<Vec<_>>()
1905 .join("\n");
1906
1907 assert!(rendered.contains("Compact(manual)"));
1908 assert!(rendered.contains("43.8k -> 9.2k tokens"));
1909 assert!(rendered.contains("archived 18 messages"));
1910 assert!(rendered.contains("preserved 4 messages"));
1911 assert!(rendered.contains("verified"));
1912 assert!(!rendered.contains("full checkpoint summary"));
1913 }
1914
1915 #[test]
1916 fn context_checkpoint_renders_verification_fallback() {
1917 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1918 msg.kind = ChatMessageKind::ContextCheckpoint;
1919 msg.metadata = Some(serde_json::json!({
1920 "trigger": "auto_threshold",
1921 "before_tokens": 43_800,
1922 "after_tokens": 9_200,
1923 "archived_message_count": 18,
1924 "preserved_message_count": 4,
1925 "duration_secs": 2.4,
1926 "verified": false,
1927 "verification_error": "provider overloaded",
1928 }));
1929
1930 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1931 let rendered = lines
1932 .iter()
1933 .map(|line| {
1934 line.spans
1935 .iter()
1936 .map(|span| span.content.as_ref())
1937 .collect::<String>()
1938 })
1939 .collect::<Vec<_>>()
1940 .join("\n");
1941
1942 assert!(rendered.contains("Compact(auto_threshold)"));
1943 assert!(rendered.contains("draft fallback"));
1944 assert!(rendered.contains("verification: provider overloaded"));
1945 }
1946
1947 #[test]
1953 fn wrap_styled_line_uses_display_width_for_cjk() {
1954 let line = Line::from(Span::raw("你好世界".to_string()));
1958 let wrapped = wrap_styled_line(line, 10, 2);
1959 assert_eq!(
1960 wrapped.len(),
1961 1,
1962 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1963 wrapped.len()
1964 );
1965 }
1966
1967 #[test]
1970 fn wrap_styled_line_ascii_wraps_when_too_long() {
1971 let line = Line::from(Span::raw(
1972 "the quick brown fox jumps over the lazy dog".to_string(),
1973 ));
1974 let wrapped = wrap_styled_line(line, 15, 2);
1975 assert!(
1976 wrapped.len() >= 2,
1977 "long ASCII input should wrap to multiple lines; got {}",
1978 wrapped.len()
1979 );
1980 }
1981
1982 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
1983 wrapped[0]
1984 .spans
1985 .iter()
1986 .map(|s| s.content.as_ref())
1987 .collect()
1988 }
1989
1990 #[test]
1996 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
1997 let line = Line::from(vec![
1998 Span::raw(" "), Span::raw(
2000 "No source files, no config, no docs, no build system and more words to wrap"
2001 .to_string(),
2002 ),
2003 ]);
2004 let wrapped = wrap_styled_line(line, 30, 2);
2005 assert!(wrapped.len() >= 2, "should wrap");
2006 let first = first_segment_text(&wrapped);
2007 assert!(
2008 first.starts_with(" ") && first.trim_start().starts_with("No source"),
2009 "first wrapped segment must keep the 2-space gutter; got {first:?}"
2010 );
2011 }
2012
2013 #[test]
2019 fn wrap_styled_line_hangs_list_continuation_under_marker() {
2020 let line = Line::from(vec![
2021 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2025 ]);
2026 let wrapped = wrap_styled_line(line, 24, 6);
2027 assert!(wrapped.len() >= 2, "should wrap");
2028 assert!(
2029 first_segment_text(&wrapped).starts_with(" • "),
2030 "first segment keeps gutter + nesting + marker"
2031 );
2032 for cont in &wrapped[1..] {
2033 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2034 assert!(
2035 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2036 "continuation hangs under the item text at col 6; got {t:?}"
2037 );
2038 }
2039 }
2040
2041 #[test]
2044 fn wrap_styled_line_keeps_bullet_at_column_zero() {
2045 let line = Line::from(vec![
2046 Span::raw("● "),
2047 Span::raw(
2048 "a fairly long first line of a message that definitely needs to wrap".to_string(),
2049 ),
2050 ]);
2051 let wrapped = wrap_styled_line(line, 25, 2);
2052 assert!(wrapped.len() >= 2, "should wrap");
2053 assert!(
2054 first_segment_text(&wrapped).starts_with('●'),
2055 "bullet must stay at column 0"
2056 );
2057 }
2058
2059 #[test]
2065 fn wrap_text_with_indent_uses_display_width_for_cjk() {
2066 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2069 assert_eq!(
2070 wrapped.len(),
2071 1,
2072 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2073 wrapped.len(),
2074 wrapped
2075 );
2076 assert_eq!(wrapped[0].trim_start(), "你好世界");
2077 }
2078
2079 #[test]
2082 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2083 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2087 assert!(
2088 wrapped.len() >= 2,
2089 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2090 wrapped.len(),
2091 wrapped
2092 );
2093 }
2094
2095 #[test]
2096 fn clamp_to_u16_saturates_past_u16_max() {
2097 assert_eq!(clamp_to_u16(0), 0);
2100 assert_eq!(clamp_to_u16(65_535), u16::MAX);
2101 assert_eq!(clamp_to_u16(65_536), u16::MAX);
2102 assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2103 }
2104
2105 #[test]
2106 fn wrap_text_with_indent_hard_breaks_overlong_token() {
2107 let token = "x".repeat(100);
2111 let width = 20;
2112 let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2113 assert!(
2114 wrapped.len() >= 5,
2115 "a 100-cell token at width 20 must span many rows; got {}",
2116 wrapped.len()
2117 );
2118 for line in &wrapped {
2119 assert!(
2120 line.chars().count() <= width,
2121 "no wrapped row may exceed the width; got {:?} ({} cells)",
2122 line,
2123 line.chars().count()
2124 );
2125 }
2126 let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2128 assert_eq!(
2129 joined, token,
2130 "hard-break must preserve the token's content"
2131 );
2132 }
2133
2134 #[test]
2135 fn wrap_styled_line_hard_breaks_overlong_token() {
2136 let token = "y".repeat(90);
2138 let style = Style::new().fg(ratatui::style::Color::Red);
2139 let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
2140 let width = 24;
2141 let wrapped = wrap_styled_line(line, width, 2);
2142 assert!(
2143 wrapped.len() >= 4,
2144 "must hard-break across rows; got {}",
2145 wrapped.len()
2146 );
2147
2148 let mut reconstructed = String::new();
2149 for l in &wrapped {
2150 let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2151 assert!(
2152 row_cells <= width,
2153 "row exceeds width: {row_cells} > {width}"
2154 );
2155 for s in &l.spans {
2156 if s.content.trim().is_empty() {
2159 continue;
2160 }
2161 assert_eq!(
2162 s.style.fg,
2163 Some(ratatui::style::Color::Red),
2164 "hard-break must preserve the span style"
2165 );
2166 reconstructed.push_str(s.content.as_ref());
2167 }
2168 }
2169 assert_eq!(reconstructed, token, "hard-break must preserve the token");
2170 }
2171
2172 #[test]
2173 fn frame_memo_hit_matches_miss() {
2174 use ratatui::Terminal;
2180 use ratatui::backend::TestBackend;
2181
2182 let theme = Theme::dark();
2183 let messages = vec![
2184 ChatMessage::assistant(
2185 "# Heading\n\nSome **bold** prose long enough that it wraps across \
2186 this narrow viewport more than once.\n\n- a list item that also \
2187 runs past the edge so it wraps\n- second item",
2188 ),
2189 ChatMessage::assistant("Short follow-up."),
2190 ];
2191
2192 let (width, height): (u16, u16) = (34, 30);
2193 let mut cache = FxHashMap::default();
2194 let mut state = ChatState::new();
2195
2196 let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2197 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2198 term.draw(|f| {
2199 let widget = ChatWidget {
2200 messages: &messages,
2201 theme: &theme,
2202 wrapped_line_cache: cache,
2203 show_reasoning: true,
2204 };
2205 f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
2206 })
2207 .unwrap();
2208 term.backend().buffer().clone()
2209 };
2210
2211 let miss = render(&mut state, &mut cache);
2212 assert!(
2213 state.frame_memo.is_some(),
2214 "first render must populate the frame memo"
2215 );
2216 let hit = render(&mut state, &mut cache);
2217 assert_eq!(
2218 miss, hit,
2219 "frame-memo hit must render identically to the miss"
2220 );
2221 assert!(
2225 !state.last_rendered_rows.is_empty(),
2226 "memo hit must preserve last_rendered_rows from the miss"
2227 );
2228 }
2229}