1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::Style,
7 text::{Line, Span},
8 widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10use rustc_hash::FxHashMap;
11use unicode_width::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)>, }
44
45impl ChatState {
46 pub fn new() -> Self {
48 Self {
49 scroll_offset: 0,
50 is_user_scrolling: false,
51 image_click_map: Vec::new(),
52 last_scroll_position: 0,
53 last_chat_area: None,
54 }
55 }
56
57 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
60 let max_scroll = content_height.saturating_sub(viewport_height);
61 if self.is_user_scrolling {
62 let capped_offset = self.scroll_offset.min(max_scroll);
65 max_scroll.saturating_sub(capped_offset)
66 } else {
67 max_scroll
69 }
70 }
71
72 pub fn scroll_up(&mut self, amount: u16) {
74 self.is_user_scrolling = true;
75 self.scroll_offset = self.scroll_offset.saturating_add(amount);
76 }
77
78 pub fn scroll_down(&mut self, amount: u16) {
81 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
82 if self.scroll_offset == 0 {
83 self.is_user_scrolling = false;
85 }
86 }
87
88 pub fn resume_auto_scroll(&mut self) {
90 self.is_user_scrolling = false;
91 self.scroll_offset = 0;
92 }
93
94 pub fn is_manually_scrolling(&self) -> bool {
96 self.is_user_scrolling
97 }
98
99 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
102 let (_, area_y, _, area_height) = self.last_chat_area?;
103
104 if screen_row < area_y || screen_row >= area_y + area_height {
106 return None;
107 }
108
109 let viewport_row = screen_row - area_y;
111 let content_line = viewport_row + self.last_scroll_position;
112
113 self.image_click_map
115 .iter()
116 .find(|(line, _)| *line == content_line)
117 .map(|(_, target)| target)
118 }
119}
120
121impl Default for ChatState {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127pub struct ChatWidget<'a> {
129 pub messages: &'a [ChatMessage],
130 pub theme: &'a Theme,
131 pub markdown_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
133 pub show_reasoning: bool,
134}
135
136impl<'a> StatefulWidget for ChatWidget<'a> {
137 type State = ChatState;
138
139 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
140 let mut lines = Vec::new();
141
142 state.image_click_map.clear();
144 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
145
146 let mut hidden_reasoning_notice_shown = false;
147
148 for (idx, msg) in self.messages.iter().enumerate() {
149 if matches!(msg.role, MessageRole::Tool) {
152 continue;
153 }
154
155 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
156 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
157 lines.extend(event_lines);
158 lines.push(Line::from(""));
159 }
160 continue;
161 }
162
163 let (role_prefix, role_color) = match msg.role {
164 MessageRole::User => (">", ratatui::style::Color::White),
165 MessageRole::Assistant => ("●", ratatui::style::Color::White),
166 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
167 MessageRole::Tool => unreachable!("Tool messages filtered above"),
168 };
169
170 if matches!(msg.role, MessageRole::Assistant) {
171 if let Some(ref thinking) = msg.thinking {
173 let thinking_trimmed = thinking.trim();
175 if thinking_trimmed.is_empty()
176 || thinking_trimmed == "None"
177 || thinking_trimmed == "none"
178 {
179 } else if self.show_reasoning {
181 lines.push(Line::from(vec![
183 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
184 Span::styled(
185 "Thinking...",
186 Style::new()
187 .fg(self.theme.colors.text_secondary.to_color())
188 .italic()
189 .dim(),
190 ),
191 ]));
192
193 let wrapped = wrap_text_with_indent(
195 thinking,
196 area.width as usize,
197 2, 2, );
200 for wrapped_line in wrapped {
201 lines.push(Line::from(Span::styled(
202 wrapped_line,
203 Style::new()
204 .fg(self.theme.colors.text_secondary.to_color())
205 .italic()
206 .dim(),
207 )));
208 }
209
210 lines.push(Line::from(""));
212 } else if !hidden_reasoning_notice_shown {
213 hidden_reasoning_notice_shown = true;
214 let marker = if msg.content.trim().is_empty() && msg.actions.is_empty() {
215 "Reasoning-only response hidden (/visible-reasoning on to show)"
216 } else {
217 "Reasoning hidden (/visible-reasoning on to show)"
218 };
219 lines.push(Line::from(vec![
220 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
221 Span::styled(
222 marker,
223 Style::new()
224 .fg(self.theme.colors.text_secondary.to_color())
225 .italic()
226 .dim(),
227 ),
228 ]));
229 if msg.content.trim().is_empty() && msg.actions.is_empty() {
230 lines.push(Line::from(""));
231 continue;
232 }
233 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
234 continue;
235 }
236 }
237
238 let mut hasher = rustc_hash::FxHasher::default();
241 msg.content.hash(&mut hasher);
242 let cache_key = hasher.finish();
243 let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
244 cached.clone()
245 } else {
246 let parsed = parse_markdown(&msg.content);
247 self.markdown_cache.insert(cache_key, parsed.clone());
248 if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
249 self.markdown_cache.clear();
250 self.markdown_cache.insert(cache_key, parsed.clone());
251 }
252 parsed
253 };
254
255 for (line_idx, mut parsed_line) in parsed_lines.into_iter().enumerate() {
256 if line_idx == 0 {
258 let mut spans = vec![Span::styled(
260 format!("{} ", role_prefix),
261 Style::new().fg(role_color).bold(),
262 )];
263 spans.extend(parsed_line.spans);
264 parsed_line = Line::from(spans);
265 } else {
266 let mut spans = vec![Span::raw(" ")];
268 spans.extend(parsed_line.spans);
269 parsed_line = Line::from(spans);
270 }
271
272 let wrapped = wrap_styled_line(parsed_line, area.width as usize, 2);
274 lines.extend(wrapped);
275 }
276
277 if !msg.actions.is_empty() {
279 if !msg.content.trim().is_empty() {
281 lines.push(Line::from(""));
282 }
283 render_actions(&msg.actions, &mut lines, self.theme, area.width as usize);
284 }
285 } else {
286 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
288 let timestamp_len = formatted_timestamp.len();
289 let min_gap = 3; let cleaned_content = &msg.content;
293
294 let role_prefix_width = role_prefix.len() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_len;
298
299 let wrapped = wrap_text_with_indent(
301 cleaned_content,
302 area.width as usize,
303 first_line_reserved, 2, );
306
307 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
308 if line_idx == 0 {
309 let text_content = wrapped_line.trim_start(); let text_len = text_content.len();
312
313 let mut spans = vec![
314 Span::styled(
315 format!("{} ", role_prefix),
316 Style::new().fg(role_color).bold(),
317 ),
318 Span::raw(text_content.to_string()),
319 ];
320
321 let content_width = role_prefix_width + text_len;
323 let total_used = content_width + min_gap + timestamp_len;
324 let extra_padding = (area.width as usize).saturating_sub(total_used);
325 spans.push(Span::raw(" ".repeat(min_gap + extra_padding)));
326 spans.push(Span::styled(
327 formatted_timestamp.clone(),
328 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
329 ));
330
331 lines.push(Line::from(spans));
332 } else {
333 lines.push(Line::from(wrapped_line.clone()));
335 }
336 }
337 }
338
339 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
346 && let Some(ref images) = msg.images
347 && !images.is_empty()
348 {
349 for (i, _) in images.iter().enumerate() {
350 let content_line = lines.len() as u16;
352 state.image_click_map.push((
353 content_line,
354 ImageClickTarget {
355 message_index: idx,
356 image_index: i,
357 },
358 ));
359 lines.push(Line::from(vec![
360 Span::styled(" ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
361 Span::styled(
362 format!("[Image #{}]", i + 1),
363 Style::new().fg(self.theme.colors.info.to_color()).italic(),
364 ),
365 ]));
366 }
367 }
368
369 lines.push(Line::from(""));
370 }
371
372 let content_height = lines.len() as u16;
382 let viewport_height = area.height;
383
384 let scroll_pos = state.get_scroll_position(content_height, viewport_height);
385 state.last_scroll_position = scroll_pos;
386
387 let paragraph = Paragraph::new(lines)
388 .block(Block::default())
389 .scroll((scroll_pos, 0));
390
391 paragraph.render(area, buf);
392 }
393}
394
395fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
396 if !matches!(msg.role, MessageRole::User) {
397 return None;
398 }
399
400 let metadata = msg.metadata.as_ref();
401 let trigger = metadata
402 .and_then(|value| value.get("trigger"))
403 .and_then(|value| value.as_str())
404 .unwrap_or("manual");
405 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
406 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
407 let archived_messages =
408 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
409 let preserved_messages =
410 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
411 let duration_secs = metadata
412 .and_then(|value| value.get("duration_secs"))
413 .and_then(|value| value.as_f64());
414 let verified = metadata
415 .and_then(|value| value.get("verified"))
416 .and_then(|value| value.as_bool());
417 let verification_error = metadata
418 .and_then(|value| value.get("verification_error"))
419 .and_then(|value| value.as_str());
420
421 let action_color = theme.colors.info.to_color();
422 let mut result = match (before_tokens, after_tokens) {
423 (Some(before), Some(after)) => {
424 format!(
425 "Success, {} -> {} tokens",
426 format_compact_count(before),
427 format_compact_count(after)
428 )
429 },
430 _ => "Success".to_string(),
431 };
432
433 if let Some(count) = archived_messages {
434 result.push_str(&format!(
435 ", archived {} {}",
436 count,
437 if count == 1 { "message" } else { "messages" }
438 ));
439 }
440 if let Some(count) = preserved_messages {
441 result.push_str(&format!(
442 ", preserved {} {}",
443 count,
444 if count == 1 { "message" } else { "messages" }
445 ));
446 }
447 if let Some(verified) = verified {
448 if verified {
449 result.push_str(", verified");
450 } else {
451 result.push_str(", draft fallback");
452 }
453 }
454 result = append_action_duration(result, duration_secs);
455
456 let mut lines = vec![
457 Line::from(vec![
458 Span::styled("● ", Style::new().fg(action_color).bold()),
459 Span::styled("Compact(", Style::new().fg(action_color).bold()),
460 Span::styled(
461 trigger.to_string(),
462 Style::new().fg(theme.colors.text_secondary.to_color()),
463 ),
464 Span::styled(")", Style::new().fg(action_color).bold()),
465 ]),
466 Line::from(vec![
467 Span::styled(" ⎿ ", Style::new().fg(action_color)),
468 Span::styled(
469 result,
470 Style::new().fg(theme.colors.text_secondary.to_color()),
471 ),
472 ]),
473 ];
474
475 if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
476 lines.push(Line::from(vec![
477 Span::styled(" ", Style::new().fg(action_color)),
478 Span::styled(
479 format!("verification: {}", compact_inline_error(error, 180)),
480 Style::new().fg(theme.colors.warning.to_color()),
481 ),
482 ]));
483 }
484
485 Some(lines)
486}
487
488fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
489 value
490 .get(key)?
491 .as_u64()
492 .and_then(|value| usize::try_from(value).ok())
493}
494
495fn compact_inline_error(text: &str, max_chars: usize) -> String {
496 let text = text.trim();
497 if text.chars().count() <= max_chars {
498 return text.to_string();
499 }
500 let keep = max_chars.saturating_sub(3);
501 let mut out: String = text.chars().take(keep).collect();
502 out.push_str("...");
503 out
504}
505
506fn render_actions(
508 actions: &[ActionDisplay],
509 lines: &mut Vec<Line>,
510 theme: &Theme,
511 viewport_width: usize,
512) {
513 for (action_idx, action) in actions.iter().enumerate() {
514 if action_idx > 0 {
515 lines.push(Line::from(""));
516 }
517 let action_color = match action.action_type.as_str() {
518 "Write" | "Edit" => theme.colors.success.to_color(),
519 "Delete" => theme.colors.warning.to_color(),
520 _ => theme.colors.info.to_color(),
521 };
522
523 lines.push(Line::from(vec![
525 Span::styled("● ", Style::new().fg(action_color).bold()),
526 Span::styled(
527 format!("{}(", action.action_type),
528 Style::new().fg(action_color).bold(),
529 ),
530 Span::styled(
531 action.target.clone(),
532 Style::new().fg(theme.colors.text_secondary.to_color()),
533 ),
534 Span::styled(")", Style::new().fg(action_color).bold()),
535 ]));
536
537 match &action.result {
538 ActionResult::Success { .. } => {
539 let result_msg = match &action.details {
541 ActionDetails::FileContent { line_count, .. } => {
542 let base = format!(
543 "Success, {} {} written",
544 line_count,
545 if *line_count == 1 { "line" } else { "lines" }
546 );
547 append_action_duration(base, action.duration_seconds)
548 },
549 ActionDetails::Diff { summary, .. } => summary.clone(),
550 ActionDetails::Preview { text, .. } => text.clone(),
551 ActionDetails::Simple => match action.action_type.as_str() {
552 "Delete" => append_action_duration(
553 format!("Success, deleted {}", action.target),
554 action.duration_seconds,
555 ),
556 _ => append_action_duration("Success".to_string(), action.duration_seconds),
557 },
558 };
559
560 for (idx, line) in result_msg.lines().enumerate() {
561 let prefix = if idx == 0 { " ⎿ " } else { " " };
562 lines.push(Line::from(vec![
563 Span::styled(prefix, Style::new().fg(action_color)),
564 Span::styled(
565 line.to_string(),
566 Style::new().fg(theme.colors.text_secondary.to_color()),
567 ),
568 ]));
569 }
570
571 if let ActionDetails::FileContent {
573 content,
574 line_count,
575 } = &action.details
576 {
577 let preview_lines: Vec<&str> = content.lines().take(10).collect();
578 if !preview_lines.is_empty() {
579 lines.push(Line::from(vec![Span::styled(
580 " ",
581 Style::new().fg(action_color),
582 )]));
583
584 let preview_content = preview_lines.join("\n");
585 let mut parsed = parse_markdown(&format!("```\n{}\n```", preview_content));
586 for parsed_line in parsed.iter_mut() {
587 let mut new_spans =
588 vec![Span::styled(" ", Style::new().fg(action_color))];
589 new_spans.append(&mut parsed_line.spans);
590 parsed_line.spans = new_spans;
591 }
592 lines.extend(parsed);
593
594 if *line_count > 10 {
595 lines.push(Line::from(vec![
596 Span::styled(" ", Style::new().fg(action_color)),
597 Span::styled(
598 format!("... ({} more lines)", line_count - 10),
599 Style::new()
600 .fg(theme.colors.text_disabled.to_color())
601 .italic(),
602 ),
603 ]));
604 }
605 }
606 }
607
608 if let ActionDetails::Diff { diff, .. } = &action.details {
610 let diff_lines: Vec<&str> = diff.lines().collect();
611 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
612
613 if !display_lines.is_empty() {
614 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
615 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
616
617 for diff_line in &display_lines {
618 match parse_diff_line(diff_line) {
623 DiffLineKind::Removed => {
624 let text = format!(" {}", diff_line);
625 let padded =
626 format!("{:<width$}", text, width = viewport_width);
627 lines.push(Line::from(vec![Span::styled(
628 padded,
629 Style::new()
630 .fg(theme.colors.error.to_color())
631 .bg(removed_bg),
632 )]));
633 },
634 DiffLineKind::Added => {
635 let text = format!(" {}", diff_line);
636 let padded =
637 format!("{:<width$}", text, width = viewport_width);
638 lines.push(Line::from(vec![Span::styled(
639 padded,
640 Style::new()
641 .fg(theme.colors.success.to_color())
642 .bg(added_bg),
643 )]));
644 },
645 DiffLineKind::Context => {
646 lines.push(Line::from(vec![
647 Span::styled(" ", Style::new().fg(action_color)),
648 Span::styled(
649 diff_line.to_string(),
650 Style::new().fg(theme.colors.text_secondary.to_color()),
651 ),
652 ]));
653 },
654 }
655 }
656
657 let remaining = diff_lines.len().saturating_sub(display_lines.len());
658 if remaining > 0 {
659 lines.push(Line::from(vec![
660 Span::styled(" ", Style::new().fg(action_color)),
661 Span::styled(
662 format!("... ({} more lines)", remaining),
663 Style::new()
664 .fg(theme.colors.text_disabled.to_color())
665 .italic(),
666 ),
667 ]));
668 }
669 }
670 }
671 },
672 ActionResult::Error { error } => {
673 let error =
674 append_action_duration(format!("Error: {}", error), action.duration_seconds);
675 lines.push(Line::from(vec![
676 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
677 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
678 ]));
679 },
680 }
681 }
682}
683
684fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
685 if let Some(seconds) = duration_seconds {
686 text.push_str(", took ");
687 text.push_str(&format_action_duration(seconds));
688 }
689 text
690}
691
692fn format_action_duration(seconds: f64) -> String {
693 if seconds < 1.0 {
694 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
695 } else if seconds < 10.0 {
696 format!("{:.1}s", seconds)
697 } else {
698 format!("{}s", seconds.round() as u64)
699 }
700}
701
702fn wrap_text_with_indent(
711 text: &str,
712 width: usize,
713 first_line_indent: usize,
714 continuation_indent: usize,
715) -> Vec<String> {
716 let mut wrapped_lines = Vec::new();
717
718 for (line_idx, line) in text.lines().enumerate() {
719 if line.is_empty() {
720 wrapped_lines.push(String::new());
721 continue;
722 }
723
724 let current_indent = if line_idx == 0 {
725 first_line_indent
726 } else {
727 continuation_indent
728 };
729 let available_width = width.saturating_sub(current_indent);
730
731 if available_width == 0 {
732 wrapped_lines.push(" ".repeat(current_indent));
733 continue;
734 }
735
736 let words: Vec<&str> = line.split_whitespace().collect();
737 if words.is_empty() {
738 wrapped_lines.push(" ".repeat(current_indent));
739 continue;
740 }
741
742 let mut current_line = String::with_capacity(width);
743 current_line.push_str(&" ".repeat(current_indent));
744 let mut current_length = 0;
747
748 for (word_idx, word) in words.iter().enumerate() {
749 let word_width = word.width();
750
751 if word_idx == 0 {
752 current_line.push_str(word);
754 current_length = word_width;
755 } else if current_length + 1 + word_width <= available_width {
756 current_line.push(' ');
759 current_line.push_str(word);
760 current_length += 1 + word_width;
761 } else {
762 wrapped_lines.push(current_line);
764 current_line = String::with_capacity(width);
765 current_line.push_str(&" ".repeat(continuation_indent));
766 current_line.push_str(word);
767 current_length = word_width;
768 }
769 }
770
771 if !current_line.trim().is_empty() {
773 wrapped_lines.push(current_line);
774 }
775 }
776
777 wrapped_lines
778}
779
780fn wrap_styled_line(
783 line: Line<'static>,
784 width: usize,
785 continuation_indent: usize,
786) -> Vec<Line<'static>> {
787 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
792
793 if total_width <= width {
795 return vec![line];
796 }
797
798 let mut result_lines = Vec::new();
800 let mut current_line_spans = Vec::new();
801 let mut current_line_width = 0usize;
802 let available_width = width.saturating_sub(continuation_indent);
803
804 for span in line.spans.clone() {
805 let span_text = span.content.to_string();
806 let span_style = span.style;
807
808 let words: Vec<&str> = span_text.split_whitespace().collect();
810
811 for (word_idx, word) in words.iter().enumerate() {
812 let word_with_space = if word_idx > 0 || current_line_width > 0 {
813 format!(" {}", word)
814 } else {
815 word.to_string()
816 };
817
818 let word_width = word_with_space.width();
819
820 if current_line_width == 0 && result_lines.is_empty() {
821 current_line_spans.push(Span::styled(word_with_space, span_style));
823 current_line_width += word_width;
824 } else if current_line_width + word_width <= available_width {
825 current_line_spans.push(Span::styled(word_with_space, span_style));
827 current_line_width += word_width;
828 } else {
829 result_lines.push(Line::from(current_line_spans));
831 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
832 current_line_spans.push(Span::styled(word.to_string(), span_style));
833 current_line_width = word.width();
834 }
835 }
836 }
837
838 if !current_line_spans.is_empty() {
840 result_lines.push(Line::from(current_line_spans));
841 }
842
843 if result_lines.is_empty() {
844 vec![line]
845 } else {
846 result_lines
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn context_checkpoint_renders_as_compact_event() {
856 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
857 msg.kind = ChatMessageKind::ContextCheckpoint;
858 msg.metadata = Some(serde_json::json!({
859 "trigger": "manual",
860 "before_tokens": 43_800,
861 "after_tokens": 9_200,
862 "archived_message_count": 18,
863 "preserved_message_count": 4,
864 "duration_secs": 2.4,
865 "verified": true,
866 }));
867
868 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
869 let rendered = lines
870 .iter()
871 .map(|line| {
872 line.spans
873 .iter()
874 .map(|span| span.content.as_ref())
875 .collect::<String>()
876 })
877 .collect::<Vec<_>>()
878 .join("\n");
879
880 assert!(rendered.contains("Compact(manual)"));
881 assert!(rendered.contains("43.8k -> 9.2k tokens"));
882 assert!(rendered.contains("archived 18 messages"));
883 assert!(rendered.contains("preserved 4 messages"));
884 assert!(rendered.contains("verified"));
885 assert!(!rendered.contains("full checkpoint summary"));
886 }
887
888 #[test]
889 fn context_checkpoint_renders_verification_fallback() {
890 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
891 msg.kind = ChatMessageKind::ContextCheckpoint;
892 msg.metadata = Some(serde_json::json!({
893 "trigger": "auto_threshold",
894 "before_tokens": 43_800,
895 "after_tokens": 9_200,
896 "archived_message_count": 18,
897 "preserved_message_count": 4,
898 "duration_secs": 2.4,
899 "verified": false,
900 "verification_error": "provider overloaded",
901 }));
902
903 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
904 let rendered = lines
905 .iter()
906 .map(|line| {
907 line.spans
908 .iter()
909 .map(|span| span.content.as_ref())
910 .collect::<String>()
911 })
912 .collect::<Vec<_>>()
913 .join("\n");
914
915 assert!(rendered.contains("Compact(auto_threshold)"));
916 assert!(rendered.contains("draft fallback"));
917 assert!(rendered.contains("verification: provider overloaded"));
918 }
919
920 #[test]
926 fn wrap_styled_line_uses_display_width_for_cjk() {
927 let line = Line::from(Span::raw("你好世界".to_string()));
931 let wrapped = wrap_styled_line(line, 10, 2);
932 assert_eq!(
933 wrapped.len(),
934 1,
935 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
936 wrapped.len()
937 );
938 }
939
940 #[test]
943 fn wrap_styled_line_ascii_wraps_when_too_long() {
944 let line = Line::from(Span::raw(
945 "the quick brown fox jumps over the lazy dog".to_string(),
946 ));
947 let wrapped = wrap_styled_line(line, 15, 2);
948 assert!(
949 wrapped.len() >= 2,
950 "long ASCII input should wrap to multiple lines; got {}",
951 wrapped.len()
952 );
953 }
954
955 #[test]
961 fn wrap_text_with_indent_uses_display_width_for_cjk() {
962 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
965 assert_eq!(
966 wrapped.len(),
967 1,
968 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
969 wrapped.len(),
970 wrapped
971 );
972 assert_eq!(wrapped[0].trim_start(), "你好世界");
973 }
974
975 #[test]
978 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
979 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
983 assert!(
984 wrapped.len() >= 2,
985 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
986 wrapped.len(),
987 wrapped
988 );
989 }
990}