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/// Pad `s` on the right with spaces until it spans `cells` display columns,
296/// measured with `UnicodeWidthStr::width` (not chars/bytes) so a CJK/emoji row's
297/// background bar fills to the true visual edge instead of falling short (#101).
298/// Never truncates — an already-too-wide `s` is returned unchanged.
299fn pad_to_cells(s: &str, cells: usize) -> String {
300    let w = s.width();
301    if w >= cells {
302        return s.to_string();
303    }
304    let mut out = String::with_capacity(s.len() + (cells - w));
305    out.push_str(s);
306    out.push_str(&" ".repeat(cells - w));
307    out
308}
309
310/// First-line spacing for a user message: the run of spaces before the
311/// right-aligned timestamp. All inputs are display-cell widths so CJK/emoji
312/// align correctly (#104). Returns `min_gap` plus whatever slack remains to
313/// push the timestamp to `content_width`'s right edge.
314fn user_timestamp_padding(
315    role_prefix_width: usize,
316    text_width: usize,
317    timestamp_width: usize,
318    min_gap: usize,
319    content_width: usize,
320) -> usize {
321    let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
322    min_gap + content_width.saturating_sub(total_used)
323}
324
325/// The plain text of a rendered line (spans concatenated, styles dropped).
326fn line_plain_text(line: &Line) -> String {
327    line.spans.iter().map(|s| s.content.as_ref()).collect()
328}
329
330/// Apply `hl` (merged onto each span's existing style) to display cells
331/// `[c0, c1)` of `line`, splitting spans at the selection boundaries so the
332/// highlight lands on exactly the selected glyphs.
333fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
334    let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
335    let mut width = 0usize;
336    for span in line.spans.drain(..) {
337        let span_w = span.content.width();
338        let (span_start, span_end) = (width, width + span_w);
339        width = span_end;
340
341        let ov0 = c0.max(span_start);
342        let ov1 = c1.min(span_end);
343        if ov1 <= ov0 {
344            new_spans.push(span); // no overlap with the selection
345            continue;
346        }
347
348        let s = span.content.as_ref();
349        let b0 = byte_at_cell(s, ov0 - span_start);
350        let b1 = byte_at_cell(s, ov1 - span_start);
351        if b0 > 0 {
352            new_spans.push(Span::styled(s[..b0].to_string(), span.style));
353        }
354        new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
355        if b1 < s.len() {
356            new_spans.push(Span::styled(s[b1..].to_string(), span.style));
357        }
358    }
359    line.spans = new_spans;
360}
361
362impl Default for ChatState {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368/// Props for ChatWidget
369pub struct ChatWidget<'a> {
370    pub messages: &'a [ChatMessage],
371    pub theme: &'a Theme,
372    /// Shared markdown parse cache: content hash → parsed lines.
373    pub markdown_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
374    pub show_reasoning: bool,
375}
376
377impl<'a> StatefulWidget for ChatWidget<'a> {
378    type State = ChatState;
379
380    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
381        let mut lines: Vec<Line<'static>> = Vec::new();
382
383        // Code-block lines are tagged with this background; computed once so
384        // the per-message render loop and the markdown cache key can use it.
385        let code_bg = self.theme.colors.code_background.to_color();
386        let theme_seed = {
387            let mut h = rustc_hash::FxHasher::default();
388            self.theme.colors.foreground.to_color().hash(&mut h);
389            code_bg.hash(&mut h);
390            self.theme.colors.header.to_color().hash(&mut h);
391            h.finish()
392        };
393
394        // Reserve the rightmost column as a scrollbar gutter so the bar never
395        // paints over text. All content wrapping/rendering uses `content_area`.
396        let gutter: u16 = if area.width > 4 { 1 } else { 0 };
397        let content_width = area.width.saturating_sub(gutter);
398        let content_area = Rect {
399            width: content_width,
400            ..area
401        };
402
403        // Clear click map for this render pass
404        state.image_click_map.clear();
405        state.last_chat_area = Some((area.x, area.y, area.width, area.height));
406
407        let mut hidden_reasoning_notice_shown = false;
408
409        for (idx, msg) in self.messages.iter().enumerate() {
410            // Skip Tool messages - they're internal to the agent loop and their
411            // content is already displayed inline in the assistant's action blocks
412            if matches!(msg.role, MessageRole::Tool) {
413                continue;
414            }
415
416            if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
417                if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
418                    lines.extend(event_lines);
419                    lines.push(Line::from(""));
420                }
421                continue;
422            }
423
424            let (role_prefix, role_color) = match msg.role {
425                MessageRole::User => (">", ratatui::style::Color::White),
426                MessageRole::Assistant => ("●", ratatui::style::Color::White),
427                MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
428                MessageRole::Tool => unreachable!("Tool messages filtered above"),
429            };
430
431            if matches!(msg.role, MessageRole::Assistant) {
432                // Render thinking block if present
433                if let Some(ref thinking) = msg.thinking {
434                    // Skip rendering if thinking content is empty or literal "None"
435                    let thinking_trimmed = thinking.trim();
436                    if thinking_trimmed.is_empty()
437                        || thinking_trimmed == "None"
438                        || thinking_trimmed == "none"
439                    {
440                        // Don't render empty/null thinking blocks
441                    } else if self.show_reasoning {
442                        // Add "Thinking..." header in italic and dimmed with grayed white dot
443                        lines.push(Line::from(vec![
444                            Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
445                            Span::styled(
446                                "Thinking...",
447                                Style::new()
448                                    .fg(self.theme.colors.text_secondary.to_color())
449                                    .italic()
450                                    .dim(),
451                            ),
452                        ]));
453
454                        // Render thinking content with proper wrapping (2-space hanging indent)
455                        let wrapped = wrap_text_with_indent(
456                            thinking,
457                            content_width as usize,
458                            2, // first line indent (2 spaces)
459                            2, // continuation indent (2 spaces)
460                        );
461                        for wrapped_line in wrapped {
462                            lines.push(Line::from(Span::styled(
463                                wrapped_line,
464                                Style::new()
465                                    .fg(self.theme.colors.text_secondary.to_color())
466                                    .italic()
467                                    .dim(),
468                            )));
469                        }
470
471                        // Add blank line after thinking block
472                        lines.push(Line::from(""));
473                    } else if !hidden_reasoning_notice_shown {
474                        hidden_reasoning_notice_shown = true;
475                        let marker = if msg.content.trim().is_empty() && msg.actions.is_empty() {
476                            "Reasoning-only response hidden (/visible-reasoning on to show)"
477                        } else {
478                            "Reasoning hidden (/visible-reasoning on to show)"
479                        };
480                        lines.push(Line::from(vec![
481                            Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
482                            Span::styled(
483                                marker,
484                                Style::new()
485                                    .fg(self.theme.colors.text_secondary.to_color())
486                                    .italic()
487                                    .dim(),
488                            ),
489                        ]));
490                        // Always separate the placeholder from whatever follows
491                        // (tool actions or the visible answer) with one blank
492                        // line — the same gap every other block gets. Without
493                        // this, a "thought, then called a tool" turn (empty
494                        // content + actions) rendered the placeholder flush
495                        // against the first "● Bash(…)" line.
496                        lines.push(Line::from(""));
497                        if msg.content.trim().is_empty() && msg.actions.is_empty() {
498                            continue;
499                        }
500                    } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
501                        continue;
502                    }
503                }
504
505                // With tool calling, message content is just text (no embedded action blocks)
506                // Use cached parsed markdown when available (avoids re-parsing
507                // every frame). The theme is folded into the key so a theme
508                // switch can't serve stale-colored cached lines.
509                let mut hasher = rustc_hash::FxHasher::default();
510                msg.content.hash(&mut hasher);
511                theme_seed.hash(&mut hasher);
512                let cache_key = hasher.finish();
513                let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
514                    cached.clone()
515                } else {
516                    let parsed = parse_markdown(&msg.content, self.theme);
517                    self.markdown_cache.insert(cache_key, parsed.clone());
518                    if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
519                        self.markdown_cache.clear();
520                        self.markdown_cache.insert(cache_key, parsed.clone());
521                    }
522                    parsed
523                };
524
525                for (line_idx, parsed_line) in parsed_lines.into_iter().enumerate() {
526                    // Code-block lines are tagged with the code background on
527                    // their base style (see markdown::parse_markdown). They're
528                    // pre-formatted: don't word-wrap (that collapses
529                    // indentation) — let the Paragraph clip overflow instead.
530                    let preformatted = parsed_line.style.bg == Some(code_bg);
531                    let base_style = parsed_line.style;
532
533                    // Continuation indent for wrapping: the 2-cell message gutter
534                    // every line carries, plus this line's own content-start column
535                    // so a wrapped list item's continuations hang under its text
536                    // (after the marker) instead of snapping back to the gutter.
537                    let continuation = if preformatted {
538                        2
539                    } else {
540                        2 + crate::render::markdown::line_hanging_indent(&parsed_line, self.theme)
541                    };
542
543                    // Add role indicator to first line or 2-space margin to others.
544                    let mut spans = if line_idx == 0 {
545                        vec![Span::styled(
546                            format!("{} ", role_prefix),
547                            Style::new().fg(role_color).bold(),
548                        )]
549                    } else {
550                        vec![Span::raw("  ")]
551                    };
552                    spans.extend(parsed_line.spans);
553                    let new_line = Line::from(spans).style(base_style);
554
555                    if preformatted {
556                        // Code: hard-wrap preserving indentation (don't
557                        // word-collapse) so wide lines stay readable.
558                        lines.extend(wrap_preformatted(new_line, content_width as usize, 2));
559                    } else {
560                        lines.extend(wrap_styled_line(
561                            new_line,
562                            content_width as usize,
563                            continuation,
564                        ));
565                    }
566                }
567
568                // Render all actions at the end of the message
569                if !msg.actions.is_empty() {
570                    // Add blank line between text content and actions
571                    if !msg.content.trim().is_empty() {
572                        lines.push(Line::from(""));
573                    }
574                    render_actions(&msg.actions, &mut lines, self.theme, content_width as usize);
575                }
576            } else {
577                // For User messages: format timestamp and display on right edge
578                let formatted_timestamp = format_relative_timestamp(msg.timestamp);
579                // Display cells, not bytes — a CJK/emoji timestamp (or message)
580                // would otherwise mis-reserve space and push the right-aligned
581                // timestamp off its column (#104).
582                let timestamp_width = formatted_timestamp.width();
583                let min_gap = 3; // minimum spaces between text and timestamp
584
585                // Content is clean — timestamps are injected at API call time only
586                let cleaned_content = &msg.content;
587
588                // Reserve space on the first line for role prefix + gap + timestamp
589                // so text wraps early enough to not overlap the timestamp
590                let role_prefix_width = role_prefix.width() + 1; // "You " = prefix + space
591                let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
592
593                // Manually wrap the user message with hanging indent (2 spaces)
594                let wrapped = wrap_text_with_indent(
595                    cleaned_content,
596                    content_width as usize,
597                    first_line_reserved, // reserve space for prefix + gap + timestamp on first line
598                    2,                   // continuation indent
599                );
600
601                for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
602                    if line_idx == 0 {
603                        // First line: add role prefix and timestamp on right
604                        let text_content = wrapped_line.trim_start(); // Remove the indent we added
605                        let text_width = text_content.width();
606
607                        let mut spans = vec![
608                            Span::styled(
609                                format!("{} ", role_prefix),
610                                Style::new().fg(role_color).bold(),
611                            ),
612                            Span::raw(text_content.to_string()),
613                        ];
614
615                        // Always add at least min_gap spaces, plus any extra from word-boundary slack.
616                        // Align the timestamp to the content right edge (before the scrollbar gutter).
617                        let pad = user_timestamp_padding(
618                            role_prefix_width,
619                            text_width,
620                            timestamp_width,
621                            min_gap,
622                            content_width as usize,
623                        );
624                        spans.push(Span::raw(" ".repeat(pad)));
625                        spans.push(Span::styled(
626                            formatted_timestamp.clone(),
627                            Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
628                        ));
629
630                        lines.push(Line::from(spans));
631                    } else {
632                        // Continuation lines: already have 2-space margin from wrap_text_with_indent
633                        lines.push(Line::from(wrapped_line.clone()));
634                    }
635                }
636            }
637
638            // Show image indicators under user and assistant messages.
639            // User images come from clipboard paste (`Attachment`); assistant
640            // images come from tool executions that emitted `ProgressEvent::
641            // Artifact` during their run — screenshot captures, inline
642            // previews from computer-use, etc. Both land in `msg.images` as
643            // base64 strings and render the same way.
644            if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
645                && let Some(ref images) = msg.images
646                && !images.is_empty()
647            {
648                for (i, _) in images.iter().enumerate() {
649                    // Record this line in the click map before pushing
650                    let content_line = lines.len() as u16;
651                    state.image_click_map.push((
652                        content_line,
653                        ImageClickTarget {
654                            message_index: idx,
655                            image_index: i,
656                        },
657                    ));
658                    lines.push(Line::from(vec![
659                        Span::styled("  ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
660                        Span::styled(
661                            format!("[Image #{}]", i + 1),
662                            Style::new().fg(self.theme.colors.info.to_color()).italic(),
663                        ),
664                    ]));
665                }
666            }
667
668            lines.push(Line::from(""));
669        }
670
671        // NOTE: The response buffer is NOT rendered during streaming (buffering mode).
672        // The response is buffered invisibly and only shown when generation is complete.
673        // This provides a Claude Code-like experience where the complete response
674        // appears instantly instead of streaming character-by-character.
675        //
676        // The status line shows progress: "↑ Sending..." → "↓ Streaming..." with timer
677
678        // Capture the plain text of each rendered row for selection
679        // extraction. Done before the highlight pass, which only changes
680        // styling, not text.
681        state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
682
683        // Paint the active drag selection (reverse video over the selected
684        // cells). Selection lines are content indices, matching `lines`.
685        if let Some((a, b)) = state.selection
686            && !lines.is_empty()
687        {
688            let (start, end) = if a <= b { (a, b) } else { (b, a) };
689            let sel_style = Style::new().add_modifier(Modifier::REVERSED);
690            let last_line = lines.len() - 1;
691            for (line_idx, line) in lines
692                .iter_mut()
693                .enumerate()
694                .take(end.0.min(last_line) + 1)
695                .skip(start.0)
696            {
697                let c0 = if line_idx == start.0 { start.1 } else { 0 };
698                let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
699                if c1 > c0 {
700                    highlight_line_cells(line, c0, c1, sel_style);
701                }
702            }
703        }
704
705        // NOTE: Wrapping is disabled because we handle it manually with hanging indents
706        // Calculate content height and viewport for proper scroll clamping
707        let content_height = lines.len() as u16;
708        let viewport_height = area.height;
709
710        let scroll_pos = state.get_scroll_position(content_height, viewport_height);
711        state.last_scroll_position = scroll_pos;
712
713        let paragraph = Paragraph::new(lines)
714            .block(Block::default())
715            .scroll((scroll_pos, 0));
716
717        paragraph.render(content_area, buf);
718
719        // Scrollbar in the reserved gutter — only when the transcript actually
720        // overflows the viewport. Uses ratatui's 0.30 Scrollbar widget.
721        if gutter == 1 && content_height > viewport_height {
722            let mut sb_state = ScrollbarState::new(content_height as usize)
723                .viewport_content_length(viewport_height as usize)
724                .position(scroll_pos as usize);
725            Scrollbar::new(ScrollbarOrientation::VerticalRight)
726                .begin_symbol(None)
727                .end_symbol(None)
728                .thumb_style(Style::new().fg(self.theme.colors.border.to_color()))
729                .track_style(Style::new().fg(self.theme.colors.text_disabled.to_color()))
730                .render(area, buf, &mut sb_state);
731        }
732    }
733}
734
735fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
736    if !matches!(msg.role, MessageRole::User) {
737        return None;
738    }
739
740    let metadata = msg.metadata.as_ref();
741    let trigger = metadata
742        .and_then(|value| value.get("trigger"))
743        .and_then(|value| value.as_str())
744        .unwrap_or("manual");
745    let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
746    let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
747    let archived_messages =
748        metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
749    let preserved_messages =
750        metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
751    let duration_secs = metadata
752        .and_then(|value| value.get("duration_secs"))
753        .and_then(|value| value.as_f64());
754    let verified = metadata
755        .and_then(|value| value.get("verified"))
756        .and_then(|value| value.as_bool());
757    let verification_error = metadata
758        .and_then(|value| value.get("verification_error"))
759        .and_then(|value| value.as_str());
760
761    let action_color = theme.colors.info.to_color();
762    let mut result = match (before_tokens, after_tokens) {
763        (Some(before), Some(after)) => {
764            format!(
765                "Success, {} -> {} tokens",
766                format_compact_count(before),
767                format_compact_count(after)
768            )
769        },
770        _ => "Success".to_string(),
771    };
772
773    if let Some(count) = archived_messages {
774        result.push_str(&format!(
775            ", archived {} {}",
776            count,
777            if count == 1 { "message" } else { "messages" }
778        ));
779    }
780    if let Some(count) = preserved_messages {
781        result.push_str(&format!(
782            ", preserved {} {}",
783            count,
784            if count == 1 { "message" } else { "messages" }
785        ));
786    }
787    if let Some(verified) = verified {
788        if verified {
789            result.push_str(", verified");
790        } else {
791            result.push_str(", draft fallback");
792        }
793    }
794    result = append_action_duration(result, duration_secs);
795
796    let mut lines = vec![
797        Line::from(vec![
798            Span::styled("● ", Style::new().fg(action_color).bold()),
799            Span::styled("Compact(", Style::new().fg(action_color).bold()),
800            Span::styled(
801                trigger.to_string(),
802                Style::new().fg(theme.colors.text_secondary.to_color()),
803            ),
804            Span::styled(")", Style::new().fg(action_color).bold()),
805        ]),
806        Line::from(vec![
807            Span::styled("  ⎿ ", Style::new().fg(action_color)),
808            Span::styled(
809                result,
810                Style::new().fg(theme.colors.text_secondary.to_color()),
811            ),
812        ]),
813    ];
814
815    if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
816        lines.push(Line::from(vec![
817            Span::styled("    ", Style::new().fg(action_color)),
818            Span::styled(
819                format!("verification: {}", compact_inline_error(error, 180)),
820                Style::new().fg(theme.colors.warning.to_color()),
821            ),
822        ]));
823    }
824
825    Some(lines)
826}
827
828fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
829    value
830        .get(key)?
831        .as_u64()
832        .and_then(|value| usize::try_from(value).ok())
833}
834
835fn compact_inline_error(text: &str, max_chars: usize) -> String {
836    let text = text.trim();
837    if text.chars().count() <= max_chars {
838        return text.to_string();
839    }
840    let keep = max_chars.saturating_sub(3);
841    let mut out: String = text.chars().take(keep).collect();
842    out.push_str("...");
843    out
844}
845
846/// Render actions in Claude Code style
847/// Expand tab characters to spaces on 4-column tab stops.
848///
849/// Tabs paint as zero cells in the terminal buffer, so a line containing them
850/// has a char count larger than its painted width. Any width math done by char
851/// count (e.g. padding a diff line so its background bar spans the row) would
852/// then come up short by one column per tab. Expanding here keeps indentation
853/// visible and makes char count match painted width.
854fn expand_tabs(s: &str) -> String {
855    const TAB_WIDTH: usize = 4;
856    if !s.contains('\t') {
857        return s.to_string();
858    }
859    let mut out = String::with_capacity(s.len() + TAB_WIDTH);
860    let mut col = 0usize;
861    for ch in s.chars() {
862        if ch == '\t' {
863            let n = TAB_WIDTH - (col % TAB_WIDTH);
864            for _ in 0..n {
865                out.push(' ');
866            }
867            col += n;
868        } else {
869            out.push(ch);
870            col += UnicodeWidthChar::width(ch).unwrap_or(0);
871        }
872    }
873    out
874}
875
876fn render_actions(
877    actions: &[ActionDisplay],
878    lines: &mut Vec<Line>,
879    theme: &Theme,
880    viewport_width: usize,
881) {
882    for (action_idx, action) in actions.iter().enumerate() {
883        if action_idx > 0 {
884            lines.push(Line::from(""));
885        }
886        let action_color = match action.action_type.as_str() {
887            "Write" | "Edit" => theme.colors.success.to_color(),
888            "Delete" => theme.colors.warning.to_color(),
889            _ => theme.colors.info.to_color(),
890        };
891
892        // Header: ● Type(target)
893        lines.push(Line::from(vec![
894            Span::styled("● ", Style::new().fg(action_color).bold()),
895            Span::styled(
896                format!("{}(", action.action_type),
897                Style::new().fg(action_color).bold(),
898            ),
899            Span::styled(
900                action.target.clone(),
901                Style::new().fg(theme.colors.text_secondary.to_color()),
902            ),
903            Span::styled(")", Style::new().fg(action_color).bold()),
904        ]));
905
906        match &action.result {
907            ActionResult::Success { .. } => {
908                // Result summary from details enum
909                let result_msg = match &action.details {
910                    ActionDetails::FileContent { line_count, .. } => {
911                        let base = format!(
912                            "Success, {} {} written",
913                            line_count,
914                            if *line_count == 1 { "line" } else { "lines" }
915                        );
916                        append_action_duration(base, action.duration_seconds)
917                    },
918                    ActionDetails::Diff { summary, .. } => summary.clone(),
919                    ActionDetails::Preview { text, .. } => text.clone(),
920                    ActionDetails::Simple => match action.action_type.as_str() {
921                        "Delete" => append_action_duration(
922                            format!("Success, deleted {}", action.target),
923                            action.duration_seconds,
924                        ),
925                        _ => append_action_duration("Success".to_string(), action.duration_seconds),
926                    },
927                };
928
929                for (idx, line) in result_msg.lines().enumerate() {
930                    let prefix = if idx == 0 { "  ⎿ " } else { "    " };
931                    lines.push(Line::from(vec![
932                        Span::styled(prefix, Style::new().fg(action_color)),
933                        Span::styled(
934                            line.to_string(),
935                            Style::new().fg(theme.colors.text_secondary.to_color()),
936                        ),
937                    ]));
938                }
939
940                // Write: syntax-highlighted file preview
941                if let ActionDetails::FileContent {
942                    content,
943                    line_count,
944                } = &action.details
945                {
946                    let preview_lines: Vec<&str> = content.lines().take(10).collect();
947                    if !preview_lines.is_empty() {
948                        lines.push(Line::from(vec![Span::styled(
949                            "    ",
950                            Style::new().fg(action_color),
951                        )]));
952
953                        let preview_content = preview_lines.join("\n");
954                        let mut parsed =
955                            parse_markdown(&format!("```\n{}\n```", preview_content), theme);
956                        for parsed_line in parsed.iter_mut() {
957                            let mut new_spans =
958                                vec![Span::styled("    ", Style::new().fg(action_color))];
959                            new_spans.append(&mut parsed_line.spans);
960                            parsed_line.spans = new_spans;
961                        }
962                        lines.extend(parsed);
963
964                        if *line_count > 10 {
965                            lines.push(Line::from(vec![
966                                Span::styled("    ", Style::new().fg(action_color)),
967                                Span::styled(
968                                    format!("... ({} more lines)", line_count - 10),
969                                    Style::new()
970                                        .fg(theme.colors.text_disabled.to_color())
971                                        .italic(),
972                                ),
973                            ]));
974                        }
975                    }
976                }
977
978                // Edit: color-coded diff
979                if let ActionDetails::Diff { diff, .. } = &action.details {
980                    let diff_lines: Vec<&str> = diff.lines().collect();
981                    let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
982
983                    if !display_lines.is_empty() {
984                        let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
985                        let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
986
987                        for diff_line in &display_lines {
988                            // Expand tabs first: the TUI paints a tab as zero
989                            // cells, so a tab-bearing line's char count exceeds
990                            // its painted width and the char-count pad below
991                            // would leave the background bar short — a ragged
992                            // "staircase" down the right edge. Expanding also
993                            // makes tab indentation actually visible.
994                            let diff_line = expand_tabs(diff_line);
995                            // Delegate the producer-format awareness to
996                            // `parse_diff_line`, which lives next to the
997                            // marker constants and stays in lockstep with
998                            // any future format change.
999                            match parse_diff_line(&diff_line) {
1000                                DiffLineKind::Removed => {
1001                                    let text = format!("    {}", diff_line);
1002                                    let padded = pad_to_cells(&text, viewport_width);
1003                                    lines.push(Line::from(vec![Span::styled(
1004                                        padded,
1005                                        Style::new()
1006                                            .fg(theme.colors.error.to_color())
1007                                            .bg(removed_bg),
1008                                    )]));
1009                                },
1010                                DiffLineKind::Added => {
1011                                    let text = format!("    {}", diff_line);
1012                                    let padded = pad_to_cells(&text, viewport_width);
1013                                    lines.push(Line::from(vec![Span::styled(
1014                                        padded,
1015                                        Style::new()
1016                                            .fg(theme.colors.success.to_color())
1017                                            .bg(added_bg),
1018                                    )]));
1019                                },
1020                                DiffLineKind::Context => {
1021                                    lines.push(Line::from(vec![
1022                                        Span::styled("    ", Style::new().fg(action_color)),
1023                                        Span::styled(
1024                                            diff_line,
1025                                            Style::new().fg(theme.colors.text_secondary.to_color()),
1026                                        ),
1027                                    ]));
1028                                },
1029                            }
1030                        }
1031
1032                        let remaining = diff_lines.len().saturating_sub(display_lines.len());
1033                        if remaining > 0 {
1034                            lines.push(Line::from(vec![
1035                                Span::styled("    ", Style::new().fg(action_color)),
1036                                Span::styled(
1037                                    format!("... ({} more lines)", remaining),
1038                                    Style::new()
1039                                        .fg(theme.colors.text_disabled.to_color())
1040                                        .italic(),
1041                                ),
1042                            ]));
1043                        }
1044                    }
1045                }
1046            },
1047            ActionResult::Error { error } => {
1048                let error =
1049                    append_action_duration(format!("Error: {}", error), action.duration_seconds);
1050                lines.push(Line::from(vec![
1051                    Span::styled("  ⎿ ", Style::new().fg(theme.colors.error.to_color())),
1052                    Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
1053                ]));
1054            },
1055        }
1056    }
1057}
1058
1059fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1060    if let Some(seconds) = duration_seconds {
1061        text.push_str(", took ");
1062        text.push_str(&format_action_duration(seconds));
1063    }
1064    text
1065}
1066
1067fn format_action_duration(seconds: f64) -> String {
1068    if seconds < 1.0 {
1069        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1070    } else if seconds < 10.0 {
1071        format!("{:.1}s", seconds)
1072    } else {
1073        format!("{}s", seconds.round() as u64)
1074    }
1075}
1076
1077/// Wrap text with hanging indent support.
1078///
1079/// `width`, `first_line_indent`, and `continuation_indent` are all measured
1080/// in **display cells**, not bytes. Word lengths are also measured in cells
1081/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
1082/// previously a CJK paragraph would wrap after ~1/3 of the line because
1083/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
1084/// codepoints.
1085fn wrap_text_with_indent(
1086    text: &str,
1087    width: usize,
1088    first_line_indent: usize,
1089    continuation_indent: usize,
1090) -> Vec<String> {
1091    let mut wrapped_lines = Vec::new();
1092
1093    for (line_idx, line) in text.lines().enumerate() {
1094        if line.is_empty() {
1095            wrapped_lines.push(String::new());
1096            continue;
1097        }
1098
1099        let current_indent = if line_idx == 0 {
1100            first_line_indent
1101        } else {
1102            continuation_indent
1103        };
1104        let available_width = width.saturating_sub(current_indent);
1105
1106        if available_width == 0 {
1107            wrapped_lines.push(" ".repeat(current_indent));
1108            continue;
1109        }
1110
1111        let words: Vec<&str> = line.split_whitespace().collect();
1112        if words.is_empty() {
1113            wrapped_lines.push(" ".repeat(current_indent));
1114            continue;
1115        }
1116
1117        let mut current_line = String::with_capacity(width);
1118        current_line.push_str(&" ".repeat(current_indent));
1119        // Display-cell widths: indent is ASCII spaces (1 cell each), so
1120        // start fresh and let words contribute their own cell widths.
1121        let mut current_length = 0;
1122
1123        for (word_idx, word) in words.iter().enumerate() {
1124            let word_width = word.width();
1125
1126            if word_idx == 0 {
1127                // First word always fits on the line
1128                current_line.push_str(word);
1129                current_length = word_width;
1130            } else if current_length + 1 + word_width <= available_width {
1131                // Word fits on current line (the +1 accounts for the
1132                // separator space, which is 1 cell)
1133                current_line.push(' ');
1134                current_line.push_str(word);
1135                current_length += 1 + word_width;
1136            } else {
1137                // Word doesn't fit, start a new line
1138                wrapped_lines.push(current_line);
1139                current_line = String::with_capacity(width);
1140                current_line.push_str(&" ".repeat(continuation_indent));
1141                current_line.push_str(word);
1142                current_length = word_width;
1143            }
1144        }
1145
1146        // Add the last line
1147        if !current_line.trim().is_empty() {
1148            wrapped_lines.push(current_line);
1149        }
1150    }
1151
1152    wrapped_lines
1153}
1154
1155/// Wrap a styled Line with hanging indent, preserving all span styles
1156/// Returns multiple Line objects with proper indentation
1157fn wrap_styled_line(
1158    line: Line<'static>,
1159    width: usize,
1160    continuation_indent: usize,
1161) -> Vec<Line<'static>> {
1162    // Widths are counted in display cells (via `UnicodeWidthStr`), not
1163    // bytes. This makes CJK double-width chars and emoji wrap at the
1164    // correct visual column, and avoids over-wrapping multi-byte ASCII-
1165    // looking glyphs.
1166    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1167
1168    // If the line fits within width, return as-is
1169    if total_width <= width {
1170        return vec![line];
1171    }
1172
1173    // Line needs wrapping - extract all text and styles
1174    let mut result_lines = Vec::new();
1175    let mut current_line_spans = Vec::new();
1176    let mut current_line_width = 0usize;
1177    let available_width = width.saturating_sub(continuation_indent);
1178
1179    // Preserve the line's existing left margin (the "  " continuation gutter the
1180    // caller prepends to every non-first message line) on the *first* wrapped
1181    // segment. `split_whitespace` below drops leading spaces and the "first word,
1182    // no indent" rule would then flush the segment to column 0 — that's the
1183    // recurring bug where a wrapped paragraph escapes the message gutter while its
1184    // own continuation lines (which get `continuation_indent`) stay aligned. A
1185    // non-whitespace prefix like "● " is unaffected (it survives `split_whitespace`).
1186    let leading_indent: usize = {
1187        let mut n = 0;
1188        for span in &line.spans {
1189            let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1190            n += spaces;
1191            if spaces < span.content.len() {
1192                break; // this span has non-space content, so leading run ends here
1193            }
1194        }
1195        n
1196    };
1197
1198    for span in line.spans.clone() {
1199        let span_text = span.content.to_string();
1200        let span_style = span.style;
1201
1202        // Split span text by words
1203        let words: Vec<&str> = span_text.split_whitespace().collect();
1204
1205        for (word_idx, word) in words.iter().enumerate() {
1206            let word_with_space = if word_idx > 0 || current_line_width > 0 {
1207                format!(" {}", word)
1208            } else {
1209                word.to_string()
1210            };
1211
1212            let word_width = word_with_space.width();
1213
1214            if current_line_width == 0 && result_lines.is_empty() {
1215                // First word of the first line: re-apply the original left margin
1216                // (dropped by split_whitespace) so the segment keeps the gutter
1217                // instead of flushing to column 0.
1218                if leading_indent > 0 {
1219                    current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1220                    current_line_width += leading_indent;
1221                }
1222                current_line_spans.push(Span::styled(word_with_space, span_style));
1223                current_line_width += word_width;
1224            } else if current_line_width + word_width <= available_width {
1225                // Word fits on current line
1226                current_line_spans.push(Span::styled(word_with_space, span_style));
1227                current_line_width += word_width;
1228            } else {
1229                // Word doesn't fit - finish current line and start new one
1230                result_lines.push(Line::from(current_line_spans));
1231                current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1232                current_line_spans.push(Span::styled(word.to_string(), span_style));
1233                current_line_width = word.width();
1234            }
1235        }
1236    }
1237
1238    // Add the last line if it has content
1239    if !current_line_spans.is_empty() {
1240        result_lines.push(Line::from(current_line_spans));
1241    }
1242
1243    if result_lines.is_empty() {
1244        vec![line]
1245    } else {
1246        result_lines
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    #[test]
1255    fn diff_background_fills_full_width_with_tabs() {
1256        // Regression: tab characters paint as zero cells, so char-count padding
1257        // left the red/green diff bar short by one column per tab — a ragged
1258        // "staircase" down the right edge. After expand_tabs, every column of a
1259        // diff row must carry the background.
1260        use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1261        use ratatui::Terminal;
1262        use ratatui::backend::TestBackend;
1263
1264        let theme = Theme::dark();
1265        let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1266        let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1267        // Lines at increasing tab depth — the exact shape that staircased.
1268        let diff = format!(
1269            "  62{m}\tconst out = [];\n  63{p}\t\tlet fixed = false;\n  64{p}\t\t\tdeeplyNested();",
1270            m = DIFF_REMOVED_MARKER,
1271            p = DIFF_ADDED_MARKER
1272        );
1273        let action = ActionDisplay {
1274            action_type: "Edit".to_string(),
1275            target: "engine.ts".to_string(),
1276            result: ActionResult::Success {
1277                output: String::new(),
1278                images: None,
1279            },
1280            details: ActionDetails::Diff {
1281                summary: "ok".to_string(),
1282                diff,
1283            },
1284            duration_seconds: Some(0.3),
1285            metadata: None,
1286        };
1287
1288        let width: u16 = 60;
1289        let mut lines: Vec<Line> = Vec::new();
1290        render_actions(&[action], &mut lines, &theme, width as usize);
1291        let h = lines.len() as u16;
1292        let backend = TestBackend::new(width, h);
1293        let mut term = Terminal::new(backend).unwrap();
1294        term.draw(|f| {
1295            Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1296        })
1297        .unwrap();
1298        let buf = term.backend().buffer();
1299
1300        for y in 0..h {
1301            let is_diff_row = (0..width).any(|x| {
1302                let bg = buf[(x, y)].bg;
1303                bg == added_bg || bg == removed_bg
1304            });
1305            if !is_diff_row {
1306                continue;
1307            }
1308            for x in 0..width {
1309                let bg = buf[(x, y)].bg;
1310                assert!(
1311                    bg == added_bg || bg == removed_bg,
1312                    "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1313                );
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn byte_at_cell_clamps_and_respects_cjk() {
1320        assert_eq!(byte_at_cell("hello", 0), 0);
1321        assert_eq!(byte_at_cell("hello", 3), 3);
1322        assert_eq!(byte_at_cell("hello", 99), 5); // clamp past end
1323        // "你好" = 2 chars, 3 bytes each, 2 cells each.
1324        assert_eq!(byte_at_cell("你好", 0), 0);
1325        assert_eq!(byte_at_cell("你好", 2), 3); // after first wide char
1326        // A cell index that lands mid-glyph keeps the glyph whole (rounds up).
1327        assert_eq!(byte_at_cell("你好", 1), 3);
1328    }
1329
1330    #[test]
1331    fn slice_by_cells_extracts_display_range() {
1332        assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1333        assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1334        assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1335    }
1336
1337    #[test]
1338    fn pad_to_cells_fills_to_display_width() {
1339        assert_eq!(pad_to_cells("ab", 5), "ab   ");
1340        // "你好" = 4 display cells; pad to 6 → exactly 2 trailing spaces (#101).
1341        assert_eq!(pad_to_cells("你好", 6), "你好  ");
1342        // Already wide enough → unchanged (never truncates).
1343        assert_eq!(pad_to_cells("你好", 3), "你好");
1344        assert_eq!(pad_to_cells("", 0), "");
1345    }
1346
1347    #[test]
1348    fn user_timestamp_padding_aligns_on_display_cells() {
1349        // ASCII: prefix(4) + text(5) + gap(3) + ts(8) = 20 used; content 40.
1350        assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
1351        // A wider (CJK) message shrinks the gap but the timestamp still lands at
1352        // the content right edge: role + text + pad + ts == content_width (#104).
1353        let pad = user_timestamp_padding(4, 10, 8, 3, 40);
1354        assert_eq!(4 + 10 + pad + 8, 40);
1355        // Overflow (text wider than the line) clamps to min_gap, never underflows.
1356        assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
1357    }
1358
1359    #[test]
1360    fn wrap_preformatted_hard_wraps_preserving_spaces() {
1361        // 18 cells, wraps at 10. Spaces are preserved (not collapsed) and the
1362        // leading indentation survives on the first row.
1363        let line = Line::from(vec![Span::raw("    aaaa bbbb cccc")]);
1364        let wrapped = wrap_preformatted(line, 10, 2);
1365        assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1366        let first: String = wrapped[0]
1367            .spans
1368            .iter()
1369            .map(|s| s.content.as_ref())
1370            .collect();
1371        assert!(
1372            first.starts_with("    aaaa"),
1373            "indentation must be preserved, got {first:?}"
1374        );
1375        let second: String = wrapped[1]
1376            .spans
1377            .iter()
1378            .map(|s| s.content.as_ref())
1379            .collect();
1380        assert!(
1381            second.starts_with("  "),
1382            "continuation should get the hanging indent, got {second:?}"
1383        );
1384    }
1385
1386    #[test]
1387    fn wrap_preformatted_short_line_unchanged() {
1388        let line = Line::from(vec![Span::raw("    short")]);
1389        let wrapped = wrap_preformatted(line, 40, 2);
1390        assert_eq!(wrapped.len(), 1);
1391        let text: String = wrapped[0]
1392            .spans
1393            .iter()
1394            .map(|s| s.content.as_ref())
1395            .collect();
1396        assert_eq!(text, "    short");
1397    }
1398
1399    /// Build a ChatState whose last frame rendered `rows`, with a selection
1400    /// already mapped to content coords, so `selected_text` can be tested
1401    /// without a real terminal.
1402    fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1403        let mut st = ChatState::new();
1404        st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1405        st.selection = Some(sel);
1406        st
1407    }
1408
1409    #[test]
1410    fn selected_text_single_line() {
1411        let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1412        assert_eq!(st.selected_text().as_deref(), Some("hello"));
1413    }
1414
1415    #[test]
1416    fn selected_text_spans_multiple_rows() {
1417        let st = state_with_rows(&["> first line", "  second line"], ((0, 2), (1, 8)));
1418        // The continuation row's "  " margin is stripped so copied text is
1419        // clean (the start row was sliced from the click column past "> ").
1420        assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1421    }
1422
1423    #[test]
1424    fn selected_text_strips_margin_but_keeps_code_indentation() {
1425        // Rendered rows: 2-cell margin + the code's own indentation. Selecting
1426        // from column 0 must drop only the 2-cell margin, not the code indent.
1427        let st = state_with_rows(
1428            &["  fn main() {", "      let x = 1;", "  }"],
1429            ((0, 0), (2, 3)),
1430        );
1431        assert_eq!(
1432            st.selected_text().as_deref(),
1433            Some("fn main() {\n    let x = 1;\n}")
1434        );
1435    }
1436
1437    #[test]
1438    fn selected_text_normalizes_reversed_drag() {
1439        // Dragging bottom-up / right-to-left yields the same text.
1440        let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1441        assert_eq!(st.selected_text().as_deref(), Some("hello"));
1442    }
1443
1444    #[test]
1445    fn selected_text_empty_selection_is_none() {
1446        // A plain click (anchor == cursor) selects nothing.
1447        let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1448        assert_eq!(st.selected_text(), None);
1449    }
1450
1451    #[test]
1452    fn highlight_line_cells_splits_spans_on_selection() {
1453        let mut line = Line::from(vec![Span::raw("abcdef")]);
1454        highlight_line_cells(
1455            &mut line,
1456            2,
1457            4,
1458            Style::new().add_modifier(Modifier::REVERSED),
1459        );
1460        // Split into "ab" | "cd"(reversed) | "ef".
1461        let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1462        assert_eq!(texts, vec!["ab", "cd", "ef"]);
1463        assert!(
1464            line.spans[1]
1465                .style
1466                .add_modifier
1467                .contains(Modifier::REVERSED)
1468        );
1469        assert!(
1470            !line.spans[0]
1471                .style
1472                .add_modifier
1473                .contains(Modifier::REVERSED)
1474        );
1475    }
1476
1477    #[test]
1478    fn context_checkpoint_renders_as_compact_event() {
1479        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1480        msg.kind = ChatMessageKind::ContextCheckpoint;
1481        msg.metadata = Some(serde_json::json!({
1482            "trigger": "manual",
1483            "before_tokens": 43_800,
1484            "after_tokens": 9_200,
1485            "archived_message_count": 18,
1486            "preserved_message_count": 4,
1487            "duration_secs": 2.4,
1488            "verified": true,
1489        }));
1490
1491        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1492        let rendered = lines
1493            .iter()
1494            .map(|line| {
1495                line.spans
1496                    .iter()
1497                    .map(|span| span.content.as_ref())
1498                    .collect::<String>()
1499            })
1500            .collect::<Vec<_>>()
1501            .join("\n");
1502
1503        assert!(rendered.contains("Compact(manual)"));
1504        assert!(rendered.contains("43.8k -> 9.2k tokens"));
1505        assert!(rendered.contains("archived 18 messages"));
1506        assert!(rendered.contains("preserved 4 messages"));
1507        assert!(rendered.contains("verified"));
1508        assert!(!rendered.contains("full checkpoint summary"));
1509    }
1510
1511    #[test]
1512    fn context_checkpoint_renders_verification_fallback() {
1513        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1514        msg.kind = ChatMessageKind::ContextCheckpoint;
1515        msg.metadata = Some(serde_json::json!({
1516            "trigger": "auto_threshold",
1517            "before_tokens": 43_800,
1518            "after_tokens": 9_200,
1519            "archived_message_count": 18,
1520            "preserved_message_count": 4,
1521            "duration_secs": 2.4,
1522            "verified": false,
1523            "verification_error": "provider overloaded",
1524        }));
1525
1526        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1527        let rendered = lines
1528            .iter()
1529            .map(|line| {
1530                line.spans
1531                    .iter()
1532                    .map(|span| span.content.as_ref())
1533                    .collect::<String>()
1534            })
1535            .collect::<Vec<_>>()
1536            .join("\n");
1537
1538        assert!(rendered.contains("Compact(auto_threshold)"));
1539        assert!(rendered.contains("draft fallback"));
1540        assert!(rendered.contains("verification: provider overloaded"));
1541    }
1542
1543    /// CJK characters are 3 bytes but 2 display cells each. The
1544    /// byte-length version of `wrap_styled_line` would incorrectly
1545    /// over-wrap such input. This test asserts the display-width
1546    /// version keeps CJK-only input on a single line when the display
1547    /// width fits, even when the byte length exceeds the width.
1548    #[test]
1549    fn wrap_styled_line_uses_display_width_for_cjk() {
1550        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
1551        // Target width of 10: byte-length would see 12 > 10 and wrap;
1552        // display-width sees 8 <= 10 and keeps it on one line.
1553        let line = Line::from(Span::raw("你好世界".to_string()));
1554        let wrapped = wrap_styled_line(line, 10, 2);
1555        assert_eq!(
1556            wrapped.len(),
1557            1,
1558            "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1559            wrapped.len()
1560        );
1561    }
1562
1563    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
1564    /// the input exceeds the width.
1565    #[test]
1566    fn wrap_styled_line_ascii_wraps_when_too_long() {
1567        let line = Line::from(Span::raw(
1568            "the quick brown fox jumps over the lazy dog".to_string(),
1569        ));
1570        let wrapped = wrap_styled_line(line, 15, 2);
1571        assert!(
1572            wrapped.len() >= 2,
1573            "long ASCII input should wrap to multiple lines; got {}",
1574            wrapped.len()
1575        );
1576    }
1577
1578    fn first_segment_text(wrapped: &[Line<'static>]) -> String {
1579        wrapped[0]
1580            .spans
1581            .iter()
1582            .map(|s| s.content.as_ref())
1583            .collect()
1584    }
1585
1586    /// Regression (recurring "paragraph escapes the gutter" bug): a non-first
1587    /// message line carries a 2-space gutter prefix; when it wraps, the first
1588    /// segment must keep that gutter, not flush to column 0. `split_whitespace`
1589    /// used to drop the leading spaces and the "first word, no indent" rule
1590    /// flushed the segment left.
1591    #[test]
1592    fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
1593        let line = Line::from(vec![
1594            Span::raw("  "), // the continuation gutter chat.rs prepends
1595            Span::raw(
1596                "No source files, no config, no docs, no build system and more words to wrap"
1597                    .to_string(),
1598            ),
1599        ]);
1600        let wrapped = wrap_styled_line(line, 30, 2);
1601        assert!(wrapped.len() >= 2, "should wrap");
1602        let first = first_segment_text(&wrapped);
1603        assert!(
1604            first.starts_with("  ") && first.trim_start().starts_with("No source"),
1605            "first wrapped segment must keep the 2-space gutter; got {first:?}"
1606        );
1607    }
1608
1609    /// End-to-end: a wrapped list item keeps the bullet on the first segment and
1610    /// hangs its continuation lines under the item text (col 6 = 2 gutter + 2
1611    /// nesting indent + 2 marker), instead of snapping back to the message gutter.
1612    /// Exercises the same span shape chat.rs builds, with the continuation indent
1613    /// chat.rs derives via markdown::line_hanging_indent (4) + the gutter (2).
1614    #[test]
1615    fn wrap_styled_line_hangs_list_continuation_under_marker() {
1616        let line = Line::from(vec![
1617            Span::raw("  "), // message gutter (chat.rs)
1618            Span::raw("  "), // list nesting indent (markdown)
1619            Span::raw("• "), // marker (markdown)
1620            Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
1621        ]);
1622        let wrapped = wrap_styled_line(line, 24, 6);
1623        assert!(wrapped.len() >= 2, "should wrap");
1624        assert!(
1625            first_segment_text(&wrapped).starts_with("    • "),
1626            "first segment keeps gutter + nesting + marker"
1627        );
1628        for cont in &wrapped[1..] {
1629            let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
1630            assert!(
1631                t.starts_with("      ") && t.chars().nth(6).is_some_and(|c| c != ' '),
1632                "continuation hangs under the item text at col 6; got {t:?}"
1633            );
1634        }
1635    }
1636
1637    /// The fix preserves whitespace margins only — the message bullet "● " must
1638    /// still sit at column 0 on the first line.
1639    #[test]
1640    fn wrap_styled_line_keeps_bullet_at_column_zero() {
1641        let line = Line::from(vec![
1642            Span::raw("● "),
1643            Span::raw(
1644                "a fairly long first line of a message that definitely needs to wrap".to_string(),
1645            ),
1646        ]);
1647        let wrapped = wrap_styled_line(line, 25, 2);
1648        assert!(wrapped.len() >= 2, "should wrap");
1649        assert!(
1650            first_segment_text(&wrapped).starts_with('●'),
1651            "bullet must stay at column 0"
1652        );
1653    }
1654
1655    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
1656    /// the plain-string wrapper used by user messages and thinking blocks.
1657    /// The byte-based version would wrap a 4-CJK paragraph after the second
1658    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
1659    /// version keeps it on one line.
1660    #[test]
1661    fn wrap_text_with_indent_uses_display_width_for_cjk() {
1662        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
1663        // with 0 indent: should fit on one line.
1664        let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
1665        assert_eq!(
1666            wrapped.len(),
1667            1,
1668            "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
1669            wrapped.len(),
1670            wrapped
1671        );
1672        assert_eq!(wrapped[0].trim_start(), "你好世界");
1673    }
1674
1675    /// Mixed content: CJK + ASCII should still wrap correctly when the
1676    /// total exceeds available cells.
1677    #[test]
1678    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
1679        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
1680        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
1681        // produce ≥ 2 lines.
1682        let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
1683        assert!(
1684            wrapped.len() >= 2,
1685            "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
1686            wrapped.len(),
1687            wrapped
1688        );
1689    }
1690}