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