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::{Color, 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::{
14    ActionDetails, ActionDisplay, ActionResult, QuestionAnswer, ToolMetadata, format_compact_count,
15};
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 the DISPLAY message slice this frame rendered. The display
27    /// slice can diverge from committed history (the continuation stitch hides
28    /// nudges and merges bubbles), so this is only a fallback locator — prefer
29    /// `image_number`.
30    pub message_index: usize,
31    /// Index into that display message's images vec
32    pub image_index: usize,
33    /// The image's stable global `[Image #N]` number, when it has one.
34    /// Position-independent, so the reducer can resolve the click against
35    /// committed history no matter how the display transcript was stitched.
36    pub image_number: Option<u64>,
37}
38
39/// State for the chat widget
40#[derive(Debug, Clone)]
41pub struct ChatState {
42    /// Manual scroll offset (only used when is_user_scrolling = true)
43    scroll_offset: u16,
44    /// Whether user is manually scrolling (not following bottom)
45    is_user_scrolling: bool,
46    /// Click map: content line number → image target (rebuilt every render)
47    pub image_click_map: Vec<(u16, ImageClickTarget)>,
48    /// Scroll position used in last render (for coordinate mapping)
49    pub last_scroll_position: u16,
50    /// Chat area rect from last render
51    pub last_chat_area: Option<(u16, u16, u16, u16)>, // (x, y, width, height)
52    /// Active drag-selection in CONTENT coordinates: `(anchor, cursor)` where
53    /// each is `(content_line, col_cells)`. Highlight + copy derive from it.
54    selection: Option<((usize, usize), (usize, usize))>,
55    /// Plain text of each rendered content row, captured every frame so the
56    /// selection can be extracted by display-cell range. Indexed by content
57    /// line (the same index the selection uses).
58    last_rendered_rows: Vec<String>,
59    /// Memoized full-frame assembly (F31): the wrapped lines and image click
60    /// map produced by the per-message render loop, keyed by a fingerprint of
61    /// every input that determines them (message set, theme, width, reasoning
62    /// toggle, day). An unchanged scrollback reuses this across frames instead
63    /// of re-parsing, re-wrapping, and rebuilding the click map every frame.
64    /// Replaced whenever the fingerprint changes.
65    frame_memo: Option<FrameMemo>,
66}
67
68/// One memoized chat-frame assembly (see `ChatState::frame_memo`). Holds the
69/// lines *before* the per-frame selection highlight (which is selection-
70/// dependent and applied to a clone each frame) plus the image click map, so a
71/// frame whose inputs are unchanged skips the whole per-message render loop
72/// (F31). Cloning is `O(total lines)`, but it replaces the markdown parse +
73/// wrap + click-map rebuild the loop would otherwise redo every frame.
74#[derive(Debug, Clone)]
75struct FrameMemo {
76    /// Fingerprint of the inputs that produced `lines` + `click_map`.
77    key: u64,
78    /// Assembled wrapped lines, before the per-frame selection highlight.
79    lines: Vec<Line<'static>>,
80    /// Image click map captured alongside `lines`.
81    click_map: Vec<(u16, ImageClickTarget)>,
82}
83
84impl ChatState {
85    /// Create a new chat state (starts in auto-follow mode)
86    pub fn new() -> Self {
87        Self {
88            scroll_offset: 0,
89            is_user_scrolling: false,
90            image_click_map: Vec::new(),
91            last_scroll_position: 0,
92            last_chat_area: None,
93            selection: None,
94            last_rendered_rows: Vec::new(),
95            frame_memo: None,
96        }
97    }
98
99    /// Get the scroll position for rendering
100    /// scroll_offset represents distance from bottom, convert to ratatui scroll position
101    pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
102        let max_scroll = content_height.saturating_sub(viewport_height);
103        if self.is_user_scrolling {
104            // Manual scroll: convert "distance from bottom" to scroll position
105            // scroll_offset=0 → show bottom (max_scroll), scroll_offset=max → show top (0)
106            let capped_offset = self.scroll_offset.min(max_scroll);
107            max_scroll.saturating_sub(capped_offset)
108        } else {
109            // Auto-scroll: show bottom of content
110            max_scroll
111        }
112    }
113
114    /// Scroll viewport up (shows older messages further from bottom)
115    pub fn scroll_up(&mut self, amount: u16) {
116        self.is_user_scrolling = true;
117        self.scroll_offset = self.scroll_offset.saturating_add(amount);
118        // A selection's content-line anchors don't track scrolling; drop it
119        // rather than leave a highlight stranded on the wrong rows.
120        self.selection = None;
121    }
122
123    /// Scroll viewport down (shows newer messages closer to bottom)
124    /// Automatically resumes auto-scroll when reaching the bottom
125    pub fn scroll_down(&mut self, amount: u16) {
126        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
127        if self.scroll_offset == 0 {
128            // Reached bottom — resume auto-follow mode
129            self.is_user_scrolling = false;
130        }
131        self.selection = None;
132    }
133
134    /// Force resume auto-scroll mode (jump to bottom)
135    pub fn resume_auto_scroll(&mut self) {
136        self.is_user_scrolling = false;
137        self.scroll_offset = 0;
138    }
139
140    /// Check if user is manually scrolling (not following bottom)
141    pub fn is_manually_scrolling(&self) -> bool {
142        self.is_user_scrolling
143    }
144
145    /// Find an image click target at the given screen coordinates.
146    /// Returns Some((message_index, image_index)) if an image indicator was clicked.
147    pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
148        let (_, area_y, _, area_height) = self.last_chat_area?;
149
150        // Check if click is within chat area
151        if screen_row < area_y || screen_row >= area_y + area_height {
152            return None;
153        }
154
155        // Convert screen row to content line
156        let viewport_row = screen_row - area_y;
157        let content_line = viewport_row + self.last_scroll_position;
158
159        // Look up in click map
160        self.image_click_map
161            .iter()
162            .find(|(line, _)| *line == content_line)
163            .map(|(_, target)| target)
164    }
165
166    /// Map a screen `(row, col)` to content `(line, col_cells)`, or `None`
167    /// when the point is outside the chat area. `col` is clamped to the chat
168    /// area's left edge so a drag past the gutter still maps to column 0.
169    fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
170        let (area_x, area_y, _, area_height) = self.last_chat_area?;
171        if screen_row < area_y || screen_row >= area_y + area_height {
172            return None;
173        }
174        let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
175        let col = screen_col.saturating_sub(area_x) as usize;
176        Some((content_line, col))
177    }
178
179    /// Begin a drag selection at the given screen position (mouse-down).
180    /// Anchors and cursor both start here; a plain click with no drag selects
181    /// nothing.
182    pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
183        self.selection = self
184            .screen_to_content(screen_row, screen_col)
185            .map(|p| (p, p));
186    }
187
188    /// Extend the in-progress selection to the given screen position (drag).
189    pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
190        if let Some((anchor, _)) = self.selection
191            && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
192        {
193            self.selection = Some((anchor, cursor));
194        }
195    }
196
197    /// Drop any active selection (and its highlight).
198    pub fn clear_selection(&mut self) {
199        self.selection = None;
200    }
201
202    /// Extract the currently-selected text from the last rendered frame, or
203    /// `None` if there's no selection or it's empty (e.g. a plain click).
204    /// Walks the retained per-row text and slices each row by display cells so
205    /// CJK / wide glyphs are never split mid-cell.
206    pub fn selected_text(&self) -> Option<String> {
207        let (a, b) = self.selection?;
208        let (start, end) = if a <= b { (a, b) } else { (b, a) };
209        if self.last_rendered_rows.is_empty() {
210            return None;
211        }
212        let last = self.last_rendered_rows.len() - 1;
213        let (start_line, start_col) = (start.0.min(last), start.1);
214        let (end_line, end_col) = (end.0.min(last), end.1);
215
216        let mut out = String::new();
217        for line in start_line..=end_line {
218            let row = &self.last_rendered_rows[line];
219            let c0 = if line == start_line { start_col } else { 0 };
220            let c1 = if line == end_line {
221                end_col
222            } else {
223                usize::MAX
224            };
225            let mut piece = slice_by_cells(row, c0, c1).to_string();
226            // Drop the rendered left margin (the "● "/"  " role/continuation
227            // prefix — up to SELECT_MARGIN_CELLS cells of spaces) so copied
228            // text is clean. Only spaces inside the margin zone [c0, MARGIN)
229            // are removed, so a code line's own indentation is preserved.
230            let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
231            while margin > 0 && piece.starts_with(' ') {
232                piece.remove(0);
233                margin -= 1;
234            }
235            out.push_str(piece.trim_end());
236            if line != end_line {
237                out.push('\n');
238            }
239        }
240        if out.is_empty() { None } else { Some(out) }
241    }
242}
243
244/// Display-cell width of the role/continuation left margin ("● " or "  ")
245/// that the renderer prepends to chat content lines. Stripped from copied
246/// selections so the clipboard gets clean text.
247const SELECT_MARGIN_CELLS: usize = 2;
248
249/// Hard-wrap a pre-formatted (code) line at `width` display cells, preserving
250/// every glyph (including whitespace) and each span's style. Continuation rows
251/// get a `indent`-space hanging indent. Unlike `wrap_styled_line` this never
252/// collapses runs of spaces, so code indentation and alignment survive.
253fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
254    if width == 0 {
255        return vec![line];
256    }
257    let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
258    if total <= width {
259        return vec![line];
260    }
261
262    let base = line.style;
263    let mut out: Vec<Line<'static>> = Vec::new();
264    let mut cur: Vec<Span<'static>> = Vec::new();
265    let mut cur_w = 0usize;
266    let mut on_first = true;
267
268    for span in line.spans {
269        let style = span.style;
270        let mut buf = String::new();
271        for ch in span.content.chars() {
272            let cw = ch.width().unwrap_or(0);
273            // Break before this char if it would overflow and the current row
274            // already holds real content (beyond the continuation indent).
275            let floor = if on_first { 0 } else { indent };
276            if cur_w + cw > width && cur_w > floor {
277                if !buf.is_empty() {
278                    cur.push(Span::styled(std::mem::take(&mut buf), style));
279                }
280                out.push(Line::from(std::mem::take(&mut cur)).style(base));
281                on_first = false;
282                cur.push(Span::styled(" ".repeat(indent), base));
283                cur_w = indent;
284            }
285            buf.push(ch);
286            cur_w += cw;
287        }
288        if !buf.is_empty() {
289            cur.push(Span::styled(buf, style));
290        }
291    }
292    if !cur.is_empty() {
293        out.push(Line::from(cur).style(base));
294    }
295    if out.is_empty() {
296        vec![Line::from("").style(base)]
297    } else {
298        out
299    }
300}
301
302/// Byte offset in `s` at the start of display-cell `target` (clamped to
303/// `s.len()`). A wide glyph straddling `target` is kept whole on the right
304/// side, so slicing never lands mid-character.
305fn byte_at_cell(s: &str, target: usize) -> usize {
306    if target == 0 {
307        return 0;
308    }
309    let mut width = 0usize;
310    for (idx, ch) in s.char_indices() {
311        if width >= target {
312            return idx;
313        }
314        width += ch.width().unwrap_or(0);
315    }
316    s.len()
317}
318
319/// Slice `s` to the display-cell range `[c0, c1)`.
320fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
321    let start = byte_at_cell(s, c0);
322    let end = byte_at_cell(s, c1).max(start);
323    &s[start..end]
324}
325
326/// Pad `s` on the right with spaces until it spans `cells` display columns,
327/// measured with `UnicodeWidthStr::width` (not chars/bytes) so a CJK/emoji row's
328/// background bar fills to the true visual edge instead of falling short (#101).
329/// Never truncates — an already-too-wide `s` is returned unchanged.
330fn pad_to_cells(s: &str, cells: usize) -> String {
331    let w = s.width();
332    if w >= cells {
333        return s.to_string();
334    }
335    let mut out = String::with_capacity(s.len() + (cells - w));
336    out.push_str(s);
337    out.push_str(&" ".repeat(cells - w));
338    out
339}
340
341/// First-line spacing for a user message: the run of spaces before the
342/// right-aligned timestamp. All inputs are display-cell widths so CJK/emoji
343/// align correctly (#104). Returns `min_gap` plus whatever slack remains to
344/// push the timestamp to `content_width`'s right edge.
345fn user_timestamp_padding(
346    role_prefix_width: usize,
347    text_width: usize,
348    timestamp_width: usize,
349    min_gap: usize,
350    content_width: usize,
351) -> usize {
352    let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
353    min_gap + content_width.saturating_sub(total_used)
354}
355
356/// The plain text of a rendered line (spans concatenated, styles dropped).
357fn line_plain_text(line: &Line) -> String {
358    line.spans.iter().map(|s| s.content.as_ref()).collect()
359}
360
361/// Saturating cast from a `usize` line counter to the `u16` ratatui scroll /
362/// click-map coordinate. A scrollback longer than `u16::MAX` rows clamps to the
363/// last addressable row instead of wrapping the index modulo 65536 (which a
364/// plain `as u16` would do, corrupting both the scroll position and the image
365/// click-map on a very long session) (F32).
366fn clamp_to_u16(n: usize) -> u16 {
367    u16::try_from(n).unwrap_or(u16::MAX)
368}
369
370/// Apply `hl` (merged onto each span's existing style) to display cells
371/// `[c0, c1)` of `line`, splitting spans at the selection boundaries so the
372/// highlight lands on exactly the selected glyphs.
373fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
374    let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
375    let mut width = 0usize;
376    for span in line.spans.drain(..) {
377        let span_w = span.content.width();
378        let (span_start, span_end) = (width, width + span_w);
379        width = span_end;
380
381        let ov0 = c0.max(span_start);
382        let ov1 = c1.min(span_end);
383        if ov1 <= ov0 {
384            new_spans.push(span); // no overlap with the selection
385            continue;
386        }
387
388        let s = span.content.as_ref();
389        let b0 = byte_at_cell(s, ov0 - span_start);
390        let b1 = byte_at_cell(s, ov1 - span_start);
391        if b0 > 0 {
392            new_spans.push(Span::styled(s[..b0].to_string(), span.style));
393        }
394        new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
395        if b1 < s.len() {
396            new_spans.push(Span::styled(s[b1..].to_string(), span.style));
397        }
398    }
399    line.spans = new_spans;
400}
401
402impl Default for ChatState {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408/// Props for ChatWidget
409pub struct ChatWidget<'a> {
410    pub messages: &'a [ChatMessage],
411    pub theme: &'a Theme,
412    /// Shared render cache: `(content, theme, width)` hash → fully wrapped,
413    /// role-prefixed assistant lines. Caching the WRAPPED output (not just the
414    /// markdown parse) keeps a committed message from being re-parsed *and*
415    /// re-wrapped every frame — it's cloned from here instead (#134).
416    pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
417    pub show_reasoning: bool,
418    /// Blink phase for in-flight (`ActionResult::Running`) action headers,
419    /// derived from `state.now` by the compose function. Ignored — including
420    /// by the frame memo — when no message carries a running action, so idle
421    /// frames don't reassemble twice a second.
422    pub blink_on: bool,
423}
424
425/// Render assistant message content (markdown) into wrapped, role-prefixed
426/// display lines.
427///
428/// Pure in its inputs — `(content, width, role prefix/color, theme)` — which is
429/// exactly what lets the result be cached per message and reused across frames
430/// without re-parsing or re-wrapping (#134). The cache key folds in content,
431/// theme, and width; role prefix/color are constant on this (assistant-only)
432/// path, so they need not be keyed.
433fn wrap_assistant_content(
434    content: &str,
435    content_width: u16,
436    role_prefix: &str,
437    role_color: ratatui::style::Color,
438    theme: &Theme,
439) -> Vec<Line<'static>> {
440    // Markdown content sits after the 2-cell message gutter.
441    let md_width = (content_width as usize).saturating_sub(2);
442    let parsed = parse_markdown(content, theme, md_width);
443
444    let mut out: Vec<Line<'static>> = Vec::new();
445    for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
446        // Code-block lines are tagged with the code background on their base
447        // style (see markdown::parse_markdown). They're pre-formatted: don't
448        // word-wrap (that collapses indentation) — let the Paragraph clip
449        // overflow instead.
450        let preformatted = parsed_line.preformatted;
451        let base_style = parsed_line.line.style;
452
453        // Continuation indent for wrapping: the 2-cell message gutter every line
454        // carries, plus this line's own content-start column so a wrapped list
455        // item's continuations hang under its text (after the marker) instead of
456        // snapping back to the gutter.
457        let continuation = if preformatted {
458            2
459        } else {
460            2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
461        };
462
463        // Add role indicator to first line or 2-space margin to others.
464        let mut spans = if line_idx == 0 {
465            vec![Span::styled(
466                format!("{} ", role_prefix),
467                Style::new().fg(role_color).bold(),
468            )]
469        } else {
470            vec![Span::raw("  ")]
471        };
472        spans.extend(parsed_line.line.spans);
473        let new_line = Line::from(spans).style(base_style);
474
475        if preformatted {
476            // Code: hard-wrap preserving indentation (don't word-collapse) so
477            // wide lines stay readable.
478            out.extend(wrap_preformatted(new_line, content_width as usize, 2));
479        } else {
480            out.extend(wrap_styled_line(
481                new_line,
482                content_width as usize,
483                continuation,
484            ));
485        }
486    }
487    out
488}
489
490/// `std::fmt::Write` shim that streams a value's formatted bytes straight into
491/// a hasher, so a `Debug`/`Display` value can be folded into a fingerprint
492/// without allocating an intermediate `String`.
493struct HashWrite<'a, H: Hasher>(&'a mut H);
494
495impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
496    fn write_str(&mut self, s: &str) -> std::fmt::Result {
497        self.0.write(s.as_bytes());
498        Ok(())
499    }
500}
501
502/// Fingerprint every input that determines the assembled chat lines + image
503/// click map: the message set (role, kind, content, thinking, actions, image
504/// count, timestamp, metadata), the theme identity, the content width, the
505/// reasoning toggle, and today's date — the only clock-dependent input, since a
506/// user timestamp renders as "Today"/"Yesterday"/an absolute date relative to it.
507///
508/// Two frames with the same fingerprint assemble byte-identical lines, so the
509/// result can be memoized across frames (F31). Uses the same 64-bit-hash-keyed
510/// caching the per-message #134 cache already relies on; the complex non-`Hash`
511/// fields (`metadata`, `actions`) are folded in via their `Debug` form so no
512/// rendered field is silently missed.
513fn frame_fingerprint(
514    messages: &[ChatMessage],
515    theme_seed: u64,
516    content_width: u16,
517    show_reasoning: bool,
518    blink_on: bool,
519) -> u64 {
520    use std::fmt::Write as _;
521    let mut h = rustc_hash::FxHasher::default();
522    theme_seed.hash(&mut h);
523    content_width.hash(&mut h);
524    show_reasoning.hash(&mut h);
525    // The day-relative label ("Today"/"Yesterday"/date) on user timestamps
526    // changes only at midnight; fold today's date in so the memo refreshes then.
527    chrono::Local::now().date_naive().hash(&mut h);
528    // The blink phase only affects frames that actually paint a running
529    // action's header dot; folding it in unconditionally would invalidate the
530    // memo twice a second on every idle frame.
531    if messages.iter().any(|m| {
532        m.actions
533            .iter()
534            .any(|a| matches!(a.result, ActionResult::Running))
535    }) {
536        blink_on.hash(&mut h);
537    }
538    messages.len().hash(&mut h);
539    for msg in messages {
540        msg.content.hash(&mut h);
541        msg.thinking.hash(&mut h);
542        // The instant fully determines `format_time(msg.timestamp)`; the
543        // day-relative label is covered by today's date above.
544        msg.timestamp.timestamp().hash(&mut h);
545        msg.images
546            .as_ref()
547            .map_or(0, |imgs| imgs.len())
548            .hash(&mut h);
549        let mut hw = HashWrite(&mut h);
550        let _ = write!(
551            hw,
552            "{:?}|{:?}|{:?}|{:?}",
553            msg.role, msg.kind, msg.metadata, msg.actions
554        );
555    }
556    h.finish()
557}
558
559impl<'a> StatefulWidget for ChatWidget<'a> {
560    type State = ChatState;
561
562    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
563        // Code-block lines are tagged with this background; computed once so
564        // the markdown cache key can use it.
565        let code_bg = self.theme.colors.code_background.to_color();
566        let theme_seed = {
567            let mut h = rustc_hash::FxHasher::default();
568            self.theme.colors.foreground.to_color().hash(&mut h);
569            code_bg.hash(&mut h);
570            self.theme.colors.header.to_color().hash(&mut h);
571            h.finish()
572        };
573
574        // Content spans the full width — there is no scrollbar gutter.
575        let content_width = area.width;
576        let content_area = area;
577
578        state.last_chat_area = Some((area.x, area.y, area.width, area.height));
579
580        // F31: skip the whole per-message assembly when nothing that affects it
581        // changed. The fingerprint folds in every render input, so a reused
582        // frame is byte-identical to a fresh one. Scrolling and drag-selection
583        // don't touch these inputs, so the common case (a static scrollback)
584        // reuses the memo instead of re-parsing and re-wrapping every message.
585        let frame_key = frame_fingerprint(
586            self.messages,
587            theme_seed,
588            content_width,
589            self.show_reasoning,
590            self.blink_on,
591        );
592        // Type is inferred from the map below (the frame_memo struct names the
593        // fields); an explicit annotation would just be a clippy::type_complexity.
594        let memo_hit = state
595            .frame_memo
596            .as_ref()
597            .filter(|m| m.key == frame_key)
598            .map(|m| (m.lines.clone(), m.click_map.clone()));
599
600        let mut lines = if let Some((cached_lines, cached_click_map)) = memo_hit {
601            // Memo hit: reuse the assembled lines; restore the click map that
602            // was captured alongside them.
603            state.image_click_map = cached_click_map;
604            cached_lines
605        } else {
606            // Memo miss: assemble fresh, then memoize for the next frame.
607            let mut lines: Vec<Line<'static>> = Vec::new();
608
609            // Clear click map for this render pass
610            state.image_click_map.clear();
611
612            for (idx, msg) in self.messages.iter().enumerate() {
613                // Skip Tool messages - they're internal to the agent loop and their
614                // content is already displayed inline in the assistant's action blocks
615                if matches!(msg.role, MessageRole::Tool) {
616                    continue;
617                }
618
619                if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
620                    if let Some(event_lines) =
621                        render_context_checkpoint_event(msg, self.theme, content_width as usize)
622                    {
623                        lines.extend(event_lines);
624                        lines.push(Line::from(""));
625                    }
626                    continue;
627                }
628
629                // Run summary ("Worked for … · used … tokens"): a muted gray line where
630                // the spinner was — dimmer than the assistant's text (same gray as the
631                // timestamp), not italic. Display-only — excluded from the model context
632                // by build_chat_request, so it never accumulates as conversation.
633                if matches!(msg.kind, ChatMessageKind::RunSummary) {
634                    lines.push(Line::from(Span::styled(
635                        format!("  {}", msg.content),
636                        Style::new().fg(self.theme.colors.text_meta.to_color()),
637                    )));
638                    lines.push(Line::from(""));
639                    continue;
640                }
641
642                // A recovery nudge is a one-shot model instruction, not user
643                // content — the stitch pre-pass hides committed ones, and this
644                // guard keeps a still-live one (mid-recovery) invisible too.
645                if matches!(msg.kind, ChatMessageKind::RecoveryNudge) {
646                    continue;
647                }
648
649                // System notices (warnings, agent completions, command
650                // replies): muted meta text — no bullet, no timestamp. The
651                // same gray as the run summary, so transcript furniture never
652                // competes with the conversation.
653                if matches!(msg.role, MessageRole::System) {
654                    let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
655                    for wrapped_line in
656                        wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
657                    {
658                        lines.push(Line::from(Span::styled(wrapped_line, meta)));
659                    }
660                    lines.push(Line::from(""));
661                    continue;
662                }
663
664                // Auto-continue stitch, streaming half: a `Continuation`
665                // extending a mergeable assistant bubble draws as that
666                // bubble's tail — no fresh `●`, no blank separator — so the
667                // reply reads as one message *while it streams*, not only
668                // after commit (committed halves are merged upstream in
669                // `stitch_committed`). An unmergeable predecessor (e.g. a
670                // compaction checkpoint) falls through to a normal bubble.
671                let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
672                    && self.messages[..idx]
673                        .iter()
674                        .rev()
675                        .find(|m| !matches!(m.role, MessageRole::Tool))
676                        .is_some_and(crate::render::mergeable_into);
677                if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
678                    lines.pop();
679                }
680
681                let (role_prefix, role_color) = match msg.role {
682                    MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
683                    MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
684                    MessageRole::System | MessageRole::Tool => {
685                        unreachable!("System and Tool messages handled above")
686                    },
687                };
688                // A stitched continuation keeps the 2-cell gutter but no
689                // bullet: a single space prefix renders as the same margin
690                // the bubble's wrapped lines already use.
691                let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
692
693                if matches!(msg.role, MessageRole::Assistant) {
694                    // Render thinking block if present
695                    if let Some(ref thinking) = msg.thinking {
696                        // Skip rendering if thinking content is empty or literal "None"
697                        let thinking_trimmed = thinking.trim();
698                        if thinking_trimmed.is_empty()
699                            || thinking_trimmed == "None"
700                            || thinking_trimmed == "none"
701                        {
702                            // Don't render empty/null thinking blocks
703                        } else if self.show_reasoning {
704                            // Add "Thinking..." header in italic and dimmed with grayed white dot
705                            lines.push(Line::from(vec![
706                                Span::styled(
707                                    "● ",
708                                    Style::new().fg(self.theme.colors.text_disabled.to_color()),
709                                ),
710                                Span::styled(
711                                    "Thinking...",
712                                    Style::new()
713                                        .fg(self.theme.colors.text_secondary.to_color())
714                                        .italic()
715                                        .dim(),
716                                ),
717                            ]));
718
719                            // Render thinking content with proper wrapping (2-space hanging indent)
720                            let wrapped = wrap_text_with_indent(
721                                thinking,
722                                content_width as usize,
723                                2, // first line indent (2 spaces)
724                                2, // continuation indent (2 spaces)
725                            );
726                            for wrapped_line in wrapped {
727                                lines.push(Line::from(Span::styled(
728                                    wrapped_line,
729                                    Style::new()
730                                        .fg(self.theme.colors.text_secondary.to_color())
731                                        .italic()
732                                        .dim(),
733                                )));
734                            }
735
736                            // Add blank line after thinking block
737                            lines.push(Line::from(""));
738                        } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
739                            // Reasoning is hidden and there's nothing else in this turn —
740                            // skip it entirely rather than render an empty bullet. No
741                            // "reasoning hidden" placeholder: /visible-reasoning controls
742                            // whether the thinking shows, silently.
743                            continue;
744                        }
745                    }
746
747                    // Assistant prose is the bulk of the scrollback. Its wrapped,
748                    // role-prefixed lines are a pure function of (content, theme,
749                    // width) — exactly this key — so cache the WRAPPED output, not
750                    // just the parse: a committed message is then cloned, never
751                    // re-parsed or re-wrapped, each frame (#134). Theme is folded in
752                    // so a theme switch can't serve stale-colored lines; width is in
753                    // the key because tables wrap to the viewport.
754                    let mut hasher = rustc_hash::FxHasher::default();
755                    msg.content.hash(&mut hasher);
756                    theme_seed.hash(&mut hasher);
757                    content_width.hash(&mut hasher);
758                    // A stitched continuation renders prefix-less; keep its
759                    // cached lines distinct from a same-content bubble.
760                    stitch_onto_prev.hash(&mut hasher);
761                    let cache_key = hasher.finish();
762
763                    let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
764                        cached.clone()
765                    } else {
766                        let block = wrap_assistant_content(
767                            &msg.content,
768                            content_width,
769                            role_prefix,
770                            role_color,
771                            self.theme,
772                        );
773                        self.wrapped_line_cache.insert(cache_key, block.clone());
774                        if self.wrapped_line_cache.len()
775                            > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
776                        {
777                            // Evict down to the cap rather than clearing the whole
778                            // cache — a wholesale clear re-rendered every message each
779                            // frame once a conversation exceeded the cap. Keep the
780                            // entry just inserted.
781                            let overflow = self.wrapped_line_cache.len()
782                                - crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
783                            let stale: Vec<u64> = self
784                                .wrapped_line_cache
785                                .keys()
786                                .copied()
787                                .filter(|&k| k != cache_key)
788                                .take(overflow)
789                                .collect();
790                            for k in stale {
791                                self.wrapped_line_cache.remove(&k);
792                            }
793                        }
794                        block
795                    };
796                    lines.extend(wrapped);
797
798                    // Render all actions at the end of the message
799                    if !msg.actions.is_empty() {
800                        // Add blank line between text content and actions
801                        if !msg.content.trim().is_empty() {
802                            lines.push(Line::from(""));
803                        }
804                        render_actions(
805                            &msg.actions,
806                            &mut lines,
807                            self.theme,
808                            content_width as usize,
809                            self.blink_on,
810                        );
811                    }
812                } else {
813                    // For User messages: format timestamp and display on right edge
814                    let formatted_timestamp = format_relative_timestamp(msg.timestamp);
815                    // Display cells, not bytes — a CJK/emoji timestamp (or message)
816                    // would otherwise mis-reserve space and push the right-aligned
817                    // timestamp off its column (#104).
818                    let timestamp_width = formatted_timestamp.width();
819                    let min_gap = 3; // minimum spaces between text and timestamp
820
821                    // Content is clean — timestamps are injected at API call time only
822                    let cleaned_content = &msg.content;
823
824                    // Reserve space on the first line for role prefix + gap + timestamp
825                    // so text wraps early enough to not overlap the timestamp
826                    let role_prefix_width = role_prefix.width() + 1; // "You " = prefix + space
827                    let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
828
829                    // Manually wrap the user message with hanging indent (2 spaces)
830                    let wrapped = wrap_text_with_indent(
831                        cleaned_content,
832                        content_width as usize,
833                        first_line_reserved, // reserve space for prefix + gap + timestamp on first line
834                        2,                   // continuation indent
835                    );
836
837                    let band_start = lines.len();
838                    for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
839                        if line_idx == 0 {
840                            // First line: add role prefix and timestamp on right
841                            let text_content = wrapped_line.trim_start(); // Remove the indent we added
842                            let text_width = text_content.width();
843
844                            let mut spans = vec![
845                                Span::styled(
846                                    format!("{} ", role_prefix),
847                                    Style::new().fg(role_color).bold(),
848                                ),
849                                Span::raw(text_content.to_string()),
850                            ];
851
852                            // Always add at least min_gap spaces, plus any extra from word-boundary slack.
853                            // Align the timestamp to the content's right edge.
854                            let pad = user_timestamp_padding(
855                                role_prefix_width,
856                                text_width,
857                                timestamp_width,
858                                min_gap,
859                                content_width as usize,
860                            );
861                            spans.push(Span::raw(" ".repeat(pad)));
862                            spans.push(Span::styled(
863                                formatted_timestamp.clone(),
864                                Style::new().fg(self.theme.colors.text_meta.to_color()),
865                            ));
866
867                            lines.push(Line::from(spans));
868                        } else {
869                            // Continuation lines: already have 2-space margin from wrap_text_with_indent
870                            lines.push(Line::from(wrapped_line.clone()));
871                        }
872                    }
873
874                    // Claude-Code-style highlight band: paint a subtle full-width
875                    // background behind every line of the user's submitted prompt. The
876                    // ">" marker, text, and timestamp keep their own foreground colors;
877                    // only the row background is added.
878                    if matches!(msg.role, MessageRole::User) {
879                        let user_bg = self.theme.colors.user_message_background.to_color();
880                        let cw = content_width as usize;
881                        for line in &mut lines[band_start..] {
882                            let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
883                            if used < cw {
884                                line.spans.push(Span::raw(" ".repeat(cw - used)));
885                            }
886                            line.style = line.style.bg(user_bg);
887                        }
888                    }
889                }
890
891                // Show image indicators under user and assistant messages.
892                // User images come from clipboard paste (`Attachment`); assistant
893                // images come from tool executions that emitted `ProgressEvent::
894                // Artifact` during their run — screenshot captures, inline
895                // previews from computer-use, etc. Both land in `msg.images` as
896                // base64 strings and render the same way.
897                if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
898                    && let Some(ref images) = msg.images
899                    && !images.is_empty()
900                {
901                    for (i, _) in images.iter().enumerate() {
902                        // Record this line in the click map before pushing.
903                        // `lines.len()` is usize; clamp to the u16 click-map/scroll
904                        // coordinate with a saturating cast at this boundary so a
905                        // scrollback past u16::MAX rows clamps instead of wrapping a
906                        // stale line index into the map (F32).
907                        let content_line = lines.len();
908                        let image_number =
909                            msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
910                        state.image_click_map.push((
911                            clamp_to_u16(content_line),
912                            ImageClickTarget {
913                                message_index: idx,
914                                image_index: i,
915                                image_number,
916                            },
917                        ));
918                        // Prefer the stable global number stored with the
919                        // message; fall back to a positional index for sessions
920                        // saved before image numbering (and assistant/tool
921                        // images, which carry no global number).
922                        let label = image_number
923                            .map(|n| format!("[Image #{n}]"))
924                            .unwrap_or_else(|| format!("[Image #{}]", i + 1));
925                        lines.push(Line::from(vec![
926                            Span::styled(
927                                "  ⎿ ",
928                                Style::new().fg(self.theme.colors.info.to_color()),
929                            ),
930                            Span::styled(
931                                label,
932                                Style::new().fg(self.theme.colors.info.to_color()).italic(),
933                            ),
934                        ]));
935                    }
936                }
937
938                lines.push(Line::from(""));
939            }
940
941            // Capture the plain text of each rendered row for selection
942            // extraction (before the per-frame highlight, which changes only
943            // styling, not text). Recomputed only on a miss: a memo hit means
944            // unchanged content, so the rows from the miss that built the memo
945            // stay valid — this skips an O(total) re-collect every frame (F31).
946            state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
947
948            // F31: memoize this assembly so an unchanged next frame reuses it
949            // instead of re-running the loop above. Store the lines *before* the
950            // selection highlight (applied per-frame below), so the cache stays
951            // selection-independent.
952            state.frame_memo = Some(FrameMemo {
953                key: frame_key,
954                lines: lines.clone(),
955                click_map: state.image_click_map.clone(),
956            });
957            lines
958        };
959
960        // NOTE: The response buffer is NOT rendered during streaming (buffering mode).
961        // The response is buffered invisibly and only shown when generation is complete.
962        // This provides a Claude Code-like experience where the complete response
963        // appears instantly instead of streaming character-by-character.
964        //
965        // The status line shows progress: "↑ Sending..." → "↓ Streaming..." with timer
966
967        // NOTE: `state.last_rendered_rows` (used by selection extraction) is
968        // refreshed inside the memo-miss branch above, not here — a memo hit
969        // keeps the rows from the miss that built it (content is unchanged on a
970        // hit), so they need not be re-collected every frame (F31).
971
972        // Paint the active drag selection (reverse video over the selected
973        // cells). Selection lines are content indices, matching `lines`.
974        if let Some((a, b)) = state.selection
975            && !lines.is_empty()
976        {
977            let (start, end) = if a <= b { (a, b) } else { (b, a) };
978            let sel_style = Style::new().add_modifier(Modifier::REVERSED);
979            let last_line = lines.len() - 1;
980            for (line_idx, line) in lines
981                .iter_mut()
982                .enumerate()
983                .take(end.0.min(last_line) + 1)
984                .skip(start.0)
985            {
986                let c0 = if line_idx == start.0 { start.1 } else { 0 };
987                let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
988                if c1 > c0 {
989                    highlight_line_cells(line, c0, c1, sel_style);
990                }
991            }
992        }
993
994        // NOTE: Wrapping is disabled because we handle it manually with hanging indents
995        // Calculate content height and viewport for proper scroll clamping.
996        // `lines.len()` is usize; convert to the u16 ratatui scroll type with a
997        // saturating cast so a scrollback longer than u16::MAX rows clamps the
998        // scroll position instead of wrapping it (F32).
999        let content_height = lines.len();
1000        let viewport_height = area.height;
1001
1002        let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
1003        state.last_scroll_position = scroll_pos;
1004
1005        let paragraph = Paragraph::new(lines)
1006            .block(Block::default())
1007            .scroll((scroll_pos, 0));
1008
1009        paragraph.render(content_area, buf);
1010    }
1011}
1012
1013fn render_context_checkpoint_event(
1014    msg: &ChatMessage,
1015    theme: &Theme,
1016    viewport_width: usize,
1017) -> Option<Vec<Line<'static>>> {
1018    if !matches!(msg.role, MessageRole::User) {
1019        return None;
1020    }
1021
1022    let metadata = msg.metadata.as_ref();
1023    let trigger = metadata
1024        .and_then(|value| value.get("trigger"))
1025        .and_then(|value| value.as_str())
1026        .unwrap_or("manual");
1027    let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
1028    let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
1029    let archived_messages =
1030        metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
1031    let preserved_messages =
1032        metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
1033    let duration_secs = metadata
1034        .and_then(|value| value.get("duration_secs"))
1035        .and_then(|value| value.as_f64());
1036    let review_status = metadata
1037        .and_then(|value| value.get("review_status"))
1038        .and_then(|value| value.as_str());
1039    let review_error = metadata
1040        .and_then(|value| value.get("review_error"))
1041        .and_then(|value| value.as_str());
1042
1043    let action_color = theme.colors.info.to_color();
1044    let mut result = match (before_tokens, after_tokens) {
1045        (Some(before), Some(after)) => {
1046            format!(
1047                "{} -> {} tokens",
1048                format_compact_count(before),
1049                format_compact_count(after)
1050            )
1051        },
1052        _ => "Context compacted".to_string(),
1053    };
1054
1055    if let Some(count) = archived_messages {
1056        result.push_str(&format!(
1057            ", archived {} {}",
1058            count,
1059            if count == 1 { "message" } else { "messages" }
1060        ));
1061    }
1062    if let Some(count) = preserved_messages {
1063        result.push_str(&format!(
1064            ", preserved {} {}",
1065            count,
1066            if count == 1 { "message" } else { "messages" }
1067        ));
1068    }
1069    if let Some(status) = review_status {
1070        match status {
1071            "reviewed" => result.push_str(", reviewed"),
1072            "draft_validated" => result.push_str(", validated draft"),
1073            _ => {},
1074        }
1075    }
1076    result = append_action_duration(result, duration_secs);
1077
1078    let mut lines = vec![Line::from(vec![
1079        Span::styled("● ", Style::new().fg(action_color).bold()),
1080        Span::styled("Compact(", Style::new().fg(action_color).bold()),
1081        Span::styled(
1082            trigger.to_string(),
1083            Style::new().fg(theme.colors.text_secondary.to_color()),
1084        ),
1085        Span::styled(")", Style::new().fg(action_color).bold()),
1086    ])];
1087    lines.extend(wrap_styled_line(
1088        Line::from(vec![
1089            Span::styled("  ⎿ ", Style::new().fg(action_color)),
1090            Span::styled(
1091                result,
1092                Style::new().fg(theme.colors.text_secondary.to_color()),
1093            ),
1094        ]),
1095        viewport_width,
1096        4,
1097    ));
1098
1099    if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
1100        lines.extend(wrap_styled_line(
1101            Line::from(vec![
1102                Span::styled("    ", Style::new().fg(action_color)),
1103                Span::styled(
1104                    format!("review: {}", compact_inline_error(error, 180)),
1105                    Style::new().fg(theme.colors.warning.to_color()),
1106                ),
1107            ]),
1108            viewport_width,
1109            4,
1110        ));
1111    }
1112
1113    Some(lines)
1114}
1115
1116fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1117    value
1118        .get(key)?
1119        .as_u64()
1120        .and_then(|value| usize::try_from(value).ok())
1121}
1122
1123fn compact_inline_error(text: &str, max_chars: usize) -> String {
1124    let text = text.trim();
1125    if text.chars().count() <= max_chars {
1126        return text.to_string();
1127    }
1128    let keep = max_chars.saturating_sub(3);
1129    let mut out: String = text.chars().take(keep).collect();
1130    out.push_str("...");
1131    out
1132}
1133
1134/// Render actions in Claude Code style
1135/// Expand tab characters to spaces on 4-column tab stops.
1136///
1137/// Tabs paint as zero cells in the terminal buffer, so a line containing them
1138/// has a char count larger than its painted width. Any width math done by char
1139/// count (e.g. padding a diff line so its background bar spans the row) would
1140/// then come up short by one column per tab. Expanding here keeps indentation
1141/// visible and makes char count match painted width.
1142fn expand_tabs(s: &str) -> String {
1143    const TAB_WIDTH: usize = 4;
1144    if !s.contains('\t') {
1145        return s.to_string();
1146    }
1147    let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1148    let mut col = 0usize;
1149    for ch in s.chars() {
1150        if ch == '\t' {
1151            let n = TAB_WIDTH - (col % TAB_WIDTH);
1152            for _ in 0..n {
1153                out.push(' ');
1154            }
1155            col += n;
1156        } else {
1157            out.push(ch);
1158            col += UnicodeWidthChar::width(ch).unwrap_or(0);
1159        }
1160    }
1161    out
1162}
1163
1164fn render_actions(
1165    actions: &[ActionDisplay],
1166    lines: &mut Vec<Line>,
1167    theme: &Theme,
1168    viewport_width: usize,
1169    blink_on: bool,
1170) {
1171    for (action_idx, action) in actions.iter().enumerate() {
1172        if action_idx > 0 {
1173            lines.push(Line::from(""));
1174        }
1175        // An answered `ask_user_question` renders as its own block — the
1176        // user's answers ARE the outcome, so the transcript shows each
1177        // question → answer pair instead of the generic `name(target)`
1178        // header over a bare duration.
1179        if let Some(meta) = &action.metadata
1180            && let ToolMetadata::Questions {
1181                answers,
1182                remembered,
1183            } = &meta.detail
1184            && matches!(action.result, ActionResult::Success { .. })
1185        {
1186            render_question_answers(answers, *remembered, lines, theme, viewport_width);
1187            continue;
1188        }
1189        // An approved plan (`exit_plan_mode`) renders as its own block: the
1190        // plan body IS the outcome, shown as markdown under a header naming
1191        // the saved plan file.
1192        if let Some(meta) = &action.metadata
1193            && let ToolMetadata::Plan { path, body, .. } = &meta.detail
1194            && matches!(action.result, ActionResult::Success { .. })
1195        {
1196            render_plan_approved(path, body, lines, theme, viewport_width);
1197            continue;
1198        }
1199        let action_color = match action.action_type.as_str() {
1200            "Write" | "Update" => theme.colors.success.to_color(),
1201            "Delete" => theme.colors.warning.to_color(),
1202            _ => theme.colors.info.to_color(),
1203        };
1204
1205        // Header: ● Type(target) — the target (a command, query, path…) wraps
1206        // instead of clipping at the viewport edge. Its own newlines are kept
1207        // as rows and overlong rows word-wrap with a hanging indent; a huge
1208        // target (e.g. a heredoc script) is capped so one Bash call can't
1209        // flood the transcript — the cap row ends in "…)" like a truncation.
1210        // An in-flight call's dot blinks (accent ↔ faded) as the live "this
1211        // one is still running" indicator; the rest of the header stays put.
1212        let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
1213            Style::new()
1214                .fg(theme.colors.text_disabled.to_color())
1215                .bold()
1216        } else {
1217            Style::new().fg(action_color).bold()
1218        };
1219        push_action_header(
1220            lines,
1221            action,
1222            action_color,
1223            dot_style,
1224            theme,
1225            viewport_width,
1226        );
1227
1228        match &action.result {
1229            // In flight: the header row (with its blinking dot) is the whole
1230            // display — the result elbow arrives with the outcome.
1231            ActionResult::Running => {},
1232            ActionResult::Success { .. } => {
1233                // Result summary from details enum
1234                let result_msg = match &action.details {
1235                    ActionDetails::FileContent { line_count, .. } => {
1236                        let base = format!(
1237                            "{} {} written",
1238                            line_count,
1239                            if *line_count == 1 { "line" } else { "lines" }
1240                        );
1241                        append_action_duration(base, action.duration_seconds)
1242                    },
1243                    ActionDetails::Diff { summary, .. } => summary.clone(),
1244                    ActionDetails::Preview { text, .. } => text.clone(),
1245                    // Success is already implied (an error renders differently),
1246                    // so a plain success needs no label — the header shows the
1247                    // action + target; the line just carries the timing.
1248                    ActionDetails::Simple => {
1249                        append_action_duration(String::new(), action.duration_seconds)
1250                    },
1251                };
1252
1253                for (idx, line) in result_msg.lines().enumerate() {
1254                    let prefix = if idx == 0 { "  ⎿ " } else { "    " };
1255                    // Word-wrap the result row (4-space hanging indent) so a
1256                    // long summary is readable instead of clipped.
1257                    lines.extend(wrap_styled_line(
1258                        Line::from(vec![
1259                            Span::styled(prefix, Style::new().fg(action_color)),
1260                            Span::styled(
1261                                line.to_string(),
1262                                Style::new().fg(theme.colors.text_secondary.to_color()),
1263                            ),
1264                        ]),
1265                        viewport_width,
1266                        4,
1267                    ));
1268                }
1269
1270                // Write: syntax-highlighted file preview
1271                if let ActionDetails::FileContent {
1272                    content,
1273                    line_count,
1274                } = &action.details
1275                {
1276                    let preview_lines: Vec<&str> = content.lines().take(10).collect();
1277                    if !preview_lines.is_empty() {
1278                        lines.push(Line::from(vec![Span::styled(
1279                            "    ",
1280                            Style::new().fg(action_color),
1281                        )]));
1282
1283                        let preview_content = preview_lines.join("\n");
1284                        let mut parsed = parse_markdown(
1285                            &format!("```\n{}\n```", preview_content),
1286                            theme,
1287                            viewport_width.saturating_sub(4),
1288                        );
1289                        for parsed_line in parsed.iter_mut() {
1290                            let mut new_spans =
1291                                vec![Span::styled("    ", Style::new().fg(action_color))];
1292                            new_spans.append(&mut parsed_line.line.spans);
1293                            parsed_line.line.spans = new_spans;
1294                        }
1295                        // Hard-wrap (not word-wrap) so code indentation and
1296                        // alignment survive; overlong rows continue with a
1297                        // 6-space hanging indent instead of clipping.
1298                        lines.extend(
1299                            parsed
1300                                .into_iter()
1301                                .flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
1302                        );
1303
1304                        if *line_count > 10 {
1305                            lines.push(Line::from(vec![
1306                                Span::styled("    ", Style::new().fg(action_color)),
1307                                Span::styled(
1308                                    format!("... ({} more lines)", line_count - 10),
1309                                    Style::new()
1310                                        .fg(theme.colors.text_disabled.to_color())
1311                                        .italic(),
1312                                ),
1313                            ]));
1314                        }
1315                    }
1316                }
1317
1318                // Edit: color-coded diff
1319                if let ActionDetails::Diff { diff, .. } = &action.details {
1320                    let diff_lines: Vec<&str> = diff.lines().collect();
1321                    let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1322
1323                    if !display_lines.is_empty() {
1324                        let removed_bg = theme.colors.diff_removed_bg.to_color();
1325                        let added_bg = theme.colors.diff_added_bg.to_color();
1326
1327                        for diff_line in &display_lines {
1328                            // Expand tabs first: the TUI paints a tab as zero
1329                            // cells, so a tab-bearing line's char count exceeds
1330                            // its painted width and the char-count pad below
1331                            // would leave the background bar short — a ragged
1332                            // "staircase" down the right edge. Expanding also
1333                            // makes tab indentation actually visible.
1334                            let diff_line = expand_tabs(diff_line);
1335                            // Delegate the producer-format awareness to
1336                            // `parse_diff_line`, which lives next to the
1337                            // marker constants and stays in lockstep with
1338                            // any future format change.
1339                            match parse_diff_line(&diff_line) {
1340                                DiffLineKind::Removed => {
1341                                    push_wrapped_diff_rows(
1342                                        lines,
1343                                        format!("    {}", diff_line),
1344                                        Style::new()
1345                                            .fg(theme.colors.error.to_color())
1346                                            .bg(removed_bg),
1347                                        viewport_width,
1348                                    );
1349                                },
1350                                DiffLineKind::Added => {
1351                                    push_wrapped_diff_rows(
1352                                        lines,
1353                                        format!("    {}", diff_line),
1354                                        Style::new()
1355                                            .fg(theme.colors.success.to_color())
1356                                            .bg(added_bg),
1357                                        viewport_width,
1358                                    );
1359                                },
1360                                DiffLineKind::Context => {
1361                                    // Hard-wrap like the colored rows so an
1362                                    // overlong context line isn't clipped.
1363                                    lines.extend(wrap_preformatted(
1364                                        Line::from(vec![
1365                                            Span::styled("    ", Style::new().fg(action_color)),
1366                                            Span::styled(
1367                                                diff_line,
1368                                                Style::new()
1369                                                    .fg(theme.colors.text_secondary.to_color()),
1370                                            ),
1371                                        ]),
1372                                        viewport_width,
1373                                        6,
1374                                    ));
1375                                },
1376                            }
1377                        }
1378
1379                        let remaining = diff_lines.len().saturating_sub(display_lines.len());
1380                        if remaining > 0 {
1381                            lines.push(Line::from(vec![
1382                                Span::styled("    ", Style::new().fg(action_color)),
1383                                Span::styled(
1384                                    format!("... ({} more lines)", remaining),
1385                                    Style::new()
1386                                        .fg(theme.colors.text_disabled.to_color())
1387                                        .italic(),
1388                                ),
1389                            ]));
1390                        }
1391                    }
1392                }
1393            },
1394            ActionResult::Error { error } => {
1395                let error =
1396                    append_action_duration(format!("Error: {}", error), action.duration_seconds);
1397                // Word-wrap so the full error body (an HTTP error JSON can run
1398                // hundreds of cells) is readable instead of clipped at the
1399                // viewport edge. Multi-line errors keep their own rows.
1400                for (idx, err_line) in error.lines().enumerate() {
1401                    let prefix = if idx == 0 { "  ⎿ " } else { "    " };
1402                    lines.extend(wrap_styled_line(
1403                        Line::from(vec![
1404                            Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
1405                            Span::styled(
1406                                err_line.to_string(),
1407                                Style::new().fg(theme.colors.error.to_color()),
1408                            ),
1409                        ]),
1410                        viewport_width,
1411                        4,
1412                    ));
1413                }
1414            },
1415        }
1416    }
1417}
1418
1419/// Record of an approved plan (`exit_plan_mode`): a header bullet naming the
1420/// plan file, then the plan body rendered as markdown under the elbow gutter
1421/// — the transcript keeps the exact text the user approved.
1422fn render_plan_approved(
1423    path: &str,
1424    body: &str,
1425    lines: &mut Vec<Line>,
1426    theme: &Theme,
1427    viewport_width: usize,
1428) {
1429    lines.push(Line::from(Span::styled(
1430        format!("● User approved the plan — {path}"),
1431        Style::new().fg(theme.colors.success.to_color()),
1432    )));
1433    let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1434    // The 4-cell gutter comes off the markdown wrap budget, matching the
1435    // question→answer block above.
1436    let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
1437    let mut first_row = true;
1438    for mut parsed_line in parsed {
1439        let gutter = if first_row { "  ⎿ " } else { "    " };
1440        first_row = false;
1441        let mut spans = vec![Span::styled(gutter, gutter_style)];
1442        spans.append(&mut parsed_line.line.spans);
1443        lines.push(Line::from(spans));
1444    }
1445}
1446
1447/// Claude-Code-style record of an answered `ask_user_question` call: a plain
1448/// header bullet plus one `· question → answer` line per question, so the
1449/// transcript preserves what the user chose (not just how long it took).
1450fn render_question_answers(
1451    answers: &[QuestionAnswer],
1452    remembered: bool,
1453    lines: &mut Vec<Line>,
1454    theme: &Theme,
1455    viewport_width: usize,
1456) {
1457    let header = if remembered {
1458        "User answered the model's questions (remembered):"
1459    } else {
1460        "User answered the model's questions:"
1461    };
1462    lines.push(Line::from(Span::styled(
1463        format!("● {header}"),
1464        Style::new().fg(theme.colors.text_primary.to_color()),
1465    )));
1466
1467    let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
1468    let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
1469    let note_style = Style::new()
1470        .fg(theme.colors.text_disabled.to_color())
1471        .italic();
1472    // The 4-cell gutter ("  ⎿ " on the first row, "    " after) comes off the
1473    // wrap budget; continuations hang 2 cells so wrapped text aligns under
1474    // the question, not the `·`.
1475    let wrap_width = viewport_width.saturating_sub(4);
1476    let mut first_row = true;
1477    for answer in answers {
1478        let value = if answer.selected.is_empty() {
1479            "(no selection)".to_string()
1480        } else {
1481            answer.selected.join(", ")
1482        };
1483        let entry = format!("· {} → {}", answer.question, value);
1484        let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
1485            .into_iter()
1486            .map(|row| (row, text_style))
1487            .collect();
1488        if let Some(note) = &answer.note {
1489            rows.extend(
1490                wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
1491                    .into_iter()
1492                    .map(|row| (row, note_style)),
1493            );
1494        }
1495        for (row, style) in rows {
1496            let gutter = if first_row { "  ⎿ " } else { "    " };
1497            first_row = false;
1498            lines.push(Line::from(vec![
1499                Span::styled(gutter, gutter_style),
1500                Span::styled(row, style),
1501            ]));
1502        }
1503    }
1504}
1505
1506/// Cap on wrapped action-header rows: a long target (a Bash heredoc, a huge
1507/// query) wraps for readability, but past this many rows it truncates with
1508/// "…)" so a single tool call can't flood the transcript.
1509const MAX_ACTION_HEADER_ROWS: usize = 4;
1510
1511/// Push the "● Type(target)" action header, wrapping the target across rows
1512/// instead of letting an over-wide one clip at the viewport edge.
1513///
1514/// The target's own newlines are preserved as row breaks; overlong rows
1515/// word-wrap with a 4-space hanging indent (an unbroken token hard-breaks).
1516/// Two cells are reserved so the closing ")" — and the "…" a capped header
1517/// gains — never overflow the last row.
1518fn push_action_header(
1519    lines: &mut Vec<Line>,
1520    action: &ActionDisplay,
1521    action_color: Color,
1522    dot_style: Style,
1523    theme: &Theme,
1524    viewport_width: usize,
1525) {
1526    let bold = Style::new().fg(action_color).bold();
1527    let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
1528    if action.target.is_empty() {
1529        lines.push(Line::from(vec![
1530            Span::styled("● ", dot_style),
1531            Span::styled(format!("{}()", action.action_type), bold),
1532        ]));
1533        return;
1534    }
1535
1536    let open = format!("{}(", action.action_type);
1537    // The first row's indent stands in for the 2-cell "● " plus the opening
1538    // "Type(" so wrapping accounts for them; it is stripped and replaced with
1539    // the real styled spans below.
1540    let first_indent = 2 + open.width();
1541    let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
1542    let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
1543    let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
1544    rows.truncate(MAX_ACTION_HEADER_ROWS);
1545
1546    let last = rows.len().saturating_sub(1);
1547    for (i, row) in rows.into_iter().enumerate() {
1548        let mut spans = if i == 0 {
1549            vec![
1550                Span::styled("● ", dot_style),
1551                Span::styled(open.clone(), bold),
1552                Span::styled(row.trim_start().to_string(), secondary),
1553            ]
1554        } else {
1555            vec![Span::styled(row, secondary)]
1556        };
1557        if i == last {
1558            if truncated {
1559                spans.push(Span::styled(
1560                    "…",
1561                    Style::new().fg(theme.colors.text_disabled.to_color()),
1562                ));
1563            }
1564            spans.push(Span::styled(")", bold));
1565        }
1566        lines.push(Line::from(spans));
1567    }
1568}
1569
1570/// Push one colored diff row, hard-wrapped at the viewport width and padded so
1571/// every produced row carries the full-width background bar (no unfilled
1572/// column on a diff row — the "staircase" invariant).
1573fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
1574    for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
1575        let padded = pad_to_cells(&line_plain_text(&row), width);
1576        lines.push(Line::from(Span::styled(padded, style)));
1577    }
1578}
1579
1580fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1581    if let Some(seconds) = duration_seconds {
1582        // An empty base (a plain success with no detail) becomes just
1583        // "took Xms" — no leading comma.
1584        if !text.is_empty() {
1585            text.push_str(", ");
1586        }
1587        text.push_str("took ");
1588        text.push_str(&format_action_duration(seconds));
1589    }
1590    text
1591}
1592
1593fn format_action_duration(seconds: f64) -> String {
1594    if seconds < 1.0 {
1595        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1596    } else if seconds < 10.0 {
1597        format!("{:.1}s", seconds)
1598    } else {
1599        format!("{}s", seconds.round() as u64)
1600    }
1601}
1602
1603/// Hard-break a single over-long token into the plain-text line accumulator,
1604/// splitting at char boundaries (UTF-8-safe, display-cell aware) so a giant
1605/// unbroken token (e.g. a 5000-char URL) wraps across lines instead of
1606/// overflowing the viewport and being clipped (F33).
1607///
1608/// Mirrors the accumulation `wrap_text_with_indent` does for normal words:
1609/// `current_line`/`current_length` carry the in-progress line (its indent
1610/// already pushed into `current_line`, not counted in `current_length`),
1611/// finished lines are pushed to `out`, and each new line gets a
1612/// `continuation_indent`-space hanging indent. `initial_budget` is the content
1613/// width available on the line the token starts on (the caller's per-line
1614/// `available_width`); subsequent lines use `width - continuation_indent`.
1615fn hard_break_plain_token(
1616    token: &str,
1617    out: &mut Vec<String>,
1618    current_line: &mut String,
1619    current_length: &mut usize,
1620    width: usize,
1621    continuation_indent: usize,
1622    initial_budget: usize,
1623) {
1624    let cont_budget = width.saturating_sub(continuation_indent).max(1);
1625    let mut line_budget = initial_budget.max(1);
1626
1627    // If the current line already holds content, flush it so the token starts
1628    // fresh on a continuation line; otherwise break onto the current (indent-
1629    // only) line directly.
1630    if *current_length > 0 {
1631        out.push(std::mem::take(current_line));
1632        current_line.push_str(&" ".repeat(continuation_indent));
1633        *current_length = 0;
1634        line_budget = cont_budget;
1635    }
1636
1637    for ch in token.chars() {
1638        let cw = ch.width().unwrap_or(0);
1639        // Break before this char if it would overflow and the line already
1640        // holds at least one glyph (so a single too-wide glyph never loops).
1641        if *current_length + cw > line_budget && *current_length > 0 {
1642            out.push(std::mem::take(current_line));
1643            current_line.push_str(&" ".repeat(continuation_indent));
1644            *current_length = 0;
1645            line_budget = cont_budget;
1646        }
1647        current_line.push(ch);
1648        *current_length += cw;
1649    }
1650}
1651
1652/// Wrap text with hanging indent support.
1653///
1654/// `width`, `first_line_indent`, and `continuation_indent` are all measured
1655/// in **display cells**, not bytes. Word lengths are also measured in cells
1656/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
1657/// previously a CJK paragraph would wrap after ~1/3 of the line because
1658/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
1659/// codepoints.
1660fn wrap_text_with_indent(
1661    text: &str,
1662    width: usize,
1663    first_line_indent: usize,
1664    continuation_indent: usize,
1665) -> Vec<String> {
1666    let mut wrapped_lines = Vec::new();
1667
1668    for (line_idx, line) in text.lines().enumerate() {
1669        if line.is_empty() {
1670            wrapped_lines.push(String::new());
1671            continue;
1672        }
1673
1674        let current_indent = if line_idx == 0 {
1675            first_line_indent
1676        } else {
1677            continuation_indent
1678        };
1679        let available_width = width.saturating_sub(current_indent);
1680
1681        if available_width == 0 {
1682            wrapped_lines.push(" ".repeat(current_indent));
1683            continue;
1684        }
1685
1686        let words: Vec<&str> = line.split_whitespace().collect();
1687        if words.is_empty() {
1688            wrapped_lines.push(" ".repeat(current_indent));
1689            continue;
1690        }
1691
1692        let mut current_line = String::with_capacity(width);
1693        current_line.push_str(&" ".repeat(current_indent));
1694        // Display-cell widths: indent is ASCII spaces (1 cell each), so
1695        // start fresh and let words contribute their own cell widths.
1696        let mut current_length = 0;
1697
1698        for (word_idx, word) in words.iter().enumerate() {
1699            let word_width = word.width();
1700
1701            if word_idx == 0 {
1702                if word_width <= available_width {
1703                    // First word fits on the line
1704                    current_line.push_str(word);
1705                    current_length = word_width;
1706                } else {
1707                    // A single token wider than the whole line (e.g. a long
1708                    // URL): hard-break it at width boundaries so it wraps
1709                    // instead of overflowing the viewport and being clipped
1710                    // (F33).
1711                    hard_break_plain_token(
1712                        word,
1713                        &mut wrapped_lines,
1714                        &mut current_line,
1715                        &mut current_length,
1716                        width,
1717                        continuation_indent,
1718                        available_width,
1719                    );
1720                }
1721            } else if current_length + 1 + word_width <= available_width {
1722                // Word fits on current line (the +1 accounts for the
1723                // separator space, which is 1 cell)
1724                current_line.push(' ');
1725                current_line.push_str(word);
1726                current_length += 1 + word_width;
1727            } else if word_width <= available_width {
1728                // Word doesn't fit, start a new line
1729                wrapped_lines.push(current_line);
1730                current_line = String::with_capacity(width);
1731                current_line.push_str(&" ".repeat(continuation_indent));
1732                current_line.push_str(word);
1733                current_length = word_width;
1734            } else {
1735                // Over-long token mid-paragraph: flush the current line, then
1736                // hard-break the token across continuation lines (F33).
1737                hard_break_plain_token(
1738                    word,
1739                    &mut wrapped_lines,
1740                    &mut current_line,
1741                    &mut current_length,
1742                    width,
1743                    continuation_indent,
1744                    available_width,
1745                );
1746            }
1747        }
1748
1749        // Add the last line
1750        if !current_line.trim().is_empty() {
1751            wrapped_lines.push(current_line);
1752        }
1753    }
1754
1755    wrapped_lines
1756}
1757
1758/// Hard-break a single over-long word into the styled line accumulator,
1759/// splitting at char boundaries (UTF-8-safe, display-cell aware) and keeping
1760/// each fragment's own style on every produced piece, so a giant unbroken
1761/// token (e.g. a long URL) wraps across rows instead of overflowing the
1762/// viewport and being clipped (F33). The styled counterpart of
1763/// `hard_break_plain_token`. The word arrives as styled fragments (see the
1764/// flattening pass in `wrap_styled_line`) because a token can change style
1765/// mid-word (`**bold**suffix`); the break must not flatten that to one style.
1766///
1767/// `current_line_spans`/`current_line_width` carry the in-progress row;
1768/// finished rows are pushed to `result_lines`; each new row opens with a
1769/// `continuation_indent`-space span. `line_capacity` is the width budget for
1770/// the row the token starts on (the first row counts its leading indent in
1771/// `current_line_width`, so its budget is the full `width`); wrapped rows use
1772/// `continuation_capacity` (the caller's `available_width`, with the indent in
1773/// a separate span and not counted).
1774#[allow(clippy::too_many_arguments)]
1775fn hard_break_styled_word(
1776    fragments: &[(String, Style)],
1777    result_lines: &mut Vec<Line<'static>>,
1778    current_line_spans: &mut Vec<Span<'static>>,
1779    current_line_width: &mut usize,
1780    continuation_indent: usize,
1781    continuation_capacity: usize,
1782    mut line_capacity: usize,
1783) {
1784    for (text, style) in fragments {
1785        let mut buf = String::new();
1786        for ch in text.chars() {
1787            let cw = ch.width().unwrap_or(0);
1788            // Break before this char if it would overflow and the row already
1789            // holds at least one glyph (so a single too-wide glyph never loops).
1790            if *current_line_width + cw > line_capacity && *current_line_width > 0 {
1791                if !buf.is_empty() {
1792                    current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
1793                }
1794                result_lines.push(Line::from(std::mem::take(current_line_spans)));
1795                current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1796                *current_line_width = 0;
1797                line_capacity = continuation_capacity.max(1);
1798            }
1799            buf.push(ch);
1800            *current_line_width += cw;
1801        }
1802        if !buf.is_empty() {
1803            current_line_spans.push(Span::styled(buf, *style));
1804        }
1805    }
1806}
1807
1808/// Wrap a styled Line with hanging indent, preserving all span styles.
1809/// Returns multiple Line objects with proper indentation.
1810///
1811/// Wrapping runs over a word stream flattened ACROSS spans: a word is a run of
1812/// styled fragments, and a word boundary exists only where the source text has
1813/// whitespace. A span ending mid-word glues onto the next span's text, so
1814/// `**bold**suffix` stays one token and a `.` right after a link's dimmed URL
1815/// stays attached (no phantom space at a style boundary). Separator spaces
1816/// between words are re-emitted UNSTYLED so a link's underline or inline
1817/// code's background never paints the gap before it.
1818fn wrap_styled_line(
1819    line: Line<'static>,
1820    width: usize,
1821    continuation_indent: usize,
1822) -> Vec<Line<'static>> {
1823    // Widths are counted in display cells (via `UnicodeWidthStr`), not
1824    // bytes. This makes CJK double-width chars and emoji wrap at the
1825    // correct visual column, and avoids over-wrapping multi-byte ASCII-
1826    // looking glyphs.
1827    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1828
1829    // If the line fits within width, return as-is
1830    if total_width <= width {
1831        return vec![line];
1832    }
1833
1834    // Line needs wrapping - extract all text and styles
1835    let mut result_lines = Vec::new();
1836    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
1837    let mut current_line_width = 0usize;
1838    let available_width = width.saturating_sub(continuation_indent);
1839
1840    // Preserve the line's existing left margin (the "  " continuation gutter the
1841    // caller prepends to every non-first message line) on the *first* wrapped
1842    // segment. The whitespace split below drops leading spaces and the "first
1843    // word, no indent" rule would then flush the segment to column 0 — that's the
1844    // recurring bug where a wrapped paragraph escapes the message gutter while its
1845    // own continuation lines (which get `continuation_indent`) stay aligned. A
1846    // non-whitespace prefix like "● " is unaffected (it survives the split).
1847    let leading_indent: usize = {
1848        let mut n = 0;
1849        for span in &line.spans {
1850            let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1851            n += spaces;
1852            if spaces < span.content.len() {
1853                break; // this span has non-space content, so leading run ends here
1854            }
1855        }
1856        n
1857    };
1858
1859    // Flatten the spans into words: each word is a run of styled fragments.
1860    // Whitespace anywhere closes the current word (runs collapse to a single
1861    // boundary); a span ending mid-word leaves the word open so the next
1862    // span's text glues on — a style change is NOT a word boundary.
1863    let mut words: Vec<Vec<(String, Style)>> = Vec::new();
1864    let mut current_word: Vec<(String, Style)> = Vec::new();
1865    for span in &line.spans {
1866        let mut frag = String::new();
1867        for ch in span.content.chars() {
1868            if ch.is_whitespace() {
1869                if !frag.is_empty() {
1870                    current_word.push((std::mem::take(&mut frag), span.style));
1871                }
1872                if !current_word.is_empty() {
1873                    words.push(std::mem::take(&mut current_word));
1874                }
1875            } else {
1876                frag.push(ch);
1877            }
1878        }
1879        if !frag.is_empty() {
1880            current_word.push((frag, span.style));
1881        }
1882    }
1883    if !current_word.is_empty() {
1884        words.push(current_word);
1885    }
1886
1887    fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
1888        for (text, style) in word {
1889            spans.push(Span::styled(text, style));
1890        }
1891    }
1892
1893    for word in words {
1894        let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
1895
1896        if current_line_width == 0 && result_lines.is_empty() {
1897            // First word of the first line: re-apply the original left margin
1898            // (dropped by the whitespace split) so the segment keeps the gutter
1899            // instead of flushing to column 0.
1900            if leading_indent > 0 {
1901                current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1902                current_line_width += leading_indent;
1903            }
1904            if word_width <= available_width {
1905                current_line_width += word_width;
1906                emit_word(&mut current_line_spans, word);
1907            } else {
1908                // A single token wider than the line (e.g. a long URL):
1909                // hard-break it at width boundaries so it wraps instead of
1910                // being clipped by the viewport (F33). The first row may use
1911                // the full `width` (its indent is already counted above);
1912                // continuation rows fall back to `available_width`.
1913                hard_break_styled_word(
1914                    &word,
1915                    &mut result_lines,
1916                    &mut current_line_spans,
1917                    &mut current_line_width,
1918                    continuation_indent,
1919                    available_width,
1920                    width,
1921                );
1922            }
1923            continue;
1924        }
1925
1926        // Separator space before this word — only when the row already holds
1927        // content, and always UNSTYLED: the space between words belongs to
1928        // neither word's style (an underlined link must not underline the gap
1929        // in front of it).
1930        let sep = usize::from(current_line_width > 0);
1931        if current_line_width + sep + word_width <= available_width {
1932            // Word fits on current line
1933            if sep == 1 {
1934                current_line_spans.push(Span::raw(" "));
1935            }
1936            current_line_width += sep + word_width;
1937            emit_word(&mut current_line_spans, word);
1938        } else if word_width <= available_width {
1939            // Word doesn't fit - finish current line and start new one
1940            result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1941            current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1942            current_line_width = word_width;
1943            emit_word(&mut current_line_spans, word);
1944        } else {
1945            // Over-long token mid-line: finish the current line, then
1946            // hard-break the token across continuation rows (F33), keeping
1947            // each fragment's style on every produced piece.
1948            result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1949            current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1950            current_line_width = 0;
1951            hard_break_styled_word(
1952                &word,
1953                &mut result_lines,
1954                &mut current_line_spans,
1955                &mut current_line_width,
1956                continuation_indent,
1957                available_width,
1958                available_width,
1959            );
1960        }
1961    }
1962
1963    // Add the last line if it has content
1964    if !current_line_spans.is_empty() {
1965        result_lines.push(Line::from(current_line_spans));
1966    }
1967
1968    if result_lines.is_empty() {
1969        vec![line]
1970    } else {
1971        result_lines
1972    }
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977    use super::*;
1978
1979    #[test]
1980    fn question_answers_render_as_question_arrow_answer_block() {
1981        use crate::domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
1982
1983        let theme = Theme::dark();
1984        let answers = vec![
1985            QuestionAnswer {
1986                header: "Snack".to_string(),
1987                question: "Which snack fuels your next coding session?".to_string(),
1988                selected: vec!["Coffee (Recommended)".to_string()],
1989                note: None,
1990            },
1991            QuestionAnswer {
1992                header: "Powers".to_string(),
1993                question: "Which superpowers would you take?".to_string(),
1994                selected: vec![
1995                    "Read any codebase instantly".to_string(),
1996                    "Bugs reproduce on demand".to_string(),
1997                ],
1998                note: Some("only on weekdays".to_string()),
1999            },
2000        ];
2001        let action = ActionDisplay {
2002            action_type: "ask_user_question".to_string(),
2003            target: String::new(),
2004            result: ActionResult::Success {
2005                output: String::new(),
2006                images: None,
2007            },
2008            details: ActionDetails::Simple,
2009            duration_seconds: Some(93.0),
2010            metadata: Some(ToolRunMetadata {
2011                detail: ToolMetadata::Questions {
2012                    answers,
2013                    remembered: false,
2014                },
2015                ..Default::default()
2016            }),
2017        };
2018
2019        let mut lines: Vec<Line> = Vec::new();
2020        render_actions(&[action], &mut lines, &theme, 120, true);
2021        let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2022        let all = rows.join("\n");
2023
2024        assert_eq!(rows[0], "● User answered the model's questions:");
2025        assert!(
2026            rows[1].starts_with("  ⎿ · Which snack fuels your next coding session? → Coffee"),
2027            "got {:?}",
2028            rows[1]
2029        );
2030        assert!(
2031            all.contains(
2032                "· Which superpowers would you take? → Read any codebase instantly, \
2033                 Bugs reproduce on demand"
2034            ),
2035            "got {all}"
2036        );
2037        assert!(all.contains("(note: only on weekdays)"), "got {all}");
2038        // The generic `name()` header and duration line are replaced entirely.
2039        assert!(!all.contains("ask_user_question("), "got {all}");
2040        assert!(!all.contains("took"), "got {all}");
2041    }
2042
2043    #[test]
2044    fn diff_background_fills_full_width_with_tabs() {
2045        // Regression: tab characters paint as zero cells, so char-count padding
2046        // left the red/green diff bar short by one column per tab — a ragged
2047        // "staircase" down the right edge. After expand_tabs, every column of a
2048        // diff row must carry the background.
2049        use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
2050        use ratatui::Terminal;
2051        use ratatui::backend::TestBackend;
2052
2053        let theme = Theme::dark();
2054        let added_bg = theme.colors.diff_added_bg.to_color();
2055        let removed_bg = theme.colors.diff_removed_bg.to_color();
2056        // Lines at increasing tab depth — the exact shape that staircased.
2057        let diff = format!(
2058            "  62{m}\tconst out = [];\n  63{p}\t\tlet fixed = false;\n  64{p}\t\t\tdeeplyNested();",
2059            m = DIFF_REMOVED_MARKER,
2060            p = DIFF_ADDED_MARKER
2061        );
2062        let action = ActionDisplay {
2063            action_type: "Update".to_string(),
2064            target: "engine.ts".to_string(),
2065            result: ActionResult::Success {
2066                output: String::new(),
2067                images: None,
2068            },
2069            details: ActionDetails::Diff {
2070                summary: "ok".to_string(),
2071                diff,
2072            },
2073            duration_seconds: Some(0.3),
2074            metadata: None,
2075        };
2076
2077        let width: u16 = 60;
2078        let mut lines: Vec<Line> = Vec::new();
2079        render_actions(&[action], &mut lines, &theme, width as usize, true);
2080        let h = lines.len() as u16;
2081        let backend = TestBackend::new(width, h);
2082        let mut term = Terminal::new(backend).unwrap();
2083        term.draw(|f| {
2084            Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
2085        })
2086        .unwrap();
2087        let buf = term.backend().buffer();
2088
2089        for y in 0..h {
2090            let is_diff_row = (0..width).any(|x| {
2091                let bg = buf[(x, y)].bg;
2092                bg == added_bg || bg == removed_bg
2093            });
2094            if !is_diff_row {
2095                continue;
2096            }
2097            for x in 0..width {
2098                let bg = buf[(x, y)].bg;
2099                assert!(
2100                    bg == added_bg || bg == removed_bg,
2101                    "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
2102                );
2103            }
2104        }
2105    }
2106
2107    /// Every rendered action row must fit the viewport width — overlong
2108    /// headers, results, and errors wrap instead of clipping at the edge.
2109    fn assert_rows_fit(lines: &[Line], width: usize) {
2110        for (i, line) in lines.iter().enumerate() {
2111            let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
2112            assert!(
2113                w <= width,
2114                "row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
2115                line_plain_text(line)
2116            );
2117        }
2118    }
2119
2120    #[test]
2121    fn action_header_and_error_wrap_instead_of_clipping() {
2122        // Regression: a long Bash command in the header and a long HTTP error
2123        // body in the result were painted as single over-wide rows and clipped
2124        // at the viewport edge instead of wrapping.
2125        let theme = Theme::dark();
2126        let action = ActionDisplay {
2127            action_type: "Error".to_string(),
2128            target: "Backend error".to_string(),
2129            result: ActionResult::Error {
2130                error: r#"HTTP error 404: {"error":{"code":"model_not_found","message":"The requested model was not found.","param":null,"type":"invalid_request_error"}}"#.to_string(),
2131            },
2132            details: ActionDetails::Simple,
2133            duration_seconds: None,
2134            metadata: None,
2135        };
2136
2137        let width = 60usize;
2138        let mut lines: Vec<Line> = Vec::new();
2139        render_actions(&[action], &mut lines, &theme, width, true);
2140
2141        assert_rows_fit(&lines, width);
2142        let rendered = lines
2143            .iter()
2144            .map(line_plain_text)
2145            .collect::<Vec<_>>()
2146            .join("\n");
2147        // The full error body must survive the wrap (word boundaries may move,
2148        // so check the tail token that clipping used to cut off).
2149        assert!(rendered.contains("invalid_request_error"));
2150        assert!(
2151            lines.len() > 2,
2152            "a 140-cell error at width 60 must span multiple rows"
2153        );
2154    }
2155
2156    #[test]
2157    fn action_header_wraps_long_command_and_keeps_closing_paren() {
2158        let theme = Theme::dark();
2159        let action = ActionDisplay {
2160            action_type: "Bash".to_string(),
2161            target: "python3 -c 'print(1)' && echo a-very-long-command-line \
2162                     that keeps going well past the sixty cell viewport edge"
2163                .to_string(),
2164            result: ActionResult::Success {
2165                output: String::new(),
2166                images: None,
2167            },
2168            details: ActionDetails::Simple,
2169            duration_seconds: Some(0.1),
2170            metadata: None,
2171        };
2172
2173        let width = 60usize;
2174        let mut lines: Vec<Line> = Vec::new();
2175        render_actions(&[action], &mut lines, &theme, width, true);
2176
2177        assert_rows_fit(&lines, width);
2178        let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2179        assert!(rows[0].starts_with("● Bash("));
2180        assert!(
2181            rows.len() >= 2,
2182            "the long command must wrap the header across rows"
2183        );
2184        let last_target_row = rows
2185            .iter()
2186            .rfind(|r| r.trim_end().ends_with(')'))
2187            .expect("wrapped header must still close its paren");
2188        assert!(last_target_row.trim_end().ends_with(')'));
2189    }
2190
2191    #[test]
2192    fn action_header_caps_rows_and_marks_truncation() {
2193        // A heredoc-sized target must not flood the transcript: the header
2194        // caps at MAX_ACTION_HEADER_ROWS and the last row signals "…)".
2195        let theme = Theme::dark();
2196        let action = ActionDisplay {
2197            action_type: "Bash".to_string(),
2198            target: "word ".repeat(400),
2199            result: ActionResult::Success {
2200                output: String::new(),
2201                images: None,
2202            },
2203            details: ActionDetails::Simple,
2204            duration_seconds: None,
2205            metadata: None,
2206        };
2207
2208        let width = 60usize;
2209        let mut lines: Vec<Line> = Vec::new();
2210        render_actions(&[action], &mut lines, &theme, width, true);
2211
2212        assert_rows_fit(&lines, width);
2213        let header_rows: Vec<String> = lines
2214            .iter()
2215            .map(line_plain_text)
2216            .take_while(|r| !r.trim_start().starts_with('⎿'))
2217            .collect();
2218        assert_eq!(
2219            header_rows.len(),
2220            MAX_ACTION_HEADER_ROWS,
2221            "header must cap at MAX_ACTION_HEADER_ROWS rows"
2222        );
2223        assert!(
2224            header_rows.last().unwrap().trim_end().ends_with("…)"),
2225            "capped header must end with …) — got {:?}",
2226            header_rows.last().unwrap()
2227        );
2228    }
2229
2230    #[test]
2231    fn action_header_preserves_multiline_command_rows() {
2232        // A multi-line command (heredoc-style) keeps its own line breaks in
2233        // the header instead of the old behavior where ratatui dropped the
2234        // newlines and glued fragments together ("'PY'from PIL import…").
2235        let theme = Theme::dark();
2236        let action = ActionDisplay {
2237            action_type: "Bash".to_string(),
2238            target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
2239            result: ActionResult::Success {
2240                output: String::new(),
2241                images: None,
2242            },
2243            details: ActionDetails::Simple,
2244            duration_seconds: None,
2245            metadata: None,
2246        };
2247
2248        let mut lines: Vec<Line> = Vec::new();
2249        render_actions(&[action], &mut lines, &theme, 80, true);
2250
2251        let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
2252        assert!(rows[0].contains("python3 - << 'PY'"));
2253        assert!(rows[1].contains("from PIL import Image"));
2254        assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
2255    }
2256
2257    #[test]
2258    fn action_result_summary_wraps_instead_of_clipping() {
2259        let theme = Theme::dark();
2260        let action = ActionDisplay {
2261            action_type: "Tasks".to_string(),
2262            target: "update 3 steps".to_string(),
2263            result: ActionResult::Success {
2264                output: String::new(),
2265                images: None,
2266            },
2267            details: ActionDetails::Preview {
2268                text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
2269                       placeholders kept intentionally until real data available. \
2270                       Task 2 and 6 deferred., to revisit later"
2271                    .to_string(),
2272                line_count: None,
2273            },
2274            duration_seconds: None,
2275            metadata: None,
2276        };
2277
2278        let width = 60usize;
2279        let mut lines: Vec<Line> = Vec::new();
2280        render_actions(&[action], &mut lines, &theme, width, true);
2281
2282        assert_rows_fit(&lines, width);
2283        let rendered = lines
2284            .iter()
2285            .map(line_plain_text)
2286            .collect::<Vec<_>>()
2287            .join("\n");
2288        assert!(
2289            rendered.contains("revisit later"),
2290            "the summary's tail must survive the wrap instead of being clipped"
2291        );
2292    }
2293
2294    #[test]
2295    fn wrapped_line_cache_hit_matches_cache_miss() {
2296        // #134: caching the WRAPPED assistant lines must be byte-for-byte
2297        // identical to wrapping fresh. Render the same messages through a shared
2298        // cache — first call misses (populates), second hits — and assert the
2299        // two frame buffers are equal; then prove a cold cache renders the same
2300        // frame as the warm one. Assistant-only messages keep the frame free of
2301        // the time-relative user timestamp, so nothing here is clock-dependent.
2302        use ratatui::Terminal;
2303        use ratatui::backend::TestBackend;
2304
2305        let theme = Theme::dark();
2306        let messages = vec![
2307            ChatMessage::assistant(
2308                "# Heading\n\nSome **bold** prose long enough that it has to wrap \
2309                 across this narrow viewport more than once.\n\n\
2310                 - a list item that also keeps going past the edge so it wraps too\n\
2311                 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
2312            ),
2313            ChatMessage::assistant("Short follow-up paragraph."),
2314        ];
2315
2316        let (width, height): (u16, u16) = (40, 40);
2317        let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2318            let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2319            let mut state = ChatState::new();
2320            term.draw(|f| {
2321                let widget = ChatWidget {
2322                    messages: &messages,
2323                    theme: &theme,
2324                    wrapped_line_cache: cache,
2325                    show_reasoning: true,
2326                    blink_on: true,
2327                };
2328                f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2329            })
2330            .unwrap();
2331            term.backend().buffer().clone()
2332        };
2333
2334        let mut shared = FxHashMap::default();
2335        let miss = render_once(&mut shared);
2336        assert!(!shared.is_empty(), "first render must populate the cache");
2337        let hit = render_once(&mut shared);
2338        assert_eq!(miss, hit, "cache hit must render identically to cache miss");
2339
2340        let mut cold_cache = FxHashMap::default();
2341        let cold = render_once(&mut cold_cache);
2342        assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
2343    }
2344
2345    #[test]
2346    fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
2347        // System notices are transcript furniture, not conversation: they must
2348        // render as indented muted-gray text — no role bullet, no right-aligned
2349        // timestamp (both belonged to the old user-layout share).
2350        use ratatui::Terminal;
2351        use ratatui::backend::TestBackend;
2352
2353        let theme = Theme::dark();
2354        let messages = vec![ChatMessage::system(
2355            "Heads up: this model reports no vision capability",
2356        )];
2357        let (width, height): (u16, u16) = (60, 10);
2358        let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2359        let mut state = ChatState::new();
2360        let mut cache = FxHashMap::default();
2361        term.draw(|f| {
2362            let widget = ChatWidget {
2363                messages: &messages,
2364                theme: &theme,
2365                wrapped_line_cache: &mut cache,
2366                show_reasoning: true,
2367                blink_on: true,
2368            };
2369            f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
2370        })
2371        .unwrap();
2372        let buf = term.backend().buffer();
2373        let rows: Vec<String> = (0..height)
2374            .map(|y| {
2375                (0..width)
2376                    .map(|x| buf[(x, y)].symbol().to_string())
2377                    .collect::<String>()
2378            })
2379            .collect();
2380        let all = rows.join("\n");
2381        assert!(
2382            !all.contains('●'),
2383            "no role bullet on system notices: {all}"
2384        );
2385        assert!(
2386            !all.contains("Today at"),
2387            "no timestamp on system notices: {all}"
2388        );
2389        let row = rows
2390            .iter()
2391            .position(|r| r.contains("Heads up"))
2392            .expect("notice rendered");
2393        assert!(
2394            rows[row].starts_with("  Heads up"),
2395            "2-space indent, nothing in the gutter: {:?}",
2396            rows[row]
2397        );
2398        let col = rows[row].find("Heads up").unwrap(); // ASCII row: byte == cell
2399        assert_eq!(
2400            buf[(col as u16, row as u16)].fg,
2401            theme.colors.text_meta.to_color(),
2402            "notice text uses the muted meta gray"
2403        );
2404    }
2405
2406    #[test]
2407    fn byte_at_cell_clamps_and_respects_cjk() {
2408        assert_eq!(byte_at_cell("hello", 0), 0);
2409        assert_eq!(byte_at_cell("hello", 3), 3);
2410        assert_eq!(byte_at_cell("hello", 99), 5); // clamp past end
2411        // "你好" = 2 chars, 3 bytes each, 2 cells each.
2412        assert_eq!(byte_at_cell("你好", 0), 0);
2413        assert_eq!(byte_at_cell("你好", 2), 3); // after first wide char
2414        // A cell index that lands mid-glyph keeps the glyph whole (rounds up).
2415        assert_eq!(byte_at_cell("你好", 1), 3);
2416    }
2417
2418    #[test]
2419    fn slice_by_cells_extracts_display_range() {
2420        assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
2421        assert_eq!(slice_by_cells("hello world", 6, 11), "world");
2422        assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
2423    }
2424
2425    #[test]
2426    fn pad_to_cells_fills_to_display_width() {
2427        assert_eq!(pad_to_cells("ab", 5), "ab   ");
2428        // "你好" = 4 display cells; pad to 6 → exactly 2 trailing spaces (#101).
2429        assert_eq!(pad_to_cells("你好", 6), "你好  ");
2430        // Already wide enough → unchanged (never truncates).
2431        assert_eq!(pad_to_cells("你好", 3), "你好");
2432        assert_eq!(pad_to_cells("", 0), "");
2433    }
2434
2435    #[test]
2436    fn user_timestamp_padding_aligns_on_display_cells() {
2437        // ASCII: prefix(4) + text(5) + gap(3) + ts(8) = 20 used; content 40.
2438        assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
2439        // A wider (CJK) message shrinks the gap but the timestamp still lands at
2440        // the content right edge: role + text + pad + ts == content_width (#104).
2441        let pad = user_timestamp_padding(4, 10, 8, 3, 40);
2442        assert_eq!(4 + 10 + pad + 8, 40);
2443        // Overflow (text wider than the line) clamps to min_gap, never underflows.
2444        assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
2445    }
2446
2447    #[test]
2448    fn wrap_preformatted_hard_wraps_preserving_spaces() {
2449        // 18 cells, wraps at 10. Spaces are preserved (not collapsed) and the
2450        // leading indentation survives on the first row.
2451        let line = Line::from(vec![Span::raw("    aaaa bbbb cccc")]);
2452        let wrapped = wrap_preformatted(line, 10, 2);
2453        assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
2454        let first: String = wrapped[0]
2455            .spans
2456            .iter()
2457            .map(|s| s.content.as_ref())
2458            .collect();
2459        assert!(
2460            first.starts_with("    aaaa"),
2461            "indentation must be preserved, got {first:?}"
2462        );
2463        let second: String = wrapped[1]
2464            .spans
2465            .iter()
2466            .map(|s| s.content.as_ref())
2467            .collect();
2468        assert!(
2469            second.starts_with("  "),
2470            "continuation should get the hanging indent, got {second:?}"
2471        );
2472    }
2473
2474    #[test]
2475    fn wrap_preformatted_short_line_unchanged() {
2476        let line = Line::from(vec![Span::raw("    short")]);
2477        let wrapped = wrap_preformatted(line, 40, 2);
2478        assert_eq!(wrapped.len(), 1);
2479        let text: String = wrapped[0]
2480            .spans
2481            .iter()
2482            .map(|s| s.content.as_ref())
2483            .collect();
2484        assert_eq!(text, "    short");
2485    }
2486
2487    /// Build a ChatState whose last frame rendered `rows`, with a selection
2488    /// already mapped to content coords, so `selected_text` can be tested
2489    /// without a real terminal.
2490    fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
2491        let mut st = ChatState::new();
2492        st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
2493        st.selection = Some(sel);
2494        st
2495    }
2496
2497    #[test]
2498    fn selected_text_single_line() {
2499        let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
2500        assert_eq!(st.selected_text().as_deref(), Some("hello"));
2501    }
2502
2503    #[test]
2504    fn selected_text_spans_multiple_rows() {
2505        let st = state_with_rows(&["> first line", "  second line"], ((0, 2), (1, 8)));
2506        // The continuation row's "  " margin is stripped so copied text is
2507        // clean (the start row was sliced from the click column past "> ").
2508        assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
2509    }
2510
2511    #[test]
2512    fn selected_text_strips_margin_but_keeps_code_indentation() {
2513        // Rendered rows: 2-cell margin + the code's own indentation. Selecting
2514        // from column 0 must drop only the 2-cell margin, not the code indent.
2515        let st = state_with_rows(
2516            &["  fn main() {", "      let x = 1;", "  }"],
2517            ((0, 0), (2, 3)),
2518        );
2519        assert_eq!(
2520            st.selected_text().as_deref(),
2521            Some("fn main() {\n    let x = 1;\n}")
2522        );
2523    }
2524
2525    #[test]
2526    fn selected_text_normalizes_reversed_drag() {
2527        // Dragging bottom-up / right-to-left yields the same text.
2528        let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
2529        assert_eq!(st.selected_text().as_deref(), Some("hello"));
2530    }
2531
2532    #[test]
2533    fn selected_text_empty_selection_is_none() {
2534        // A plain click (anchor == cursor) selects nothing.
2535        let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
2536        assert_eq!(st.selected_text(), None);
2537    }
2538
2539    #[test]
2540    fn highlight_line_cells_splits_spans_on_selection() {
2541        let mut line = Line::from(vec![Span::raw("abcdef")]);
2542        highlight_line_cells(
2543            &mut line,
2544            2,
2545            4,
2546            Style::new().add_modifier(Modifier::REVERSED),
2547        );
2548        // Split into "ab" | "cd"(reversed) | "ef".
2549        let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
2550        assert_eq!(texts, vec!["ab", "cd", "ef"]);
2551        assert!(
2552            line.spans[1]
2553                .style
2554                .add_modifier
2555                .contains(Modifier::REVERSED)
2556        );
2557        assert!(
2558            !line.spans[0]
2559                .style
2560                .add_modifier
2561                .contains(Modifier::REVERSED)
2562        );
2563    }
2564
2565    #[test]
2566    fn context_checkpoint_renders_as_compact_event() {
2567        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2568        msg.kind = ChatMessageKind::ContextCheckpoint;
2569        msg.metadata = Some(serde_json::json!({
2570            "trigger": "manual",
2571            "before_tokens": 43_800,
2572            "after_tokens": 9_200,
2573            "archived_message_count": 18,
2574            "preserved_message_count": 4,
2575            "duration_secs": 2.4,
2576            "review_status": "reviewed",
2577        }));
2578
2579        let lines =
2580            render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2581        let rendered = lines
2582            .iter()
2583            .map(|line| {
2584                line.spans
2585                    .iter()
2586                    .map(|span| span.content.as_ref())
2587                    .collect::<String>()
2588            })
2589            .collect::<Vec<_>>()
2590            .join("\n");
2591
2592        assert!(rendered.contains("Compact(manual)"));
2593        assert!(rendered.contains("43.8k -> 9.2k tokens"));
2594        assert!(rendered.contains("archived 18 messages"));
2595        assert!(rendered.contains("preserved 4 messages"));
2596        assert!(rendered.contains("reviewed"));
2597        assert!(!rendered.contains("full checkpoint summary"));
2598    }
2599
2600    #[test]
2601    fn context_checkpoint_renders_validated_draft() {
2602        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
2603        msg.kind = ChatMessageKind::ContextCheckpoint;
2604        msg.metadata = Some(serde_json::json!({
2605            "trigger": "auto_threshold",
2606            "before_tokens": 43_800,
2607            "after_tokens": 9_200,
2608            "archived_message_count": 18,
2609            "preserved_message_count": 4,
2610            "duration_secs": 2.4,
2611            "review_status": "draft_validated",
2612            "review_error": "provider overloaded",
2613        }));
2614
2615        let lines =
2616            render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
2617        let rendered = lines
2618            .iter()
2619            .map(|line| {
2620                line.spans
2621                    .iter()
2622                    .map(|span| span.content.as_ref())
2623                    .collect::<String>()
2624            })
2625            .collect::<Vec<_>>()
2626            .join("\n");
2627
2628        assert!(rendered.contains("Compact(auto_threshold)"));
2629        assert!(rendered.contains("validated draft"));
2630        assert!(rendered.contains("review: provider overloaded"));
2631    }
2632
2633    /// CJK characters are 3 bytes but 2 display cells each. The
2634    /// byte-length version of `wrap_styled_line` would incorrectly
2635    /// over-wrap such input. This test asserts the display-width
2636    /// version keeps CJK-only input on a single line when the display
2637    /// width fits, even when the byte length exceeds the width.
2638    #[test]
2639    fn wrap_styled_line_uses_display_width_for_cjk() {
2640        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
2641        // Target width of 10: byte-length would see 12 > 10 and wrap;
2642        // display-width sees 8 <= 10 and keeps it on one line.
2643        let line = Line::from(Span::raw("你好世界".to_string()));
2644        let wrapped = wrap_styled_line(line, 10, 2);
2645        assert_eq!(
2646            wrapped.len(),
2647            1,
2648            "CJK input fitting in display-width should NOT be wrapped; got {} lines",
2649            wrapped.len()
2650        );
2651    }
2652
2653    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
2654    /// the input exceeds the width.
2655    #[test]
2656    fn wrap_styled_line_ascii_wraps_when_too_long() {
2657        let line = Line::from(Span::raw(
2658            "the quick brown fox jumps over the lazy dog".to_string(),
2659        ));
2660        let wrapped = wrap_styled_line(line, 15, 2);
2661        assert!(
2662            wrapped.len() >= 2,
2663            "long ASCII input should wrap to multiple lines; got {}",
2664            wrapped.len()
2665        );
2666    }
2667
2668    fn first_segment_text(wrapped: &[Line<'static>]) -> String {
2669        wrapped[0]
2670            .spans
2671            .iter()
2672            .map(|s| s.content.as_ref())
2673            .collect()
2674    }
2675
2676    /// Regression (recurring "paragraph escapes the gutter" bug): a non-first
2677    /// message line carries a 2-space gutter prefix; when it wraps, the first
2678    /// segment must keep that gutter, not flush to column 0. `split_whitespace`
2679    /// used to drop the leading spaces and the "first word, no indent" rule
2680    /// flushed the segment left.
2681    #[test]
2682    fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
2683        let line = Line::from(vec![
2684            Span::raw("  "), // the continuation gutter chat.rs prepends
2685            Span::raw(
2686                "No source files, no config, no docs, no build system and more words to wrap"
2687                    .to_string(),
2688            ),
2689        ]);
2690        let wrapped = wrap_styled_line(line, 30, 2);
2691        assert!(wrapped.len() >= 2, "should wrap");
2692        let first = first_segment_text(&wrapped);
2693        assert!(
2694            first.starts_with("  ") && first.trim_start().starts_with("No source"),
2695            "first wrapped segment must keep the 2-space gutter; got {first:?}"
2696        );
2697    }
2698
2699    /// End-to-end: a wrapped list item keeps the bullet on the first segment and
2700    /// hangs its continuation lines under the item text (col 6 = 2 gutter + 2
2701    /// nesting indent + 2 marker), instead of snapping back to the message gutter.
2702    /// Exercises the same span shape chat.rs builds, with the continuation indent
2703    /// chat.rs derives via markdown::line_hanging_indent (4) + the gutter (2).
2704    #[test]
2705    fn wrap_styled_line_hangs_list_continuation_under_marker() {
2706        let line = Line::from(vec![
2707            Span::raw("  "), // message gutter (chat.rs)
2708            Span::raw("  "), // list nesting indent (markdown)
2709            Span::raw("• "), // marker (markdown)
2710            Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2711        ]);
2712        let wrapped = wrap_styled_line(line, 24, 6);
2713        assert!(wrapped.len() >= 2, "should wrap");
2714        assert!(
2715            first_segment_text(&wrapped).starts_with("    • "),
2716            "first segment keeps gutter + nesting + marker"
2717        );
2718        for cont in &wrapped[1..] {
2719            let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2720            assert!(
2721                t.starts_with("      ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2722                "continuation hangs under the item text at col 6; got {t:?}"
2723            );
2724        }
2725    }
2726
2727    /// The fix preserves whitespace margins only — the message bullet "● " must
2728    /// still sit at column 0 on the first line.
2729    #[test]
2730    fn wrap_styled_line_keeps_bullet_at_column_zero() {
2731        let line = Line::from(vec![
2732            Span::raw("● "),
2733            Span::raw(
2734                "a fairly long first line of a message that definitely needs to wrap".to_string(),
2735            ),
2736        ]);
2737        let wrapped = wrap_styled_line(line, 25, 2);
2738        assert!(wrapped.len() >= 2, "should wrap");
2739        assert!(
2740            first_segment_text(&wrapped).starts_with('●'),
2741            "bullet must stay at column 0"
2742        );
2743    }
2744
2745    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
2746    /// the plain-string wrapper used by user messages and thinking blocks.
2747    /// The byte-based version would wrap a 4-CJK paragraph after the second
2748    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
2749    /// version keeps it on one line.
2750    #[test]
2751    fn wrap_text_with_indent_uses_display_width_for_cjk() {
2752        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
2753        // with 0 indent: should fit on one line.
2754        let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2755        assert_eq!(
2756            wrapped.len(),
2757            1,
2758            "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2759            wrapped.len(),
2760            wrapped
2761        );
2762        assert_eq!(wrapped[0].trim_start(), "你好世界");
2763    }
2764
2765    /// Mixed content: CJK + ASCII should still wrap correctly when the
2766    /// total exceeds available cells.
2767    #[test]
2768    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2769        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
2770        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
2771        // produce ≥ 2 lines.
2772        let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2773        assert!(
2774            wrapped.len() >= 2,
2775            "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2776            wrapped.len(),
2777            wrapped
2778        );
2779    }
2780
2781    #[test]
2782    fn clamp_to_u16_saturates_past_u16_max() {
2783        // F32: line counters past u16::MAX must clamp to the last addressable
2784        // row, never wrap modulo 65536 (which a plain `as u16` would do).
2785        assert_eq!(clamp_to_u16(0), 0);
2786        assert_eq!(clamp_to_u16(65_535), u16::MAX);
2787        assert_eq!(clamp_to_u16(65_536), u16::MAX);
2788        assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2789    }
2790
2791    #[test]
2792    fn wrap_text_with_indent_hard_breaks_overlong_token() {
2793        // F33: a single unbroken token far wider than the viewport must
2794        // hard-break at width boundaries instead of overflowing and being
2795        // clipped. No internal spaces, so word-wrapping alone can't split it.
2796        let token = "x".repeat(100);
2797        let width = 20;
2798        let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2799        assert!(
2800            wrapped.len() >= 5,
2801            "a 100-cell token at width 20 must span many rows; got {}",
2802            wrapped.len()
2803        );
2804        for line in &wrapped {
2805            assert!(
2806                line.chars().count() <= width,
2807                "no wrapped row may exceed the width; got {:?} ({} cells)",
2808                line,
2809                line.chars().count()
2810            );
2811        }
2812        // Stripping each row's hanging indent reconstructs the token intact.
2813        let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2814        assert_eq!(
2815            joined, token,
2816            "hard-break must preserve the token's content"
2817        );
2818    }
2819
2820    #[test]
2821    fn wrap_styled_line_hard_breaks_overlong_token() {
2822        // F33 (styled path): the same hard-break, preserving each piece's style.
2823        let token = "y".repeat(90);
2824        let style = Style::new().fg(ratatui::style::Color::Red);
2825        let line = Line::from(vec![Span::raw("  "), Span::styled(token.clone(), style)]);
2826        let width = 24;
2827        let wrapped = wrap_styled_line(line, width, 2);
2828        assert!(
2829            wrapped.len() >= 4,
2830            "must hard-break across rows; got {}",
2831            wrapped.len()
2832        );
2833
2834        let mut reconstructed = String::new();
2835        for l in &wrapped {
2836            let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2837            assert!(
2838                row_cells <= width,
2839                "row exceeds width: {row_cells} > {width}"
2840            );
2841            for s in &l.spans {
2842                // Skip indent/gutter spans (whitespace only); every content
2843                // piece must keep the original red foreground.
2844                if s.content.trim().is_empty() {
2845                    continue;
2846                }
2847                assert_eq!(
2848                    s.style.fg,
2849                    Some(ratatui::style::Color::Red),
2850                    "hard-break must preserve the span style"
2851                );
2852                reconstructed.push_str(s.content.as_ref());
2853            }
2854        }
2855        assert_eq!(reconstructed, token, "hard-break must preserve the token");
2856    }
2857
2858    /// The separator space re-inserted between words must be unstyled: when a
2859    /// wrapped line contains an underlined link span, the gap before the link
2860    /// used to inherit the underline (visibly underlined space in the TUI).
2861    #[test]
2862    fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
2863        let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
2864        let line = Line::from(vec![
2865            Span::raw("  "),
2866            Span::raw("some filler words long enough to force a wrap here "),
2867            Span::styled("underlined-link-text", underlined),
2868            Span::raw(" and a bit more trailing filler after the link"),
2869        ]);
2870        let wrapped = wrap_styled_line(line, 30, 2);
2871        assert!(wrapped.len() >= 2, "fixture must actually wrap");
2872        for l in &wrapped {
2873            for s in &l.spans {
2874                if s.content.chars().all(|c| c == ' ') {
2875                    assert_eq!(
2876                        s.style,
2877                        Style::default(),
2878                        "whitespace span {:?} must be unstyled",
2879                        s.content
2880                    );
2881                }
2882            }
2883        }
2884    }
2885
2886    /// A span boundary WITHOUT source whitespace is not a word boundary: the
2887    /// dimmed "(url)" suffix a markdown link gets, followed by a bare "." text
2888    /// span, must stay "(url)." — not gain a phantom space ("(url) .").
2889    #[test]
2890    fn wrap_styled_line_no_phantom_space_at_span_boundary() {
2891        let dim = Style::new().fg(ratatui::style::Color::DarkGray);
2892        let line = Line::from(vec![
2893            Span::raw("  "),
2894            Span::raw("filler text that pushes the line well past the width limit "),
2895            Span::styled("(https://example.com)".to_string(), dim),
2896            Span::raw("."),
2897        ]);
2898        let wrapped = wrap_styled_line(line, 30, 2);
2899        assert!(wrapped.len() >= 2, "fixture must actually wrap");
2900        let text: String = wrapped
2901            .iter()
2902            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2903            .collect();
2904        assert!(
2905            text.contains("(https://example.com)."),
2906            "period must stay glued to the URL suffix; got {text:?}"
2907        );
2908        assert!(
2909            !text.contains("(https://example.com) ."),
2910            "no phantom space before the period; got {text:?}"
2911        );
2912    }
2913
2914    /// A style change mid-word ("**bold**suffix") is not a word boundary: the
2915    /// two fragments must land on the same row as one token, each keeping its
2916    /// own style.
2917    #[test]
2918    fn wrap_styled_line_keeps_mid_word_style_change_glued() {
2919        let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
2920        let line = Line::from(vec![
2921            Span::raw("  "),
2922            Span::raw("leading filler words to force wrapping "),
2923            Span::styled("bold", bold),
2924            Span::raw("suffix"),
2925            Span::raw(" trailing filler words to force more wrapping"),
2926        ]);
2927        let wrapped = wrap_styled_line(line, 30, 2);
2928        assert!(wrapped.len() >= 2, "fixture must actually wrap");
2929        let rows: Vec<String> = wrapped
2930            .iter()
2931            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
2932            .collect();
2933        assert_eq!(
2934            rows.iter().filter(|r| r.contains("boldsuffix")).count(),
2935            1,
2936            "glued token must land whole on exactly one row; rows: {rows:?}"
2937        );
2938        for l in &wrapped {
2939            for s in &l.spans {
2940                if s.content.as_ref() == "bold" {
2941                    assert_eq!(s.style, bold, "bold fragment keeps its modifier");
2942                }
2943                if s.content.as_ref() == "suffix" {
2944                    assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
2945                }
2946            }
2947        }
2948    }
2949
2950    /// An over-long glued token made of differently styled fragments must
2951    /// hard-break across rows with each fragment's style preserved and no
2952    /// content lost — it enters the break path as ONE token, not two words.
2953    #[test]
2954    fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
2955        let red = Style::new().fg(ratatui::style::Color::Red);
2956        let blue = Style::new().fg(ratatui::style::Color::Blue);
2957        let line = Line::from(vec![
2958            Span::raw("  "),
2959            Span::styled("a".repeat(40), red),
2960            Span::styled("b".repeat(40), blue),
2961        ]);
2962        let width = 24;
2963        let wrapped = wrap_styled_line(line, width, 2);
2964        assert!(
2965            wrapped.len() >= 4,
2966            "80-cell token at width 24 must span >= 4 rows; got {}",
2967            wrapped.len()
2968        );
2969        let mut reconstructed = String::new();
2970        for l in &wrapped {
2971            let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
2972            assert!(
2973                row_cells <= width,
2974                "row exceeds width: {row_cells} > {width}"
2975            );
2976            for s in &l.spans {
2977                if s.content.trim().is_empty() {
2978                    continue;
2979                }
2980                let expected = if s.content.contains('a') { red } else { blue };
2981                assert!(
2982                    !(s.content.contains('a') && s.content.contains('b')),
2983                    "fragments must not merge across the style boundary"
2984                );
2985                assert_eq!(s.style, expected, "fragment style preserved across break");
2986                reconstructed.push_str(s.content.as_ref());
2987            }
2988        }
2989        assert_eq!(
2990            reconstructed,
2991            format!("{}{}", "a".repeat(40), "b".repeat(40)),
2992            "hard-break must preserve the whole glued token"
2993        );
2994    }
2995
2996    /// A whitespace-only span between two text spans still separates words —
2997    /// gluing only happens where the source truly has no whitespace.
2998    #[test]
2999    fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
3000        let line = Line::from(vec![
3001            Span::raw("  "),
3002            Span::raw("filler words that push this line past the wrap width "),
3003            Span::raw("foo"),
3004            Span::raw(" "),
3005            Span::raw("bar"),
3006        ]);
3007        let wrapped = wrap_styled_line(line, 30, 2);
3008        assert!(wrapped.len() >= 2, "fixture must actually wrap");
3009        let text: String = wrapped
3010            .iter()
3011            .map(|l| {
3012                l.spans
3013                    .iter()
3014                    .map(|s| s.content.as_ref())
3015                    .collect::<String>()
3016            })
3017            .collect::<Vec<_>>()
3018            .join("\n");
3019        assert!(
3020            text.contains("foo bar") || text.contains("foo\n  bar"),
3021            "whitespace-only span must keep the words apart; got {text:?}"
3022        );
3023        assert!(
3024            !text.contains("foobar"),
3025            "words must not glue; got {text:?}"
3026        );
3027    }
3028
3029    #[test]
3030    fn frame_memo_hit_matches_miss() {
3031        // F31: memoizing the assembled frame must be byte-for-byte identical to
3032        // re-assembling it. Render the SAME state twice — the first render
3033        // populates the frame memo, the second reuses it — and assert the
3034        // buffers are equal. Assistant-only messages keep the frame free of the
3035        // clock-relative user timestamp, so nothing here is time-dependent.
3036        use ratatui::Terminal;
3037        use ratatui::backend::TestBackend;
3038
3039        let theme = Theme::dark();
3040        let messages = vec![
3041            ChatMessage::assistant(
3042                "# Heading\n\nSome **bold** prose long enough that it wraps across \
3043                 this narrow viewport more than once.\n\n- a list item that also \
3044                 runs past the edge so it wraps\n- second item",
3045            ),
3046            ChatMessage::assistant("Short follow-up."),
3047        ];
3048
3049        let (width, height): (u16, u16) = (34, 30);
3050        let mut cache = FxHashMap::default();
3051        let mut state = ChatState::new();
3052
3053        let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
3054            let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
3055            term.draw(|f| {
3056                let widget = ChatWidget {
3057                    messages: &messages,
3058                    theme: &theme,
3059                    wrapped_line_cache: cache,
3060                    show_reasoning: true,
3061                    blink_on: true,
3062                };
3063                f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
3064            })
3065            .unwrap();
3066            term.backend().buffer().clone()
3067        };
3068
3069        let miss = render(&mut state, &mut cache);
3070        assert!(
3071            state.frame_memo.is_some(),
3072            "first render must populate the frame memo"
3073        );
3074        let hit = render(&mut state, &mut cache);
3075        assert_eq!(
3076            miss, hit,
3077            "frame-memo hit must render identically to the miss"
3078        );
3079        // The rows used for selection extraction are only re-collected on a
3080        // miss; assert the hit path left them intact (not cleared/stale) so
3081        // copy/selection still works on a reused frame (F31).
3082        assert!(
3083            !state.last_rendered_rows.is_empty(),
3084            "memo hit must preserve last_rendered_rows from the miss"
3085        );
3086    }
3087
3088    #[test]
3089    fn append_action_duration_handles_empty_base() {
3090        // A plain success with no detail (e.g. the Delete line) → just "took Xms",
3091        // no leading comma.
3092        assert_eq!(
3093            append_action_duration(String::new(), Some(0.035)),
3094            "took 35ms"
3095        );
3096        // A detail line keeps its text before the timing.
3097        assert_eq!(
3098            append_action_duration("3 lines read".to_string(), Some(1.25)),
3099            "3 lines read, took 1.2s"
3100        );
3101        // No duration → text unchanged (empty stays empty → renders no line).
3102        assert_eq!(append_action_duration(String::new(), None), "");
3103    }
3104}