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}
134
135impl<'a> StatefulWidget for ChatWidget<'a> {
136 type State = ChatState;
137
138 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
139 let mut lines = Vec::new();
140
141 state.image_click_map.clear();
143 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
144
145 for (idx, msg) in self.messages.iter().enumerate() {
146 if matches!(msg.role, MessageRole::Tool) {
149 continue;
150 }
151
152 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
153 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
154 lines.extend(event_lines);
155 lines.push(Line::from(""));
156 }
157 continue;
158 }
159
160 let (role_prefix, role_color) = match msg.role {
161 MessageRole::User => (">", ratatui::style::Color::White),
162 MessageRole::Assistant => ("●", ratatui::style::Color::White),
163 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
164 MessageRole::Tool => unreachable!("Tool messages filtered above"),
165 };
166
167 if matches!(msg.role, MessageRole::Assistant) {
168 if let Some(ref thinking) = msg.thinking {
170 let thinking_trimmed = thinking.trim();
172 if thinking_trimmed.is_empty()
173 || thinking_trimmed == "None"
174 || thinking_trimmed == "none"
175 {
176 } else {
178 lines.push(Line::from(vec![
180 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
181 Span::styled(
182 "Thinking...",
183 Style::new()
184 .fg(self.theme.colors.text_secondary.to_color())
185 .italic()
186 .dim(),
187 ),
188 ]));
189
190 let wrapped = wrap_text_with_indent(
192 thinking,
193 area.width as usize,
194 2, 2, );
197 for wrapped_line in wrapped {
198 lines.push(Line::from(Span::styled(
199 wrapped_line,
200 Style::new()
201 .fg(self.theme.colors.text_secondary.to_color())
202 .italic()
203 .dim(),
204 )));
205 }
206
207 lines.push(Line::from(""));
209 }
210 }
211
212 let mut hasher = rustc_hash::FxHasher::default();
215 msg.content.hash(&mut hasher);
216 let cache_key = hasher.finish();
217 let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
218 cached.clone()
219 } else {
220 let parsed = parse_markdown(&msg.content);
221 self.markdown_cache.insert(cache_key, parsed.clone());
222 if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
223 self.markdown_cache.clear();
224 self.markdown_cache.insert(cache_key, parsed.clone());
225 }
226 parsed
227 };
228
229 for (line_idx, mut parsed_line) in parsed_lines.into_iter().enumerate() {
230 if line_idx == 0 {
232 let mut spans = vec![Span::styled(
234 format!("{} ", role_prefix),
235 Style::new().fg(role_color).bold(),
236 )];
237 spans.extend(parsed_line.spans);
238 parsed_line = Line::from(spans);
239 } else {
240 let mut spans = vec![Span::raw(" ")];
242 spans.extend(parsed_line.spans);
243 parsed_line = Line::from(spans);
244 }
245
246 let wrapped = wrap_styled_line(parsed_line, area.width as usize, 2);
248 lines.extend(wrapped);
249 }
250
251 if !msg.actions.is_empty() {
253 if !msg.content.trim().is_empty() {
255 lines.push(Line::from(""));
256 }
257 render_actions(&msg.actions, &mut lines, self.theme, area.width as usize);
258 }
259 } else {
260 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
262 let timestamp_len = formatted_timestamp.len();
263 let min_gap = 3; let cleaned_content = &msg.content;
267
268 let role_prefix_width = role_prefix.len() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_len;
272
273 let wrapped = wrap_text_with_indent(
275 cleaned_content,
276 area.width as usize,
277 first_line_reserved, 2, );
280
281 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
282 if line_idx == 0 {
283 let text_content = wrapped_line.trim_start(); let text_len = text_content.len();
286
287 let mut spans = vec![
288 Span::styled(
289 format!("{} ", role_prefix),
290 Style::new().fg(role_color).bold(),
291 ),
292 Span::raw(text_content.to_string()),
293 ];
294
295 let content_width = role_prefix_width + text_len;
297 let total_used = content_width + min_gap + timestamp_len;
298 let extra_padding = (area.width as usize).saturating_sub(total_used);
299 spans.push(Span::raw(" ".repeat(min_gap + extra_padding)));
300 spans.push(Span::styled(
301 formatted_timestamp.clone(),
302 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
303 ));
304
305 lines.push(Line::from(spans));
306 } else {
307 lines.push(Line::from(wrapped_line.clone()));
309 }
310 }
311 }
312
313 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
320 && let Some(ref images) = msg.images
321 && !images.is_empty()
322 {
323 for (i, _) in images.iter().enumerate() {
324 let content_line = lines.len() as u16;
326 state.image_click_map.push((
327 content_line,
328 ImageClickTarget {
329 message_index: idx,
330 image_index: i,
331 },
332 ));
333 lines.push(Line::from(vec![
334 Span::styled(" ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
335 Span::styled(
336 format!("[Image #{}]", i + 1),
337 Style::new().fg(self.theme.colors.info.to_color()).italic(),
338 ),
339 ]));
340 }
341 }
342
343 lines.push(Line::from(""));
344 }
345
346 let content_height = lines.len() as u16;
356 let viewport_height = area.height;
357
358 let scroll_pos = state.get_scroll_position(content_height, viewport_height);
359 state.last_scroll_position = scroll_pos;
360
361 let paragraph = Paragraph::new(lines)
362 .block(Block::default())
363 .scroll((scroll_pos, 0));
364
365 paragraph.render(area, buf);
366 }
367}
368
369fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
370 if !matches!(msg.role, MessageRole::User) {
371 return None;
372 }
373
374 let metadata = msg.metadata.as_ref();
375 let trigger = metadata
376 .and_then(|value| value.get("trigger"))
377 .and_then(|value| value.as_str())
378 .unwrap_or("manual");
379 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
380 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
381 let archived_messages =
382 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
383 let preserved_messages =
384 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
385 let duration_secs = metadata
386 .and_then(|value| value.get("duration_secs"))
387 .and_then(|value| value.as_f64());
388
389 let action_color = theme.colors.info.to_color();
390 let mut result = match (before_tokens, after_tokens) {
391 (Some(before), Some(after)) => {
392 format!(
393 "Success, {} -> {} tokens",
394 format_compact_count(before),
395 format_compact_count(after)
396 )
397 },
398 _ => "Success".to_string(),
399 };
400
401 if let Some(count) = archived_messages {
402 result.push_str(&format!(
403 ", archived {} {}",
404 count,
405 if count == 1 { "message" } else { "messages" }
406 ));
407 }
408 if let Some(count) = preserved_messages {
409 result.push_str(&format!(
410 ", preserved {} {}",
411 count,
412 if count == 1 { "message" } else { "messages" }
413 ));
414 }
415 result = append_action_duration(result, duration_secs);
416
417 Some(vec![
418 Line::from(vec![
419 Span::styled("● ", Style::new().fg(action_color).bold()),
420 Span::styled("Compact(", Style::new().fg(action_color).bold()),
421 Span::styled(
422 trigger.to_string(),
423 Style::new().fg(theme.colors.text_secondary.to_color()),
424 ),
425 Span::styled(")", Style::new().fg(action_color).bold()),
426 ]),
427 Line::from(vec![
428 Span::styled(" ⎿ ", Style::new().fg(action_color)),
429 Span::styled(
430 result,
431 Style::new().fg(theme.colors.text_secondary.to_color()),
432 ),
433 ]),
434 ])
435}
436
437fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
438 value
439 .get(key)?
440 .as_u64()
441 .and_then(|value| usize::try_from(value).ok())
442}
443
444fn render_actions(
446 actions: &[ActionDisplay],
447 lines: &mut Vec<Line>,
448 theme: &Theme,
449 viewport_width: usize,
450) {
451 for (action_idx, action) in actions.iter().enumerate() {
452 if action_idx > 0 {
453 lines.push(Line::from(""));
454 }
455 let action_color = match action.action_type.as_str() {
456 "Write" | "Edit" => theme.colors.success.to_color(),
457 "Delete" => theme.colors.warning.to_color(),
458 _ => theme.colors.info.to_color(),
459 };
460
461 lines.push(Line::from(vec![
463 Span::styled("● ", Style::new().fg(action_color).bold()),
464 Span::styled(
465 format!("{}(", action.action_type),
466 Style::new().fg(action_color).bold(),
467 ),
468 Span::styled(
469 action.target.clone(),
470 Style::new().fg(theme.colors.text_secondary.to_color()),
471 ),
472 Span::styled(")", Style::new().fg(action_color).bold()),
473 ]));
474
475 match &action.result {
476 ActionResult::Success { .. } => {
477 let result_msg = match &action.details {
479 ActionDetails::FileContent { line_count, .. } => {
480 let base = format!(
481 "Success, {} {} written",
482 line_count,
483 if *line_count == 1 { "line" } else { "lines" }
484 );
485 append_action_duration(base, action.duration_seconds)
486 },
487 ActionDetails::Diff { summary, .. } => summary.clone(),
488 ActionDetails::Preview { text, .. } => text.clone(),
489 ActionDetails::Simple => match action.action_type.as_str() {
490 "Delete" => append_action_duration(
491 format!("Success, deleted {}", action.target),
492 action.duration_seconds,
493 ),
494 _ => append_action_duration("Success".to_string(), action.duration_seconds),
495 },
496 };
497
498 for (idx, line) in result_msg.lines().enumerate() {
499 let prefix = if idx == 0 { " ⎿ " } else { " " };
500 lines.push(Line::from(vec![
501 Span::styled(prefix, Style::new().fg(action_color)),
502 Span::styled(
503 line.to_string(),
504 Style::new().fg(theme.colors.text_secondary.to_color()),
505 ),
506 ]));
507 }
508
509 if let ActionDetails::FileContent {
511 content,
512 line_count,
513 } = &action.details
514 {
515 let preview_lines: Vec<&str> = content.lines().take(10).collect();
516 if !preview_lines.is_empty() {
517 lines.push(Line::from(vec![Span::styled(
518 " ",
519 Style::new().fg(action_color),
520 )]));
521
522 let preview_content = preview_lines.join("\n");
523 let mut parsed = parse_markdown(&format!("```\n{}\n```", preview_content));
524 for parsed_line in parsed.iter_mut() {
525 let mut new_spans =
526 vec![Span::styled(" ", Style::new().fg(action_color))];
527 new_spans.append(&mut parsed_line.spans);
528 parsed_line.spans = new_spans;
529 }
530 lines.extend(parsed);
531
532 if *line_count > 10 {
533 lines.push(Line::from(vec![
534 Span::styled(" ", Style::new().fg(action_color)),
535 Span::styled(
536 format!("... ({} more lines)", line_count - 10),
537 Style::new()
538 .fg(theme.colors.text_disabled.to_color())
539 .italic(),
540 ),
541 ]));
542 }
543 }
544 }
545
546 if let ActionDetails::Diff { diff, .. } = &action.details {
548 let diff_lines: Vec<&str> = diff.lines().collect();
549 let display_lines: Vec<&str> =
550 diff_lines.iter().skip(1).take(20).copied().collect();
551
552 if !display_lines.is_empty() {
553 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
554 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
555
556 for diff_line in &display_lines {
557 match parse_diff_line(diff_line) {
562 DiffLineKind::Removed => {
563 let text = format!(" {}", diff_line);
564 let padded =
565 format!("{:<width$}", text, width = viewport_width);
566 lines.push(Line::from(vec![Span::styled(
567 padded,
568 Style::new()
569 .fg(theme.colors.error.to_color())
570 .bg(removed_bg),
571 )]));
572 },
573 DiffLineKind::Added => {
574 let text = format!(" {}", diff_line);
575 let padded =
576 format!("{:<width$}", text, width = viewport_width);
577 lines.push(Line::from(vec![Span::styled(
578 padded,
579 Style::new()
580 .fg(theme.colors.success.to_color())
581 .bg(added_bg),
582 )]));
583 },
584 DiffLineKind::Context => {
585 lines.push(Line::from(vec![
586 Span::styled(" ", Style::new().fg(action_color)),
587 Span::styled(
588 diff_line.to_string(),
589 Style::new().fg(theme.colors.text_secondary.to_color()),
590 ),
591 ]));
592 },
593 }
594 }
595
596 let remaining = diff_lines.len().saturating_sub(21);
597 if remaining > 0 {
598 lines.push(Line::from(vec![
599 Span::styled(" ", Style::new().fg(action_color)),
600 Span::styled(
601 format!("... ({} more lines)", remaining),
602 Style::new()
603 .fg(theme.colors.text_disabled.to_color())
604 .italic(),
605 ),
606 ]));
607 }
608 }
609 }
610 },
611 ActionResult::Error { error } => {
612 let error =
613 append_action_duration(format!("Error: {}", error), action.duration_seconds);
614 lines.push(Line::from(vec![
615 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
616 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
617 ]));
618 },
619 }
620 }
621}
622
623fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
624 if let Some(seconds) = duration_seconds {
625 text.push_str(", took ");
626 text.push_str(&format_action_duration(seconds));
627 }
628 text
629}
630
631fn format_action_duration(seconds: f64) -> String {
632 if seconds < 1.0 {
633 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
634 } else if seconds < 10.0 {
635 format!("{:.1}s", seconds)
636 } else {
637 format!("{}s", seconds.round() as u64)
638 }
639}
640
641fn wrap_text_with_indent(
650 text: &str,
651 width: usize,
652 first_line_indent: usize,
653 continuation_indent: usize,
654) -> Vec<String> {
655 let mut wrapped_lines = Vec::new();
656
657 for (line_idx, line) in text.lines().enumerate() {
658 if line.is_empty() {
659 wrapped_lines.push(String::new());
660 continue;
661 }
662
663 let current_indent = if line_idx == 0 {
664 first_line_indent
665 } else {
666 continuation_indent
667 };
668 let available_width = width.saturating_sub(current_indent);
669
670 if available_width == 0 {
671 wrapped_lines.push(" ".repeat(current_indent));
672 continue;
673 }
674
675 let words: Vec<&str> = line.split_whitespace().collect();
676 if words.is_empty() {
677 wrapped_lines.push(" ".repeat(current_indent));
678 continue;
679 }
680
681 let mut current_line = String::with_capacity(width);
682 current_line.push_str(&" ".repeat(current_indent));
683 let mut current_length = 0;
686
687 for (word_idx, word) in words.iter().enumerate() {
688 let word_width = word.width();
689
690 if word_idx == 0 {
691 current_line.push_str(word);
693 current_length = word_width;
694 } else if current_length + 1 + word_width <= available_width {
695 current_line.push(' ');
698 current_line.push_str(word);
699 current_length += 1 + word_width;
700 } else {
701 wrapped_lines.push(current_line);
703 current_line = String::with_capacity(width);
704 current_line.push_str(&" ".repeat(continuation_indent));
705 current_line.push_str(word);
706 current_length = word_width;
707 }
708 }
709
710 if !current_line.trim().is_empty() {
712 wrapped_lines.push(current_line);
713 }
714 }
715
716 wrapped_lines
717}
718
719fn wrap_styled_line(
722 line: Line<'static>,
723 width: usize,
724 continuation_indent: usize,
725) -> Vec<Line<'static>> {
726 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
731
732 if total_width <= width {
734 return vec![line];
735 }
736
737 let mut result_lines = Vec::new();
739 let mut current_line_spans = Vec::new();
740 let mut current_line_width = 0usize;
741 let available_width = width.saturating_sub(continuation_indent);
742
743 for span in line.spans.clone() {
744 let span_text = span.content.to_string();
745 let span_style = span.style;
746
747 let words: Vec<&str> = span_text.split_whitespace().collect();
749
750 for (word_idx, word) in words.iter().enumerate() {
751 let word_with_space = if word_idx > 0 || current_line_width > 0 {
752 format!(" {}", word)
753 } else {
754 word.to_string()
755 };
756
757 let word_width = word_with_space.width();
758
759 if current_line_width == 0 && result_lines.is_empty() {
760 current_line_spans.push(Span::styled(word_with_space, span_style));
762 current_line_width += word_width;
763 } else if current_line_width + word_width <= available_width {
764 current_line_spans.push(Span::styled(word_with_space, span_style));
766 current_line_width += word_width;
767 } else {
768 result_lines.push(Line::from(current_line_spans));
770 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
771 current_line_spans.push(Span::styled(word.to_string(), span_style));
772 current_line_width = word.width();
773 }
774 }
775 }
776
777 if !current_line_spans.is_empty() {
779 result_lines.push(Line::from(current_line_spans));
780 }
781
782 if result_lines.is_empty() {
783 vec![line]
784 } else {
785 result_lines
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn context_checkpoint_renders_as_compact_event() {
795 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
796 msg.kind = ChatMessageKind::ContextCheckpoint;
797 msg.metadata = Some(serde_json::json!({
798 "trigger": "manual",
799 "before_tokens": 43_800,
800 "after_tokens": 9_200,
801 "archived_message_count": 18,
802 "preserved_message_count": 4,
803 "duration_secs": 2.4,
804 }));
805
806 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
807 let rendered = lines
808 .iter()
809 .map(|line| {
810 line.spans
811 .iter()
812 .map(|span| span.content.as_ref())
813 .collect::<String>()
814 })
815 .collect::<Vec<_>>()
816 .join("\n");
817
818 assert!(rendered.contains("Compact(manual)"));
819 assert!(rendered.contains("43.8k -> 9.2k tokens"));
820 assert!(rendered.contains("archived 18 messages"));
821 assert!(rendered.contains("preserved 4 messages"));
822 assert!(!rendered.contains("full checkpoint summary"));
823 }
824
825 #[test]
831 fn wrap_styled_line_uses_display_width_for_cjk() {
832 let line = Line::from(Span::raw("你好世界".to_string()));
836 let wrapped = wrap_styled_line(line, 10, 2);
837 assert_eq!(
838 wrapped.len(),
839 1,
840 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
841 wrapped.len()
842 );
843 }
844
845 #[test]
848 fn wrap_styled_line_ascii_wraps_when_too_long() {
849 let line = Line::from(Span::raw(
850 "the quick brown fox jumps over the lazy dog".to_string(),
851 ));
852 let wrapped = wrap_styled_line(line, 15, 2);
853 assert!(
854 wrapped.len() >= 2,
855 "long ASCII input should wrap to multiple lines; got {}",
856 wrapped.len()
857 );
858 }
859
860 #[test]
866 fn wrap_text_with_indent_uses_display_width_for_cjk() {
867 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
870 assert_eq!(
871 wrapped.len(),
872 1,
873 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
874 wrapped.len(),
875 wrapped
876 );
877 assert_eq!(wrapped[0].trim_start(), "你好世界");
878 }
879
880 #[test]
883 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
884 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
888 assert!(
889 wrapped.len() >= 2,
890 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
891 wrapped.len(),
892 wrapped
893 );
894 }
895}