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 line_plain_text(line: &Line) -> String {
297 line.spans.iter().map(|s| s.content.as_ref()).collect()
298}
299
300fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
304 let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
305 let mut width = 0usize;
306 for span in line.spans.drain(..) {
307 let span_w = span.content.width();
308 let (span_start, span_end) = (width, width + span_w);
309 width = span_end;
310
311 let ov0 = c0.max(span_start);
312 let ov1 = c1.min(span_end);
313 if ov1 <= ov0 {
314 new_spans.push(span); continue;
316 }
317
318 let s = span.content.as_ref();
319 let b0 = byte_at_cell(s, ov0 - span_start);
320 let b1 = byte_at_cell(s, ov1 - span_start);
321 if b0 > 0 {
322 new_spans.push(Span::styled(s[..b0].to_string(), span.style));
323 }
324 new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
325 if b1 < s.len() {
326 new_spans.push(Span::styled(s[b1..].to_string(), span.style));
327 }
328 }
329 line.spans = new_spans;
330}
331
332impl Default for ChatState {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338pub struct ChatWidget<'a> {
340 pub messages: &'a [ChatMessage],
341 pub theme: &'a Theme,
342 pub markdown_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
344 pub show_reasoning: bool,
345}
346
347impl<'a> StatefulWidget for ChatWidget<'a> {
348 type State = ChatState;
349
350 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
351 let mut lines: Vec<Line<'static>> = Vec::new();
352
353 let code_bg = self.theme.colors.code_background.to_color();
356 let theme_seed = {
357 let mut h = rustc_hash::FxHasher::default();
358 self.theme.colors.foreground.to_color().hash(&mut h);
359 code_bg.hash(&mut h);
360 self.theme.colors.header.to_color().hash(&mut h);
361 h.finish()
362 };
363
364 let gutter: u16 = if area.width > 4 { 1 } else { 0 };
367 let content_width = area.width.saturating_sub(gutter);
368 let content_area = Rect {
369 width: content_width,
370 ..area
371 };
372
373 state.image_click_map.clear();
375 state.last_chat_area = Some((area.x, area.y, area.width, area.height));
376
377 let mut hidden_reasoning_notice_shown = false;
378
379 for (idx, msg) in self.messages.iter().enumerate() {
380 if matches!(msg.role, MessageRole::Tool) {
383 continue;
384 }
385
386 if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
387 if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
388 lines.extend(event_lines);
389 lines.push(Line::from(""));
390 }
391 continue;
392 }
393
394 let (role_prefix, role_color) = match msg.role {
395 MessageRole::User => (">", ratatui::style::Color::White),
396 MessageRole::Assistant => ("●", ratatui::style::Color::White),
397 MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
398 MessageRole::Tool => unreachable!("Tool messages filtered above"),
399 };
400
401 if matches!(msg.role, MessageRole::Assistant) {
402 if let Some(ref thinking) = msg.thinking {
404 let thinking_trimmed = thinking.trim();
406 if thinking_trimmed.is_empty()
407 || thinking_trimmed == "None"
408 || thinking_trimmed == "none"
409 {
410 } else if self.show_reasoning {
412 lines.push(Line::from(vec![
414 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
415 Span::styled(
416 "Thinking...",
417 Style::new()
418 .fg(self.theme.colors.text_secondary.to_color())
419 .italic()
420 .dim(),
421 ),
422 ]));
423
424 let wrapped = wrap_text_with_indent(
426 thinking,
427 content_width as usize,
428 2, 2, );
431 for wrapped_line in wrapped {
432 lines.push(Line::from(Span::styled(
433 wrapped_line,
434 Style::new()
435 .fg(self.theme.colors.text_secondary.to_color())
436 .italic()
437 .dim(),
438 )));
439 }
440
441 lines.push(Line::from(""));
443 } else if !hidden_reasoning_notice_shown {
444 hidden_reasoning_notice_shown = true;
445 let marker = if msg.content.trim().is_empty() && msg.actions.is_empty() {
446 "Reasoning-only response hidden (/visible-reasoning on to show)"
447 } else {
448 "Reasoning hidden (/visible-reasoning on to show)"
449 };
450 lines.push(Line::from(vec![
451 Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
452 Span::styled(
453 marker,
454 Style::new()
455 .fg(self.theme.colors.text_secondary.to_color())
456 .italic()
457 .dim(),
458 ),
459 ]));
460 lines.push(Line::from(""));
467 if msg.content.trim().is_empty() && msg.actions.is_empty() {
468 continue;
469 }
470 } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
471 continue;
472 }
473 }
474
475 let mut hasher = rustc_hash::FxHasher::default();
480 msg.content.hash(&mut hasher);
481 theme_seed.hash(&mut hasher);
482 let cache_key = hasher.finish();
483 let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
484 cached.clone()
485 } else {
486 let parsed = parse_markdown(&msg.content, self.theme);
487 self.markdown_cache.insert(cache_key, parsed.clone());
488 if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
489 self.markdown_cache.clear();
490 self.markdown_cache.insert(cache_key, parsed.clone());
491 }
492 parsed
493 };
494
495 for (line_idx, parsed_line) in parsed_lines.into_iter().enumerate() {
496 let preformatted = parsed_line.style.bg == Some(code_bg);
501 let base_style = parsed_line.style;
502
503 let mut spans = if line_idx == 0 {
505 vec![Span::styled(
506 format!("{} ", role_prefix),
507 Style::new().fg(role_color).bold(),
508 )]
509 } else {
510 vec![Span::raw(" ")]
511 };
512 spans.extend(parsed_line.spans);
513 let new_line = Line::from(spans).style(base_style);
514
515 if preformatted {
516 lines.extend(wrap_preformatted(new_line, content_width as usize, 2));
519 } else {
520 lines.extend(wrap_styled_line(new_line, content_width as usize, 2));
521 }
522 }
523
524 if !msg.actions.is_empty() {
526 if !msg.content.trim().is_empty() {
528 lines.push(Line::from(""));
529 }
530 render_actions(&msg.actions, &mut lines, self.theme, content_width as usize);
531 }
532 } else {
533 let formatted_timestamp = format_relative_timestamp(msg.timestamp);
535 let timestamp_len = formatted_timestamp.len();
536 let min_gap = 3; let cleaned_content = &msg.content;
540
541 let role_prefix_width = role_prefix.len() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_len;
545
546 let wrapped = wrap_text_with_indent(
548 cleaned_content,
549 content_width as usize,
550 first_line_reserved, 2, );
553
554 for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
555 if line_idx == 0 {
556 let text_content = wrapped_line.trim_start(); let text_len = text_content.len();
559
560 let mut spans = vec![
561 Span::styled(
562 format!("{} ", role_prefix),
563 Style::new().fg(role_color).bold(),
564 ),
565 Span::raw(text_content.to_string()),
566 ];
567
568 let used_width = role_prefix_width + text_len;
571 let total_used = used_width + min_gap + timestamp_len;
572 let extra_padding = (content_width as usize).saturating_sub(total_used);
573 spans.push(Span::raw(" ".repeat(min_gap + extra_padding)));
574 spans.push(Span::styled(
575 formatted_timestamp.clone(),
576 Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
577 ));
578
579 lines.push(Line::from(spans));
580 } else {
581 lines.push(Line::from(wrapped_line.clone()));
583 }
584 }
585 }
586
587 if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
594 && let Some(ref images) = msg.images
595 && !images.is_empty()
596 {
597 for (i, _) in images.iter().enumerate() {
598 let content_line = lines.len() as u16;
600 state.image_click_map.push((
601 content_line,
602 ImageClickTarget {
603 message_index: idx,
604 image_index: i,
605 },
606 ));
607 lines.push(Line::from(vec![
608 Span::styled(" ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
609 Span::styled(
610 format!("[Image #{}]", i + 1),
611 Style::new().fg(self.theme.colors.info.to_color()).italic(),
612 ),
613 ]));
614 }
615 }
616
617 lines.push(Line::from(""));
618 }
619
620 state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
631
632 if let Some((a, b)) = state.selection
635 && !lines.is_empty()
636 {
637 let (start, end) = if a <= b { (a, b) } else { (b, a) };
638 let sel_style = Style::new().add_modifier(Modifier::REVERSED);
639 let last_line = lines.len() - 1;
640 for (line_idx, line) in lines
641 .iter_mut()
642 .enumerate()
643 .take(end.0.min(last_line) + 1)
644 .skip(start.0)
645 {
646 let c0 = if line_idx == start.0 { start.1 } else { 0 };
647 let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
648 if c1 > c0 {
649 highlight_line_cells(line, c0, c1, sel_style);
650 }
651 }
652 }
653
654 let content_height = lines.len() as u16;
657 let viewport_height = area.height;
658
659 let scroll_pos = state.get_scroll_position(content_height, viewport_height);
660 state.last_scroll_position = scroll_pos;
661
662 let paragraph = Paragraph::new(lines)
663 .block(Block::default())
664 .scroll((scroll_pos, 0));
665
666 paragraph.render(content_area, buf);
667
668 if gutter == 1 && content_height > viewport_height {
671 let mut sb_state = ScrollbarState::new(content_height as usize)
672 .viewport_content_length(viewport_height as usize)
673 .position(scroll_pos as usize);
674 Scrollbar::new(ScrollbarOrientation::VerticalRight)
675 .begin_symbol(None)
676 .end_symbol(None)
677 .thumb_style(Style::new().fg(self.theme.colors.border.to_color()))
678 .track_style(Style::new().fg(self.theme.colors.text_disabled.to_color()))
679 .render(area, buf, &mut sb_state);
680 }
681 }
682}
683
684fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
685 if !matches!(msg.role, MessageRole::User) {
686 return None;
687 }
688
689 let metadata = msg.metadata.as_ref();
690 let trigger = metadata
691 .and_then(|value| value.get("trigger"))
692 .and_then(|value| value.as_str())
693 .unwrap_or("manual");
694 let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
695 let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
696 let archived_messages =
697 metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
698 let preserved_messages =
699 metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
700 let duration_secs = metadata
701 .and_then(|value| value.get("duration_secs"))
702 .and_then(|value| value.as_f64());
703 let verified = metadata
704 .and_then(|value| value.get("verified"))
705 .and_then(|value| value.as_bool());
706 let verification_error = metadata
707 .and_then(|value| value.get("verification_error"))
708 .and_then(|value| value.as_str());
709
710 let action_color = theme.colors.info.to_color();
711 let mut result = match (before_tokens, after_tokens) {
712 (Some(before), Some(after)) => {
713 format!(
714 "Success, {} -> {} tokens",
715 format_compact_count(before),
716 format_compact_count(after)
717 )
718 },
719 _ => "Success".to_string(),
720 };
721
722 if let Some(count) = archived_messages {
723 result.push_str(&format!(
724 ", archived {} {}",
725 count,
726 if count == 1 { "message" } else { "messages" }
727 ));
728 }
729 if let Some(count) = preserved_messages {
730 result.push_str(&format!(
731 ", preserved {} {}",
732 count,
733 if count == 1 { "message" } else { "messages" }
734 ));
735 }
736 if let Some(verified) = verified {
737 if verified {
738 result.push_str(", verified");
739 } else {
740 result.push_str(", draft fallback");
741 }
742 }
743 result = append_action_duration(result, duration_secs);
744
745 let mut lines = vec![
746 Line::from(vec![
747 Span::styled("● ", Style::new().fg(action_color).bold()),
748 Span::styled("Compact(", Style::new().fg(action_color).bold()),
749 Span::styled(
750 trigger.to_string(),
751 Style::new().fg(theme.colors.text_secondary.to_color()),
752 ),
753 Span::styled(")", Style::new().fg(action_color).bold()),
754 ]),
755 Line::from(vec![
756 Span::styled(" ⎿ ", Style::new().fg(action_color)),
757 Span::styled(
758 result,
759 Style::new().fg(theme.colors.text_secondary.to_color()),
760 ),
761 ]),
762 ];
763
764 if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
765 lines.push(Line::from(vec![
766 Span::styled(" ", Style::new().fg(action_color)),
767 Span::styled(
768 format!("verification: {}", compact_inline_error(error, 180)),
769 Style::new().fg(theme.colors.warning.to_color()),
770 ),
771 ]));
772 }
773
774 Some(lines)
775}
776
777fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
778 value
779 .get(key)?
780 .as_u64()
781 .and_then(|value| usize::try_from(value).ok())
782}
783
784fn compact_inline_error(text: &str, max_chars: usize) -> String {
785 let text = text.trim();
786 if text.chars().count() <= max_chars {
787 return text.to_string();
788 }
789 let keep = max_chars.saturating_sub(3);
790 let mut out: String = text.chars().take(keep).collect();
791 out.push_str("...");
792 out
793}
794
795fn render_actions(
797 actions: &[ActionDisplay],
798 lines: &mut Vec<Line>,
799 theme: &Theme,
800 viewport_width: usize,
801) {
802 for (action_idx, action) in actions.iter().enumerate() {
803 if action_idx > 0 {
804 lines.push(Line::from(""));
805 }
806 let action_color = match action.action_type.as_str() {
807 "Write" | "Edit" => theme.colors.success.to_color(),
808 "Delete" => theme.colors.warning.to_color(),
809 _ => theme.colors.info.to_color(),
810 };
811
812 lines.push(Line::from(vec![
814 Span::styled("● ", Style::new().fg(action_color).bold()),
815 Span::styled(
816 format!("{}(", action.action_type),
817 Style::new().fg(action_color).bold(),
818 ),
819 Span::styled(
820 action.target.clone(),
821 Style::new().fg(theme.colors.text_secondary.to_color()),
822 ),
823 Span::styled(")", Style::new().fg(action_color).bold()),
824 ]));
825
826 match &action.result {
827 ActionResult::Success { .. } => {
828 let result_msg = match &action.details {
830 ActionDetails::FileContent { line_count, .. } => {
831 let base = format!(
832 "Success, {} {} written",
833 line_count,
834 if *line_count == 1 { "line" } else { "lines" }
835 );
836 append_action_duration(base, action.duration_seconds)
837 },
838 ActionDetails::Diff { summary, .. } => summary.clone(),
839 ActionDetails::Preview { text, .. } => text.clone(),
840 ActionDetails::Simple => match action.action_type.as_str() {
841 "Delete" => append_action_duration(
842 format!("Success, deleted {}", action.target),
843 action.duration_seconds,
844 ),
845 _ => append_action_duration("Success".to_string(), action.duration_seconds),
846 },
847 };
848
849 for (idx, line) in result_msg.lines().enumerate() {
850 let prefix = if idx == 0 { " ⎿ " } else { " " };
851 lines.push(Line::from(vec![
852 Span::styled(prefix, Style::new().fg(action_color)),
853 Span::styled(
854 line.to_string(),
855 Style::new().fg(theme.colors.text_secondary.to_color()),
856 ),
857 ]));
858 }
859
860 if let ActionDetails::FileContent {
862 content,
863 line_count,
864 } = &action.details
865 {
866 let preview_lines: Vec<&str> = content.lines().take(10).collect();
867 if !preview_lines.is_empty() {
868 lines.push(Line::from(vec![Span::styled(
869 " ",
870 Style::new().fg(action_color),
871 )]));
872
873 let preview_content = preview_lines.join("\n");
874 let mut parsed =
875 parse_markdown(&format!("```\n{}\n```", preview_content), theme);
876 for parsed_line in parsed.iter_mut() {
877 let mut new_spans =
878 vec![Span::styled(" ", Style::new().fg(action_color))];
879 new_spans.append(&mut parsed_line.spans);
880 parsed_line.spans = new_spans;
881 }
882 lines.extend(parsed);
883
884 if *line_count > 10 {
885 lines.push(Line::from(vec![
886 Span::styled(" ", Style::new().fg(action_color)),
887 Span::styled(
888 format!("... ({} more lines)", line_count - 10),
889 Style::new()
890 .fg(theme.colors.text_disabled.to_color())
891 .italic(),
892 ),
893 ]));
894 }
895 }
896 }
897
898 if let ActionDetails::Diff { diff, .. } = &action.details {
900 let diff_lines: Vec<&str> = diff.lines().collect();
901 let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
902
903 if !display_lines.is_empty() {
904 let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
905 let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
906
907 for diff_line in &display_lines {
908 match parse_diff_line(diff_line) {
913 DiffLineKind::Removed => {
914 let text = format!(" {}", diff_line);
915 let padded =
916 format!("{:<width$}", text, width = viewport_width);
917 lines.push(Line::from(vec![Span::styled(
918 padded,
919 Style::new()
920 .fg(theme.colors.error.to_color())
921 .bg(removed_bg),
922 )]));
923 },
924 DiffLineKind::Added => {
925 let text = format!(" {}", diff_line);
926 let padded =
927 format!("{:<width$}", text, width = viewport_width);
928 lines.push(Line::from(vec![Span::styled(
929 padded,
930 Style::new()
931 .fg(theme.colors.success.to_color())
932 .bg(added_bg),
933 )]));
934 },
935 DiffLineKind::Context => {
936 lines.push(Line::from(vec![
937 Span::styled(" ", Style::new().fg(action_color)),
938 Span::styled(
939 diff_line.to_string(),
940 Style::new().fg(theme.colors.text_secondary.to_color()),
941 ),
942 ]));
943 },
944 }
945 }
946
947 let remaining = diff_lines.len().saturating_sub(display_lines.len());
948 if remaining > 0 {
949 lines.push(Line::from(vec![
950 Span::styled(" ", Style::new().fg(action_color)),
951 Span::styled(
952 format!("... ({} more lines)", remaining),
953 Style::new()
954 .fg(theme.colors.text_disabled.to_color())
955 .italic(),
956 ),
957 ]));
958 }
959 }
960 }
961 },
962 ActionResult::Error { error } => {
963 let error =
964 append_action_duration(format!("Error: {}", error), action.duration_seconds);
965 lines.push(Line::from(vec![
966 Span::styled(" ⎿ ", Style::new().fg(theme.colors.error.to_color())),
967 Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
968 ]));
969 },
970 }
971 }
972}
973
974fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
975 if let Some(seconds) = duration_seconds {
976 text.push_str(", took ");
977 text.push_str(&format_action_duration(seconds));
978 }
979 text
980}
981
982fn format_action_duration(seconds: f64) -> String {
983 if seconds < 1.0 {
984 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
985 } else if seconds < 10.0 {
986 format!("{:.1}s", seconds)
987 } else {
988 format!("{}s", seconds.round() as u64)
989 }
990}
991
992fn wrap_text_with_indent(
1001 text: &str,
1002 width: usize,
1003 first_line_indent: usize,
1004 continuation_indent: usize,
1005) -> Vec<String> {
1006 let mut wrapped_lines = Vec::new();
1007
1008 for (line_idx, line) in text.lines().enumerate() {
1009 if line.is_empty() {
1010 wrapped_lines.push(String::new());
1011 continue;
1012 }
1013
1014 let current_indent = if line_idx == 0 {
1015 first_line_indent
1016 } else {
1017 continuation_indent
1018 };
1019 let available_width = width.saturating_sub(current_indent);
1020
1021 if available_width == 0 {
1022 wrapped_lines.push(" ".repeat(current_indent));
1023 continue;
1024 }
1025
1026 let words: Vec<&str> = line.split_whitespace().collect();
1027 if words.is_empty() {
1028 wrapped_lines.push(" ".repeat(current_indent));
1029 continue;
1030 }
1031
1032 let mut current_line = String::with_capacity(width);
1033 current_line.push_str(&" ".repeat(current_indent));
1034 let mut current_length = 0;
1037
1038 for (word_idx, word) in words.iter().enumerate() {
1039 let word_width = word.width();
1040
1041 if word_idx == 0 {
1042 current_line.push_str(word);
1044 current_length = word_width;
1045 } else if current_length + 1 + word_width <= available_width {
1046 current_line.push(' ');
1049 current_line.push_str(word);
1050 current_length += 1 + word_width;
1051 } else {
1052 wrapped_lines.push(current_line);
1054 current_line = String::with_capacity(width);
1055 current_line.push_str(&" ".repeat(continuation_indent));
1056 current_line.push_str(word);
1057 current_length = word_width;
1058 }
1059 }
1060
1061 if !current_line.trim().is_empty() {
1063 wrapped_lines.push(current_line);
1064 }
1065 }
1066
1067 wrapped_lines
1068}
1069
1070fn wrap_styled_line(
1073 line: Line<'static>,
1074 width: usize,
1075 continuation_indent: usize,
1076) -> Vec<Line<'static>> {
1077 let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1082
1083 if total_width <= width {
1085 return vec![line];
1086 }
1087
1088 let mut result_lines = Vec::new();
1090 let mut current_line_spans = Vec::new();
1091 let mut current_line_width = 0usize;
1092 let available_width = width.saturating_sub(continuation_indent);
1093
1094 for span in line.spans.clone() {
1095 let span_text = span.content.to_string();
1096 let span_style = span.style;
1097
1098 let words: Vec<&str> = span_text.split_whitespace().collect();
1100
1101 for (word_idx, word) in words.iter().enumerate() {
1102 let word_with_space = if word_idx > 0 || current_line_width > 0 {
1103 format!(" {}", word)
1104 } else {
1105 word.to_string()
1106 };
1107
1108 let word_width = word_with_space.width();
1109
1110 if current_line_width == 0 && result_lines.is_empty() {
1111 current_line_spans.push(Span::styled(word_with_space, span_style));
1113 current_line_width += word_width;
1114 } else if current_line_width + word_width <= available_width {
1115 current_line_spans.push(Span::styled(word_with_space, span_style));
1117 current_line_width += word_width;
1118 } else {
1119 result_lines.push(Line::from(current_line_spans));
1121 current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1122 current_line_spans.push(Span::styled(word.to_string(), span_style));
1123 current_line_width = word.width();
1124 }
1125 }
1126 }
1127
1128 if !current_line_spans.is_empty() {
1130 result_lines.push(Line::from(current_line_spans));
1131 }
1132
1133 if result_lines.is_empty() {
1134 vec![line]
1135 } else {
1136 result_lines
1137 }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143
1144 #[test]
1145 fn byte_at_cell_clamps_and_respects_cjk() {
1146 assert_eq!(byte_at_cell("hello", 0), 0);
1147 assert_eq!(byte_at_cell("hello", 3), 3);
1148 assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
1151 assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
1154 }
1155
1156 #[test]
1157 fn slice_by_cells_extracts_display_range() {
1158 assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1159 assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1160 assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1161 }
1162
1163 #[test]
1164 fn wrap_preformatted_hard_wraps_preserving_spaces() {
1165 let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
1168 let wrapped = wrap_preformatted(line, 10, 2);
1169 assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1170 let first: String = wrapped[0]
1171 .spans
1172 .iter()
1173 .map(|s| s.content.as_ref())
1174 .collect();
1175 assert!(
1176 first.starts_with(" aaaa"),
1177 "indentation must be preserved, got {first:?}"
1178 );
1179 let second: String = wrapped[1]
1180 .spans
1181 .iter()
1182 .map(|s| s.content.as_ref())
1183 .collect();
1184 assert!(
1185 second.starts_with(" "),
1186 "continuation should get the hanging indent, got {second:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn wrap_preformatted_short_line_unchanged() {
1192 let line = Line::from(vec![Span::raw(" short")]);
1193 let wrapped = wrap_preformatted(line, 40, 2);
1194 assert_eq!(wrapped.len(), 1);
1195 let text: String = wrapped[0]
1196 .spans
1197 .iter()
1198 .map(|s| s.content.as_ref())
1199 .collect();
1200 assert_eq!(text, " short");
1201 }
1202
1203 fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1207 let mut st = ChatState::new();
1208 st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1209 st.selection = Some(sel);
1210 st
1211 }
1212
1213 #[test]
1214 fn selected_text_single_line() {
1215 let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1216 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1217 }
1218
1219 #[test]
1220 fn selected_text_spans_multiple_rows() {
1221 let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
1222 assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1225 }
1226
1227 #[test]
1228 fn selected_text_strips_margin_but_keeps_code_indentation() {
1229 let st = state_with_rows(
1232 &[" fn main() {", " let x = 1;", " }"],
1233 ((0, 0), (2, 3)),
1234 );
1235 assert_eq!(
1236 st.selected_text().as_deref(),
1237 Some("fn main() {\n let x = 1;\n}")
1238 );
1239 }
1240
1241 #[test]
1242 fn selected_text_normalizes_reversed_drag() {
1243 let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1245 assert_eq!(st.selected_text().as_deref(), Some("hello"));
1246 }
1247
1248 #[test]
1249 fn selected_text_empty_selection_is_none() {
1250 let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1252 assert_eq!(st.selected_text(), None);
1253 }
1254
1255 #[test]
1256 fn highlight_line_cells_splits_spans_on_selection() {
1257 let mut line = Line::from(vec![Span::raw("abcdef")]);
1258 highlight_line_cells(
1259 &mut line,
1260 2,
1261 4,
1262 Style::new().add_modifier(Modifier::REVERSED),
1263 );
1264 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1266 assert_eq!(texts, vec!["ab", "cd", "ef"]);
1267 assert!(
1268 line.spans[1]
1269 .style
1270 .add_modifier
1271 .contains(Modifier::REVERSED)
1272 );
1273 assert!(
1274 !line.spans[0]
1275 .style
1276 .add_modifier
1277 .contains(Modifier::REVERSED)
1278 );
1279 }
1280
1281 #[test]
1282 fn context_checkpoint_renders_as_compact_event() {
1283 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1284 msg.kind = ChatMessageKind::ContextCheckpoint;
1285 msg.metadata = Some(serde_json::json!({
1286 "trigger": "manual",
1287 "before_tokens": 43_800,
1288 "after_tokens": 9_200,
1289 "archived_message_count": 18,
1290 "preserved_message_count": 4,
1291 "duration_secs": 2.4,
1292 "verified": true,
1293 }));
1294
1295 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1296 let rendered = lines
1297 .iter()
1298 .map(|line| {
1299 line.spans
1300 .iter()
1301 .map(|span| span.content.as_ref())
1302 .collect::<String>()
1303 })
1304 .collect::<Vec<_>>()
1305 .join("\n");
1306
1307 assert!(rendered.contains("Compact(manual)"));
1308 assert!(rendered.contains("43.8k -> 9.2k tokens"));
1309 assert!(rendered.contains("archived 18 messages"));
1310 assert!(rendered.contains("preserved 4 messages"));
1311 assert!(rendered.contains("verified"));
1312 assert!(!rendered.contains("full checkpoint summary"));
1313 }
1314
1315 #[test]
1316 fn context_checkpoint_renders_verification_fallback() {
1317 let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1318 msg.kind = ChatMessageKind::ContextCheckpoint;
1319 msg.metadata = Some(serde_json::json!({
1320 "trigger": "auto_threshold",
1321 "before_tokens": 43_800,
1322 "after_tokens": 9_200,
1323 "archived_message_count": 18,
1324 "preserved_message_count": 4,
1325 "duration_secs": 2.4,
1326 "verified": false,
1327 "verification_error": "provider overloaded",
1328 }));
1329
1330 let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1331 let rendered = lines
1332 .iter()
1333 .map(|line| {
1334 line.spans
1335 .iter()
1336 .map(|span| span.content.as_ref())
1337 .collect::<String>()
1338 })
1339 .collect::<Vec<_>>()
1340 .join("\n");
1341
1342 assert!(rendered.contains("Compact(auto_threshold)"));
1343 assert!(rendered.contains("draft fallback"));
1344 assert!(rendered.contains("verification: provider overloaded"));
1345 }
1346
1347 #[test]
1353 fn wrap_styled_line_uses_display_width_for_cjk() {
1354 let line = Line::from(Span::raw("你好世界".to_string()));
1358 let wrapped = wrap_styled_line(line, 10, 2);
1359 assert_eq!(
1360 wrapped.len(),
1361 1,
1362 "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1363 wrapped.len()
1364 );
1365 }
1366
1367 #[test]
1370 fn wrap_styled_line_ascii_wraps_when_too_long() {
1371 let line = Line::from(Span::raw(
1372 "the quick brown fox jumps over the lazy dog".to_string(),
1373 ));
1374 let wrapped = wrap_styled_line(line, 15, 2);
1375 assert!(
1376 wrapped.len() >= 2,
1377 "long ASCII input should wrap to multiple lines; got {}",
1378 wrapped.len()
1379 );
1380 }
1381
1382 #[test]
1388 fn wrap_text_with_indent_uses_display_width_for_cjk() {
1389 let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
1392 assert_eq!(
1393 wrapped.len(),
1394 1,
1395 "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
1396 wrapped.len(),
1397 wrapped
1398 );
1399 assert_eq!(wrapped[0].trim_start(), "你好世界");
1400 }
1401
1402 #[test]
1405 fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
1406 let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
1410 assert!(
1411 wrapped.len() >= 2,
1412 "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
1413 wrapped.len(),
1414 wrapped
1415 );
1416 }
1417}