Skip to main content

mermaid_cli/render/widgets/
chat.rs

1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10use rustc_hash::FxHashMap;
11use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13use crate::domain::{ActionDetails, ActionDisplay, ActionResult, format_compact_count};
14use crate::models::ChatMessageKind;
15use crate::models::{ChatMessage, MessageRole};
16use crate::render::diff::{DiffLineKind, parse_diff_line};
17use crate::render::markdown::parse_markdown;
18use crate::render::theme::Theme;
19use crate::utils::format_relative_timestamp;
20
21/// Entry in the click map: maps a content line to an image in chat history
22#[derive(Debug, Clone)]
23pub struct ImageClickTarget {
24    /// Index into session_state.messages
25    pub message_index: usize,
26    /// Index into that message's images vec
27    pub image_index: usize,
28}
29
30/// State for the chat widget
31#[derive(Debug, Clone)]
32pub struct ChatState {
33    /// Manual scroll offset (only used when is_user_scrolling = true)
34    scroll_offset: u16,
35    /// Whether user is manually scrolling (not following bottom)
36    is_user_scrolling: bool,
37    /// Click map: content line number → image target (rebuilt every render)
38    pub image_click_map: Vec<(u16, ImageClickTarget)>,
39    /// Scroll position used in last render (for coordinate mapping)
40    pub last_scroll_position: u16,
41    /// Chat area rect from last render
42    pub last_chat_area: Option<(u16, u16, u16, u16)>, // (x, y, width, height)
43    /// Active drag-selection in CONTENT coordinates: `(anchor, cursor)` where
44    /// each is `(content_line, col_cells)`. Highlight + copy derive from it.
45    selection: Option<((usize, usize), (usize, usize))>,
46    /// Plain text of each rendered content row, captured every frame so the
47    /// selection can be extracted by display-cell range. Indexed by content
48    /// line (the same index the selection uses).
49    last_rendered_rows: Vec<String>,
50    /// Memoized full-frame assembly (F31): the wrapped lines and image click
51    /// map produced by the per-message render loop, keyed by a fingerprint of
52    /// every input that determines them (message set, theme, width, reasoning
53    /// toggle, day). An unchanged scrollback reuses this across frames instead
54    /// of re-parsing, re-wrapping, and rebuilding the click map every frame.
55    /// Replaced whenever the fingerprint changes.
56    frame_memo: Option<FrameMemo>,
57}
58
59/// One memoized chat-frame assembly (see `ChatState::frame_memo`). Holds the
60/// lines *before* the per-frame selection highlight (which is selection-
61/// dependent and applied to a clone each frame) plus the image click map, so a
62/// frame whose inputs are unchanged skips the whole per-message render loop
63/// (F31). Cloning is `O(total lines)`, but it replaces the markdown parse +
64/// wrap + click-map rebuild the loop would otherwise redo every frame.
65#[derive(Debug, Clone)]
66struct FrameMemo {
67    /// Fingerprint of the inputs that produced `lines` + `click_map`.
68    key: u64,
69    /// Assembled wrapped lines, before the per-frame selection highlight.
70    lines: Vec<Line<'static>>,
71    /// Image click map captured alongside `lines`.
72    click_map: Vec<(u16, ImageClickTarget)>,
73}
74
75impl ChatState {
76    /// Create a new chat state (starts in auto-follow mode)
77    pub fn new() -> Self {
78        Self {
79            scroll_offset: 0,
80            is_user_scrolling: false,
81            image_click_map: Vec::new(),
82            last_scroll_position: 0,
83            last_chat_area: None,
84            selection: None,
85            last_rendered_rows: Vec::new(),
86            frame_memo: None,
87        }
88    }
89
90    /// Get the scroll position for rendering
91    /// scroll_offset represents distance from bottom, convert to ratatui scroll position
92    pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
93        let max_scroll = content_height.saturating_sub(viewport_height);
94        if self.is_user_scrolling {
95            // Manual scroll: convert "distance from bottom" to scroll position
96            // scroll_offset=0 → show bottom (max_scroll), scroll_offset=max → show top (0)
97            let capped_offset = self.scroll_offset.min(max_scroll);
98            max_scroll.saturating_sub(capped_offset)
99        } else {
100            // Auto-scroll: show bottom of content
101            max_scroll
102        }
103    }
104
105    /// Scroll viewport up (shows older messages further from bottom)
106    pub fn scroll_up(&mut self, amount: u16) {
107        self.is_user_scrolling = true;
108        self.scroll_offset = self.scroll_offset.saturating_add(amount);
109        // A selection's content-line anchors don't track scrolling; drop it
110        // rather than leave a highlight stranded on the wrong rows.
111        self.selection = None;
112    }
113
114    /// Scroll viewport down (shows newer messages closer to bottom)
115    /// Automatically resumes auto-scroll when reaching the bottom
116    pub fn scroll_down(&mut self, amount: u16) {
117        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
118        if self.scroll_offset == 0 {
119            // Reached bottom — resume auto-follow mode
120            self.is_user_scrolling = false;
121        }
122        self.selection = None;
123    }
124
125    /// Force resume auto-scroll mode (jump to bottom)
126    pub fn resume_auto_scroll(&mut self) {
127        self.is_user_scrolling = false;
128        self.scroll_offset = 0;
129    }
130
131    /// Check if user is manually scrolling (not following bottom)
132    pub fn is_manually_scrolling(&self) -> bool {
133        self.is_user_scrolling
134    }
135
136    /// Find an image click target at the given screen coordinates.
137    /// Returns Some((message_index, image_index)) if an image indicator was clicked.
138    pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
139        let (_, area_y, _, area_height) = self.last_chat_area?;
140
141        // Check if click is within chat area
142        if screen_row < area_y || screen_row >= area_y + area_height {
143            return None;
144        }
145
146        // Convert screen row to content line
147        let viewport_row = screen_row - area_y;
148        let content_line = viewport_row + self.last_scroll_position;
149
150        // Look up in click map
151        self.image_click_map
152            .iter()
153            .find(|(line, _)| *line == content_line)
154            .map(|(_, target)| target)
155    }
156
157    /// Map a screen `(row, col)` to content `(line, col_cells)`, or `None`
158    /// when the point is outside the chat area. `col` is clamped to the chat
159    /// area's left edge so a drag past the gutter still maps to column 0.
160    fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
161        let (area_x, area_y, _, area_height) = self.last_chat_area?;
162        if screen_row < area_y || screen_row >= area_y + area_height {
163            return None;
164        }
165        let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
166        let col = screen_col.saturating_sub(area_x) as usize;
167        Some((content_line, col))
168    }
169
170    /// Begin a drag selection at the given screen position (mouse-down).
171    /// Anchors and cursor both start here; a plain click with no drag selects
172    /// nothing.
173    pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
174        self.selection = self
175            .screen_to_content(screen_row, screen_col)
176            .map(|p| (p, p));
177    }
178
179    /// Extend the in-progress selection to the given screen position (drag).
180    pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
181        if let Some((anchor, _)) = self.selection
182            && let Some(cursor) = self.screen_to_content(screen_row, screen_col)
183        {
184            self.selection = Some((anchor, cursor));
185        }
186    }
187
188    /// Drop any active selection (and its highlight).
189    pub fn clear_selection(&mut self) {
190        self.selection = None;
191    }
192
193    /// Extract the currently-selected text from the last rendered frame, or
194    /// `None` if there's no selection or it's empty (e.g. a plain click).
195    /// Walks the retained per-row text and slices each row by display cells so
196    /// CJK / wide glyphs are never split mid-cell.
197    pub fn selected_text(&self) -> Option<String> {
198        let (a, b) = self.selection?;
199        let (start, end) = if a <= b { (a, b) } else { (b, a) };
200        if self.last_rendered_rows.is_empty() {
201            return None;
202        }
203        let last = self.last_rendered_rows.len() - 1;
204        let (start_line, start_col) = (start.0.min(last), start.1);
205        let (end_line, end_col) = (end.0.min(last), end.1);
206
207        let mut out = String::new();
208        for line in start_line..=end_line {
209            let row = &self.last_rendered_rows[line];
210            let c0 = if line == start_line { start_col } else { 0 };
211            let c1 = if line == end_line {
212                end_col
213            } else {
214                usize::MAX
215            };
216            let mut piece = slice_by_cells(row, c0, c1).to_string();
217            // Drop the rendered left margin (the "● "/"  " role/continuation
218            // prefix — up to SELECT_MARGIN_CELLS cells of spaces) so copied
219            // text is clean. Only spaces inside the margin zone [c0, MARGIN)
220            // are removed, so a code line's own indentation is preserved.
221            let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
222            while margin > 0 && piece.starts_with(' ') {
223                piece.remove(0);
224                margin -= 1;
225            }
226            out.push_str(piece.trim_end());
227            if line != end_line {
228                out.push('\n');
229            }
230        }
231        if out.is_empty() { None } else { Some(out) }
232    }
233}
234
235/// Display-cell width of the role/continuation left margin ("● " or "  ")
236/// that the renderer prepends to chat content lines. Stripped from copied
237/// selections so the clipboard gets clean text.
238const SELECT_MARGIN_CELLS: usize = 2;
239
240/// Hard-wrap a pre-formatted (code) line at `width` display cells, preserving
241/// every glyph (including whitespace) and each span's style. Continuation rows
242/// get a `indent`-space hanging indent. Unlike `wrap_styled_line` this never
243/// collapses runs of spaces, so code indentation and alignment survive.
244fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
245    if width == 0 {
246        return vec![line];
247    }
248    let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
249    if total <= width {
250        return vec![line];
251    }
252
253    let base = line.style;
254    let mut out: Vec<Line<'static>> = Vec::new();
255    let mut cur: Vec<Span<'static>> = Vec::new();
256    let mut cur_w = 0usize;
257    let mut on_first = true;
258
259    for span in line.spans {
260        let style = span.style;
261        let mut buf = String::new();
262        for ch in span.content.chars() {
263            let cw = ch.width().unwrap_or(0);
264            // Break before this char if it would overflow and the current row
265            // already holds real content (beyond the continuation indent).
266            let floor = if on_first { 0 } else { indent };
267            if cur_w + cw > width && cur_w > floor {
268                if !buf.is_empty() {
269                    cur.push(Span::styled(std::mem::take(&mut buf), style));
270                }
271                out.push(Line::from(std::mem::take(&mut cur)).style(base));
272                on_first = false;
273                cur.push(Span::styled(" ".repeat(indent), base));
274                cur_w = indent;
275            }
276            buf.push(ch);
277            cur_w += cw;
278        }
279        if !buf.is_empty() {
280            cur.push(Span::styled(buf, style));
281        }
282    }
283    if !cur.is_empty() {
284        out.push(Line::from(cur).style(base));
285    }
286    if out.is_empty() {
287        vec![Line::from("").style(base)]
288    } else {
289        out
290    }
291}
292
293/// Byte offset in `s` at the start of display-cell `target` (clamped to
294/// `s.len()`). A wide glyph straddling `target` is kept whole on the right
295/// side, so slicing never lands mid-character.
296fn byte_at_cell(s: &str, target: usize) -> usize {
297    if target == 0 {
298        return 0;
299    }
300    let mut width = 0usize;
301    for (idx, ch) in s.char_indices() {
302        if width >= target {
303            return idx;
304        }
305        width += ch.width().unwrap_or(0);
306    }
307    s.len()
308}
309
310/// Slice `s` to the display-cell range `[c0, c1)`.
311fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
312    let start = byte_at_cell(s, c0);
313    let end = byte_at_cell(s, c1).max(start);
314    &s[start..end]
315}
316
317/// Pad `s` on the right with spaces until it spans `cells` display columns,
318/// measured with `UnicodeWidthStr::width` (not chars/bytes) so a CJK/emoji row's
319/// background bar fills to the true visual edge instead of falling short (#101).
320/// Never truncates — an already-too-wide `s` is returned unchanged.
321fn pad_to_cells(s: &str, cells: usize) -> String {
322    let w = s.width();
323    if w >= cells {
324        return s.to_string();
325    }
326    let mut out = String::with_capacity(s.len() + (cells - w));
327    out.push_str(s);
328    out.push_str(&" ".repeat(cells - w));
329    out
330}
331
332/// First-line spacing for a user message: the run of spaces before the
333/// right-aligned timestamp. All inputs are display-cell widths so CJK/emoji
334/// align correctly (#104). Returns `min_gap` plus whatever slack remains to
335/// push the timestamp to `content_width`'s right edge.
336fn user_timestamp_padding(
337    role_prefix_width: usize,
338    text_width: usize,
339    timestamp_width: usize,
340    min_gap: usize,
341    content_width: usize,
342) -> usize {
343    let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
344    min_gap + content_width.saturating_sub(total_used)
345}
346
347/// The plain text of a rendered line (spans concatenated, styles dropped).
348fn line_plain_text(line: &Line) -> String {
349    line.spans.iter().map(|s| s.content.as_ref()).collect()
350}
351
352/// Saturating cast from a `usize` line counter to the `u16` ratatui scroll /
353/// click-map coordinate. A scrollback longer than `u16::MAX` rows clamps to the
354/// last addressable row instead of wrapping the index modulo 65536 (which a
355/// plain `as u16` would do, corrupting both the scroll position and the image
356/// click-map on a very long session) (F32).
357fn clamp_to_u16(n: usize) -> u16 {
358    u16::try_from(n).unwrap_or(u16::MAX)
359}
360
361/// Apply `hl` (merged onto each span's existing style) to display cells
362/// `[c0, c1)` of `line`, splitting spans at the selection boundaries so the
363/// highlight lands on exactly the selected glyphs.
364fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
365    let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
366    let mut width = 0usize;
367    for span in line.spans.drain(..) {
368        let span_w = span.content.width();
369        let (span_start, span_end) = (width, width + span_w);
370        width = span_end;
371
372        let ov0 = c0.max(span_start);
373        let ov1 = c1.min(span_end);
374        if ov1 <= ov0 {
375            new_spans.push(span); // no overlap with the selection
376            continue;
377        }
378
379        let s = span.content.as_ref();
380        let b0 = byte_at_cell(s, ov0 - span_start);
381        let b1 = byte_at_cell(s, ov1 - span_start);
382        if b0 > 0 {
383            new_spans.push(Span::styled(s[..b0].to_string(), span.style));
384        }
385        new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
386        if b1 < s.len() {
387            new_spans.push(Span::styled(s[b1..].to_string(), span.style));
388        }
389    }
390    line.spans = new_spans;
391}
392
393impl Default for ChatState {
394    fn default() -> Self {
395        Self::new()
396    }
397}
398
399/// Props for ChatWidget
400pub struct ChatWidget<'a> {
401    pub messages: &'a [ChatMessage],
402    pub theme: &'a Theme,
403    /// Shared render cache: `(content, theme, width)` hash → fully wrapped,
404    /// role-prefixed assistant lines. Caching the WRAPPED output (not just the
405    /// markdown parse) keeps a committed message from being re-parsed *and*
406    /// re-wrapped every frame — it's cloned from here instead (#134).
407    pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
408    pub show_reasoning: bool,
409}
410
411/// Render assistant message content (markdown) into wrapped, role-prefixed
412/// display lines.
413///
414/// Pure in its inputs — `(content, width, role prefix/color, theme)` — which is
415/// exactly what lets the result be cached per message and reused across frames
416/// without re-parsing or re-wrapping (#134). The cache key folds in content,
417/// theme, and width; role prefix/color are constant on this (assistant-only)
418/// path, so they need not be keyed.
419fn wrap_assistant_content(
420    content: &str,
421    content_width: u16,
422    role_prefix: &str,
423    role_color: ratatui::style::Color,
424    theme: &Theme,
425) -> Vec<Line<'static>> {
426    // Markdown content sits after the 2-cell message gutter.
427    let md_width = (content_width as usize).saturating_sub(2);
428    let parsed = parse_markdown(content, theme, md_width);
429
430    let mut out: Vec<Line<'static>> = Vec::new();
431    for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
432        // Code-block lines are tagged with the code background on their base
433        // style (see markdown::parse_markdown). They're pre-formatted: don't
434        // word-wrap (that collapses indentation) — let the Paragraph clip
435        // overflow instead.
436        let preformatted = parsed_line.preformatted;
437        let base_style = parsed_line.line.style;
438
439        // Continuation indent for wrapping: the 2-cell message gutter every line
440        // carries, plus this line's own content-start column so a wrapped list
441        // item's continuations hang under its text (after the marker) instead of
442        // snapping back to the gutter.
443        let continuation = if preformatted {
444            2
445        } else {
446            2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
447        };
448
449        // Add role indicator to first line or 2-space margin to others.
450        let mut spans = if line_idx == 0 {
451            vec![Span::styled(
452                format!("{} ", role_prefix),
453                Style::new().fg(role_color).bold(),
454            )]
455        } else {
456            vec![Span::raw("  ")]
457        };
458        spans.extend(parsed_line.line.spans);
459        let new_line = Line::from(spans).style(base_style);
460
461        if preformatted {
462            // Code: hard-wrap preserving indentation (don't word-collapse) so
463            // wide lines stay readable.
464            out.extend(wrap_preformatted(new_line, content_width as usize, 2));
465        } else {
466            out.extend(wrap_styled_line(
467                new_line,
468                content_width as usize,
469                continuation,
470            ));
471        }
472    }
473    out
474}
475
476/// `std::fmt::Write` shim that streams a value's formatted bytes straight into
477/// a hasher, so a `Debug`/`Display` value can be folded into a fingerprint
478/// without allocating an intermediate `String`.
479struct HashWrite<'a, H: Hasher>(&'a mut H);
480
481impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
482    fn write_str(&mut self, s: &str) -> std::fmt::Result {
483        self.0.write(s.as_bytes());
484        Ok(())
485    }
486}
487
488/// Fingerprint every input that determines the assembled chat lines + image
489/// click map: the message set (role, kind, content, thinking, actions, image
490/// count, timestamp, metadata), the theme identity, the content width, the
491/// reasoning toggle, and today's date — the only clock-dependent input, since a
492/// user timestamp renders as "Today"/"Yesterday"/an absolute date relative to it.
493///
494/// Two frames with the same fingerprint assemble byte-identical lines, so the
495/// result can be memoized across frames (F31). Uses the same 64-bit-hash-keyed
496/// caching the per-message #134 cache already relies on; the complex non-`Hash`
497/// fields (`metadata`, `actions`) are folded in via their `Debug` form so no
498/// rendered field is silently missed.
499fn frame_fingerprint(
500    messages: &[ChatMessage],
501    theme_seed: u64,
502    content_width: u16,
503    show_reasoning: bool,
504) -> u64 {
505    use std::fmt::Write as _;
506    let mut h = rustc_hash::FxHasher::default();
507    theme_seed.hash(&mut h);
508    content_width.hash(&mut h);
509    show_reasoning.hash(&mut h);
510    // The day-relative label ("Today"/"Yesterday"/date) on user timestamps
511    // changes only at midnight; fold today's date in so the memo refreshes then.
512    chrono::Local::now().date_naive().hash(&mut h);
513    messages.len().hash(&mut h);
514    for msg in messages {
515        msg.content.hash(&mut h);
516        msg.thinking.hash(&mut h);
517        // The instant fully determines `format_time(msg.timestamp)`; the
518        // day-relative label is covered by today's date above.
519        msg.timestamp.timestamp().hash(&mut h);
520        msg.images
521            .as_ref()
522            .map_or(0, |imgs| imgs.len())
523            .hash(&mut h);
524        let mut hw = HashWrite(&mut h);
525        let _ = write!(
526            hw,
527            "{:?}|{:?}|{:?}|{:?}",
528            msg.role, msg.kind, msg.metadata, msg.actions
529        );
530    }
531    h.finish()
532}
533
534impl<'a> StatefulWidget for ChatWidget<'a> {
535    type State = ChatState;
536
537    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
538        // Code-block lines are tagged with this background; computed once so
539        // the markdown cache key can use it.
540        let code_bg = self.theme.colors.code_background.to_color();
541        let theme_seed = {
542            let mut h = rustc_hash::FxHasher::default();
543            self.theme.colors.foreground.to_color().hash(&mut h);
544            code_bg.hash(&mut h);
545            self.theme.colors.header.to_color().hash(&mut h);
546            h.finish()
547        };
548
549        // Content spans the full width — there is no scrollbar gutter.
550        let content_width = area.width;
551        let content_area = area;
552
553        state.last_chat_area = Some((area.x, area.y, area.width, area.height));
554
555        // F31: skip the whole per-message assembly when nothing that affects it
556        // changed. The fingerprint folds in every render input, so a reused
557        // frame is byte-identical to a fresh one. Scrolling and drag-selection
558        // don't touch these inputs, so the common case (a static scrollback)
559        // reuses the memo instead of re-parsing and re-wrapping every message.
560        let frame_key = frame_fingerprint(
561            self.messages,
562            theme_seed,
563            content_width,
564            self.show_reasoning,
565        );
566        // Type is inferred from the map below (the frame_memo struct names the
567        // fields); an explicit annotation would just be a clippy::type_complexity.
568        let memo_hit = state
569            .frame_memo
570            .as_ref()
571            .filter(|m| m.key == frame_key)
572            .map(|m| (m.lines.clone(), m.click_map.clone()));
573
574        let mut lines = if let Some((cached_lines, cached_click_map)) = memo_hit {
575            // Memo hit: reuse the assembled lines; restore the click map that
576            // was captured alongside them.
577            state.image_click_map = cached_click_map;
578            cached_lines
579        } else {
580            // Memo miss: assemble fresh, then memoize for the next frame.
581            let mut lines: Vec<Line<'static>> = Vec::new();
582
583            // Clear click map for this render pass
584            state.image_click_map.clear();
585
586            for (idx, msg) in self.messages.iter().enumerate() {
587                // Skip Tool messages - they're internal to the agent loop and their
588                // content is already displayed inline in the assistant's action blocks
589                if matches!(msg.role, MessageRole::Tool) {
590                    continue;
591                }
592
593                if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
594                    if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
595                        lines.extend(event_lines);
596                        lines.push(Line::from(""));
597                    }
598                    continue;
599                }
600
601                // Run summary ("Worked for … · used … tokens"): a muted gray line where
602                // the spinner was — dimmer than the assistant's text (same gray as the
603                // timestamp), not italic. Display-only — excluded from the model context
604                // by build_chat_request, so it never accumulates as conversation.
605                if matches!(msg.kind, ChatMessageKind::RunSummary) {
606                    lines.push(Line::from(Span::styled(
607                        format!("  {}", msg.content),
608                        Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
609                    )));
610                    lines.push(Line::from(""));
611                    continue;
612                }
613
614                let (role_prefix, role_color) = match msg.role {
615                    MessageRole::User => (">", ratatui::style::Color::White),
616                    MessageRole::Assistant => ("●", ratatui::style::Color::White),
617                    MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
618                    MessageRole::Tool => unreachable!("Tool messages filtered above"),
619                };
620
621                if matches!(msg.role, MessageRole::Assistant) {
622                    // Render thinking block if present
623                    if let Some(ref thinking) = msg.thinking {
624                        // Skip rendering if thinking content is empty or literal "None"
625                        let thinking_trimmed = thinking.trim();
626                        if thinking_trimmed.is_empty()
627                            || thinking_trimmed == "None"
628                            || thinking_trimmed == "none"
629                        {
630                            // Don't render empty/null thinking blocks
631                        } else if self.show_reasoning {
632                            // Add "Thinking..." header in italic and dimmed with grayed white dot
633                            lines.push(Line::from(vec![
634                                Span::styled(
635                                    "● ",
636                                    Style::new().fg(ratatui::style::Color::DarkGray),
637                                ),
638                                Span::styled(
639                                    "Thinking...",
640                                    Style::new()
641                                        .fg(self.theme.colors.text_secondary.to_color())
642                                        .italic()
643                                        .dim(),
644                                ),
645                            ]));
646
647                            // Render thinking content with proper wrapping (2-space hanging indent)
648                            let wrapped = wrap_text_with_indent(
649                                thinking,
650                                content_width as usize,
651                                2, // first line indent (2 spaces)
652                                2, // continuation indent (2 spaces)
653                            );
654                            for wrapped_line in wrapped {
655                                lines.push(Line::from(Span::styled(
656                                    wrapped_line,
657                                    Style::new()
658                                        .fg(self.theme.colors.text_secondary.to_color())
659                                        .italic()
660                                        .dim(),
661                                )));
662                            }
663
664                            // Add blank line after thinking block
665                            lines.push(Line::from(""));
666                        } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
667                            // Reasoning is hidden and there's nothing else in this turn —
668                            // skip it entirely rather than render an empty bullet. No
669                            // "reasoning hidden" placeholder: /visible-reasoning controls
670                            // whether the thinking shows, silently.
671                            continue;
672                        }
673                    }
674
675                    // Assistant prose is the bulk of the scrollback. Its wrapped,
676                    // role-prefixed lines are a pure function of (content, theme,
677                    // width) — exactly this key — so cache the WRAPPED output, not
678                    // just the parse: a committed message is then cloned, never
679                    // re-parsed or re-wrapped, each frame (#134). Theme is folded in
680                    // so a theme switch can't serve stale-colored lines; width is in
681                    // the key because tables wrap to the viewport.
682                    let mut hasher = rustc_hash::FxHasher::default();
683                    msg.content.hash(&mut hasher);
684                    theme_seed.hash(&mut hasher);
685                    content_width.hash(&mut hasher);
686                    let cache_key = hasher.finish();
687
688                    let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
689                        cached.clone()
690                    } else {
691                        let block = wrap_assistant_content(
692                            &msg.content,
693                            content_width,
694                            role_prefix,
695                            role_color,
696                            self.theme,
697                        );
698                        self.wrapped_line_cache.insert(cache_key, block.clone());
699                        if self.wrapped_line_cache.len()
700                            > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
701                        {
702                            // Evict down to the cap rather than clearing the whole
703                            // cache — a wholesale clear re-rendered every message each
704                            // frame once a conversation exceeded the cap. Keep the
705                            // entry just inserted.
706                            let overflow = self.wrapped_line_cache.len()
707                                - crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
708                            let stale: Vec<u64> = self
709                                .wrapped_line_cache
710                                .keys()
711                                .copied()
712                                .filter(|&k| k != cache_key)
713                                .take(overflow)
714                                .collect();
715                            for k in stale {
716                                self.wrapped_line_cache.remove(&k);
717                            }
718                        }
719                        block
720                    };
721                    lines.extend(wrapped);
722
723                    // Render all actions at the end of the message
724                    if !msg.actions.is_empty() {
725                        // Add blank line between text content and actions
726                        if !msg.content.trim().is_empty() {
727                            lines.push(Line::from(""));
728                        }
729                        render_actions(
730                            &msg.actions,
731                            &mut lines,
732                            self.theme,
733                            content_width as usize,
734                        );
735                    }
736                } else {
737                    // For User messages: format timestamp and display on right edge
738                    let formatted_timestamp = format_relative_timestamp(msg.timestamp);
739                    // Display cells, not bytes — a CJK/emoji timestamp (or message)
740                    // would otherwise mis-reserve space and push the right-aligned
741                    // timestamp off its column (#104).
742                    let timestamp_width = formatted_timestamp.width();
743                    let min_gap = 3; // minimum spaces between text and timestamp
744
745                    // Content is clean — timestamps are injected at API call time only
746                    let cleaned_content = &msg.content;
747
748                    // Reserve space on the first line for role prefix + gap + timestamp
749                    // so text wraps early enough to not overlap the timestamp
750                    let role_prefix_width = role_prefix.width() + 1; // "You " = prefix + space
751                    let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
752
753                    // Manually wrap the user message with hanging indent (2 spaces)
754                    let wrapped = wrap_text_with_indent(
755                        cleaned_content,
756                        content_width as usize,
757                        first_line_reserved, // reserve space for prefix + gap + timestamp on first line
758                        2,                   // continuation indent
759                    );
760
761                    let band_start = lines.len();
762                    for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
763                        if line_idx == 0 {
764                            // First line: add role prefix and timestamp on right
765                            let text_content = wrapped_line.trim_start(); // Remove the indent we added
766                            let text_width = text_content.width();
767
768                            let mut spans = vec![
769                                Span::styled(
770                                    format!("{} ", role_prefix),
771                                    Style::new().fg(role_color).bold(),
772                                ),
773                                Span::raw(text_content.to_string()),
774                            ];
775
776                            // Always add at least min_gap spaces, plus any extra from word-boundary slack.
777                            // Align the timestamp to the content's right edge.
778                            let pad = user_timestamp_padding(
779                                role_prefix_width,
780                                text_width,
781                                timestamp_width,
782                                min_gap,
783                                content_width as usize,
784                            );
785                            spans.push(Span::raw(" ".repeat(pad)));
786                            spans.push(Span::styled(
787                                formatted_timestamp.clone(),
788                                Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
789                            ));
790
791                            lines.push(Line::from(spans));
792                        } else {
793                            // Continuation lines: already have 2-space margin from wrap_text_with_indent
794                            lines.push(Line::from(wrapped_line.clone()));
795                        }
796                    }
797
798                    // Claude-Code-style highlight band: paint a subtle full-width
799                    // background behind every line of the user's submitted prompt. The
800                    // ">" marker, text, and timestamp keep their own foreground colors;
801                    // only the row background is added. Other roles (system notices)
802                    // share this layout but must NOT be highlighted.
803                    if matches!(msg.role, MessageRole::User) {
804                        let user_bg = self.theme.colors.user_message_background.to_color();
805                        let cw = content_width as usize;
806                        for line in &mut lines[band_start..] {
807                            let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
808                            if used < cw {
809                                line.spans.push(Span::raw(" ".repeat(cw - used)));
810                            }
811                            line.style = line.style.bg(user_bg);
812                        }
813                    }
814                }
815
816                // Show image indicators under user and assistant messages.
817                // User images come from clipboard paste (`Attachment`); assistant
818                // images come from tool executions that emitted `ProgressEvent::
819                // Artifact` during their run — screenshot captures, inline
820                // previews from computer-use, etc. Both land in `msg.images` as
821                // base64 strings and render the same way.
822                if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
823                    && let Some(ref images) = msg.images
824                    && !images.is_empty()
825                {
826                    for (i, _) in images.iter().enumerate() {
827                        // Record this line in the click map before pushing.
828                        // `lines.len()` is usize; clamp to the u16 click-map/scroll
829                        // coordinate with a saturating cast at this boundary so a
830                        // scrollback past u16::MAX rows clamps instead of wrapping a
831                        // stale line index into the map (F32).
832                        let content_line = lines.len();
833                        state.image_click_map.push((
834                            clamp_to_u16(content_line),
835                            ImageClickTarget {
836                                message_index: idx,
837                                image_index: i,
838                            },
839                        ));
840                        lines.push(Line::from(vec![
841                            Span::styled(
842                                "  ⎿ ",
843                                Style::new().fg(self.theme.colors.info.to_color()),
844                            ),
845                            Span::styled(
846                                format!("[Image #{}]", i + 1),
847                                Style::new().fg(self.theme.colors.info.to_color()).italic(),
848                            ),
849                        ]));
850                    }
851                }
852
853                lines.push(Line::from(""));
854            }
855
856            // Capture the plain text of each rendered row for selection
857            // extraction (before the per-frame highlight, which changes only
858            // styling, not text). Recomputed only on a miss: a memo hit means
859            // unchanged content, so the rows from the miss that built the memo
860            // stay valid — this skips an O(total) re-collect every frame (F31).
861            state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
862
863            // F31: memoize this assembly so an unchanged next frame reuses it
864            // instead of re-running the loop above. Store the lines *before* the
865            // selection highlight (applied per-frame below), so the cache stays
866            // selection-independent.
867            state.frame_memo = Some(FrameMemo {
868                key: frame_key,
869                lines: lines.clone(),
870                click_map: state.image_click_map.clone(),
871            });
872            lines
873        };
874
875        // NOTE: The response buffer is NOT rendered during streaming (buffering mode).
876        // The response is buffered invisibly and only shown when generation is complete.
877        // This provides a Claude Code-like experience where the complete response
878        // appears instantly instead of streaming character-by-character.
879        //
880        // The status line shows progress: "↑ Sending..." → "↓ Streaming..." with timer
881
882        // NOTE: `state.last_rendered_rows` (used by selection extraction) is
883        // refreshed inside the memo-miss branch above, not here — a memo hit
884        // keeps the rows from the miss that built it (content is unchanged on a
885        // hit), so they need not be re-collected every frame (F31).
886
887        // Paint the active drag selection (reverse video over the selected
888        // cells). Selection lines are content indices, matching `lines`.
889        if let Some((a, b)) = state.selection
890            && !lines.is_empty()
891        {
892            let (start, end) = if a <= b { (a, b) } else { (b, a) };
893            let sel_style = Style::new().add_modifier(Modifier::REVERSED);
894            let last_line = lines.len() - 1;
895            for (line_idx, line) in lines
896                .iter_mut()
897                .enumerate()
898                .take(end.0.min(last_line) + 1)
899                .skip(start.0)
900            {
901                let c0 = if line_idx == start.0 { start.1 } else { 0 };
902                let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
903                if c1 > c0 {
904                    highlight_line_cells(line, c0, c1, sel_style);
905                }
906            }
907        }
908
909        // NOTE: Wrapping is disabled because we handle it manually with hanging indents
910        // Calculate content height and viewport for proper scroll clamping.
911        // `lines.len()` is usize; convert to the u16 ratatui scroll type with a
912        // saturating cast so a scrollback longer than u16::MAX rows clamps the
913        // scroll position instead of wrapping it (F32).
914        let content_height = lines.len();
915        let viewport_height = area.height;
916
917        let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
918        state.last_scroll_position = scroll_pos;
919
920        let paragraph = Paragraph::new(lines)
921            .block(Block::default())
922            .scroll((scroll_pos, 0));
923
924        paragraph.render(content_area, buf);
925    }
926}
927
928fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
929    if !matches!(msg.role, MessageRole::User) {
930        return None;
931    }
932
933    let metadata = msg.metadata.as_ref();
934    let trigger = metadata
935        .and_then(|value| value.get("trigger"))
936        .and_then(|value| value.as_str())
937        .unwrap_or("manual");
938    let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
939    let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
940    let archived_messages =
941        metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
942    let preserved_messages =
943        metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
944    let duration_secs = metadata
945        .and_then(|value| value.get("duration_secs"))
946        .and_then(|value| value.as_f64());
947    let verified = metadata
948        .and_then(|value| value.get("verified"))
949        .and_then(|value| value.as_bool());
950    let verification_error = metadata
951        .and_then(|value| value.get("verification_error"))
952        .and_then(|value| value.as_str());
953
954    let action_color = theme.colors.info.to_color();
955    let mut result = match (before_tokens, after_tokens) {
956        (Some(before), Some(after)) => {
957            format!(
958                "Success, {} -> {} tokens",
959                format_compact_count(before),
960                format_compact_count(after)
961            )
962        },
963        _ => "Success".to_string(),
964    };
965
966    if let Some(count) = archived_messages {
967        result.push_str(&format!(
968            ", archived {} {}",
969            count,
970            if count == 1 { "message" } else { "messages" }
971        ));
972    }
973    if let Some(count) = preserved_messages {
974        result.push_str(&format!(
975            ", preserved {} {}",
976            count,
977            if count == 1 { "message" } else { "messages" }
978        ));
979    }
980    if let Some(verified) = verified {
981        if verified {
982            result.push_str(", verified");
983        } else {
984            result.push_str(", draft fallback");
985        }
986    }
987    result = append_action_duration(result, duration_secs);
988
989    let mut lines = vec![
990        Line::from(vec![
991            Span::styled("● ", Style::new().fg(action_color).bold()),
992            Span::styled("Compact(", Style::new().fg(action_color).bold()),
993            Span::styled(
994                trigger.to_string(),
995                Style::new().fg(theme.colors.text_secondary.to_color()),
996            ),
997            Span::styled(")", Style::new().fg(action_color).bold()),
998        ]),
999        Line::from(vec![
1000            Span::styled("  ⎿ ", Style::new().fg(action_color)),
1001            Span::styled(
1002                result,
1003                Style::new().fg(theme.colors.text_secondary.to_color()),
1004            ),
1005        ]),
1006    ];
1007
1008    if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
1009        lines.push(Line::from(vec![
1010            Span::styled("    ", Style::new().fg(action_color)),
1011            Span::styled(
1012                format!("verification: {}", compact_inline_error(error, 180)),
1013                Style::new().fg(theme.colors.warning.to_color()),
1014            ),
1015        ]));
1016    }
1017
1018    Some(lines)
1019}
1020
1021fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
1022    value
1023        .get(key)?
1024        .as_u64()
1025        .and_then(|value| usize::try_from(value).ok())
1026}
1027
1028fn compact_inline_error(text: &str, max_chars: usize) -> String {
1029    let text = text.trim();
1030    if text.chars().count() <= max_chars {
1031        return text.to_string();
1032    }
1033    let keep = max_chars.saturating_sub(3);
1034    let mut out: String = text.chars().take(keep).collect();
1035    out.push_str("...");
1036    out
1037}
1038
1039/// Render actions in Claude Code style
1040/// Expand tab characters to spaces on 4-column tab stops.
1041///
1042/// Tabs paint as zero cells in the terminal buffer, so a line containing them
1043/// has a char count larger than its painted width. Any width math done by char
1044/// count (e.g. padding a diff line so its background bar spans the row) would
1045/// then come up short by one column per tab. Expanding here keeps indentation
1046/// visible and makes char count match painted width.
1047fn expand_tabs(s: &str) -> String {
1048    const TAB_WIDTH: usize = 4;
1049    if !s.contains('\t') {
1050        return s.to_string();
1051    }
1052    let mut out = String::with_capacity(s.len() + TAB_WIDTH);
1053    let mut col = 0usize;
1054    for ch in s.chars() {
1055        if ch == '\t' {
1056            let n = TAB_WIDTH - (col % TAB_WIDTH);
1057            for _ in 0..n {
1058                out.push(' ');
1059            }
1060            col += n;
1061        } else {
1062            out.push(ch);
1063            col += UnicodeWidthChar::width(ch).unwrap_or(0);
1064        }
1065    }
1066    out
1067}
1068
1069fn render_actions(
1070    actions: &[ActionDisplay],
1071    lines: &mut Vec<Line>,
1072    theme: &Theme,
1073    viewport_width: usize,
1074) {
1075    for (action_idx, action) in actions.iter().enumerate() {
1076        if action_idx > 0 {
1077            lines.push(Line::from(""));
1078        }
1079        let action_color = match action.action_type.as_str() {
1080            "Write" | "Edit" => theme.colors.success.to_color(),
1081            "Delete" => theme.colors.warning.to_color(),
1082            _ => theme.colors.info.to_color(),
1083        };
1084
1085        // Header: ● Type(target)
1086        lines.push(Line::from(vec![
1087            Span::styled("● ", Style::new().fg(action_color).bold()),
1088            Span::styled(
1089                format!("{}(", action.action_type),
1090                Style::new().fg(action_color).bold(),
1091            ),
1092            Span::styled(
1093                action.target.clone(),
1094                Style::new().fg(theme.colors.text_secondary.to_color()),
1095            ),
1096            Span::styled(")", Style::new().fg(action_color).bold()),
1097        ]));
1098
1099        match &action.result {
1100            ActionResult::Success { .. } => {
1101                // Result summary from details enum
1102                let result_msg = match &action.details {
1103                    ActionDetails::FileContent { line_count, .. } => {
1104                        let base = format!(
1105                            "Success, {} {} written",
1106                            line_count,
1107                            if *line_count == 1 { "line" } else { "lines" }
1108                        );
1109                        append_action_duration(base, action.duration_seconds)
1110                    },
1111                    ActionDetails::Diff { summary, .. } => summary.clone(),
1112                    ActionDetails::Preview { text, .. } => text.clone(),
1113                    ActionDetails::Simple => match action.action_type.as_str() {
1114                        "Delete" => append_action_duration(
1115                            format!("Success, deleted {}", action.target),
1116                            action.duration_seconds,
1117                        ),
1118                        _ => append_action_duration("Success".to_string(), action.duration_seconds),
1119                    },
1120                };
1121
1122                for (idx, line) in result_msg.lines().enumerate() {
1123                    let prefix = if idx == 0 { "  ⎿ " } else { "    " };
1124                    lines.push(Line::from(vec![
1125                        Span::styled(prefix, Style::new().fg(action_color)),
1126                        Span::styled(
1127                            line.to_string(),
1128                            Style::new().fg(theme.colors.text_secondary.to_color()),
1129                        ),
1130                    ]));
1131                }
1132
1133                // Write: syntax-highlighted file preview
1134                if let ActionDetails::FileContent {
1135                    content,
1136                    line_count,
1137                } = &action.details
1138                {
1139                    let preview_lines: Vec<&str> = content.lines().take(10).collect();
1140                    if !preview_lines.is_empty() {
1141                        lines.push(Line::from(vec![Span::styled(
1142                            "    ",
1143                            Style::new().fg(action_color),
1144                        )]));
1145
1146                        let preview_content = preview_lines.join("\n");
1147                        let mut parsed = parse_markdown(
1148                            &format!("```\n{}\n```", preview_content),
1149                            theme,
1150                            viewport_width.saturating_sub(4),
1151                        );
1152                        for parsed_line in parsed.iter_mut() {
1153                            let mut new_spans =
1154                                vec![Span::styled("    ", Style::new().fg(action_color))];
1155                            new_spans.append(&mut parsed_line.line.spans);
1156                            parsed_line.line.spans = new_spans;
1157                        }
1158                        lines.extend(parsed.into_iter().map(|ml| ml.line));
1159
1160                        if *line_count > 10 {
1161                            lines.push(Line::from(vec![
1162                                Span::styled("    ", Style::new().fg(action_color)),
1163                                Span::styled(
1164                                    format!("... ({} more lines)", line_count - 10),
1165                                    Style::new()
1166                                        .fg(theme.colors.text_disabled.to_color())
1167                                        .italic(),
1168                                ),
1169                            ]));
1170                        }
1171                    }
1172                }
1173
1174                // Edit: color-coded diff
1175                if let ActionDetails::Diff { diff, .. } = &action.details {
1176                    let diff_lines: Vec<&str> = diff.lines().collect();
1177                    let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
1178
1179                    if !display_lines.is_empty() {
1180                        let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1181                        let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1182
1183                        for diff_line in &display_lines {
1184                            // Expand tabs first: the TUI paints a tab as zero
1185                            // cells, so a tab-bearing line's char count exceeds
1186                            // its painted width and the char-count pad below
1187                            // would leave the background bar short — a ragged
1188                            // "staircase" down the right edge. Expanding also
1189                            // makes tab indentation actually visible.
1190                            let diff_line = expand_tabs(diff_line);
1191                            // Delegate the producer-format awareness to
1192                            // `parse_diff_line`, which lives next to the
1193                            // marker constants and stays in lockstep with
1194                            // any future format change.
1195                            match parse_diff_line(&diff_line) {
1196                                DiffLineKind::Removed => {
1197                                    let text = format!("    {}", diff_line);
1198                                    let padded = pad_to_cells(&text, viewport_width);
1199                                    lines.push(Line::from(vec![Span::styled(
1200                                        padded,
1201                                        Style::new()
1202                                            .fg(theme.colors.error.to_color())
1203                                            .bg(removed_bg),
1204                                    )]));
1205                                },
1206                                DiffLineKind::Added => {
1207                                    let text = format!("    {}", diff_line);
1208                                    let padded = pad_to_cells(&text, viewport_width);
1209                                    lines.push(Line::from(vec![Span::styled(
1210                                        padded,
1211                                        Style::new()
1212                                            .fg(theme.colors.success.to_color())
1213                                            .bg(added_bg),
1214                                    )]));
1215                                },
1216                                DiffLineKind::Context => {
1217                                    lines.push(Line::from(vec![
1218                                        Span::styled("    ", Style::new().fg(action_color)),
1219                                        Span::styled(
1220                                            diff_line,
1221                                            Style::new().fg(theme.colors.text_secondary.to_color()),
1222                                        ),
1223                                    ]));
1224                                },
1225                            }
1226                        }
1227
1228                        let remaining = diff_lines.len().saturating_sub(display_lines.len());
1229                        if remaining > 0 {
1230                            lines.push(Line::from(vec![
1231                                Span::styled("    ", Style::new().fg(action_color)),
1232                                Span::styled(
1233                                    format!("... ({} more lines)", remaining),
1234                                    Style::new()
1235                                        .fg(theme.colors.text_disabled.to_color())
1236                                        .italic(),
1237                                ),
1238                            ]));
1239                        }
1240                    }
1241                }
1242            },
1243            ActionResult::Error { error } => {
1244                let error =
1245                    append_action_duration(format!("Error: {}", error), action.duration_seconds);
1246                lines.push(Line::from(vec![
1247                    Span::styled("  ⎿ ", Style::new().fg(theme.colors.error.to_color())),
1248                    Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
1249                ]));
1250            },
1251        }
1252    }
1253}
1254
1255fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
1256    if let Some(seconds) = duration_seconds {
1257        text.push_str(", took ");
1258        text.push_str(&format_action_duration(seconds));
1259    }
1260    text
1261}
1262
1263fn format_action_duration(seconds: f64) -> String {
1264    if seconds < 1.0 {
1265        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1266    } else if seconds < 10.0 {
1267        format!("{:.1}s", seconds)
1268    } else {
1269        format!("{}s", seconds.round() as u64)
1270    }
1271}
1272
1273/// Hard-break a single over-long token into the plain-text line accumulator,
1274/// splitting at char boundaries (UTF-8-safe, display-cell aware) so a giant
1275/// unbroken token (e.g. a 5000-char URL) wraps across lines instead of
1276/// overflowing the viewport and being clipped (F33).
1277///
1278/// Mirrors the accumulation `wrap_text_with_indent` does for normal words:
1279/// `current_line`/`current_length` carry the in-progress line (its indent
1280/// already pushed into `current_line`, not counted in `current_length`),
1281/// finished lines are pushed to `out`, and each new line gets a
1282/// `continuation_indent`-space hanging indent. `initial_budget` is the content
1283/// width available on the line the token starts on (the caller's per-line
1284/// `available_width`); subsequent lines use `width - continuation_indent`.
1285fn hard_break_plain_token(
1286    token: &str,
1287    out: &mut Vec<String>,
1288    current_line: &mut String,
1289    current_length: &mut usize,
1290    width: usize,
1291    continuation_indent: usize,
1292    initial_budget: usize,
1293) {
1294    let cont_budget = width.saturating_sub(continuation_indent).max(1);
1295    let mut line_budget = initial_budget.max(1);
1296
1297    // If the current line already holds content, flush it so the token starts
1298    // fresh on a continuation line; otherwise break onto the current (indent-
1299    // only) line directly.
1300    if *current_length > 0 {
1301        out.push(std::mem::take(current_line));
1302        current_line.push_str(&" ".repeat(continuation_indent));
1303        *current_length = 0;
1304        line_budget = cont_budget;
1305    }
1306
1307    for ch in token.chars() {
1308        let cw = ch.width().unwrap_or(0);
1309        // Break before this char if it would overflow and the line already
1310        // holds at least one glyph (so a single too-wide glyph never loops).
1311        if *current_length + cw > line_budget && *current_length > 0 {
1312            out.push(std::mem::take(current_line));
1313            current_line.push_str(&" ".repeat(continuation_indent));
1314            *current_length = 0;
1315            line_budget = cont_budget;
1316        }
1317        current_line.push(ch);
1318        *current_length += cw;
1319    }
1320}
1321
1322/// Wrap text with hanging indent support.
1323///
1324/// `width`, `first_line_indent`, and `continuation_indent` are all measured
1325/// in **display cells**, not bytes. Word lengths are also measured in cells
1326/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
1327/// previously a CJK paragraph would wrap after ~1/3 of the line because
1328/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
1329/// codepoints.
1330fn wrap_text_with_indent(
1331    text: &str,
1332    width: usize,
1333    first_line_indent: usize,
1334    continuation_indent: usize,
1335) -> Vec<String> {
1336    let mut wrapped_lines = Vec::new();
1337
1338    for (line_idx, line) in text.lines().enumerate() {
1339        if line.is_empty() {
1340            wrapped_lines.push(String::new());
1341            continue;
1342        }
1343
1344        let current_indent = if line_idx == 0 {
1345            first_line_indent
1346        } else {
1347            continuation_indent
1348        };
1349        let available_width = width.saturating_sub(current_indent);
1350
1351        if available_width == 0 {
1352            wrapped_lines.push(" ".repeat(current_indent));
1353            continue;
1354        }
1355
1356        let words: Vec<&str> = line.split_whitespace().collect();
1357        if words.is_empty() {
1358            wrapped_lines.push(" ".repeat(current_indent));
1359            continue;
1360        }
1361
1362        let mut current_line = String::with_capacity(width);
1363        current_line.push_str(&" ".repeat(current_indent));
1364        // Display-cell widths: indent is ASCII spaces (1 cell each), so
1365        // start fresh and let words contribute their own cell widths.
1366        let mut current_length = 0;
1367
1368        for (word_idx, word) in words.iter().enumerate() {
1369            let word_width = word.width();
1370
1371            if word_idx == 0 {
1372                if word_width <= available_width {
1373                    // First word fits on the line
1374                    current_line.push_str(word);
1375                    current_length = word_width;
1376                } else {
1377                    // A single token wider than the whole line (e.g. a long
1378                    // URL): hard-break it at width boundaries so it wraps
1379                    // instead of overflowing the viewport and being clipped
1380                    // (F33).
1381                    hard_break_plain_token(
1382                        word,
1383                        &mut wrapped_lines,
1384                        &mut current_line,
1385                        &mut current_length,
1386                        width,
1387                        continuation_indent,
1388                        available_width,
1389                    );
1390                }
1391            } else if current_length + 1 + word_width <= available_width {
1392                // Word fits on current line (the +1 accounts for the
1393                // separator space, which is 1 cell)
1394                current_line.push(' ');
1395                current_line.push_str(word);
1396                current_length += 1 + word_width;
1397            } else if word_width <= available_width {
1398                // Word doesn't fit, start a new line
1399                wrapped_lines.push(current_line);
1400                current_line = String::with_capacity(width);
1401                current_line.push_str(&" ".repeat(continuation_indent));
1402                current_line.push_str(word);
1403                current_length = word_width;
1404            } else {
1405                // Over-long token mid-paragraph: flush the current line, then
1406                // hard-break the token across continuation lines (F33).
1407                hard_break_plain_token(
1408                    word,
1409                    &mut wrapped_lines,
1410                    &mut current_line,
1411                    &mut current_length,
1412                    width,
1413                    continuation_indent,
1414                    available_width,
1415                );
1416            }
1417        }
1418
1419        // Add the last line
1420        if !current_line.trim().is_empty() {
1421            wrapped_lines.push(current_line);
1422        }
1423    }
1424
1425    wrapped_lines
1426}
1427
1428/// Hard-break a single over-long token into the styled line accumulator,
1429/// splitting at char boundaries (UTF-8-safe, display-cell aware) and keeping
1430/// `style` on every produced piece, so a giant unbroken token (e.g. a long URL)
1431/// wraps across rows instead of overflowing the viewport and being clipped
1432/// (F33). The styled counterpart of `hard_break_plain_token`.
1433///
1434/// `current_line_spans`/`current_line_width` carry the in-progress row;
1435/// finished rows are pushed to `result_lines`; each new row opens with a
1436/// `continuation_indent`-space span. `line_capacity` is the width budget for
1437/// the row the token starts on (the first row counts its leading indent in
1438/// `current_line_width`, so its budget is the full `width`); wrapped rows use
1439/// `continuation_capacity` (the caller's `available_width`, with the indent in
1440/// a separate span and not counted).
1441#[allow(clippy::too_many_arguments)]
1442fn hard_break_styled_token(
1443    token: &str,
1444    style: Style,
1445    result_lines: &mut Vec<Line<'static>>,
1446    current_line_spans: &mut Vec<Span<'static>>,
1447    current_line_width: &mut usize,
1448    continuation_indent: usize,
1449    continuation_capacity: usize,
1450    mut line_capacity: usize,
1451) {
1452    let mut buf = String::new();
1453    for ch in token.chars() {
1454        let cw = ch.width().unwrap_or(0);
1455        // Break before this char if it would overflow and the row already holds
1456        // at least one glyph (so a single too-wide glyph never loops).
1457        if *current_line_width + cw > line_capacity && *current_line_width > 0 {
1458            if !buf.is_empty() {
1459                current_line_spans.push(Span::styled(std::mem::take(&mut buf), style));
1460            }
1461            result_lines.push(Line::from(std::mem::take(current_line_spans)));
1462            current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1463            *current_line_width = 0;
1464            line_capacity = continuation_capacity.max(1);
1465        }
1466        buf.push(ch);
1467        *current_line_width += cw;
1468    }
1469    if !buf.is_empty() {
1470        current_line_spans.push(Span::styled(buf, style));
1471    }
1472}
1473
1474/// Wrap a styled Line with hanging indent, preserving all span styles
1475/// Returns multiple Line objects with proper indentation
1476fn wrap_styled_line(
1477    line: Line<'static>,
1478    width: usize,
1479    continuation_indent: usize,
1480) -> Vec<Line<'static>> {
1481    // Widths are counted in display cells (via `UnicodeWidthStr`), not
1482    // bytes. This makes CJK double-width chars and emoji wrap at the
1483    // correct visual column, and avoids over-wrapping multi-byte ASCII-
1484    // looking glyphs.
1485    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
1486
1487    // If the line fits within width, return as-is
1488    if total_width <= width {
1489        return vec![line];
1490    }
1491
1492    // Line needs wrapping - extract all text and styles
1493    let mut result_lines = Vec::new();
1494    let mut current_line_spans = Vec::new();
1495    let mut current_line_width = 0usize;
1496    let available_width = width.saturating_sub(continuation_indent);
1497
1498    // Preserve the line's existing left margin (the "  " continuation gutter the
1499    // caller prepends to every non-first message line) on the *first* wrapped
1500    // segment. `split_whitespace` below drops leading spaces and the "first word,
1501    // no indent" rule would then flush the segment to column 0 — that's the
1502    // recurring bug where a wrapped paragraph escapes the message gutter while its
1503    // own continuation lines (which get `continuation_indent`) stay aligned. A
1504    // non-whitespace prefix like "● " is unaffected (it survives `split_whitespace`).
1505    let leading_indent: usize = {
1506        let mut n = 0;
1507        for span in &line.spans {
1508            let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
1509            n += spaces;
1510            if spaces < span.content.len() {
1511                break; // this span has non-space content, so leading run ends here
1512            }
1513        }
1514        n
1515    };
1516
1517    for span in line.spans.clone() {
1518        let span_text = span.content.to_string();
1519        let span_style = span.style;
1520
1521        // Split span text by words
1522        let words: Vec<&str> = span_text.split_whitespace().collect();
1523
1524        for (word_idx, word) in words.iter().enumerate() {
1525            let word_with_space = if word_idx > 0 || current_line_width > 0 {
1526                format!(" {}", word)
1527            } else {
1528                word.to_string()
1529            };
1530
1531            let word_width = word_with_space.width();
1532
1533            if current_line_width == 0 && result_lines.is_empty() {
1534                // First word of the first line: re-apply the original left margin
1535                // (dropped by split_whitespace) so the segment keeps the gutter
1536                // instead of flushing to column 0.
1537                if leading_indent > 0 {
1538                    current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
1539                    current_line_width += leading_indent;
1540                }
1541                if word_width <= available_width {
1542                    current_line_spans.push(Span::styled(word_with_space, span_style));
1543                    current_line_width += word_width;
1544                } else {
1545                    // A single token wider than the line (e.g. a long URL):
1546                    // hard-break it at width boundaries so it wraps instead of
1547                    // being clipped by the viewport (F33). The first row may use
1548                    // the full `width` (its indent is already counted above);
1549                    // continuation rows fall back to `available_width`.
1550                    hard_break_styled_token(
1551                        word,
1552                        span_style,
1553                        &mut result_lines,
1554                        &mut current_line_spans,
1555                        &mut current_line_width,
1556                        continuation_indent,
1557                        available_width,
1558                        width,
1559                    );
1560                }
1561            } else if current_line_width + word_width <= available_width {
1562                // Word fits on current line
1563                current_line_spans.push(Span::styled(word_with_space, span_style));
1564                current_line_width += word_width;
1565            } else if word.width() <= available_width {
1566                // Word doesn't fit - finish current line and start new one
1567                result_lines.push(Line::from(current_line_spans));
1568                current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
1569                current_line_spans.push(Span::styled(word.to_string(), span_style));
1570                current_line_width = word.width();
1571            } else {
1572                // Over-long token mid-line: finish the current line, then
1573                // hard-break the token across continuation rows (F33), keeping
1574                // the span's style on every produced piece.
1575                result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
1576                current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
1577                current_line_width = 0;
1578                hard_break_styled_token(
1579                    word,
1580                    span_style,
1581                    &mut result_lines,
1582                    &mut current_line_spans,
1583                    &mut current_line_width,
1584                    continuation_indent,
1585                    available_width,
1586                    available_width,
1587                );
1588            }
1589        }
1590    }
1591
1592    // Add the last line if it has content
1593    if !current_line_spans.is_empty() {
1594        result_lines.push(Line::from(current_line_spans));
1595    }
1596
1597    if result_lines.is_empty() {
1598        vec![line]
1599    } else {
1600        result_lines
1601    }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606    use super::*;
1607
1608    #[test]
1609    fn diff_background_fills_full_width_with_tabs() {
1610        // Regression: tab characters paint as zero cells, so char-count padding
1611        // left the red/green diff bar short by one column per tab — a ragged
1612        // "staircase" down the right edge. After expand_tabs, every column of a
1613        // diff row must carry the background.
1614        use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
1615        use ratatui::Terminal;
1616        use ratatui::backend::TestBackend;
1617
1618        let theme = Theme::dark();
1619        let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
1620        let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
1621        // Lines at increasing tab depth — the exact shape that staircased.
1622        let diff = format!(
1623            "  62{m}\tconst out = [];\n  63{p}\t\tlet fixed = false;\n  64{p}\t\t\tdeeplyNested();",
1624            m = DIFF_REMOVED_MARKER,
1625            p = DIFF_ADDED_MARKER
1626        );
1627        let action = ActionDisplay {
1628            action_type: "Edit".to_string(),
1629            target: "engine.ts".to_string(),
1630            result: ActionResult::Success {
1631                output: String::new(),
1632                images: None,
1633            },
1634            details: ActionDetails::Diff {
1635                summary: "ok".to_string(),
1636                diff,
1637            },
1638            duration_seconds: Some(0.3),
1639            metadata: None,
1640        };
1641
1642        let width: u16 = 60;
1643        let mut lines: Vec<Line> = Vec::new();
1644        render_actions(&[action], &mut lines, &theme, width as usize);
1645        let h = lines.len() as u16;
1646        let backend = TestBackend::new(width, h);
1647        let mut term = Terminal::new(backend).unwrap();
1648        term.draw(|f| {
1649            Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
1650        })
1651        .unwrap();
1652        let buf = term.backend().buffer();
1653
1654        for y in 0..h {
1655            let is_diff_row = (0..width).any(|x| {
1656                let bg = buf[(x, y)].bg;
1657                bg == added_bg || bg == removed_bg
1658            });
1659            if !is_diff_row {
1660                continue;
1661            }
1662            for x in 0..width {
1663                let bg = buf[(x, y)].bg;
1664                assert!(
1665                    bg == added_bg || bg == removed_bg,
1666                    "diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
1667                );
1668            }
1669        }
1670    }
1671
1672    #[test]
1673    fn wrapped_line_cache_hit_matches_cache_miss() {
1674        // #134: caching the WRAPPED assistant lines must be byte-for-byte
1675        // identical to wrapping fresh. Render the same messages through a shared
1676        // cache — first call misses (populates), second hits — and assert the
1677        // two frame buffers are equal; then prove a cold cache renders the same
1678        // frame as the warm one. Assistant-only messages keep the frame free of
1679        // the time-relative user timestamp, so nothing here is clock-dependent.
1680        use ratatui::Terminal;
1681        use ratatui::backend::TestBackend;
1682
1683        let theme = Theme::dark();
1684        let messages = vec![
1685            ChatMessage::assistant(
1686                "# Heading\n\nSome **bold** prose long enough that it has to wrap \
1687                 across this narrow viewport more than once.\n\n\
1688                 - a list item that also keeps going past the edge so it wraps too\n\
1689                 - second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
1690            ),
1691            ChatMessage::assistant("Short follow-up paragraph."),
1692        ];
1693
1694        let (width, height): (u16, u16) = (40, 40);
1695        let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
1696            let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
1697            let mut state = ChatState::new();
1698            term.draw(|f| {
1699                let widget = ChatWidget {
1700                    messages: &messages,
1701                    theme: &theme,
1702                    wrapped_line_cache: cache,
1703                    show_reasoning: true,
1704                };
1705                f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
1706            })
1707            .unwrap();
1708            term.backend().buffer().clone()
1709        };
1710
1711        let mut shared = FxHashMap::default();
1712        let miss = render_once(&mut shared);
1713        assert!(!shared.is_empty(), "first render must populate the cache");
1714        let hit = render_once(&mut shared);
1715        assert_eq!(miss, hit, "cache hit must render identically to cache miss");
1716
1717        let mut cold_cache = FxHashMap::default();
1718        let cold = render_once(&mut cold_cache);
1719        assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
1720    }
1721
1722    #[test]
1723    fn byte_at_cell_clamps_and_respects_cjk() {
1724        assert_eq!(byte_at_cell("hello", 0), 0);
1725        assert_eq!(byte_at_cell("hello", 3), 3);
1726        assert_eq!(byte_at_cell("hello", 99), 5); // clamp past end
1727        // "你好" = 2 chars, 3 bytes each, 2 cells each.
1728        assert_eq!(byte_at_cell("你好", 0), 0);
1729        assert_eq!(byte_at_cell("你好", 2), 3); // after first wide char
1730        // A cell index that lands mid-glyph keeps the glyph whole (rounds up).
1731        assert_eq!(byte_at_cell("你好", 1), 3);
1732    }
1733
1734    #[test]
1735    fn slice_by_cells_extracts_display_range() {
1736        assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
1737        assert_eq!(slice_by_cells("hello world", 6, 11), "world");
1738        assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
1739    }
1740
1741    #[test]
1742    fn pad_to_cells_fills_to_display_width() {
1743        assert_eq!(pad_to_cells("ab", 5), "ab   ");
1744        // "你好" = 4 display cells; pad to 6 → exactly 2 trailing spaces (#101).
1745        assert_eq!(pad_to_cells("你好", 6), "你好  ");
1746        // Already wide enough → unchanged (never truncates).
1747        assert_eq!(pad_to_cells("你好", 3), "你好");
1748        assert_eq!(pad_to_cells("", 0), "");
1749    }
1750
1751    #[test]
1752    fn user_timestamp_padding_aligns_on_display_cells() {
1753        // ASCII: prefix(4) + text(5) + gap(3) + ts(8) = 20 used; content 40.
1754        assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
1755        // A wider (CJK) message shrinks the gap but the timestamp still lands at
1756        // the content right edge: role + text + pad + ts == content_width (#104).
1757        let pad = user_timestamp_padding(4, 10, 8, 3, 40);
1758        assert_eq!(4 + 10 + pad + 8, 40);
1759        // Overflow (text wider than the line) clamps to min_gap, never underflows.
1760        assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
1761    }
1762
1763    #[test]
1764    fn wrap_preformatted_hard_wraps_preserving_spaces() {
1765        // 18 cells, wraps at 10. Spaces are preserved (not collapsed) and the
1766        // leading indentation survives on the first row.
1767        let line = Line::from(vec![Span::raw("    aaaa bbbb cccc")]);
1768        let wrapped = wrap_preformatted(line, 10, 2);
1769        assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
1770        let first: String = wrapped[0]
1771            .spans
1772            .iter()
1773            .map(|s| s.content.as_ref())
1774            .collect();
1775        assert!(
1776            first.starts_with("    aaaa"),
1777            "indentation must be preserved, got {first:?}"
1778        );
1779        let second: String = wrapped[1]
1780            .spans
1781            .iter()
1782            .map(|s| s.content.as_ref())
1783            .collect();
1784        assert!(
1785            second.starts_with("  "),
1786            "continuation should get the hanging indent, got {second:?}"
1787        );
1788    }
1789
1790    #[test]
1791    fn wrap_preformatted_short_line_unchanged() {
1792        let line = Line::from(vec![Span::raw("    short")]);
1793        let wrapped = wrap_preformatted(line, 40, 2);
1794        assert_eq!(wrapped.len(), 1);
1795        let text: String = wrapped[0]
1796            .spans
1797            .iter()
1798            .map(|s| s.content.as_ref())
1799            .collect();
1800        assert_eq!(text, "    short");
1801    }
1802
1803    /// Build a ChatState whose last frame rendered `rows`, with a selection
1804    /// already mapped to content coords, so `selected_text` can be tested
1805    /// without a real terminal.
1806    fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
1807        let mut st = ChatState::new();
1808        st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
1809        st.selection = Some(sel);
1810        st
1811    }
1812
1813    #[test]
1814    fn selected_text_single_line() {
1815        let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
1816        assert_eq!(st.selected_text().as_deref(), Some("hello"));
1817    }
1818
1819    #[test]
1820    fn selected_text_spans_multiple_rows() {
1821        let st = state_with_rows(&["> first line", "  second line"], ((0, 2), (1, 8)));
1822        // The continuation row's "  " margin is stripped so copied text is
1823        // clean (the start row was sliced from the click column past "> ").
1824        assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
1825    }
1826
1827    #[test]
1828    fn selected_text_strips_margin_but_keeps_code_indentation() {
1829        // Rendered rows: 2-cell margin + the code's own indentation. Selecting
1830        // from column 0 must drop only the 2-cell margin, not the code indent.
1831        let st = state_with_rows(
1832            &["  fn main() {", "      let x = 1;", "  }"],
1833            ((0, 0), (2, 3)),
1834        );
1835        assert_eq!(
1836            st.selected_text().as_deref(),
1837            Some("fn main() {\n    let x = 1;\n}")
1838        );
1839    }
1840
1841    #[test]
1842    fn selected_text_normalizes_reversed_drag() {
1843        // Dragging bottom-up / right-to-left yields the same text.
1844        let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
1845        assert_eq!(st.selected_text().as_deref(), Some("hello"));
1846    }
1847
1848    #[test]
1849    fn selected_text_empty_selection_is_none() {
1850        // A plain click (anchor == cursor) selects nothing.
1851        let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
1852        assert_eq!(st.selected_text(), None);
1853    }
1854
1855    #[test]
1856    fn highlight_line_cells_splits_spans_on_selection() {
1857        let mut line = Line::from(vec![Span::raw("abcdef")]);
1858        highlight_line_cells(
1859            &mut line,
1860            2,
1861            4,
1862            Style::new().add_modifier(Modifier::REVERSED),
1863        );
1864        // Split into "ab" | "cd"(reversed) | "ef".
1865        let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1866        assert_eq!(texts, vec!["ab", "cd", "ef"]);
1867        assert!(
1868            line.spans[1]
1869                .style
1870                .add_modifier
1871                .contains(Modifier::REVERSED)
1872        );
1873        assert!(
1874            !line.spans[0]
1875                .style
1876                .add_modifier
1877                .contains(Modifier::REVERSED)
1878        );
1879    }
1880
1881    #[test]
1882    fn context_checkpoint_renders_as_compact_event() {
1883        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1884        msg.kind = ChatMessageKind::ContextCheckpoint;
1885        msg.metadata = Some(serde_json::json!({
1886            "trigger": "manual",
1887            "before_tokens": 43_800,
1888            "after_tokens": 9_200,
1889            "archived_message_count": 18,
1890            "preserved_message_count": 4,
1891            "duration_secs": 2.4,
1892            "verified": true,
1893        }));
1894
1895        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1896        let rendered = lines
1897            .iter()
1898            .map(|line| {
1899                line.spans
1900                    .iter()
1901                    .map(|span| span.content.as_ref())
1902                    .collect::<String>()
1903            })
1904            .collect::<Vec<_>>()
1905            .join("\n");
1906
1907        assert!(rendered.contains("Compact(manual)"));
1908        assert!(rendered.contains("43.8k -> 9.2k tokens"));
1909        assert!(rendered.contains("archived 18 messages"));
1910        assert!(rendered.contains("preserved 4 messages"));
1911        assert!(rendered.contains("verified"));
1912        assert!(!rendered.contains("full checkpoint summary"));
1913    }
1914
1915    #[test]
1916    fn context_checkpoint_renders_verification_fallback() {
1917        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
1918        msg.kind = ChatMessageKind::ContextCheckpoint;
1919        msg.metadata = Some(serde_json::json!({
1920            "trigger": "auto_threshold",
1921            "before_tokens": 43_800,
1922            "after_tokens": 9_200,
1923            "archived_message_count": 18,
1924            "preserved_message_count": 4,
1925            "duration_secs": 2.4,
1926            "verified": false,
1927            "verification_error": "provider overloaded",
1928        }));
1929
1930        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
1931        let rendered = lines
1932            .iter()
1933            .map(|line| {
1934                line.spans
1935                    .iter()
1936                    .map(|span| span.content.as_ref())
1937                    .collect::<String>()
1938            })
1939            .collect::<Vec<_>>()
1940            .join("\n");
1941
1942        assert!(rendered.contains("Compact(auto_threshold)"));
1943        assert!(rendered.contains("draft fallback"));
1944        assert!(rendered.contains("verification: provider overloaded"));
1945    }
1946
1947    /// CJK characters are 3 bytes but 2 display cells each. The
1948    /// byte-length version of `wrap_styled_line` would incorrectly
1949    /// over-wrap such input. This test asserts the display-width
1950    /// version keeps CJK-only input on a single line when the display
1951    /// width fits, even when the byte length exceeds the width.
1952    #[test]
1953    fn wrap_styled_line_uses_display_width_for_cjk() {
1954        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
1955        // Target width of 10: byte-length would see 12 > 10 and wrap;
1956        // display-width sees 8 <= 10 and keeps it on one line.
1957        let line = Line::from(Span::raw("你好世界".to_string()));
1958        let wrapped = wrap_styled_line(line, 10, 2);
1959        assert_eq!(
1960            wrapped.len(),
1961            1,
1962            "CJK input fitting in display-width should NOT be wrapped; got {} lines",
1963            wrapped.len()
1964        );
1965    }
1966
1967    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
1968    /// the input exceeds the width.
1969    #[test]
1970    fn wrap_styled_line_ascii_wraps_when_too_long() {
1971        let line = Line::from(Span::raw(
1972            "the quick brown fox jumps over the lazy dog".to_string(),
1973        ));
1974        let wrapped = wrap_styled_line(line, 15, 2);
1975        assert!(
1976            wrapped.len() >= 2,
1977            "long ASCII input should wrap to multiple lines; got {}",
1978            wrapped.len()
1979        );
1980    }
1981
1982    fn first_segment_text(wrapped: &[Line<'static>]) -> String {
1983        wrapped[0]
1984            .spans
1985            .iter()
1986            .map(|s| s.content.as_ref())
1987            .collect()
1988    }
1989
1990    /// Regression (recurring "paragraph escapes the gutter" bug): a non-first
1991    /// message line carries a 2-space gutter prefix; when it wraps, the first
1992    /// segment must keep that gutter, not flush to column 0. `split_whitespace`
1993    /// used to drop the leading spaces and the "first word, no indent" rule
1994    /// flushed the segment left.
1995    #[test]
1996    fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
1997        let line = Line::from(vec![
1998            Span::raw("  "), // the continuation gutter chat.rs prepends
1999            Span::raw(
2000                "No source files, no config, no docs, no build system and more words to wrap"
2001                    .to_string(),
2002            ),
2003        ]);
2004        let wrapped = wrap_styled_line(line, 30, 2);
2005        assert!(wrapped.len() >= 2, "should wrap");
2006        let first = first_segment_text(&wrapped);
2007        assert!(
2008            first.starts_with("  ") && first.trim_start().starts_with("No source"),
2009            "first wrapped segment must keep the 2-space gutter; got {first:?}"
2010        );
2011    }
2012
2013    /// End-to-end: a wrapped list item keeps the bullet on the first segment and
2014    /// hangs its continuation lines under the item text (col 6 = 2 gutter + 2
2015    /// nesting indent + 2 marker), instead of snapping back to the message gutter.
2016    /// Exercises the same span shape chat.rs builds, with the continuation indent
2017    /// chat.rs derives via markdown::line_hanging_indent (4) + the gutter (2).
2018    #[test]
2019    fn wrap_styled_line_hangs_list_continuation_under_marker() {
2020        let line = Line::from(vec![
2021            Span::raw("  "), // message gutter (chat.rs)
2022            Span::raw("  "), // list nesting indent (markdown)
2023            Span::raw("• "), // marker (markdown)
2024            Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
2025        ]);
2026        let wrapped = wrap_styled_line(line, 24, 6);
2027        assert!(wrapped.len() >= 2, "should wrap");
2028        assert!(
2029            first_segment_text(&wrapped).starts_with("    • "),
2030            "first segment keeps gutter + nesting + marker"
2031        );
2032        for cont in &wrapped[1..] {
2033            let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
2034            assert!(
2035                t.starts_with("      ") && t.chars().nth(6).is_some_and(|c| c != ' '),
2036                "continuation hangs under the item text at col 6; got {t:?}"
2037            );
2038        }
2039    }
2040
2041    /// The fix preserves whitespace margins only — the message bullet "● " must
2042    /// still sit at column 0 on the first line.
2043    #[test]
2044    fn wrap_styled_line_keeps_bullet_at_column_zero() {
2045        let line = Line::from(vec![
2046            Span::raw("● "),
2047            Span::raw(
2048                "a fairly long first line of a message that definitely needs to wrap".to_string(),
2049            ),
2050        ]);
2051        let wrapped = wrap_styled_line(line, 25, 2);
2052        assert!(wrapped.len() >= 2, "should wrap");
2053        assert!(
2054            first_segment_text(&wrapped).starts_with('●'),
2055            "bullet must stay at column 0"
2056        );
2057    }
2058
2059    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
2060    /// the plain-string wrapper used by user messages and thinking blocks.
2061    /// The byte-based version would wrap a 4-CJK paragraph after the second
2062    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
2063    /// version keeps it on one line.
2064    #[test]
2065    fn wrap_text_with_indent_uses_display_width_for_cjk() {
2066        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
2067        // with 0 indent: should fit on one line.
2068        let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
2069        assert_eq!(
2070            wrapped.len(),
2071            1,
2072            "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
2073            wrapped.len(),
2074            wrapped
2075        );
2076        assert_eq!(wrapped[0].trim_start(), "你好世界");
2077    }
2078
2079    /// Mixed content: CJK + ASCII should still wrap correctly when the
2080    /// total exceeds available cells.
2081    #[test]
2082    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
2083        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
2084        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
2085        // produce ≥ 2 lines.
2086        let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
2087        assert!(
2088            wrapped.len() >= 2,
2089            "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
2090            wrapped.len(),
2091            wrapped
2092        );
2093    }
2094
2095    #[test]
2096    fn clamp_to_u16_saturates_past_u16_max() {
2097        // F32: line counters past u16::MAX must clamp to the last addressable
2098        // row, never wrap modulo 65536 (which a plain `as u16` would do).
2099        assert_eq!(clamp_to_u16(0), 0);
2100        assert_eq!(clamp_to_u16(65_535), u16::MAX);
2101        assert_eq!(clamp_to_u16(65_536), u16::MAX);
2102        assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
2103    }
2104
2105    #[test]
2106    fn wrap_text_with_indent_hard_breaks_overlong_token() {
2107        // F33: a single unbroken token far wider than the viewport must
2108        // hard-break at width boundaries instead of overflowing and being
2109        // clipped. No internal spaces, so word-wrapping alone can't split it.
2110        let token = "x".repeat(100);
2111        let width = 20;
2112        let wrapped = wrap_text_with_indent(&token, width, 2, 2);
2113        assert!(
2114            wrapped.len() >= 5,
2115            "a 100-cell token at width 20 must span many rows; got {}",
2116            wrapped.len()
2117        );
2118        for line in &wrapped {
2119            assert!(
2120                line.chars().count() <= width,
2121                "no wrapped row may exceed the width; got {:?} ({} cells)",
2122                line,
2123                line.chars().count()
2124            );
2125        }
2126        // Stripping each row's hanging indent reconstructs the token intact.
2127        let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
2128        assert_eq!(
2129            joined, token,
2130            "hard-break must preserve the token's content"
2131        );
2132    }
2133
2134    #[test]
2135    fn wrap_styled_line_hard_breaks_overlong_token() {
2136        // F33 (styled path): the same hard-break, preserving each piece's style.
2137        let token = "y".repeat(90);
2138        let style = Style::new().fg(ratatui::style::Color::Red);
2139        let line = Line::from(vec![Span::raw("  "), Span::styled(token.clone(), style)]);
2140        let width = 24;
2141        let wrapped = wrap_styled_line(line, width, 2);
2142        assert!(
2143            wrapped.len() >= 4,
2144            "must hard-break across rows; got {}",
2145            wrapped.len()
2146        );
2147
2148        let mut reconstructed = String::new();
2149        for l in &wrapped {
2150            let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
2151            assert!(
2152                row_cells <= width,
2153                "row exceeds width: {row_cells} > {width}"
2154            );
2155            for s in &l.spans {
2156                // Skip indent/gutter spans (whitespace only); every content
2157                // piece must keep the original red foreground.
2158                if s.content.trim().is_empty() {
2159                    continue;
2160                }
2161                assert_eq!(
2162                    s.style.fg,
2163                    Some(ratatui::style::Color::Red),
2164                    "hard-break must preserve the span style"
2165                );
2166                reconstructed.push_str(s.content.as_ref());
2167            }
2168        }
2169        assert_eq!(reconstructed, token, "hard-break must preserve the token");
2170    }
2171
2172    #[test]
2173    fn frame_memo_hit_matches_miss() {
2174        // F31: memoizing the assembled frame must be byte-for-byte identical to
2175        // re-assembling it. Render the SAME state twice — the first render
2176        // populates the frame memo, the second reuses it — and assert the
2177        // buffers are equal. Assistant-only messages keep the frame free of the
2178        // clock-relative user timestamp, so nothing here is time-dependent.
2179        use ratatui::Terminal;
2180        use ratatui::backend::TestBackend;
2181
2182        let theme = Theme::dark();
2183        let messages = vec![
2184            ChatMessage::assistant(
2185                "# Heading\n\nSome **bold** prose long enough that it wraps across \
2186                 this narrow viewport more than once.\n\n- a list item that also \
2187                 runs past the edge so it wraps\n- second item",
2188            ),
2189            ChatMessage::assistant("Short follow-up."),
2190        ];
2191
2192        let (width, height): (u16, u16) = (34, 30);
2193        let mut cache = FxHashMap::default();
2194        let mut state = ChatState::new();
2195
2196        let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
2197            let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
2198            term.draw(|f| {
2199                let widget = ChatWidget {
2200                    messages: &messages,
2201                    theme: &theme,
2202                    wrapped_line_cache: cache,
2203                    show_reasoning: true,
2204                };
2205                f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
2206            })
2207            .unwrap();
2208            term.backend().buffer().clone()
2209        };
2210
2211        let miss = render(&mut state, &mut cache);
2212        assert!(
2213            state.frame_memo.is_some(),
2214            "first render must populate the frame memo"
2215        );
2216        let hit = render(&mut state, &mut cache);
2217        assert_eq!(
2218            miss, hit,
2219            "frame-memo hit must render identically to the miss"
2220        );
2221        // The rows used for selection extraction are only re-collected on a
2222        // miss; assert the hit path left them intact (not cleared/stale) so
2223        // copy/selection still works on a reused frame (F31).
2224        assert!(
2225            !state.last_rendered_rows.is_empty(),
2226            "memo hit must preserve last_rendered_rows from the miss"
2227        );
2228    }
2229}