1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Modifier, Style},
7 text::{Line, Span},
8 widgets::{
9 Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
10 },
11};
12use rustc_hash::FxHashMap;
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15use crate::domain::{ActionDetails, ActionDisplay, ActionResult, format_compact_count};
16use crate::models::ChatMessageKind;
17use crate::models::{ChatMessage, MessageRole};
18use crate::render::diff::{DiffLineKind, parse_diff_line};
19use crate::render::markdown::parse_markdown;
20use crate::render::theme::Theme;
21use crate::utils::format_relative_timestamp;
22
23#[derive(Debug, Clone)]
25pub struct ImageClickTarget {
26 pub message_index: usize,
28 pub image_index: usize,
30}
31
32#[derive(Debug, Clone)]
34pub struct ChatState {
35 scroll_offset: u16,
37 is_user_scrolling: bool,
39 pub image_click_map: Vec<(u16, ImageClickTarget)>,
41 pub last_scroll_position: u16,
43 pub last_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
48 last_rendered_rows: Vec<String>,
52}
53
54impl ChatState {
55 pub fn new() -> Self {
57 Self {
58 scroll_offset: 0,
59 is_user_scrolling: false,
60 image_click_map: Vec::new(),
61 last_scroll_position: 0,
62 last_chat_area: None,
63 selection: None,
64 last_rendered_rows: Vec::new(),
65 }
66 }
67
68 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
71 let max_scroll = content_height.saturating_sub(viewport_height);
72 if self.is_user_scrolling {
73 let capped_offset = self.scroll_offset.min(max_scroll);
76 max_scroll.saturating_sub(capped_offset)
77 } else {
78 max_scroll
80 }
81 }
82
83 pub fn scroll_up(&mut self, amount: u16) {
85 self.is_user_scrolling = true;
86 self.scroll_offset = self.scroll_offset.saturating_add(amount);
87 self.selection = None;
90 }
91
92 pub fn scroll_down(&mut self, amount: u16) {
95 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
96 if self.scroll_offset == 0 {
97 self.is_user_scrolling = false;
99 }
100 self.selection = None;
101 }
102
103 pub fn resume_auto_scroll(&mut self) {
105 self.is_user_scrolling = false;
106 self.scroll_offset = 0;
107 }
108
109 pub fn is_manually_scrolling(&self) -> bool {
111 self.is_user_scrolling
112 }
113
114 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
117 let (_, area_y, _, area_height) = self.last_chat_area?;
118
119 if screen_row < area_y || screen_row >= area_y + area_height {
121 return None;
122 }
123
124 let viewport_row = screen_row - area_y;
126 let content_line = viewport_row + self.last_scroll_position;
127
128 self.image_click_map
130 .iter()
131 .find(|(line, _)| *line == content_line)
132 .map(|(_, target)| target)
133 }
134
135 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
139 let (area_x, area_y, _, area_height) = self.last_chat_area?;
140 if screen_row < area_y || screen_row >= area_y + area_height {
141 return None;
142 }
143 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
144 let col = screen_col.saturating_sub(area_x) as usize;
145 Some((content_line, col))
146 }
147
148 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
152 self.selection = self
153 .screen_to_content(screen_row, screen_col)
154 .map(|p| (p, p));
155 }
156
157 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
159 if let Some((anchor, _)) = self.selection
160 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
161 {
162 self.selection = Some((anchor, cursor));
163 }
164 }
165
166 pub fn clear_selection(&mut self) {
168 self.selection = None;
169 }
170
171 pub fn selected_text(&self) -> Option<String> {
176 let (a, b) = self.selection?;
177 let (start, end) = if a <= b { (a, b) } else { (b, a) };
178 if self.last_rendered_rows.is_empty() {
179 return None;
180 }
181 let last = self.last_rendered_rows.len() - 1;
182 let (start_line, start_col) = (start.0.min(last), start.1);
183 let (end_line, end_col) = (end.0.min(last), end.1);
184
185 let mut out = String::new();
186 for line in start_line..=end_line {
187 let row = &self.last_rendered_rows[line];
188 let c0 = if line == start_line { start_col } else { 0 };
189 let c1 = if line == end_line {
190 end_col
191 } else {
192 usize::MAX
193 };
194 let mut piece = slice_by_cells(row, c0, c1).to_string();
195 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
200 while margin > 0 && piece.starts_with(' ') {
201 piece.remove(0);
202 margin -= 1;
203 }
204 out.push_str(piece.trim_end());
205 if line != end_line {
206 out.push('\n');
207 }
208 }
209 if out.is_empty() { None } else { Some(out) }
210 }
211}
212
213const SELECT_MARGIN_CELLS: usize = 2;
217
218fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
223 if width == 0 {
224 return vec![line];
225 }
226 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
227 if total <= width {
228 return vec![line];
229 }
230
231 let base = line.style;
232 let mut out: Vec<Line<'static>> = Vec::new();
233 let mut cur: Vec<Span<'static>> = Vec::new();
234 let mut cur_w = 0usize;
235 let mut on_first = true;
236
237 for span in line.spans {
238 let style = span.style;
239 let mut buf = String::new();
240 for ch in span.content.chars() {
241 let cw = ch.width().unwrap_or(0);
242 let floor = if on_first { 0 } else { indent };
245 if cur_w + cw > width && cur_w > floor {
246 if !buf.is_empty() {
247 cur.push(Span::styled(std::mem::take(&mut buf), style));
248 }
249 out.push(Line::from(std::mem::take(&mut cur)).style(base));
250 on_first = false;
251 cur.push(Span::styled(" ".repeat(indent), base));
252 cur_w = indent;
253 }
254 buf.push(ch);
255 cur_w += cw;
256 }
257 if !buf.is_empty() {
258 cur.push(Span::styled(buf, style));
259 }
260 }
261 if !cur.is_empty() {
262 out.push(Line::from(cur).style(base));
263 }
264 if out.is_empty() {
265 vec![Line::from("").style(base)]
266 } else {
267 out
268 }
269}
270
271fn byte_at_cell(s: &str, target: usize) -> usize {
275 if target == 0 {
276 return 0;
277 }
278 let mut width = 0usize;
279 for (idx, ch) in s.char_indices() {
280 if width >= target {
281 return idx;
282 }
283 width += ch.width().unwrap_or(0);
284 }
285 s.len()
286}
287
288fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
290 let start = byte_at_cell(s, c0);
291 let end = byte_at_cell(s, c1).max(start);
292 &s[start..end]
293}
294
295fn pad_to_cells(s: &str, cells: usize) -> String {
300 let w = s.width();
301 if w >= cells {
302 return s.to_string();
303 }
304 let mut out = String::with_capacity(s.len() + (cells - w));
305 out.push_str(s);
306 out.push_str(&" ".repeat(cells - w));
307 out
308}
309
310fn user_timestamp_padding(
315 role_prefix_width: usize,
316 text_width: usize,
317 timestamp_width: usize,
318 min_gap: usize,
319 content_width: usize,
320) -> usize {
321 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
322 min_gap + content_width.saturating_sub(total_used)
323}
324
325fn line_plain_text(line: &Line) -> String {
327 line.spans.iter().map(|s| s.content.as_ref()).collect()
328}
329
330fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
334 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
335 let mut width = 0usize;
336 for span in line.spans.drain(..) {
337 let span_w = span.content.width();
338 let (span_start, span_end) = (width, width + span_w);
339 width = span_end;
340
341 let ov0 = c0.max(span_start);
342 let ov1 = c1.min(span_end);
343 if ov1 <= ov0 {
344 new_spans.push(span); continue;
346 }
347
348 let s = span.content.as_ref();
349 let b0 = byte_at_cell(s, ov0 - span_start);
350 let b1 = byte_at_cell(s, ov1 - span_start);
351 if b0 > 0 {
352 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
353 }
354 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
355 if b1 < s.len() {
356 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
357 }
358 }
359 line.spans = new_spans;
360}
361
362impl Default for ChatState {
363 fn default() -> Self {
364 Self::new()
365 }
366}
367
368pub struct ChatWidget<'a> {
370 pub messages: &'a [ChatMessage],
371 pub theme: &'a Theme,
372 pub markdown_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
374 pub show_reasoning: bool,
375}
376
377impl<'a> StatefulWidget for ChatWidget<'a> {
378 type State = ChatState;
379
380 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
381 let mut lines: Vec<Line<'static>> = Vec::new();
382
383 let code_bg = self.theme.colors.code_background.to_color();
386 let theme_seed = {
387 let mut h = rustc_hash::FxHasher::default();
388 self.theme.colors.foreground.to_color().hash(&mut h);
389 code_bg.hash(&mut h);
390 self.theme.colors.header.to_color().hash(&mut h);
391 h.finish()
392 };
393
394 let gutter: u16 = if area.width > 4 { 1 } else { 0 };
397 let content_width = area.width.saturating_sub(gutter);
398 let content_area = Rect {
399 width: content_width,
400 ..area
401 };
402
403 state.image_click_map.clear();
405 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
406
407 let mut hidden_reasoning_notice_shown = false;
408
409 for (idx, msg) in self.messages.iter().enumerate() {
410 if matches!(msg.role, MessageRole::Tool) {
413 continue;
414 }
415
416 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
417 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
418 lines.extend(event_lines);
419 lines.push(Line::from(""));
420 }
421 continue;
422 }
423
424 let (role_prefix, role_color) = match msg.role {
425 MessageRole::User => (">", ratatui::style::Color::White),
426 MessageRole::Assistant => ("●", ratatui::style::Color::White),
427 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
428 MessageRole::Tool => unreachable!("Tool messages filtered above"),
429 };
430
431 if matches!(msg.role, MessageRole::Assistant) {
432 if let Some(ref thinking) = msg.thinking {
434 let thinking_trimmed = thinking.trim();
436 if thinking_trimmed.is_empty()
437 || thinking_trimmed == "None"
438 || thinking_trimmed == "none"
439 {
440 } else if self.show_reasoning {
442 lines.push(Line::from(vec![
444 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
445 Span::styled(
446 "Thinking...",
447 Style::new()
448 .fg(self.theme.colors.text_secondary.to_color())
449 .italic()
450 .dim(),
451 ),
452 ]));
453
454 let wrapped = wrap_text_with_indent(
456 thinking,
457 content_width as usize,
458 2, 2, );
461 for wrapped_line in wrapped {
462 lines.push(Line::from(Span::styled(
463 wrapped_line,
464 Style::new()
465 .fg(self.theme.colors.text_secondary.to_color())
466 .italic()
467 .dim(),
468 )));
469 }
470
471 lines.push(Line::from(""));
473 } else if !hidden_reasoning_notice_shown {
474 hidden_reasoning_notice_shown = true;
475 let marker = if msg.content.trim().is_empty() && msg.actions.is_empty() {
476 "Reasoning-only response hidden (/visible-reasoning on to show)"
477 } else {
478 "Reasoning hidden (/visible-reasoning on to show)"
479 };
480 lines.push(Line::from(vec![
481 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
482 Span::styled(
483 marker,
484 Style::new()
485 .fg(self.theme.colors.text_secondary.to_color())
486 .italic()
487 .dim(),
488 ),
489 ]));
490 lines.push(Line::from(""));
497 if msg.content.trim().is_empty() && msg.actions.is_empty() {
498 continue;
499 }
500 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
501 continue;
502 }
503 }
504
505 let mut hasher = rustc_hash::FxHasher::default();
510 msg.content.hash(&mut hasher);
511 theme_seed.hash(&mut hasher);
512 let cache_key = hasher.finish();
513 let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
514 cached.clone()
515 } else {
516 let parsed = parse_markdown(&msg.content, self.theme);
517 self.markdown_cache.insert(cache_key, parsed.clone());
518 if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
519 self.markdown_cache.clear();
520 self.markdown_cache.insert(cache_key, parsed.clone());
521 }
522 parsed
523 };
524
525 for (line_idx, parsed_line) in parsed_lines.into_iter().enumerate() {
526 let preformatted = parsed_line.style.bg == Some(code_bg);
531 let base_style = parsed_line.style;
532
533 let continuation = if preformatted {
538 2
539 } else {
540 2 + crate::render::markdown::line_hanging_indent(&parsed_line, self.theme)
541 };
542
543 let mut spans = if line_idx == 0 {
545 vec![Span::styled(
546 format!("{} ", role_prefix),
547 Style::new().fg(role_color).bold(),
548 )]
549 } else {
550 vec![Span::raw(" ")]
551 };
552 spans.extend(parsed_line.spans);
553 let new_line = Line::from(spans).style(base_style);
554
555 if preformatted {
556 lines.extend(wrap_preformatted(new_line, content_width as usize, 2));
559 } else {
560 lines.extend(wrap_styled_line(
561 new_line,
562 content_width as usize,
563 continuation,
564 ));
565 }
566 }
567
568 if !msg.actions.is_empty() {
570 if !msg.content.trim().is_empty() {
572 lines.push(Line::from(""));
573 }
574 render_actions(&msg.actions, &mut lines, self.theme, content_width as usize);
575 }
576 } else {
577 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
579 let timestamp_width = formatted_timestamp.width();
583 let min_gap = 3; let cleaned_content = &msg.content;
587
588 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
592
593 let wrapped = wrap_text_with_indent(
595 cleaned_content,
596 content_width as usize,
597 first_line_reserved, 2, );
600
601 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
602 if line_idx == 0 {
603 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
606
607 let mut spans = vec![
608 Span::styled(
609 format!("{} ", role_prefix),
610 Style::new().fg(role_color).bold(),
611 ),
612 Span::raw(text_content.to_string()),
613 ];
614
615 let pad = user_timestamp_padding(
618 role_prefix_width,
619 text_width,
620 timestamp_width,
621 min_gap,
622 content_width as usize,
623 );
624 spans.push(Span::raw(" ".repeat(pad)));
625 spans.push(Span::styled(
626 formatted_timestamp.clone(),
627 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
628 ));
629
630 lines.push(Line::from(spans));
631 } else {
632 lines.push(Line::from(wrapped_line.clone()));
634 }
635 }
636 }
637
638 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
645 && let Some(ref images) = msg.images
646 && !images.is_empty()
647 {
648 for (i, _) in images.iter().enumerate() {
649 let content_line = lines.len() as u16;
651 state.image_click_map.push((
652 content_line,
653 ImageClickTarget {
654 message_index: idx,
655 image_index: i,
656 },
657 ));
658 lines.push(Line::from(vec![
659 Span::styled(" ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
660 Span::styled(
661 format!("[Image #{}]", i + 1),
662 Style::new().fg(self.theme.colors.info.to_color()).italic(),
663 ),
664 ]));
665 }
666 }
667
668 lines.push(Line::from(""));
669 }
670
671 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
682
683 if let Some((a, b)) = state.selection
686 && !lines.is_empty()
687 {
688 let (start, end) = if a <= b { (a, b) } else { (b, a) };
689 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
690 let last_line = lines.len() - 1;
691 for (line_idx, line) in lines
692 .iter_mut()
693 .enumerate()
694 .take(end.0.min(last_line) + 1)
695 .skip(start.0)
696 {
697 let c0 = if line_idx == start.0 { start.1 } else { 0 };
698 let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
699 if c1 > c0 {
700 highlight_line_cells(line, c0, c1, sel_style);
701 }
702 }
703 }
704
705 let content_height = lines.len() as u16;
708 let viewport_height = area.height;
709
710 let scroll_pos = state.get_scroll_position(content_height, viewport_height);
711 state.last_scroll_position = scroll_pos;
712
713 let paragraph = Paragraph::new(lines)
714 .block(Block::default())
715 .scroll((scroll_pos, 0));
716
717 paragraph.render(content_area, buf);
718
719 if gutter == 1 && content_height > viewport_height {
722 let mut sb_state = ScrollbarState::new(content_height as usize)
723 .viewport_content_length(viewport_height as usize)
724 .position(scroll_pos as usize);
725 Scrollbar::new(ScrollbarOrientation::VerticalRight)
726 .begin_symbol(None)
727 .end_symbol(None)
728 .thumb_style(Style::new().fg(self.theme.colors.border.to_color()))
729 .track_style(Style::new().fg(self.theme.colors.text_disabled.to_color()))
730 .render(area, buf, &mut sb_state);
731 }
732 }
733}
734
735fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
736 if !matches!(msg.role, MessageRole::User) {
737 return None;
738 }
739
740 let metadata = msg.metadata.as_ref();
741 let trigger = metadata
742 .and_then(|value| value.get("trigger"))
743 .and_then(|value| value.as_str())
744 .unwrap_or("manual");
745 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
746 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
747 let archived_messages =
748 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
749 let preserved_messages =
750 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
751 let duration_secs = metadata
752 .and_then(|value| value.get("duration_secs"))
753 .and_then(|value| value.as_f64());
754 let verified = metadata
755 .and_then(|value| value.get("verified"))
756 .and_then(|value| value.as_bool());
757 let verification_error = metadata
758 .and_then(|value| value.get("verification_error"))
759 .and_then(|value| value.as_str());
760
761 let action_color = theme.colors.info.to_color();
762 let mut result = match (before_tokens, after_tokens) {
763 (Some(before), Some(after)) => {
764 format!(
765 "Success, {} -> {} tokens",
766 format_compact_count(before),
767 format_compact_count(after)
768 )
769 },
770 _ => "Success".to_string(),
771 };
772
773 if let Some(count) = archived_messages {
774 result.push_str(&format!(
775 ", archived {} {}",
776 count,
777 if count == 1 { "message" } else { "messages" }
778 ));
779 }
780 if let Some(count) = preserved_messages {
781 result.push_str(&format!(
782 ", preserved {} {}",
783 count,
784 if count == 1 { "message" } else { "messages" }
785 ));
786 }
787 if let Some(verified) = verified {
788 if verified {
789 result.push_str(", verified");
790 } else {
791 result.push_str(", draft fallback");
792 }
793 }
794 result = append_action_duration(result, duration_secs);
795
796 let mut lines = vec![
797 Line::from(vec![
798 Span::styled("● ", Style::new().fg(action_color).bold()),
799 Span::styled("Compact(", Style::new().fg(action_color).bold()),
800 Span::styled(
801 trigger.to_string(),
802 Style::new().fg(theme.colors.text_secondary.to_color()),
803 ),
804 Span::styled(")", Style::new().fg(action_color).bold()),
805 ]),
806 Line::from(vec![
807 Span::styled(" ⎿ ", Style::new().fg(action_color)),
808 Span::styled(
809 result,
810 Style::new().fg(theme.colors.text_secondary.to_color()),
811 ),
812 ]),
813 ];
814
815 if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
816 lines.push(Line::from(vec![
817 Span::styled(" ", Style::new().fg(action_color)),
818 Span::styled(
819 format!("verification: {}", compact_inline_error(error, 180)),
820 Style::new().fg(theme.colors.warning.to_color()),
821 ),
822 ]));
823 }
824
825 Some(lines)
826}
827
828fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
829 value
830 .get(key)?
831 .as_u64()
832 .and_then(|value| usize::try_from(value).ok())
833}
834
835fn compact_inline_error(text: &str, max_chars: usize) -> String {
836 let text = text.trim();
837 if text.chars().count() <= max_chars {
838 return text.to_string();
839 }
840 let keep = max_chars.saturating_sub(3);
841 let mut out: String = text.chars().take(keep).collect();
842 out.push_str("...");
843 out
844}
845
846fn expand_tabs(s: &str) -> String {
855 const TAB_WIDTH: usize = 4;
856 if !s.contains('\t') {
857 return s.to_string();
858 }
859 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
860 let mut col = 0usize;
861 for ch in s.chars() {
862 if ch == '\t' {
863 let n = TAB_WIDTH - (col % TAB_WIDTH);
864 for _ in 0..n {
865 out.push(' ');
866 }
867 col += n;
868 } else {
869 out.push(ch);
870 col += UnicodeWidthChar::width(ch).unwrap_or(0);
871 }
872 }
873 out
874}
875
876fn render_actions(
877 actions: &[ActionDisplay],
878 lines: &mut Vec<Line>,
879 theme: &Theme,
880 viewport_width: usize,
881) {
882 for (action_idx, action) in actions.iter().enumerate() {
883 if action_idx > 0 {
884 lines.push(Line::from(""));
885 }
886 let action_color = match action.action_type.as_str() {
887 "Write" | "Edit" => theme.colors.success.to_color(),
888 "Delete" => theme.colors.warning.to_color(),
889 _ => theme.colors.info.to_color(),
890 };
891
892 lines.push(Line::from(vec![
894 Span::styled("● ", Style::new().fg(action_color).bold()),
895 Span::styled(
896 format!("{}(", action.action_type),
897 Style::new().fg(action_color).bold(),
898 ),
899 Span::styled(
900 action.target.clone(),
901 Style::new().fg(theme.colors.text_secondary.to_color()),
902 ),
903 Span::styled(")", Style::new().fg(action_color).bold()),
904 ]));
905
906 match &action.result {
907 ActionResult::Success { .. } => {
908 let result_msg = match &action.details {
910 ActionDetails::FileContent { line_count, .. } => {
911 let base = format!(
912 "Success, {} {} written",
913 line_count,
914 if *line_count == 1 { "line" } else { "lines" }
915 );
916 append_action_duration(base, action.duration_seconds)
917 },
918 ActionDetails::Diff { summary, .. } => summary.clone(),
919 ActionDetails::Preview { text, .. } => text.clone(),
920 ActionDetails::Simple => match action.action_type.as_str() {
921 "Delete" => append_action_duration(
922 format!("Success, deleted {}", action.target),
923 action.duration_seconds,
924 ),
925 _ => append_action_duration("Success".to_string(), action.duration_seconds),
926 },
927 };
928
929 for (idx, line) in result_msg.lines().enumerate() {
930 let prefix = if idx == 0 { " ⎿ " } else { " " };
931 lines.push(Line::from(vec![
932 Span::styled(prefix, Style::new().fg(action_color)),
933 Span::styled(
934 line.to_string(),
935 Style::new().fg(theme.colors.text_secondary.to_color()),
936 ),
937 ]));
938 }
939
940 if let ActionDetails::FileContent {
942 content,
943 line_count,
944 } = &action.details
945 {
946 let preview_lines: Vec<&str> = content.lines().take(10).collect();
947 if !preview_lines.is_empty() {
948 lines.push(Line::from(vec![Span::styled(
949 " ",
950 Style::new().fg(action_color),
951 )]));
952
953 let preview_content = preview_lines.join("\n");
954 let mut parsed =
955 parse_markdown(&format!("```\n{}\n```", preview_content), theme);
956 for parsed_line in parsed.iter_mut() {
957 let mut new_spans =
958 vec![Span::styled(" ", Style::new().fg(action_color))];
959 new_spans.append(&mut parsed_line.spans);
960 parsed_line.spans = new_spans;
961 }
962 lines.extend(parsed);
963
964 if *line_count > 10 {
965 lines.push(Line::from(vec![
966 Span::styled(" ", Style::new().fg(action_color)),
967 Span::styled(
968 format!("... ({} more lines)", line_count - 10),
969 Style::new()
970 .fg(theme.colors.text_disabled.to_color())
971 .italic(),
972 ),
973 ]));
974 }
975 }
976 }
977
978 if let ActionDetails::Diff { diff, .. } = &action.details {
980 let diff_lines: Vec<&str> = diff.lines().collect();
981 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
982
983 if !display_lines.is_empty() {
984 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
985 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
986
987 for diff_line in &display_lines {
988 let diff_line = expand_tabs(diff_line);
995 match parse_diff_line(&diff_line) {
1000 DiffLineKind::Removed => {
1001 let text = format!(" {}", diff_line);
1002 let padded = pad_to_cells(&text, viewport_width);
1003 lines.push(Line::from(vec![Span::styled(
1004 padded,
1005 Style::new()
1006 .fg(theme.colors.error.to_color())
1007 .bg(removed_bg),
1008 )]));
1009 },
1010 DiffLineKind::Added => {
1011 let text = format!(" {}", diff_line);
1012 let padded = pad_to_cells(&text, viewport_width);
1013 lines.push(Line::from(vec![Span::styled(
1014 padded,
1015 Style::new()
1016 .fg(theme.colors.success.to_color())
1017 .bg(added_bg),
1018 )]));
1019 },
1020 DiffLineKind::Context => {
1021 lines.push(Line::from(vec![
1022 Span::styled(" ", Style::new().fg(action_color)),
1023 Span::styled(
1024 diff_line,
1025 Style::new().fg(theme.colors.text_secondary.to_color()),
1026 ),
1027 ]));
1028 },
1029 }
1030 }
1031
1032 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1033 if remaining > 0 {
1034 lines.push(Line::from(vec![
1035 Span::styled(" ", Style::new().fg(action_color)),
1036 Span::styled(
1037 format!("... ({} more lines)", remaining),
1038 Style::new()
1039 .fg(theme.colors.text_disabled.to_color())
1040 .italic(),
1041 ),
1042 ]));
1043 }
1044 }
1045 }
1046 },
1047 ActionResult::Error { error } => {
1048 let error =
1049 append_action_duration(format!("Error: {}", error), action.duration_seconds);
1050 lines.push(Line::from(vec![
1051 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
1052 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
1053 ]));
1054 },
1055 }
1056 }
1057}
1058
1059fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1060 if let Some(seconds) = duration_seconds {
1061 text.push_str(", took ");
1062 text.push_str(&format_action_duration(seconds));
1063 }
1064 text
1065}
1066
1067fn format_action_duration(seconds: f64) -> String {
1068 if seconds < 1.0 {
1069 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1070 } else if seconds < 10.0 {
1071 format!("{:.1}s", seconds)
1072 } else {
1073 format!("{}s", seconds.round() as u64)
1074 }
1075}
1076
1077fn wrap_text_with_indent(
1086 text: &str,
1087 width: usize,
1088 first_line_indent: usize,
1089 continuation_indent: usize,
1090) -> Vec<String> {
1091 let mut wrapped_lines = Vec::new();
1092
1093 for (line_idx, line) in text.lines().enumerate() {
1094 if line.is_empty() {
1095 wrapped_lines.push(String::new());
1096 continue;
1097 }
1098
1099 let current_indent = if line_idx == 0 {
1100 first_line_indent
1101 } else {
1102 continuation_indent
1103 };
1104 let available_width = width.saturating_sub(current_indent);
1105
1106 if available_width == 0 {
1107 wrapped_lines.push(" ".repeat(current_indent));
1108 continue;
1109 }
1110
1111 let words: Vec<&str> = line.split_whitespace().collect();
1112 if words.is_empty() {
1113 wrapped_lines.push(" ".repeat(current_indent));
1114 continue;
1115 }
1116
1117 let mut current_line = String::with_capacity(width);
1118 current_line.push_str(&" ".repeat(current_indent));
1119 let mut current_length = 0;
1122
1123 for (word_idx, word) in words.iter().enumerate() {
1124 let word_width = word.width();
1125
1126 if word_idx == 0 {
1127 current_line.push_str(word);
1129 current_length = word_width;
1130 } else if current_length + 1 + word_width <= available_width {
1131 current_line.push(' ');
1134 current_line.push_str(word);
1135 current_length += 1 + word_width;
1136 } else {
1137 wrapped_lines.push(current_line);
1139 current_line = String::with_capacity(width);
1140 current_line.push_str(&" ".repeat(continuation_indent));
1141 current_line.push_str(word);
1142 current_length = word_width;
1143 }
1144 }
1145
1146 if !current_line.trim().is_empty() {
1148 wrapped_lines.push(current_line);
1149 }
1150 }
1151
1152 wrapped_lines
1153}
1154
1155fn wrap_styled_line(
1158 line: Line<'static>,
1159 width: usize,
1160 continuation_indent: usize,
1161) -> Vec<Line<'static>> {
1162 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1167
1168 if total_width <= width {
1170 return vec![line];
1171 }
1172
1173 let mut result_lines = Vec::new();
1175 let mut current_line_spans = Vec::new();
1176 let mut current_line_width = 0usize;
1177 let available_width = width.saturating_sub(continuation_indent);
1178
1179 let leading_indent: usize = {
1187 let mut n = 0;
1188 for span in &line.spans {
1189 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1190 n += spaces;
1191 if spaces < span.content.len() {
1192 break; }
1194 }
1195 n
1196 };
1197
1198 for span in line.spans.clone() {
1199 let span_text = span.content.to_string();
1200 let span_style = span.style;
1201
1202 let words: Vec<&str> = span_text.split_whitespace().collect();
1204
1205 for (word_idx, word) in words.iter().enumerate() {
1206 let word_with_space = if word_idx > 0 || current_line_width > 0 {
1207 format!(" {}", word)
1208 } else {
1209 word.to_string()
1210 };
1211
1212 let word_width = word_with_space.width();
1213
1214 if current_line_width == 0 && result_lines.is_empty() {
1215 if leading_indent > 0 {
1219 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1220 current_line_width += leading_indent;
1221 }
1222 current_line_spans.push(Span::styled(word_with_space, span_style));
1223 current_line_width += word_width;
1224 } else if current_line_width + word_width <= available_width {
1225 current_line_spans.push(Span::styled(word_with_space, span_style));
1227 current_line_width += word_width;
1228 } else {
1229 result_lines.push(Line::from(current_line_spans));
1231 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1232 current_line_spans.push(Span::styled(word.to_string(), span_style));
1233 current_line_width = word.width();
1234 }
1235 }
1236 }
1237
1238 if !current_line_spans.is_empty() {
1240 result_lines.push(Line::from(current_line_spans));
1241 }
1242
1243 if result_lines.is_empty() {
1244 vec![line]
1245 } else {
1246 result_lines
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253
1254 #[test]
1255 fn diff_background_fills_full_width_with_tabs() {
1256 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1261 use ratatui::Terminal;
1262 use ratatui::backend::TestBackend;
1263
1264 let theme = Theme::dark();
1265 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1266 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1267 let diff = format!(
1269 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
1270 m = DIFF_REMOVED_MARKER,
1271 p = DIFF_ADDED_MARKER
1272 );
1273 let action = ActionDisplay {
1274 action_type: "Edit".to_string(),
1275 target: "engine.ts".to_string(),
1276 result: ActionResult::Success {
1277 output: String::new(),
1278 images: None,
1279 },
1280 details: ActionDetails::Diff {
1281 summary: "ok".to_string(),
1282 diff,
1283 },
1284 duration_seconds: Some(0.3),
1285 metadata: None,
1286 };
1287
1288 let width: u16 = 60;
1289 let mut lines: Vec<Line> = Vec::new();
1290 render_actions(&[action], &mut lines, &theme, width as usize);
1291 let h = lines.len() as u16;
1292 let backend = TestBackend::new(width, h);
1293 let mut term = Terminal::new(backend).unwrap();
1294 term.draw(|f| {
1295 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1296 })
1297 .unwrap();
1298 let buf = term.backend().buffer();
1299
1300 for y in 0..h {
1301 let is_diff_row = (0..width).any(|x| {
1302 let bg = buf[(x, y)].bg;
1303 bg == added_bg || bg == removed_bg
1304 });
1305 if !is_diff_row {
1306 continue;
1307 }
1308 for x in 0..width {
1309 let bg = buf[(x, y)].bg;
1310 assert!(
1311 bg == added_bg || bg == removed_bg,
1312 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1313 );
1314 }
1315 }
1316 }
1317
1318 #[test]
1319 fn byte_at_cell_clamps_and_respects_cjk() {
1320 assert_eq!(byte_at_cell("hello", 0), 0);
1321 assert_eq!(byte_at_cell("hello", 3), 3);
1322 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
1325 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
1328 }
1329
1330 #[test]
1331 fn slice_by_cells_extracts_display_range() {
1332 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1333 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1334 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1335 }
1336
1337 #[test]
1338 fn pad_to_cells_fills_to_display_width() {
1339 assert_eq!(pad_to_cells("ab", 5), "ab ");
1340 assert_eq!(pad_to_cells("你好", 6), "你好 ");
1342 assert_eq!(pad_to_cells("你好", 3), "你好");
1344 assert_eq!(pad_to_cells("", 0), "");
1345 }
1346
1347 #[test]
1348 fn user_timestamp_padding_aligns_on_display_cells() {
1349 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
1351 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
1354 assert_eq!(4 + 10 + pad + 8, 40);
1355 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
1357 }
1358
1359 #[test]
1360 fn wrap_preformatted_hard_wraps_preserving_spaces() {
1361 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
1364 let wrapped = wrap_preformatted(line, 10, 2);
1365 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1366 let first: String = wrapped[0]
1367 .spans
1368 .iter()
1369 .map(|s| s.content.as_ref())
1370 .collect();
1371 assert!(
1372 first.starts_with(" aaaa"),
1373 "indentation must be preserved, got {first:?}"
1374 );
1375 let second: String = wrapped[1]
1376 .spans
1377 .iter()
1378 .map(|s| s.content.as_ref())
1379 .collect();
1380 assert!(
1381 second.starts_with(" "),
1382 "continuation should get the hanging indent, got {second:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn wrap_preformatted_short_line_unchanged() {
1388 let line = Line::from(vec![Span::raw(" short")]);
1389 let wrapped = wrap_preformatted(line, 40, 2);
1390 assert_eq!(wrapped.len(), 1);
1391 let text: String = wrapped[0]
1392 .spans
1393 .iter()
1394 .map(|s| s.content.as_ref())
1395 .collect();
1396 assert_eq!(text, " short");
1397 }
1398
1399 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1403 let mut st = ChatState::new();
1404 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1405 st.selection = Some(sel);
1406 st
1407 }
1408
1409 #[test]
1410 fn selected_text_single_line() {
1411 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1412 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1413 }
1414
1415 #[test]
1416 fn selected_text_spans_multiple_rows() {
1417 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
1418 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1421 }
1422
1423 #[test]
1424 fn selected_text_strips_margin_but_keeps_code_indentation() {
1425 let st = state_with_rows(
1428 &[" fn main() {", " let x = 1;", " }"],
1429 ((0, 0), (2, 3)),
1430 );
1431 assert_eq!(
1432 st.selected_text().as_deref(),
1433 Some("fn main() {\n let x = 1;\n}")
1434 );
1435 }
1436
1437 #[test]
1438 fn selected_text_normalizes_reversed_drag() {
1439 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1441 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1442 }
1443
1444 #[test]
1445 fn selected_text_empty_selection_is_none() {
1446 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1448 assert_eq!(st.selected_text(), None);
1449 }
1450
1451 #[test]
1452 fn highlight_line_cells_splits_spans_on_selection() {
1453 let mut line = Line::from(vec![Span::raw("abcdef")]);
1454 highlight_line_cells(
1455 &mut line,
1456 2,
1457 4,
1458 Style::new().add_modifier(Modifier::REVERSED),
1459 );
1460 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1462 assert_eq!(texts, vec!["ab", "cd", "ef"]);
1463 assert!(
1464 line.spans[1]
1465 .style
1466 .add_modifier
1467 .contains(Modifier::REVERSED)
1468 );
1469 assert!(
1470 !line.spans[0]
1471 .style
1472 .add_modifier
1473 .contains(Modifier::REVERSED)
1474 );
1475 }
1476
1477 #[test]
1478 fn context_checkpoint_renders_as_compact_event() {
1479 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1480 msg.kind = ChatMessageKind::ContextCheckpoint;
1481 msg.metadata = Some(serde_json::json!({
1482 "trigger": "manual",
1483 "before_tokens": 43_800,
1484 "after_tokens": 9_200,
1485 "archived_message_count": 18,
1486 "preserved_message_count": 4,
1487 "duration_secs": 2.4,
1488 "verified": true,
1489 }));
1490
1491 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1492 let rendered = lines
1493 .iter()
1494 .map(|line| {
1495 line.spans
1496 .iter()
1497 .map(|span| span.content.as_ref())
1498 .collect::<String>()
1499 })
1500 .collect::<Vec<_>>()
1501 .join("\n");
1502
1503 assert!(rendered.contains("Compact(manual)"));
1504 assert!(rendered.contains("43.8k -> 9.2k tokens"));
1505 assert!(rendered.contains("archived 18 messages"));
1506 assert!(rendered.contains("preserved 4 messages"));
1507 assert!(rendered.contains("verified"));
1508 assert!(!rendered.contains("full checkpoint summary"));
1509 }
1510
1511 #[test]
1512 fn context_checkpoint_renders_verification_fallback() {
1513 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1514 msg.kind = ChatMessageKind::ContextCheckpoint;
1515 msg.metadata = Some(serde_json::json!({
1516 "trigger": "auto_threshold",
1517 "before_tokens": 43_800,
1518 "after_tokens": 9_200,
1519 "archived_message_count": 18,
1520 "preserved_message_count": 4,
1521 "duration_secs": 2.4,
1522 "verified": false,
1523 "verification_error": "provider overloaded",
1524 }));
1525
1526 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1527 let rendered = lines
1528 .iter()
1529 .map(|line| {
1530 line.spans
1531 .iter()
1532 .map(|span| span.content.as_ref())
1533 .collect::<String>()
1534 })
1535 .collect::<Vec<_>>()
1536 .join("\n");
1537
1538 assert!(rendered.contains("Compact(auto_threshold)"));
1539 assert!(rendered.contains("draft fallback"));
1540 assert!(rendered.contains("verification: provider overloaded"));
1541 }
1542
1543 #[test]
1549 fn wrap_styled_line_uses_display_width_for_cjk() {
1550 let line = Line::from(Span::raw("你好世界".to_string()));
1554 let wrapped = wrap_styled_line(line, 10, 2);
1555 assert_eq!(
1556 wrapped.len(),
1557 1,
1558 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1559 wrapped.len()
1560 );
1561 }
1562
1563 #[test]
1566 fn wrap_styled_line_ascii_wraps_when_too_long() {
1567 let line = Line::from(Span::raw(
1568 "the quick brown fox jumps over the lazy dog".to_string(),
1569 ));
1570 let wrapped = wrap_styled_line(line, 15, 2);
1571 assert!(
1572 wrapped.len() >= 2,
1573 "long ASCII input should wrap to multiple lines; got {}",
1574 wrapped.len()
1575 );
1576 }
1577
1578 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
1579 wrapped[0]
1580 .spans
1581 .iter()
1582 .map(|s| s.content.as_ref())
1583 .collect()
1584 }
1585
1586 #[test]
1592 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
1593 let line = Line::from(vec![
1594 Span::raw(" "), Span::raw(
1596 "No source files, no config, no docs, no build system and more words to wrap"
1597 .to_string(),
1598 ),
1599 ]);
1600 let wrapped = wrap_styled_line(line, 30, 2);
1601 assert!(wrapped.len() >= 2, "should wrap");
1602 let first = first_segment_text(&wrapped);
1603 assert!(
1604 first.starts_with(" ") && first.trim_start().starts_with("No source"),
1605 "first wrapped segment must keep the 2-space gutter; got {first:?}"
1606 );
1607 }
1608
1609 #[test]
1615 fn wrap_styled_line_hangs_list_continuation_under_marker() {
1616 let line = Line::from(vec![
1617 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
1621 ]);
1622 let wrapped = wrap_styled_line(line, 24, 6);
1623 assert!(wrapped.len() >= 2, "should wrap");
1624 assert!(
1625 first_segment_text(&wrapped).starts_with(" • "),
1626 "first segment keeps gutter + nesting + marker"
1627 );
1628 for cont in &wrapped[1..] {
1629 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
1630 assert!(
1631 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
1632 "continuation hangs under the item text at col 6; got {t:?}"
1633 );
1634 }
1635 }
1636
1637 #[test]
1640 fn wrap_styled_line_keeps_bullet_at_column_zero() {
1641 let line = Line::from(vec![
1642 Span::raw("● "),
1643 Span::raw(
1644 "a fairly long first line of a message that definitely needs to wrap".to_string(),
1645 ),
1646 ]);
1647 let wrapped = wrap_styled_line(line, 25, 2);
1648 assert!(wrapped.len() >= 2, "should wrap");
1649 assert!(
1650 first_segment_text(&wrapped).starts_with('●'),
1651 "bullet must stay at column 0"
1652 );
1653 }
1654
1655 #[test]
1661 fn wrap_text_with_indent_uses_display_width_for_cjk() {
1662 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
1665 assert_eq!(
1666 wrapped.len(),
1667 1,
1668 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
1669 wrapped.len(),
1670 wrapped
1671 );
1672 assert_eq!(wrapped[0].trim_start(), "你好世界");
1673 }
1674
1675 #[test]
1678 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
1679 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
1683 assert!(
1684 wrapped.len() >= 2,
1685 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
1686 wrapped.len(),
1687 wrapped
1688 );
1689 }
1690}