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