Skip to main content

mermaid_cli/render/widgets/
chat.rs

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