Skip to main content

mermaid_cli/render/widgets/
chat.rs

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/// Entry in the click map: maps a content line to an image in chat history
24#[derive(Debug, Clone)]
25pub struct ImageClickTarget {
26    /// Index into session_state.messages
27    pub message_index: usize,
28    /// Index into that message's images vec
29    pub image_index: usize,
30}
31
32/// State for the chat widget
33#[derive(Debug, Clone)]
34pub struct ChatState {
35    /// Manual scroll offset (only used when is_user_scrolling = true)
36    scroll_offset: u16,
37    /// Whether user is manually scrolling (not following bottom)
38    is_user_scrolling: bool,
39    /// Click map: content line number → image target (rebuilt every render)
40    pub image_click_map: Vec<(u16, ImageClickTarget)>,
41    /// Scroll position used in last render (for coordinate mapping)
42    pub last_scroll_position: u16,
43    /// Chat area rect from last render
44    pub last_chat_area: Option<(u16, u16, u16, u16)>, // (x, y, width, height)
45    /// Active drag-selection in CONTENT coordinates: `(anchor, cursor)` where
46    /// each is `(content_line, col_cells)`. Highlight + copy derive from it.
47    selection: Option<((usize, usize), (usize, usize))>,
48    /// Plain text of each rendered content row, captured every frame so the
49    /// selection can be extracted by display-cell range. Indexed by content
50    /// line (the same index the selection uses).
51    last_rendered_rows: Vec<String>,
52}
53
54impl ChatState {
55    /// Create a new chat state (starts in auto-follow mode)
56    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    /// Get the scroll position for rendering
69    /// scroll_offset represents distance from bottom, convert to ratatui scroll position
70    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            // Manual scroll: convert "distance from bottom" to scroll position
74            // scroll_offset=0 → show bottom (max_scroll), scroll_offset=max → show top (0)
75            let capped_offset = self.scroll_offset.min(max_scroll);
76            max_scroll.saturating_sub(capped_offset)
77        } else {
78            // Auto-scroll: show bottom of content
79            max_scroll
80        }
81    }
82
83    /// Scroll viewport up (shows older messages further from bottom)
84    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        // A selection's content-line anchors don't track scrolling; drop it
88        // rather than leave a highlight stranded on the wrong rows.
89        self.selection = None;
90    }
91
92    /// Scroll viewport down (shows newer messages closer to bottom)
93    /// Automatically resumes auto-scroll when reaching the bottom
94    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            // Reached bottom — resume auto-follow mode
98            self.is_user_scrolling = false;
99        }
100        self.selection = None;
101    }
102
103    /// Force resume auto-scroll mode (jump to bottom)
104    pub fn resume_auto_scroll(&mut self) {
105        self.is_user_scrolling = false;
106        self.scroll_offset = 0;
107    }
108
109    /// Check if user is manually scrolling (not following bottom)
110    pub fn is_manually_scrolling(&self) -> bool {
111        self.is_user_scrolling
112    }
113
114    /// Find an image click target at the given screen coordinates.
115    /// Returns Some((message_index, image_index)) if an image indicator was clicked.
116    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        // Check if click is within chat area
120        if screen_row < area_y || screen_row >= area_y + area_height {
121            return None;
122        }
123
124        // Convert screen row to content line
125        let viewport_row = screen_row - area_y;
126        let content_line = viewport_row + self.last_scroll_position;
127
128        // Look up in click map
129        self.image_click_map
130            .iter()
131            .find(|(line, _)| *line == content_line)
132            .map(|(_, target)| target)
133    }
134
135    /// Map a screen `(row, col)` to content `(line, col_cells)`, or `None`
136    /// when the point is outside the chat area. `col` is clamped to the chat
137    /// area's left edge so a drag past the gutter still maps to column 0.
138    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    /// Begin a drag selection at the given screen position (mouse-down).
149    /// Anchors and cursor both start here; a plain click with no drag selects
150    /// nothing.
151    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    /// Extend the in-progress selection to the given screen position (drag).
158    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    /// Drop any active selection (and its highlight).
167    pub fn clear_selection(&mut self) {
168        self.selection = None;
169    }
170
171    /// Extract the currently-selected text from the last rendered frame, or
172    /// `None` if there's no selection or it's empty (e.g. a plain click).
173    /// Walks the retained per-row text and slices each row by display cells so
174    /// CJK / wide glyphs are never split mid-cell.
175    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            // Drop the rendered left margin (the "● "/"  " role/continuation
196            // prefix — up to SELECT_MARGIN_CELLS cells of spaces) so copied
197            // text is clean. Only spaces inside the margin zone [c0, MARGIN)
198            // are removed, so a code line's own indentation is preserved.
199            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
213/// Display-cell width of the role/continuation left margin ("● " or "  ")
214/// that the renderer prepends to chat content lines. Stripped from copied
215/// selections so the clipboard gets clean text.
216const SELECT_MARGIN_CELLS: usize = 2;
217
218/// Hard-wrap a pre-formatted (code) line at `width` display cells, preserving
219/// every glyph (including whitespace) and each span's style. Continuation rows
220/// get a `indent`-space hanging indent. Unlike `wrap_styled_line` this never
221/// collapses runs of spaces, so code indentation and alignment survive.
222fn 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            // Break before this char if it would overflow and the current row
243            // already holds real content (beyond the continuation indent).
244            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
271/// Byte offset in `s` at the start of display-cell `target` (clamped to
272/// `s.len()`). A wide glyph straddling `target` is kept whole on the right
273/// side, so slicing never lands mid-character.
274fn 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
288/// Slice `s` to the display-cell range `[c0, c1)`.
289fn 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
295/// The plain text of a rendered line (spans concatenated, styles dropped).
296fn line_plain_text(line: &Line) -> String {
297    line.spans.iter().map(|s| s.content.as_ref()).collect()
298}
299
300/// Apply `hl` (merged onto each span's existing style) to display cells
301/// `[c0, c1)` of `line`, splitting spans at the selection boundaries so the
302/// highlight lands on exactly the selected glyphs.
303fn 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); // no overlap with the selection
315            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
338/// Props for ChatWidget
339pub struct ChatWidget<'a> {
340    pub messages: &'a [ChatMessage],
341    pub theme: &'a Theme,
342    /// Shared markdown parse cache: content hash → parsed lines.
343    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        // Code-block lines are tagged with this background; computed once so
354        // the per-message render loop and the markdown cache key can use it.
355        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        // Reserve the rightmost column as a scrollbar gutter so the bar never
365        // paints over text. All content wrapping/rendering uses `content_area`.
366        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        // Clear click map for this render pass
374        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            // Skip Tool messages - they're internal to the agent loop and their
381            // content is already displayed inline in the assistant's action blocks
382            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                // Render thinking block if present
403                if let Some(ref thinking) = msg.thinking {
404                    // Skip rendering if thinking content is empty or literal "None"
405                    let thinking_trimmed = thinking.trim();
406                    if thinking_trimmed.is_empty()
407                        || thinking_trimmed == "None"
408                        || thinking_trimmed == "none"
409                    {
410                        // Don't render empty/null thinking blocks
411                    } else if self.show_reasoning {
412                        // Add "Thinking..." header in italic and dimmed with grayed white dot
413                        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                        // Render thinking content with proper wrapping (2-space hanging indent)
425                        let wrapped = wrap_text_with_indent(
426                            thinking,
427                            content_width as usize,
428                            2, // first line indent (2 spaces)
429                            2, // continuation indent (2 spaces)
430                        );
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                        // Add blank line after thinking block
442                        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                        // Always separate the placeholder from whatever follows
461                        // (tool actions or the visible answer) with one blank
462                        // line — the same gap every other block gets. Without
463                        // this, a "thought, then called a tool" turn (empty
464                        // content + actions) rendered the placeholder flush
465                        // against the first "● Bash(…)" line.
466                        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                // With tool calling, message content is just text (no embedded action blocks)
476                // Use cached parsed markdown when available (avoids re-parsing
477                // every frame). The theme is folded into the key so a theme
478                // switch can't serve stale-colored cached lines.
479                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                    // Code-block lines are tagged with the code background on
497                    // their base style (see markdown::parse_markdown). They're
498                    // pre-formatted: don't word-wrap (that collapses
499                    // indentation) — let the Paragraph clip overflow instead.
500                    let preformatted = parsed_line.style.bg == Some(code_bg);
501                    let base_style = parsed_line.style;
502
503                    // Add role indicator to first line or 2-space margin to others.
504                    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                        // Code: hard-wrap preserving indentation (don't
517                        // word-collapse) so wide lines stay readable.
518                        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                // Render all actions at the end of the message
525                if !msg.actions.is_empty() {
526                    // Add blank line between text content and actions
527                    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                // For User messages: format timestamp and display on right edge
534                let formatted_timestamp = format_relative_timestamp(msg.timestamp);
535                let timestamp_len = formatted_timestamp.len();
536                let min_gap = 3; // minimum spaces between text and timestamp
537
538                // Content is clean — timestamps are injected at API call time only
539                let cleaned_content = &msg.content;
540
541                // Reserve space on the first line for role prefix + gap + timestamp
542                // so text wraps early enough to not overlap the timestamp
543                let role_prefix_width = role_prefix.len() + 1; // "You " = prefix + space
544                let first_line_reserved = role_prefix_width + min_gap + timestamp_len;
545
546                // Manually wrap the user message with hanging indent (2 spaces)
547                let wrapped = wrap_text_with_indent(
548                    cleaned_content,
549                    content_width as usize,
550                    first_line_reserved, // reserve space for prefix + gap + timestamp on first line
551                    2,                   // continuation indent
552                );
553
554                for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
555                    if line_idx == 0 {
556                        // First line: add role prefix and timestamp on right
557                        let text_content = wrapped_line.trim_start(); // Remove the indent we added
558                        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                        // Always add at least min_gap spaces, plus any extra from word-boundary slack.
569                        // Align the timestamp to the content right edge (before the scrollbar gutter).
570                        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                        // Continuation lines: already have 2-space margin from wrap_text_with_indent
582                        lines.push(Line::from(wrapped_line.clone()));
583                    }
584                }
585            }
586
587            // Show image indicators under user and assistant messages.
588            // User images come from clipboard paste (`Attachment`); assistant
589            // images come from tool executions that emitted `ProgressEvent::
590            // Artifact` during their run — screenshot captures, inline
591            // previews from computer-use, etc. Both land in `msg.images` as
592            // base64 strings and render the same way.
593            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                    // Record this line in the click map before pushing
599                    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        // NOTE: The response buffer is NOT rendered during streaming (buffering mode).
621        // The response is buffered invisibly and only shown when generation is complete.
622        // This provides a Claude Code-like experience where the complete response
623        // appears instantly instead of streaming character-by-character.
624        //
625        // The status line shows progress: "↑ Sending..." → "↓ Streaming..." with timer
626
627        // Capture the plain text of each rendered row for selection
628        // extraction. Done before the highlight pass, which only changes
629        // styling, not text.
630        state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
631
632        // Paint the active drag selection (reverse video over the selected
633        // cells). Selection lines are content indices, matching `lines`.
634        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        // NOTE: Wrapping is disabled because we handle it manually with hanging indents
655        // Calculate content height and viewport for proper scroll clamping
656        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        // Scrollbar in the reserved gutter — only when the transcript actually
669        // overflows the viewport. Uses ratatui's 0.30 Scrollbar widget.
670        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
795/// Render actions in Claude Code style
796fn 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        // Header: ● Type(target)
813        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                // Result summary from details enum
829                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                // Write: syntax-highlighted file preview
861                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                // Edit: color-coded diff
899                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                            // Delegate the producer-format awareness to
909                            // `parse_diff_line`, which lives next to the
910                            // marker constants and stays in lockstep with
911                            // any future format change.
912                            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
992/// Wrap text with hanging indent support.
993///
994/// `width`, `first_line_indent`, and `continuation_indent` are all measured
995/// in **display cells**, not bytes. Word lengths are also measured in cells
996/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
997/// previously a CJK paragraph would wrap after ~1/3 of the line because
998/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
999/// codepoints.
1000fn 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        // Display-cell widths: indent is ASCII spaces (1 cell each), so
1035        // start fresh and let words contribute their own cell widths.
1036        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                // First word always fits on the line
1043                current_line.push_str(word);
1044                current_length = word_width;
1045            } else if current_length + 1 + word_width <= available_width {
1046                // Word fits on current line (the +1 accounts for the
1047                // separator space, which is 1 cell)
1048                current_line.push(' ');
1049                current_line.push_str(word);
1050                current_length += 1 + word_width;
1051            } else {
1052                // Word doesn't fit, start a new line
1053                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        // Add the last line
1062        if !current_line.trim().is_empty() {
1063            wrapped_lines.push(current_line);
1064        }
1065    }
1066
1067    wrapped_lines
1068}
1069
1070/// Wrap a styled Line with hanging indent, preserving all span styles
1071/// Returns multiple Line objects with proper indentation
1072fn wrap_styled_line(
1073    line: Line<'static>,
1074    width: usize,
1075    continuation_indent: usize,
1076) -> Vec<Line<'static>> {
1077    // Widths are counted in display cells (via `UnicodeWidthStr`), not
1078    // bytes. This makes CJK double-width chars and emoji wrap at the
1079    // correct visual column, and avoids over-wrapping multi-byte ASCII-
1080    // looking glyphs.
1081    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1082
1083    // If the line fits within width, return as-is
1084    if total_width <= width {
1085        return vec![line];
1086    }
1087
1088    // Line needs wrapping - extract all text and styles
1089    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        // Split span text by words
1099        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                // First word of first line - no indent
1112                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                // Word fits on current line
1116                current_line_spans.push(Span::styled(word_with_space, span_style));
1117                current_line_width += word_width;
1118            } else {
1119                // Word doesn't fit - finish current line and start new one
1120                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    // Add the last line if it has content
1129    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); // clamp past end
1149        // "你好" = 2 chars, 3 bytes each, 2 cells each.
1150        assert_eq!(byte_at_cell("你好", 0), 0);
1151        assert_eq!(byte_at_cell("你好", 2), 3); // after first wide char
1152        // A cell index that lands mid-glyph keeps the glyph whole (rounds up).
1153        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        // 18 cells, wraps at 10. Spaces are preserved (not collapsed) and the
1166        // leading indentation survives on the first row.
1167        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    /// Build a ChatState whose last frame rendered `rows`, with a selection
1204    /// already mapped to content coords, so `selected_text` can be tested
1205    /// without a real terminal.
1206    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        // The continuation row's "  " margin is stripped so copied text is
1223        // clean (the start row was sliced from the click column past "> ").
1224        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        // Rendered rows: 2-cell margin + the code's own indentation. Selecting
1230        // from column 0 must drop only the 2-cell margin, not the code indent.
1231        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        // Dragging bottom-up / right-to-left yields the same text.
1244        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        // A plain click (anchor == cursor) selects nothing.
1251        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        // Split into "ab" | "cd"(reversed) | "ef".
1265        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    /// CJK characters are 3 bytes but 2 display cells each. The
1348    /// byte-length version of `wrap_styled_line` would incorrectly
1349    /// over-wrap such input. This test asserts the display-width
1350    /// version keeps CJK-only input on a single line when the display
1351    /// width fits, even when the byte length exceeds the width.
1352    #[test]
1353    fn wrap_styled_line_uses_display_width_for_cjk() {
1354        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
1355        // Target width of 10: byte-length would see 12 > 10 and wrap;
1356        // display-width sees 8 <= 10 and keeps it on one line.
1357        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    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
1368    /// the input exceeds the width.
1369    #[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    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
1383    /// the plain-string wrapper used by user messages and thinking blocks.
1384    /// The byte-based version would wrap a 4-CJK paragraph after the second
1385    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
1386    /// version keeps it on one line.
1387    #[test]
1388    fn wrap_text_with_indent_uses_display_width_for_cjk() {
1389        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
1390        // with 0 indent: should fit on one line.
1391        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    /// Mixed content: CJK + ASCII should still wrap correctly when the
1403    /// total exceeds available cells.
1404    #[test]
1405    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
1406        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
1407        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
1408        // produce ≥ 2 lines.
1409        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}