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::{MarkdownLine, 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}
51
52impl ChatState {
53 pub fn new() -> Self {
55 Self {
56 scroll_offset: 0,
57 is_user_scrolling: false,
58 image_click_map: Vec::new(),
59 last_scroll_position: 0,
60 last_chat_area: None,
61 selection: None,
62 last_rendered_rows: Vec::new(),
63 }
64 }
65
66 pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
69 let max_scroll = content_height.saturating_sub(viewport_height);
70 if self.is_user_scrolling {
71 let capped_offset = self.scroll_offset.min(max_scroll);
74 max_scroll.saturating_sub(capped_offset)
75 } else {
76 max_scroll
78 }
79 }
80
81 pub fn scroll_up(&mut self, amount: u16) {
83 self.is_user_scrolling = true;
84 self.scroll_offset = self.scroll_offset.saturating_add(amount);
85 self.selection = None;
88 }
89
90 pub fn scroll_down(&mut self, amount: u16) {
93 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
94 if self.scroll_offset == 0 {
95 self.is_user_scrolling = false;
97 }
98 self.selection = None;
99 }
100
101 pub fn resume_auto_scroll(&mut self) {
103 self.is_user_scrolling = false;
104 self.scroll_offset = 0;
105 }
106
107 pub fn is_manually_scrolling(&self) -> bool {
109 self.is_user_scrolling
110 }
111
112 pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
115 let (_, area_y, _, area_height) = self.last_chat_area?;
116
117 if screen_row < area_y || screen_row >= area_y + area_height {
119 return None;
120 }
121
122 let viewport_row = screen_row - area_y;
124 let content_line = viewport_row + self.last_scroll_position;
125
126 self.image_click_map
128 .iter()
129 .find(|(line, _)| *line == content_line)
130 .map(|(_, target)| target)
131 }
132
133 fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
137 let (area_x, area_y, _, area_height) = self.last_chat_area?;
138 if screen_row < area_y || screen_row >= area_y + area_height {
139 return None;
140 }
141 let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
142 let col = screen_col.saturating_sub(area_x) as usize;
143 Some((content_line, col))
144 }
145
146 pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
150 self.selection = self
151 .screen_to_content(screen_row, screen_col)
152 .map(|p| (p, p));
153 }
154
155 pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
157 if let Some((anchor, _)) = self.selection
158 && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
159 {
160 self.selection = Some((anchor, cursor));
161 }
162 }
163
164 pub fn clear_selection(&mut self) {
166 self.selection = None;
167 }
168
169 pub fn selected_text(&self) -> Option<String> {
174 let (a, b) = self.selection?;
175 let (start, end) = if a <= b { (a, b) } else { (b, a) };
176 if self.last_rendered_rows.is_empty() {
177 return None;
178 }
179 let last = self.last_rendered_rows.len() - 1;
180 let (start_line, start_col) = (start.0.min(last), start.1);
181 let (end_line, end_col) = (end.0.min(last), end.1);
182
183 let mut out = String::new();
184 for line in start_line..=end_line {
185 let row = &self.last_rendered_rows[line];
186 let c0 = if line == start_line { start_col } else { 0 };
187 let c1 = if line == end_line {
188 end_col
189 } else {
190 usize::MAX
191 };
192 let mut piece = slice_by_cells(row, c0, c1).to_string();
193 let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
198 while margin > 0 && piece.starts_with(' ') {
199 piece.remove(0);
200 margin -= 1;
201 }
202 out.push_str(piece.trim_end());
203 if line != end_line {
204 out.push('\n');
205 }
206 }
207 if out.is_empty() { None } else { Some(out) }
208 }
209}
210
211const SELECT_MARGIN_CELLS: usize = 2;
215
216fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
221 if width == 0 {
222 return vec![line];
223 }
224 let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
225 if total <= width {
226 return vec![line];
227 }
228
229 let base = line.style;
230 let mut out: Vec<Line<'static>> = Vec::new();
231 let mut cur: Vec<Span<'static>> = Vec::new();
232 let mut cur_w = 0usize;
233 let mut on_first = true;
234
235 for span in line.spans {
236 let style = span.style;
237 let mut buf = String::new();
238 for ch in span.content.chars() {
239 let cw = ch.width().unwrap_or(0);
240 let floor = if on_first { 0 } else { indent };
243 if cur_w + cw > width && cur_w > floor {
244 if !buf.is_empty() {
245 cur.push(Span::styled(std::mem::take(&mut buf), style));
246 }
247 out.push(Line::from(std::mem::take(&mut cur)).style(base));
248 on_first = false;
249 cur.push(Span::styled(" ".repeat(indent), base));
250 cur_w = indent;
251 }
252 buf.push(ch);
253 cur_w += cw;
254 }
255 if !buf.is_empty() {
256 cur.push(Span::styled(buf, style));
257 }
258 }
259 if !cur.is_empty() {
260 out.push(Line::from(cur).style(base));
261 }
262 if out.is_empty() {
263 vec![Line::from("").style(base)]
264 } else {
265 out
266 }
267}
268
269fn byte_at_cell(s: &str, target: usize) -> usize {
273 if target == 0 {
274 return 0;
275 }
276 let mut width = 0usize;
277 for (idx, ch) in s.char_indices() {
278 if width >= target {
279 return idx;
280 }
281 width += ch.width().unwrap_or(0);
282 }
283 s.len()
284}
285
286fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
288 let start = byte_at_cell(s, c0);
289 let end = byte_at_cell(s, c1).max(start);
290 &s[start..end]
291}
292
293fn pad_to_cells(s: &str, cells: usize) -> String {
298 let w = s.width();
299 if w >= cells {
300 return s.to_string();
301 }
302 let mut out = String::with_capacity(s.len() + (cells - w));
303 out.push_str(s);
304 out.push_str(&" ".repeat(cells - w));
305 out
306}
307
308fn user_timestamp_padding(
313 role_prefix_width: usize,
314 text_width: usize,
315 timestamp_width: usize,
316 min_gap: usize,
317 content_width: usize,
318) -> usize {
319 let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
320 min_gap + content_width.saturating_sub(total_used)
321}
322
323fn line_plain_text(line: &Line) -> String {
325 line.spans.iter().map(|s| s.content.as_ref()).collect()
326}
327
328fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
332 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
333 let mut width = 0usize;
334 for span in line.spans.drain(..) {
335 let span_w = span.content.width();
336 let (span_start, span_end) = (width, width + span_w);
337 width = span_end;
338
339 let ov0 = c0.max(span_start);
340 let ov1 = c1.min(span_end);
341 if ov1 <= ov0 {
342 new_spans.push(span); continue;
344 }
345
346 let s = span.content.as_ref();
347 let b0 = byte_at_cell(s, ov0 - span_start);
348 let b1 = byte_at_cell(s, ov1 - span_start);
349 if b0 > 0 {
350 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
351 }
352 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
353 if b1 < s.len() {
354 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
355 }
356 }
357 line.spans = new_spans;
358}
359
360impl Default for ChatState {
361 fn default() -> Self {
362 Self::new()
363 }
364}
365
366pub struct ChatWidget<'a> {
368 pub messages: &'a [ChatMessage],
369 pub theme: &'a Theme,
370 pub markdown_cache: &'a mut FxHashMap<u64, Vec<MarkdownLine>>,
372 pub show_reasoning: bool,
373}
374
375impl<'a> StatefulWidget for ChatWidget<'a> {
376 type State = ChatState;
377
378 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
379 let mut lines: Vec<Line<'static>> = Vec::new();
380
381 let code_bg = self.theme.colors.code_background.to_color();
384 let theme_seed = {
385 let mut h = rustc_hash::FxHasher::default();
386 self.theme.colors.foreground.to_color().hash(&mut h);
387 code_bg.hash(&mut h);
388 self.theme.colors.header.to_color().hash(&mut h);
389 h.finish()
390 };
391
392 let content_width = area.width;
394 let content_area = area;
395
396 state.image_click_map.clear();
398 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
399
400 for (idx, msg) in self.messages.iter().enumerate() {
401 if matches!(msg.role, MessageRole::Tool) {
404 continue;
405 }
406
407 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
408 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
409 lines.extend(event_lines);
410 lines.push(Line::from(""));
411 }
412 continue;
413 }
414
415 if matches!(msg.kind, ChatMessageKind::RunSummary) {
420 lines.push(Line::from(Span::styled(
421 format!(" {}", msg.content),
422 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
423 )));
424 lines.push(Line::from(""));
425 continue;
426 }
427
428 let (role_prefix, role_color) = match msg.role {
429 MessageRole::User => (">", ratatui::style::Color::White),
430 MessageRole::Assistant => ("●", ratatui::style::Color::White),
431 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
432 MessageRole::Tool => unreachable!("Tool messages filtered above"),
433 };
434
435 if matches!(msg.role, MessageRole::Assistant) {
436 if let Some(ref thinking) = msg.thinking {
438 let thinking_trimmed = thinking.trim();
440 if thinking_trimmed.is_empty()
441 || thinking_trimmed == "None"
442 || thinking_trimmed == "none"
443 {
444 } else if self.show_reasoning {
446 lines.push(Line::from(vec![
448 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
449 Span::styled(
450 "Thinking...",
451 Style::new()
452 .fg(self.theme.colors.text_secondary.to_color())
453 .italic()
454 .dim(),
455 ),
456 ]));
457
458 let wrapped = wrap_text_with_indent(
460 thinking,
461 content_width as usize,
462 2, 2, );
465 for wrapped_line in wrapped {
466 lines.push(Line::from(Span::styled(
467 wrapped_line,
468 Style::new()
469 .fg(self.theme.colors.text_secondary.to_color())
470 .italic()
471 .dim(),
472 )));
473 }
474
475 lines.push(Line::from(""));
477 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
478 continue;
483 }
484 }
485
486 let mut hasher = rustc_hash::FxHasher::default();
491 msg.content.hash(&mut hasher);
492 theme_seed.hash(&mut hasher);
493 content_width.hash(&mut hasher);
496 let cache_key = hasher.finish();
497 let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
498 cached.clone()
499 } else {
500 let md_width = (content_width as usize).saturating_sub(2);
502 let parsed = parse_markdown(&msg.content, self.theme, md_width);
503 self.markdown_cache.insert(cache_key, parsed.clone());
504 if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
505 self.markdown_cache.clear();
506 self.markdown_cache.insert(cache_key, parsed.clone());
507 }
508 parsed
509 };
510
511 for (line_idx, parsed_line) in parsed_lines.into_iter().enumerate() {
512 let preformatted = parsed_line.preformatted;
517 let base_style = parsed_line.line.style;
518
519 let continuation = if preformatted {
524 2
525 } else {
526 2 + crate::render::markdown::line_hanging_indent(
527 &parsed_line.line,
528 self.theme,
529 )
530 };
531
532 let mut spans = if line_idx == 0 {
534 vec![Span::styled(
535 format!("{} ", role_prefix),
536 Style::new().fg(role_color).bold(),
537 )]
538 } else {
539 vec![Span::raw(" ")]
540 };
541 spans.extend(parsed_line.line.spans);
542 let new_line = Line::from(spans).style(base_style);
543
544 if preformatted {
545 lines.extend(wrap_preformatted(new_line, content_width as usize, 2));
548 } else {
549 lines.extend(wrap_styled_line(
550 new_line,
551 content_width as usize,
552 continuation,
553 ));
554 }
555 }
556
557 if !msg.actions.is_empty() {
559 if !msg.content.trim().is_empty() {
561 lines.push(Line::from(""));
562 }
563 render_actions(&msg.actions, &mut lines, self.theme, content_width as usize);
564 }
565 } else {
566 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
568 let timestamp_width = formatted_timestamp.width();
572 let min_gap = 3; let cleaned_content = &msg.content;
576
577 let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
581
582 let wrapped = wrap_text_with_indent(
584 cleaned_content,
585 content_width as usize,
586 first_line_reserved, 2, );
589
590 let band_start = lines.len();
591 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
592 if line_idx == 0 {
593 let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
596
597 let mut spans = vec![
598 Span::styled(
599 format!("{} ", role_prefix),
600 Style::new().fg(role_color).bold(),
601 ),
602 Span::raw(text_content.to_string()),
603 ];
604
605 let pad = user_timestamp_padding(
608 role_prefix_width,
609 text_width,
610 timestamp_width,
611 min_gap,
612 content_width as usize,
613 );
614 spans.push(Span::raw(" ".repeat(pad)));
615 spans.push(Span::styled(
616 formatted_timestamp.clone(),
617 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
618 ));
619
620 lines.push(Line::from(spans));
621 } else {
622 lines.push(Line::from(wrapped_line.clone()));
624 }
625 }
626
627 if matches!(msg.role, MessageRole::User) {
633 let user_bg = self.theme.colors.user_message_background.to_color();
634 let cw = content_width as usize;
635 for line in &mut lines[band_start..] {
636 let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
637 if used < cw {
638 line.spans.push(Span::raw(" ".repeat(cw - used)));
639 }
640 line.style = line.style.bg(user_bg);
641 }
642 }
643 }
644
645 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
652 && let Some(ref images) = msg.images
653 && !images.is_empty()
654 {
655 for (i, _) in images.iter().enumerate() {
656 let content_line = lines.len() as u16;
658 state.image_click_map.push((
659 content_line,
660 ImageClickTarget {
661 message_index: idx,
662 image_index: i,
663 },
664 ));
665 lines.push(Line::from(vec![
666 Span::styled(" ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
667 Span::styled(
668 format!("[Image #{}]", i + 1),
669 Style::new().fg(self.theme.colors.info.to_color()).italic(),
670 ),
671 ]));
672 }
673 }
674
675 lines.push(Line::from(""));
676 }
677
678 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
689
690 if let Some((a, b)) = state.selection
693 && !lines.is_empty()
694 {
695 let (start, end) = if a <= b { (a, b) } else { (b, a) };
696 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
697 let last_line = lines.len() - 1;
698 for (line_idx, line) in lines
699 .iter_mut()
700 .enumerate()
701 .take(end.0.min(last_line) + 1)
702 .skip(start.0)
703 {
704 let c0 = if line_idx == start.0 { start.1 } else { 0 };
705 let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
706 if c1 > c0 {
707 highlight_line_cells(line, c0, c1, sel_style);
708 }
709 }
710 }
711
712 let content_height = lines.len() as u16;
715 let viewport_height = area.height;
716
717 let scroll_pos = state.get_scroll_position(content_height, viewport_height);
718 state.last_scroll_position = scroll_pos;
719
720 let paragraph = Paragraph::new(lines)
721 .block(Block::default())
722 .scroll((scroll_pos, 0));
723
724 paragraph.render(content_area, buf);
725 }
726}
727
728fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
729 if !matches!(msg.role, MessageRole::User) {
730 return None;
731 }
732
733 let metadata = msg.metadata.as_ref();
734 let trigger = metadata
735 .and_then(|value| value.get("trigger"))
736 .and_then(|value| value.as_str())
737 .unwrap_or("manual");
738 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
739 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
740 let archived_messages =
741 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
742 let preserved_messages =
743 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
744 let duration_secs = metadata
745 .and_then(|value| value.get("duration_secs"))
746 .and_then(|value| value.as_f64());
747 let verified = metadata
748 .and_then(|value| value.get("verified"))
749 .and_then(|value| value.as_bool());
750 let verification_error = metadata
751 .and_then(|value| value.get("verification_error"))
752 .and_then(|value| value.as_str());
753
754 let action_color = theme.colors.info.to_color();
755 let mut result = match (before_tokens, after_tokens) {
756 (Some(before), Some(after)) => {
757 format!(
758 "Success, {} -> {} tokens",
759 format_compact_count(before),
760 format_compact_count(after)
761 )
762 },
763 _ => "Success".to_string(),
764 };
765
766 if let Some(count) = archived_messages {
767 result.push_str(&format!(
768 ", archived {} {}",
769 count,
770 if count == 1 { "message" } else { "messages" }
771 ));
772 }
773 if let Some(count) = preserved_messages {
774 result.push_str(&format!(
775 ", preserved {} {}",
776 count,
777 if count == 1 { "message" } else { "messages" }
778 ));
779 }
780 if let Some(verified) = verified {
781 if verified {
782 result.push_str(", verified");
783 } else {
784 result.push_str(", draft fallback");
785 }
786 }
787 result = append_action_duration(result, duration_secs);
788
789 let mut lines = vec![
790 Line::from(vec![
791 Span::styled("● ", Style::new().fg(action_color).bold()),
792 Span::styled("Compact(", Style::new().fg(action_color).bold()),
793 Span::styled(
794 trigger.to_string(),
795 Style::new().fg(theme.colors.text_secondary.to_color()),
796 ),
797 Span::styled(")", Style::new().fg(action_color).bold()),
798 ]),
799 Line::from(vec![
800 Span::styled(" ⎿ ", Style::new().fg(action_color)),
801 Span::styled(
802 result,
803 Style::new().fg(theme.colors.text_secondary.to_color()),
804 ),
805 ]),
806 ];
807
808 if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
809 lines.push(Line::from(vec![
810 Span::styled(" ", Style::new().fg(action_color)),
811 Span::styled(
812 format!("verification: {}", compact_inline_error(error, 180)),
813 Style::new().fg(theme.colors.warning.to_color()),
814 ),
815 ]));
816 }
817
818 Some(lines)
819}
820
821fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
822 value
823 .get(key)?
824 .as_u64()
825 .and_then(|value| usize::try_from(value).ok())
826}
827
828fn compact_inline_error(text: &str, max_chars: usize) -> String {
829 let text = text.trim();
830 if text.chars().count() <= max_chars {
831 return text.to_string();
832 }
833 let keep = max_chars.saturating_sub(3);
834 let mut out: String = text.chars().take(keep).collect();
835 out.push_str("...");
836 out
837}
838
839fn expand_tabs(s: &str) -> String {
848 const TAB_WIDTH: usize = 4;
849 if !s.contains('\t') {
850 return s.to_string();
851 }
852 let mut out = String::with_capacity(s.len() + TAB_WIDTH);
853 let mut col = 0usize;
854 for ch in s.chars() {
855 if ch == '\t' {
856 let n = TAB_WIDTH - (col % TAB_WIDTH);
857 for _ in 0..n {
858 out.push(' ');
859 }
860 col += n;
861 } else {
862 out.push(ch);
863 col += UnicodeWidthChar::width(ch).unwrap_or(0);
864 }
865 }
866 out
867}
868
869fn render_actions(
870 actions: &[ActionDisplay],
871 lines: &mut Vec<Line>,
872 theme: &Theme,
873 viewport_width: usize,
874) {
875 for (action_idx, action) in actions.iter().enumerate() {
876 if action_idx > 0 {
877 lines.push(Line::from(""));
878 }
879 let action_color = match action.action_type.as_str() {
880 "Write" | "Edit" => theme.colors.success.to_color(),
881 "Delete" => theme.colors.warning.to_color(),
882 _ => theme.colors.info.to_color(),
883 };
884
885 lines.push(Line::from(vec![
887 Span::styled("● ", Style::new().fg(action_color).bold()),
888 Span::styled(
889 format!("{}(", action.action_type),
890 Style::new().fg(action_color).bold(),
891 ),
892 Span::styled(
893 action.target.clone(),
894 Style::new().fg(theme.colors.text_secondary.to_color()),
895 ),
896 Span::styled(")", Style::new().fg(action_color).bold()),
897 ]));
898
899 match &action.result {
900 ActionResult::Success { .. } => {
901 let result_msg = match &action.details {
903 ActionDetails::FileContent { line_count, .. } => {
904 let base = format!(
905 "Success, {} {} written",
906 line_count,
907 if *line_count == 1 { "line" } else { "lines" }
908 );
909 append_action_duration(base, action.duration_seconds)
910 },
911 ActionDetails::Diff { summary, .. } => summary.clone(),
912 ActionDetails::Preview { text, .. } => text.clone(),
913 ActionDetails::Simple => match action.action_type.as_str() {
914 "Delete" => append_action_duration(
915 format!("Success, deleted {}", action.target),
916 action.duration_seconds,
917 ),
918 _ => append_action_duration("Success".to_string(), action.duration_seconds),
919 },
920 };
921
922 for (idx, line) in result_msg.lines().enumerate() {
923 let prefix = if idx == 0 { " ⎿ " } else { " " };
924 lines.push(Line::from(vec![
925 Span::styled(prefix, Style::new().fg(action_color)),
926 Span::styled(
927 line.to_string(),
928 Style::new().fg(theme.colors.text_secondary.to_color()),
929 ),
930 ]));
931 }
932
933 if let ActionDetails::FileContent {
935 content,
936 line_count,
937 } = &action.details
938 {
939 let preview_lines: Vec<&str> = content.lines().take(10).collect();
940 if !preview_lines.is_empty() {
941 lines.push(Line::from(vec![Span::styled(
942 " ",
943 Style::new().fg(action_color),
944 )]));
945
946 let preview_content = preview_lines.join("\n");
947 let mut parsed = parse_markdown(
948 &format!("```\n{}\n```", preview_content),
949 theme,
950 viewport_width.saturating_sub(4),
951 );
952 for parsed_line in parsed.iter_mut() {
953 let mut new_spans =
954 vec![Span::styled(" ", Style::new().fg(action_color))];
955 new_spans.append(&mut parsed_line.line.spans);
956 parsed_line.line.spans = new_spans;
957 }
958 lines.extend(parsed.into_iter().map(|ml| ml.line));
959
960 if *line_count > 10 {
961 lines.push(Line::from(vec![
962 Span::styled(" ", Style::new().fg(action_color)),
963 Span::styled(
964 format!("... ({} more lines)", line_count - 10),
965 Style::new()
966 .fg(theme.colors.text_disabled.to_color())
967 .italic(),
968 ),
969 ]));
970 }
971 }
972 }
973
974 if let ActionDetails::Diff { diff, .. } = &action.details {
976 let diff_lines: Vec<&str> = diff.lines().collect();
977 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
978
979 if !display_lines.is_empty() {
980 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
981 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
982
983 for diff_line in &display_lines {
984 let diff_line = expand_tabs(diff_line);
991 match parse_diff_line(&diff_line) {
996 DiffLineKind::Removed => {
997 let text = format!(" {}", diff_line);
998 let padded = pad_to_cells(&text, viewport_width);
999 lines.push(Line::from(vec![Span::styled(
1000 padded,
1001 Style::new()
1002 .fg(theme.colors.error.to_color())
1003 .bg(removed_bg),
1004 )]));
1005 },
1006 DiffLineKind::Added => {
1007 let text = format!(" {}", diff_line);
1008 let padded = pad_to_cells(&text, viewport_width);
1009 lines.push(Line::from(vec![Span::styled(
1010 padded,
1011 Style::new()
1012 .fg(theme.colors.success.to_color())
1013 .bg(added_bg),
1014 )]));
1015 },
1016 DiffLineKind::Context => {
1017 lines.push(Line::from(vec![
1018 Span::styled(" ", Style::new().fg(action_color)),
1019 Span::styled(
1020 diff_line,
1021 Style::new().fg(theme.colors.text_secondary.to_color()),
1022 ),
1023 ]));
1024 },
1025 }
1026 }
1027
1028 let remaining = diff_lines.len().saturating_sub(display_lines.len());
1029 if remaining > 0 {
1030 lines.push(Line::from(vec![
1031 Span::styled(" ", Style::new().fg(action_color)),
1032 Span::styled(
1033 format!("... ({} more lines)", remaining),
1034 Style::new()
1035 .fg(theme.colors.text_disabled.to_color())
1036 .italic(),
1037 ),
1038 ]));
1039 }
1040 }
1041 }
1042 },
1043 ActionResult::Error { error } => {
1044 let error =
1045 append_action_duration(format!("Error: {}", error), action.duration_seconds);
1046 lines.push(Line::from(vec![
1047 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
1048 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
1049 ]));
1050 },
1051 }
1052 }
1053}
1054
1055fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1056 if let Some(seconds) = duration_seconds {
1057 text.push_str(", took ");
1058 text.push_str(&format_action_duration(seconds));
1059 }
1060 text
1061}
1062
1063fn format_action_duration(seconds: f64) -> String {
1064 if seconds < 1.0 {
1065 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1066 } else if seconds < 10.0 {
1067 format!("{:.1}s", seconds)
1068 } else {
1069 format!("{}s", seconds.round() as u64)
1070 }
1071}
1072
1073fn wrap_text_with_indent(
1082 text: &str,
1083 width: usize,
1084 first_line_indent: usize,
1085 continuation_indent: usize,
1086) -> Vec<String> {
1087 let mut wrapped_lines = Vec::new();
1088
1089 for (line_idx, line) in text.lines().enumerate() {
1090 if line.is_empty() {
1091 wrapped_lines.push(String::new());
1092 continue;
1093 }
1094
1095 let current_indent = if line_idx == 0 {
1096 first_line_indent
1097 } else {
1098 continuation_indent
1099 };
1100 let available_width = width.saturating_sub(current_indent);
1101
1102 if available_width == 0 {
1103 wrapped_lines.push(" ".repeat(current_indent));
1104 continue;
1105 }
1106
1107 let words: Vec<&str> = line.split_whitespace().collect();
1108 if words.is_empty() {
1109 wrapped_lines.push(" ".repeat(current_indent));
1110 continue;
1111 }
1112
1113 let mut current_line = String::with_capacity(width);
1114 current_line.push_str(&" ".repeat(current_indent));
1115 let mut current_length = 0;
1118
1119 for (word_idx, word) in words.iter().enumerate() {
1120 let word_width = word.width();
1121
1122 if word_idx == 0 {
1123 current_line.push_str(word);
1125 current_length = word_width;
1126 } else if current_length + 1 + word_width <= available_width {
1127 current_line.push(' ');
1130 current_line.push_str(word);
1131 current_length += 1 + word_width;
1132 } else {
1133 wrapped_lines.push(current_line);
1135 current_line = String::with_capacity(width);
1136 current_line.push_str(&" ".repeat(continuation_indent));
1137 current_line.push_str(word);
1138 current_length = word_width;
1139 }
1140 }
1141
1142 if !current_line.trim().is_empty() {
1144 wrapped_lines.push(current_line);
1145 }
1146 }
1147
1148 wrapped_lines
1149}
1150
1151fn wrap_styled_line(
1154 line: Line<'static>,
1155 width: usize,
1156 continuation_indent: usize,
1157) -> Vec<Line<'static>> {
1158 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1163
1164 if total_width <= width {
1166 return vec![line];
1167 }
1168
1169 let mut result_lines = Vec::new();
1171 let mut current_line_spans = Vec::new();
1172 let mut current_line_width = 0usize;
1173 let available_width = width.saturating_sub(continuation_indent);
1174
1175 let leading_indent: usize = {
1183 let mut n = 0;
1184 for span in &line.spans {
1185 let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1186 n += spaces;
1187 if spaces < span.content.len() {
1188 break; }
1190 }
1191 n
1192 };
1193
1194 for span in line.spans.clone() {
1195 let span_text = span.content.to_string();
1196 let span_style = span.style;
1197
1198 let words: Vec<&str> = span_text.split_whitespace().collect();
1200
1201 for (word_idx, word) in words.iter().enumerate() {
1202 let word_with_space = if word_idx > 0 || current_line_width > 0 {
1203 format!(" {}", word)
1204 } else {
1205 word.to_string()
1206 };
1207
1208 let word_width = word_with_space.width();
1209
1210 if current_line_width == 0 && result_lines.is_empty() {
1211 if leading_indent > 0 {
1215 current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1216 current_line_width += leading_indent;
1217 }
1218 current_line_spans.push(Span::styled(word_with_space, span_style));
1219 current_line_width += word_width;
1220 } else if current_line_width + word_width <= available_width {
1221 current_line_spans.push(Span::styled(word_with_space, span_style));
1223 current_line_width += word_width;
1224 } else {
1225 result_lines.push(Line::from(current_line_spans));
1227 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1228 current_line_spans.push(Span::styled(word.to_string(), span_style));
1229 current_line_width = word.width();
1230 }
1231 }
1232 }
1233
1234 if !current_line_spans.is_empty() {
1236 result_lines.push(Line::from(current_line_spans));
1237 }
1238
1239 if result_lines.is_empty() {
1240 vec![line]
1241 } else {
1242 result_lines
1243 }
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::*;
1249
1250 #[test]
1251 fn diff_background_fills_full_width_with_tabs() {
1252 use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1257 use ratatui::Terminal;
1258 use ratatui::backend::TestBackend;
1259
1260 let theme = Theme::dark();
1261 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1262 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1263 let diff = format!(
1265 " 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
1266 m = DIFF_REMOVED_MARKER,
1267 p = DIFF_ADDED_MARKER
1268 );
1269 let action = ActionDisplay {
1270 action_type: "Edit".to_string(),
1271 target: "engine.ts".to_string(),
1272 result: ActionResult::Success {
1273 output: String::new(),
1274 images: None,
1275 },
1276 details: ActionDetails::Diff {
1277 summary: "ok".to_string(),
1278 diff,
1279 },
1280 duration_seconds: Some(0.3),
1281 metadata: None,
1282 };
1283
1284 let width: u16 = 60;
1285 let mut lines: Vec<Line> = Vec::new();
1286 render_actions(&[action], &mut lines, &theme, width as usize);
1287 let h = lines.len() as u16;
1288 let backend = TestBackend::new(width, h);
1289 let mut term = Terminal::new(backend).unwrap();
1290 term.draw(|f| {
1291 Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1292 })
1293 .unwrap();
1294 let buf = term.backend().buffer();
1295
1296 for y in 0..h {
1297 let is_diff_row = (0..width).any(|x| {
1298 let bg = buf[(x, y)].bg;
1299 bg == added_bg || bg == removed_bg
1300 });
1301 if !is_diff_row {
1302 continue;
1303 }
1304 for x in 0..width {
1305 let bg = buf[(x, y)].bg;
1306 assert!(
1307 bg == added_bg || bg == removed_bg,
1308 "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1309 );
1310 }
1311 }
1312 }
1313
1314 #[test]
1315 fn byte_at_cell_clamps_and_respects_cjk() {
1316 assert_eq!(byte_at_cell("hello", 0), 0);
1317 assert_eq!(byte_at_cell("hello", 3), 3);
1318 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
1321 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
1324 }
1325
1326 #[test]
1327 fn slice_by_cells_extracts_display_range() {
1328 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1329 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1330 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1331 }
1332
1333 #[test]
1334 fn pad_to_cells_fills_to_display_width() {
1335 assert_eq!(pad_to_cells("ab", 5), "ab ");
1336 assert_eq!(pad_to_cells("你好", 6), "你好 ");
1338 assert_eq!(pad_to_cells("你好", 3), "你好");
1340 assert_eq!(pad_to_cells("", 0), "");
1341 }
1342
1343 #[test]
1344 fn user_timestamp_padding_aligns_on_display_cells() {
1345 assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
1347 let pad = user_timestamp_padding(4, 10, 8, 3, 40);
1350 assert_eq!(4 + 10 + pad + 8, 40);
1351 assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
1353 }
1354
1355 #[test]
1356 fn wrap_preformatted_hard_wraps_preserving_spaces() {
1357 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
1360 let wrapped = wrap_preformatted(line, 10, 2);
1361 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1362 let first: String = wrapped[0]
1363 .spans
1364 .iter()
1365 .map(|s| s.content.as_ref())
1366 .collect();
1367 assert!(
1368 first.starts_with(" aaaa"),
1369 "indentation must be preserved, got {first:?}"
1370 );
1371 let second: String = wrapped[1]
1372 .spans
1373 .iter()
1374 .map(|s| s.content.as_ref())
1375 .collect();
1376 assert!(
1377 second.starts_with(" "),
1378 "continuation should get the hanging indent, got {second:?}"
1379 );
1380 }
1381
1382 #[test]
1383 fn wrap_preformatted_short_line_unchanged() {
1384 let line = Line::from(vec![Span::raw(" short")]);
1385 let wrapped = wrap_preformatted(line, 40, 2);
1386 assert_eq!(wrapped.len(), 1);
1387 let text: String = wrapped[0]
1388 .spans
1389 .iter()
1390 .map(|s| s.content.as_ref())
1391 .collect();
1392 assert_eq!(text, " short");
1393 }
1394
1395 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1399 let mut st = ChatState::new();
1400 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1401 st.selection = Some(sel);
1402 st
1403 }
1404
1405 #[test]
1406 fn selected_text_single_line() {
1407 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1408 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1409 }
1410
1411 #[test]
1412 fn selected_text_spans_multiple_rows() {
1413 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
1414 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1417 }
1418
1419 #[test]
1420 fn selected_text_strips_margin_but_keeps_code_indentation() {
1421 let st = state_with_rows(
1424 &[" fn main() {", " let x = 1;", " }"],
1425 ((0, 0), (2, 3)),
1426 );
1427 assert_eq!(
1428 st.selected_text().as_deref(),
1429 Some("fn main() {\n let x = 1;\n}")
1430 );
1431 }
1432
1433 #[test]
1434 fn selected_text_normalizes_reversed_drag() {
1435 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1437 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1438 }
1439
1440 #[test]
1441 fn selected_text_empty_selection_is_none() {
1442 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1444 assert_eq!(st.selected_text(), None);
1445 }
1446
1447 #[test]
1448 fn highlight_line_cells_splits_spans_on_selection() {
1449 let mut line = Line::from(vec![Span::raw("abcdef")]);
1450 highlight_line_cells(
1451 &mut line,
1452 2,
1453 4,
1454 Style::new().add_modifier(Modifier::REVERSED),
1455 );
1456 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1458 assert_eq!(texts, vec!["ab", "cd", "ef"]);
1459 assert!(
1460 line.spans[1]
1461 .style
1462 .add_modifier
1463 .contains(Modifier::REVERSED)
1464 );
1465 assert!(
1466 !line.spans[0]
1467 .style
1468 .add_modifier
1469 .contains(Modifier::REVERSED)
1470 );
1471 }
1472
1473 #[test]
1474 fn context_checkpoint_renders_as_compact_event() {
1475 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1476 msg.kind = ChatMessageKind::ContextCheckpoint;
1477 msg.metadata = Some(serde_json::json!({
1478 "trigger": "manual",
1479 "before_tokens": 43_800,
1480 "after_tokens": 9_200,
1481 "archived_message_count": 18,
1482 "preserved_message_count": 4,
1483 "duration_secs": 2.4,
1484 "verified": true,
1485 }));
1486
1487 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1488 let rendered = lines
1489 .iter()
1490 .map(|line| {
1491 line.spans
1492 .iter()
1493 .map(|span| span.content.as_ref())
1494 .collect::<String>()
1495 })
1496 .collect::<Vec<_>>()
1497 .join("\n");
1498
1499 assert!(rendered.contains("Compact(manual)"));
1500 assert!(rendered.contains("43.8k -> 9.2k tokens"));
1501 assert!(rendered.contains("archived 18 messages"));
1502 assert!(rendered.contains("preserved 4 messages"));
1503 assert!(rendered.contains("verified"));
1504 assert!(!rendered.contains("full checkpoint summary"));
1505 }
1506
1507 #[test]
1508 fn context_checkpoint_renders_verification_fallback() {
1509 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1510 msg.kind = ChatMessageKind::ContextCheckpoint;
1511 msg.metadata = Some(serde_json::json!({
1512 "trigger": "auto_threshold",
1513 "before_tokens": 43_800,
1514 "after_tokens": 9_200,
1515 "archived_message_count": 18,
1516 "preserved_message_count": 4,
1517 "duration_secs": 2.4,
1518 "verified": false,
1519 "verification_error": "provider overloaded",
1520 }));
1521
1522 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1523 let rendered = lines
1524 .iter()
1525 .map(|line| {
1526 line.spans
1527 .iter()
1528 .map(|span| span.content.as_ref())
1529 .collect::<String>()
1530 })
1531 .collect::<Vec<_>>()
1532 .join("\n");
1533
1534 assert!(rendered.contains("Compact(auto_threshold)"));
1535 assert!(rendered.contains("draft fallback"));
1536 assert!(rendered.contains("verification: provider overloaded"));
1537 }
1538
1539 #[test]
1545 fn wrap_styled_line_uses_display_width_for_cjk() {
1546 let line = Line::from(Span::raw("你好世界".to_string()));
1550 let wrapped = wrap_styled_line(line, 10, 2);
1551 assert_eq!(
1552 wrapped.len(),
1553 1,
1554 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1555 wrapped.len()
1556 );
1557 }
1558
1559 #[test]
1562 fn wrap_styled_line_ascii_wraps_when_too_long() {
1563 let line = Line::from(Span::raw(
1564 "the quick brown fox jumps over the lazy dog".to_string(),
1565 ));
1566 let wrapped = wrap_styled_line(line, 15, 2);
1567 assert!(
1568 wrapped.len() >= 2,
1569 "long ASCII input should wrap to multiple lines; got {}",
1570 wrapped.len()
1571 );
1572 }
1573
1574 fn first_segment_text(wrapped: &[Line<'static>]) -> String {
1575 wrapped[0]
1576 .spans
1577 .iter()
1578 .map(|s| s.content.as_ref())
1579 .collect()
1580 }
1581
1582 #[test]
1588 fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
1589 let line = Line::from(vec![
1590 Span::raw(" "), Span::raw(
1592 "No source files, no config, no docs, no build system and more words to wrap"
1593 .to_string(),
1594 ),
1595 ]);
1596 let wrapped = wrap_styled_line(line, 30, 2);
1597 assert!(wrapped.len() >= 2, "should wrap");
1598 let first = first_segment_text(&wrapped);
1599 assert!(
1600 first.starts_with(" ") && first.trim_start().starts_with("No source"),
1601 "first wrapped segment must keep the 2-space gutter; got {first:?}"
1602 );
1603 }
1604
1605 #[test]
1611 fn wrap_styled_line_hangs_list_continuation_under_marker() {
1612 let line = Line::from(vec![
1613 Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
1617 ]);
1618 let wrapped = wrap_styled_line(line, 24, 6);
1619 assert!(wrapped.len() >= 2, "should wrap");
1620 assert!(
1621 first_segment_text(&wrapped).starts_with(" • "),
1622 "first segment keeps gutter + nesting + marker"
1623 );
1624 for cont in &wrapped[1..] {
1625 let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
1626 assert!(
1627 t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
1628 "continuation hangs under the item text at col 6; got {t:?}"
1629 );
1630 }
1631 }
1632
1633 #[test]
1636 fn wrap_styled_line_keeps_bullet_at_column_zero() {
1637 let line = Line::from(vec![
1638 Span::raw("● "),
1639 Span::raw(
1640 "a fairly long first line of a message that definitely needs to wrap".to_string(),
1641 ),
1642 ]);
1643 let wrapped = wrap_styled_line(line, 25, 2);
1644 assert!(wrapped.len() >= 2, "should wrap");
1645 assert!(
1646 first_segment_text(&wrapped).starts_with('●'),
1647 "bullet must stay at column 0"
1648 );
1649 }
1650
1651 #[test]
1657 fn wrap_text_with_indent_uses_display_width_for_cjk() {
1658 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
1661 assert_eq!(
1662 wrapped.len(),
1663 1,
1664 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
1665 wrapped.len(),
1666 wrapped
1667 );
1668 assert_eq!(wrapped[0].trim_start(), "你好世界");
1669 }
1670
1671 #[test]
1674 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
1675 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
1679 assert!(
1680 wrapped.len() >= 2,
1681 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
1682 wrapped.len(),
1683 wrapped
1684 );
1685 }
1686}