Skip to main content

mermaid_cli/render/widgets/
chat.rs

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