Skip to main content

vtcode_ui/tui/core_tui/widgets/
transcript.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Style},
5    text::{Line, Span},
6    widgets::{Clear, Paragraph, Widget},
7};
8
9use crate::tui::config::constants::ui;
10use crate::tui::ui::tui::session::{Session, TranscriptLine};
11use tui_shimmer::shimmer_spans_with_style_at_phase;
12use vtcode_config::constants::tools;
13
14/// Widget for rendering the transcript area with conversation history
15///
16/// This widget handles:
17/// - Scroll viewport management
18/// - Content caching and optimization
19/// - Text wrapping and overflow
20/// - Queue overlay rendering
21///
22/// # Example
23/// ```ignore
24/// TranscriptWidget::new(session)
25///     .show_scrollbar(true)
26///     .custom_style(style)
27///     .render(area, buf);
28/// ```
29pub struct TranscriptWidget<'a> {
30    session: &'a mut Session,
31    show_scrollbar: bool,
32    custom_style: Option<Style>,
33}
34
35impl<'a> TranscriptWidget<'a> {
36    /// Create a new TranscriptWidget with required parameters
37    pub fn new(session: &'a mut Session) -> Self {
38        Self { session, show_scrollbar: false, custom_style: None }
39    }
40
41    /// Enable or disable scrollbar rendering
42    #[must_use]
43    pub fn show_scrollbar(mut self, show: bool) -> Self {
44        self.show_scrollbar = show;
45        self
46    }
47
48    /// Set a custom style for the transcript
49    #[must_use]
50    pub fn custom_style(mut self, style: Style) -> Self {
51        self.custom_style = Some(style);
52        self
53    }
54}
55
56impl<'a> Widget for TranscriptWidget<'a> {
57    #[cfg_attr(feature = "profiling", hotpath::measure)]
58    fn render(self, area: Rect, buf: &mut Buffer) {
59        self.session.set_progress_area(None);
60        if area.height == 0 || area.width == 0 {
61            self.session.set_transcript_area(None);
62            self.session.clear_transcript_file_link_targets();
63            return;
64        }
65
66        let mut inner = transcript_content_area(area);
67        if self.session.progress.is_active() && inner.height > 0 && inner.width > 0 {
68            let progress_area = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
69            self.session.render_progress(progress_area, buf);
70            inner.height = inner.height.saturating_sub(1);
71        }
72
73        if inner.height == 0 || inner.width == 0 {
74            self.session.set_transcript_area(None);
75            self.session.clear_transcript_file_link_targets();
76            return;
77        }
78        let layout = self.session.layout_sticky_prompt(inner);
79        let scroll_area = layout.body;
80        let content_width = scroll_area.width;
81        let viewport_rows = usize::from(scroll_area.height);
82        let visible_start = layout.source_row;
83        if let Some(header) = layout.header {
84            let header_area = Rect::new(inner.x, inner.y, inner.width, 1);
85            let style = self.session.styles.sticky_prompt_style();
86            Clear.render(header_area, buf);
87            Paragraph::new(header).style(style).render(header_area, buf);
88        }
89
90        // Use cached visible lines to avoid rebuilding on every frame
91        let cached_lines = self
92            .session
93            .collect_transcript_window_cached(content_width, visible_start, viewport_rows);
94
95        // Check if we need to mutate the lines (queue overlay). Bottom padding
96        // rows need no mutation — the buffer is already default-styled empty
97        // space, so padding the line list only forced a per-frame clone.
98        let needs_mutation = !self.session.queued_inputs.is_empty();
99
100        // Fast path: no queue overlay, no live indicator shimmer, and no
101        // explicit links — paint cached rows in place without cloning Lines
102        // into Paragraph (hotpath: that clone was ~15KB/frame).
103        let has_links = cached_lines.iter().any(|line| !line.explicit_links.is_empty());
104        let spinner_active = active_indicator_shimmer_phase(self.session).is_some();
105        if !needs_mutation && !has_links && !spinner_active {
106            self.session.clear_transcript_file_link_targets();
107            if self.session.transcript_clear_required {
108                Clear.render(scroll_area, buf);
109                self.session.transcript_clear_required = false;
110            }
111            let default_style = self.session.styles.default_style();
112            let default_bg = default_style.bg;
113            paint_pre_wrapped_lines(cached_lines.as_slice(), scroll_area, buf, default_style);
114            apply_borrowed_line_backgrounds(buf, scroll_area, cached_lines.as_slice(), default_bg);
115            clear_transcript_gutters(area, inner, default_style, buf);
116            return;
117        }
118
119        let mut visible_lines = if needs_mutation {
120            // Need to mutate (queue overlay), so clone, pad to the viewport
121            // (overlay paints at the bottom), and modify.
122            let mut lines = cached_lines.to_vec();
123            let fill_count = viewport_rows.saturating_sub(lines.len());
124            if fill_count > 0 {
125                let target_len = lines.len() + fill_count;
126                lines.resize_with(target_len, TranscriptLine::default);
127            }
128            self.session.overlay_queue_lines(&mut lines, content_width);
129            self.session.decorate_visible_cached_transcript_links(lines, scroll_area)
130        } else {
131            self.session
132                .decorate_borrowed_cached_transcript_links(cached_lines.as_slice(), scroll_area)
133        };
134        apply_active_file_operation_spinner(self.session, &mut visible_lines);
135
136        // Only clear if content actually changed, not on viewport-only scroll
137        // This is a significant optimization: avoids expensive Clear operation on most scrolls
138        if self.session.transcript_clear_required {
139            Clear.render(scroll_area, buf);
140            self.session.transcript_clear_required = false;
141        }
142        // Paint full-width line tints AFTER Paragraph. Paragraph::render
143        // first fills the whole area with `default_style` (terminal bg),
144        // which would wipe a pre-painted band on cells past the line text.
145        // Precompute per-row tints so the owned lines can move into Paragraph
146        // without a second clone.
147        let default_style = self.session.styles.default_style();
148        let default_bg = default_style.bg;
149        let row_tints: Vec<Option<Color>> = visible_lines.iter().map(line_background).collect();
150        // Lines are already wrapped to `content_width` == `scroll_area.width` in
151        // reflow. Re-wrapping in Paragraph every frame was the dominant
152        // steady-state render cost (hotpath: ~21KB/frame).
153        let paragraph = Paragraph::new(visible_lines).style(default_style);
154        paragraph.render(scroll_area, buf);
155        apply_precomputed_line_backgrounds(buf, scroll_area, &row_tints, default_bg);
156        clear_transcript_gutters(area, inner, default_style, buf);
157    }
158}
159
160fn transcript_content_area(area: Rect) -> Rect {
161    let gutter = transcript_horizontal_gutter(area.width);
162    Rect::new(
163        area.x.saturating_add(gutter),
164        area.y,
165        area.width.saturating_sub(gutter.saturating_mul(2)),
166        area.height,
167    )
168}
169
170fn transcript_horizontal_gutter(width: u16) -> u16 {
171    if width >= ui::INLINE_TRANSCRIPT_WIDE_GUTTER_MIN_WIDTH {
172        ui::INLINE_TRANSCRIPT_WIDE_GUTTER_COLUMNS
173    } else if width >= ui::INLINE_TRANSCRIPT_STANDARD_GUTTER_MIN_WIDTH {
174        ui::INLINE_TRANSCRIPT_STANDARD_GUTTER_COLUMNS
175    } else {
176        0
177    }
178}
179
180fn clear_transcript_gutters(area: Rect, content_area: Rect, style: Style, buf: &mut Buffer) {
181    let left_width = content_area.x.saturating_sub(area.x);
182    if left_width > 0 {
183        let left = Rect::new(area.x, area.y, left_width, area.height);
184        Clear.render(left, buf);
185        buf.set_style(left, style);
186    }
187
188    let right_x = content_area.right();
189    let right_width = area.right().saturating_sub(right_x);
190    if right_width > 0 {
191        let right = Rect::new(right_x, area.y, right_width, area.height);
192        Clear.render(right, buf);
193        buf.set_style(right, style);
194    }
195}
196
197/// Paint pre-wrapped transcript rows by writing spans directly into the buffer.
198/// Avoids cloning `Line`s into `Paragraph` on the common no-link path.
199/// Span styles patch `default_style` (same merge order as Paragraph).
200fn paint_pre_wrapped_lines(lines: &[TranscriptLine], area: Rect, buf: &mut Buffer, default_style: Style) {
201    buf.set_style(area, default_style);
202    let max_rows = usize::from(area.height).min(lines.len());
203    for (row, transcript_line) in lines.iter().take(max_rows).enumerate() {
204        let y = area.y + row as u16;
205        let mut x = area.x;
206        for span in &transcript_line.line.spans {
207            if x >= area.right() {
208                break;
209            }
210            let remaining = area.right().saturating_sub(x);
211            if remaining == 0 {
212                break;
213            }
214            let merged = default_style.patch(span.style);
215            let (end_x, _end_y) = buf.set_stringn(x, y, span.content.as_ref(), remaining as usize, merged);
216            x = end_x;
217        }
218    }
219}
220
221/// Fill untinted cells on a diff row using `line_background` from borrowed rows.
222fn apply_borrowed_line_backgrounds(buf: &mut Buffer, area: Rect, lines: &[TranscriptLine], default_bg: Option<Color>) {
223    if area.width == 0 || area.height == 0 {
224        return;
225    }
226    let max_rows = usize::from(area.height).min(lines.len());
227    for (row, transcript_line) in lines.iter().take(max_rows).enumerate() {
228        let Some(bg) = line_background(&transcript_line.line) else {
229            continue;
230        };
231        let y = area.y + row as u16;
232        for x in area.left()..area.right() {
233            let cell = &mut buf[(x, y)];
234            if cell.bg == Color::Reset || Some(cell.bg) == default_bg {
235                cell.bg = bg;
236            }
237        }
238    }
239}
240
241const FILE_OPERATION_STATUS_TOOLS: &[&str] = &[
242    tools::WRITE_FILE,
243    tools::CREATE_FILE,
244    tools::EDIT_FILE,
245    tools::APPLY_PATCH,
246    tools::SEARCH_REPLACE,
247    tools::DELETE_FILE,
248    tools::UNIFIED_FILE,
249];
250
251const FILE_OPERATION_INDICATORS: &[&str] = &[
252    "❋ Writing ",
253    "❋ Editing ",
254    "❋ Applying patch to ",
255    "❋ Search/replace in ",
256    "❋ Deleting ",
257    "❋ Drafting plan",
258    "❋ Validating plan",
259    "❋ Persisting plan",
260    "❋ Preparing approval",
261];
262
263fn apply_active_file_operation_spinner(session: &Session, lines: &mut [Line<'static>]) {
264    let Some(phase) = active_indicator_shimmer_phase(session) else {
265        return;
266    };
267
268    // Sweep a shimmer across the whole indicator line (icon + verb + target)
269    // at the shared shimmer phase, so the transcript pulses in sync with the
270    // footer. Only the newest indicator row animates and only while its work
271    // is live; the cached transcript is never mutated, so the static `❋`
272    // row (with its links/colors) returns untouched when activity ends.
273    for line in lines.iter_mut().rev() {
274        if !is_file_operation_indicator_line(line) {
275            continue;
276        }
277        let text = line.spans.iter().map(|span| span.content.as_ref()).collect::<String>();
278        let base_style = line.spans.first().map(|span| span.style).unwrap_or_default();
279        line.spans = shimmer_spans_with_style_at_phase(&text, base_style, phase);
280        break;
281    }
282}
283
284/// Shimmer phase for the active transcript indicator row, if any.
285///
286/// Returns `None` when animation is suppressed (reduced motion, screen
287/// reader) or nothing is live, so idle frames skip the span rebuild
288/// entirely. File tools key off `Running tool: <name>`; planning phases key
289/// off their footer statuses (`Drafting/Validating/Persisting plan...`,
290/// `Preparing approval...`), which the runloop keeps live while the long
291/// synthesis runs.
292fn active_indicator_shimmer_phase(session: &Session) -> Option<f32> {
293    if !session.appearance.should_animate_progress_status() {
294        return None;
295    }
296
297    let left = session.input_status_left.as_deref()?.to_ascii_lowercase();
298    if let Some(tool_name) = left.strip_prefix("running tool: ") {
299        return FILE_OPERATION_STATUS_TOOLS
300            .contains(&tool_name)
301            .then(|| session.shimmer_state.phase());
302    }
303
304    let is_planning_status = left.contains("drafting plan")
305        || left.contains("validating plan")
306        || left.contains("persisting plan")
307        || left.contains("preparing approval");
308    is_planning_status.then(|| session.shimmer_state.phase())
309}
310
311fn is_file_operation_indicator_line(line: &Line<'_>) -> bool {
312    let text = line.spans.iter().map(|span| span.content.as_ref()).collect::<String>();
313    FILE_OPERATION_INDICATORS.iter().any(|pattern| text.contains(pattern))
314}
315
316/// Full-row tint for a diff line.
317///
318/// Uses the marker/gutter background rather than a changed-word chip so a row
319/// with several syntax spans does not extend the stronger chip across padding.
320///
321/// Returns `None` for side-by-side rows — those mix coloured panes with an
322/// uncoloured sibling (or two different colours), and full-width fill would
323/// paint the empty pane with the other side's tint.
324fn line_background(line: &Line<'_>) -> Option<Color> {
325    // Fast reject: plain prose has no tinted spans and no side-by-side divider.
326    if !line.spans.iter().any(|span| span.style.bg.is_some()) {
327        return None;
328    }
329    let mut first_background = None;
330    let mut marker_background = None;
331    let mut has_uncolored_divider = false;
332    for span in &line.spans {
333        match span.style.bg {
334            Some(Color::Reset) if span.content.trim() == "│" => has_uncolored_divider = true,
335            Some(Color::Reset) => {}
336            Some(bg) => {
337                first_background.get_or_insert(bg);
338                if marker_background.is_none() && matches!(span.content.chars().next(), Some('+' | '-')) {
339                    marker_background = Some(bg);
340                }
341            }
342            None if span.content.trim() == "│" => has_uncolored_divider = true,
343            None => {}
344        }
345    }
346    // Side-by-side rows carry an uncoloured divider. Full-width fill would
347    // bleed one pane's tint into the other. Do not inspect every later span
348    // for a marker: a changed word can legitimately begin with `+` or `-`
349    // (for example `--last`) without turning a unified row into two panes.
350    if has_uncolored_divider {
351        return None;
352    }
353    marker_background.or(first_background)
354}
355
356/// Fill untinted cells on a diff row with the line tint.
357///
358/// Only cells still on the terminal default background are painted. Word-chip
359/// cells (stronger red/green) must keep their colour so the two-level band
360/// survives the full-width fill.
361fn apply_precomputed_line_backgrounds(
362    buf: &mut Buffer,
363    area: Rect,
364    row_tints: &[Option<Color>],
365    default_bg: Option<Color>,
366) {
367    if area.width == 0 || area.height == 0 {
368        return;
369    }
370
371    let max_rows = usize::from(area.height).min(row_tints.len());
372    for (row, bg) in row_tints.iter().take(max_rows).enumerate() {
373        let Some(bg) = bg else {
374            continue;
375        };
376        let y = area.y + row as u16;
377        for x in area.left()..area.right() {
378            let cell = &mut buf[(x, y)];
379            // Reset / terminal default → line tint. Any explicit paint
380            // (word chip, already-tinted span, etc.) is left alone.
381            if cell.bg == Color::Reset || Some(cell.bg) == default_bg {
382                cell.bg = *bg;
383            }
384        }
385    }
386}
387
388/// Fill untinted cells on a diff row with the line tint (test helper).
389#[cfg(test)]
390fn apply_full_width_line_backgrounds(buf: &mut Buffer, area: Rect, lines: &[Line<'_>], default_bg: Option<Color>) {
391    let row_tints: Vec<Option<Color>> = lines.iter().map(line_background).collect();
392    apply_precomputed_line_backgrounds(buf, area, &row_tints, default_bg);
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::tui::core_tui::types::{InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
399    use std::sync::Arc;
400
401    fn segment(text: &str) -> InlineSegment {
402        InlineSegment {
403            text: text.to_string(),
404            style: Arc::new(InlineTextStyle::default()),
405        }
406    }
407
408    #[test]
409    fn full_width_diff_tint_survives_paragraph_default_bg() {
410        use ratatui::style::Style as RatStyle;
411        use ratatui::text::{Line as RatLine, Span as RatSpan};
412
413        let area = Rect::new(0, 0, 20, 1);
414        let mut buf = Buffer::empty(area);
415        // Simulate Paragraph painting the whole area with the terminal default.
416        buf.set_style(area, RatStyle::default().bg(Color::Black));
417        let line = RatLine::from(vec![RatSpan::styled("+ hi", RatStyle::default().bg(Color::Rgb(20, 58, 45)))]);
418        let lines = vec![line];
419        // Correct order: Paragraph (already simulated) then full-width tint.
420        apply_full_width_line_backgrounds(&mut buf, area, &lines, Some(Color::Black));
421
422        let bg = Color::Rgb(20, 58, 45);
423        assert_eq!(buf[(0, 0)].bg, bg, "left edge must keep the tint");
424        assert_eq!(buf[(19, 0)].bg, bg, "right edge must be full-width tinted");
425    }
426
427    #[test]
428    fn full_width_fill_preserves_word_chip_backgrounds() {
429        use ratatui::style::Style as RatStyle;
430        use ratatui::text::{Line as RatLine, Span as RatSpan};
431
432        let area = Rect::new(0, 0, 20, 1);
433        let mut buf = Buffer::empty(area);
434        let line_bg = Color::Rgb(20, 58, 45);
435        let chip_bg = Color::Rgb(36, 100, 70);
436        buf.set_style(area, RatStyle::default().bg(Color::Black));
437        // Simulate Paragraph writing: line tint on part, chip on "words".
438        buf[(3, 0)].bg = chip_bg;
439        buf[(4, 0)].bg = chip_bg;
440        buf[(5, 0)].bg = line_bg;
441
442        let line = RatLine::from(vec![RatSpan::styled("ab", RatStyle::default().bg(line_bg))]);
443        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
444
445        assert_eq!(buf[(3, 0)].bg, chip_bg, "word chip must not be overwritten");
446        assert_eq!(buf[(4, 0)].bg, chip_bg, "word chip must not be overwritten");
447        assert_eq!(buf[(19, 0)].bg, line_bg, "empty cells fill with line tint");
448        assert_eq!(buf[(0, 0)].bg, line_bg);
449    }
450
451    #[test]
452    fn full_width_fill_uses_the_row_tint_when_word_chips_are_present() {
453        use ratatui::style::Style as RatStyle;
454        use ratatui::text::{Line as RatLine, Span as RatSpan};
455
456        let area = Rect::new(0, 0, 20, 1);
457        let mut buf = Buffer::empty(area);
458        let line_bg = Color::Rgb(20, 58, 45);
459        let chip_bg = Color::Rgb(36, 100, 70);
460        buf.set_style(area, RatStyle::default().bg(Color::Black));
461        for x in 1..8 {
462            buf[(x, 0)].bg = chip_bg;
463        }
464        let line = RatLine::from(vec![
465            RatSpan::styled("+", RatStyle::default().bg(line_bg)),
466            RatSpan::styled("changed", RatStyle::default().bg(chip_bg)),
467            RatSpan::styled(" ", RatStyle::default().bg(line_bg)),
468        ]);
469
470        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
471
472        assert_eq!(buf[(1, 0)].bg, chip_bg, "word chip must remain stronger");
473        assert_eq!(buf[(19, 0)].bg, line_bg, "row tint must fill after word chip");
474    }
475
476    #[test]
477    fn full_width_fill_uses_the_marker_tint_when_syntax_chips_outnumber_it() {
478        use ratatui::style::Style as RatStyle;
479        use ratatui::text::{Line as RatLine, Span as RatSpan};
480
481        let area = Rect::new(0, 0, 20, 1);
482        let mut buf = Buffer::empty(area);
483        let line_bg = Color::Rgb(20, 58, 45);
484        let chip_bg = Color::Rgb(36, 100, 70);
485        buf.set_style(area, RatStyle::default().bg(Color::Black));
486        let line = RatLine::from(vec![
487            RatSpan::styled("+", RatStyle::default().bg(line_bg)),
488            RatSpan::styled("changed", RatStyle::default().bg(chip_bg)),
489            RatSpan::styled(" syntax", RatStyle::default().bg(chip_bg)),
490            RatSpan::styled(" tokens", RatStyle::default().bg(chip_bg)),
491        ]);
492
493        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
494
495        assert_eq!(buf[(19, 0)].bg, line_bg, "padding must use the marker tint");
496    }
497
498    #[test]
499    fn full_width_fill_does_not_misclassify_dash_prefixed_word_chip() {
500        use ratatui::style::Style as RatStyle;
501        use ratatui::text::{Line as RatLine, Span as RatSpan};
502
503        let area = Rect::new(0, 0, 36, 1);
504        let mut buf = Buffer::empty(area);
505        let line_bg = Color::Rgb(70, 38, 42);
506        let chip_bg = Color::Rgb(140, 52, 58);
507        buf.set_style(area, RatStyle::default().bg(Color::Black));
508        let line = RatLine::from(vec![
509            RatSpan::styled("-", RatStyle::default().bg(line_bg)),
510            RatSpan::styled(" 256 │ vtcode trajectory ", RatStyle::default().bg(line_bg)),
511            RatSpan::styled("--last", RatStyle::default().bg(chip_bg)),
512        ]);
513        for x in 25..31 {
514            buf[(x, 0)].bg = chip_bg;
515        }
516
517        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
518
519        assert_eq!(buf[(26, 0)].bg, chip_bg, "intraline chip must remain stronger");
520        assert_eq!(buf[(35, 0)].bg, line_bg, "a dash-prefixed chip must not create a tint gap");
521    }
522
523    #[test]
524    fn full_width_fill_survives_an_uncolored_indent_before_the_marker() {
525        use ratatui::style::Style as RatStyle;
526        use ratatui::text::{Line as RatLine, Span as RatSpan};
527
528        let area = Rect::new(0, 0, 20, 1);
529        let mut buf = Buffer::empty(area);
530        let line_bg = Color::Rgb(20, 58, 45);
531        buf.set_style(area, RatStyle::default().bg(Color::Black));
532        let line = RatLine::from(vec![
533            RatSpan::raw("    "),
534            RatSpan::styled("+", RatStyle::default().bg(line_bg)),
535            RatSpan::styled("new", RatStyle::default().bg(line_bg)),
536        ]);
537
538        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
539
540        assert_eq!(buf[(19, 0)].bg, line_bg, "indentation must not disable row tinting");
541    }
542
543    #[test]
544    fn full_width_fill_skips_side_by_side_dividers() {
545        use ratatui::style::Style as RatStyle;
546        use ratatui::text::{Line as RatLine, Span as RatSpan};
547
548        let area = Rect::new(0, 0, 20, 1);
549        let mut buf = Buffer::empty(area);
550        let left_bg = Color::Rgb(70, 38, 42);
551        let right_bg = Color::Rgb(20, 58, 45);
552        buf.set_style(area, RatStyle::default().bg(Color::Black));
553        let line = RatLine::from(vec![
554            RatSpan::styled("-", RatStyle::default().bg(left_bg)),
555            RatSpan::raw("old       "),
556            RatSpan::raw("│"),
557            RatSpan::styled("+", RatStyle::default().bg(right_bg)),
558            RatSpan::styled("new", RatStyle::default().bg(right_bg)),
559        ]);
560
561        apply_full_width_line_backgrounds(&mut buf, area, &[line], Some(Color::Black));
562
563        assert_eq!(buf[(19, 0)].bg, Color::Black, "pane tint must not bleed across the row");
564    }
565
566    fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
567        (area.left()..area.right()).map(|x| buf[(x, row)].symbol()).collect::<String>()
568    }
569
570    #[test]
571    fn transcript_gutters_adapt_to_available_width() {
572        assert_eq!(
573            transcript_content_area(Rect::new(7, 3, 120, 12)),
574            Rect::new(9, 3, 116, 12),
575            "wide terminals get a modest two-column gutter",
576        );
577        assert_eq!(
578            transcript_content_area(Rect::new(7, 3, 80, 12)),
579            Rect::new(8, 3, 78, 12),
580            "standard terminals use a smaller gutter",
581        );
582        assert_eq!(
583            transcript_content_area(Rect::new(7, 3, 48, 12)),
584            Rect::new(7, 3, 48, 12),
585            "very narrow terminals keep the full width",
586        );
587
588        let area = Rect::new(0, 0, 120, 6);
589        let mut session = Session::new(InlineTheme::default(), None, 12);
590        session.push_line(InlineMessageKind::Agent, vec![segment("wide transcript")]);
591        let mut buf = Buffer::empty(area);
592        for x in [0, 1, 118, 119] {
593            buf[(x, 0)].set_symbol("X");
594        }
595
596        TranscriptWidget::new(&mut session).render(area, &mut buf);
597
598        assert_eq!(session.transcript_area(), Some(Rect::new(2, 0, 116, 6)));
599        assert_eq!(buf[(0, 0)].symbol(), " ", "left gutter must stay clear");
600        assert_eq!(buf[(1, 0)].symbol(), " ", "left gutter must stay clear");
601        assert_eq!(buf[(2, 0)].symbol(), "w", "message text starts inside the gutter");
602        assert_eq!(buf[(118, 0)].symbol(), " ", "right gutter must stay clear");
603        assert_eq!(buf[(119, 0)].symbol(), " ", "right gutter must stay clear");
604    }
605
606    #[test]
607    fn transcript_content_area_keeps_link_and_render_coordinates_aligned() {
608        let area = Rect::new(10, 2, 80, 8);
609        let content_area = transcript_content_area(area);
610        let url = "https://example.com/docs";
611        let mut session = Session::new(InlineTheme::default(), None, 12);
612        session.push_line(InlineMessageKind::Agent, vec![segment(&format!("Open {url}"))]);
613        let mut buf = Buffer::empty(area);
614
615        TranscriptWidget::new(&mut session).render(area, &mut buf);
616
617        assert_eq!(session.transcript_area(), Some(content_area));
618        let link_start = content_area.x + "Open ".len() as u16;
619        assert_eq!(buf[(link_start, area.y)].symbol(), "h");
620        assert!(
621            session.update_transcript_file_link_hover(link_start, area.y),
622            "the rendered URL cell should hit the matching transcript link target",
623        );
624        assert_eq!(row_text(&buf, area, area.y).chars().next(), Some(' '));
625    }
626
627    #[test]
628    fn very_narrow_transcript_keeps_wrapped_content_inside_the_viewport() {
629        let area = Rect::new(0, 0, 40, 6);
630        let mut session = Session::new(InlineTheme::default(), None, 12);
631        session.push_line(
632            InlineMessageKind::Agent,
633            vec![segment("alpha beta gamma delta epsilon zeta eta theta iota kappa")],
634        );
635        let mut buf = Buffer::empty(area);
636
637        TranscriptWidget::new(&mut session).render(area, &mut buf);
638
639        assert_eq!(session.transcript_area(), Some(area));
640        let rows: Vec<String> = (area.y..area.bottom())
641            .map(|row| row_text(&buf, area, row).trim_end().to_string())
642            .filter(|row| !row.is_empty())
643            .collect();
644        assert!(rows.len() >= 2, "narrow transcript should wrap into multiple rows: {rows:?}");
645        assert!(rows.iter().all(|row| row.chars().count() <= usize::from(area.width)));
646    }
647
648    #[test]
649    fn scroll_metric_invalidation_does_not_request_transcript_clear() {
650        let mut session = Session::new(InlineTheme::default(), None, 12);
651        session.transcript_clear_required = false;
652
653        session.invalidate_scroll_metrics();
654
655        assert!(!session.transcript_clear_required);
656    }
657
658    #[test]
659    fn render_clears_stale_wrapped_rows_when_requested() {
660        let area = Rect::new(0, 0, 14, 6);
661        let inner = area;
662        let mut buf = Buffer::empty(area);
663        let mut session = Session::new(InlineTheme::default(), None, 12);
664        session.push_line(InlineMessageKind::Agent, vec![segment("this line wraps across several rows")]);
665
666        TranscriptWidget::new(&mut session).render(area, &mut buf);
667
668        let revision = session.next_revision();
669        session.lines[0].segments = vec![segment("short")];
670        session.lines[0].revision = revision;
671        session.mark_line_dirty(0);
672        session.invalidate_transcript_cache();
673        for row in inner.y + 1..inner.bottom() {
674            for x in inner.left()..inner.right() {
675                buf[(x, row)].set_symbol("X");
676            }
677        }
678
679        TranscriptWidget::new(&mut session).render(area, &mut buf);
680
681        assert!((inner.y + 1..inner.bottom()).all(|row| row_text(&buf, inner, row).trim().is_empty()));
682    }
683
684    #[test]
685    fn render_preserves_queue_overlay_lines() {
686        let area = Rect::new(0, 0, 20, 6);
687        let inner = area;
688        let mut buf = Buffer::empty(area);
689        let mut session = Session::new(InlineTheme::default(), None, 12);
690        session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
691        session.push_queued_input("queued follow-up".to_string());
692
693        TranscriptWidget::new(&mut session).render(area, &mut buf);
694
695        let has_queued = (inner.y..inner.bottom()).any(|row| row_text(&buf, inner, row).contains("queued"));
696        assert!(has_queued, "queued overlay should be visible");
697    }
698
699    #[test]
700    fn render_queue_overlay_orders_items_fifo_oldest_on_top() {
701        let area = Rect::new(0, 0, 30, 8);
702        let inner = area;
703        let mut buf = Buffer::empty(area);
704        let mut session = Session::new(InlineTheme::default(), None, 12);
705        session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
706        session.push_queued_input("first".to_string());
707        session.push_queued_input("second".to_string());
708        session.push_queued_input("third".to_string());
709
710        TranscriptWidget::new(&mut session).render(area, &mut buf);
711
712        // The queue is strict FIFO, so the overlay shows the oldest message at
713        // the top and the newest directly above the input field.
714        let overlay_rows: Vec<String> = (inner.y..inner.bottom())
715            .map(|row| row_text(&buf, inner, row))
716            .map(|text| text.trim().to_string())
717            .filter(|text| !text.is_empty())
718            .collect();
719        let first_row = overlay_rows.iter().position(|row| row.contains("first"));
720        let second_row = overlay_rows.iter().position(|row| row.contains("second"));
721        let third_row = overlay_rows.iter().position(|row| row.contains("third"));
722        assert!(
723            first_row.is_some() && second_row.is_some() && third_row.is_some(),
724            "all three queued items must be visible in the overlay, got: {overlay_rows:?}"
725        );
726        assert!(
727            first_row.unwrap() < second_row.unwrap() && second_row.unwrap() < third_row.unwrap(),
728            "expected FIFO order first < second < third, got: {overlay_rows:?}"
729        );
730    }
731
732    #[test]
733    fn render_queue_overlay_flattens_multiline_entries() {
734        let area = Rect::new(0, 0, 40, 8);
735        let inner = area;
736        let mut buf = Buffer::empty(area);
737        let mut session = Session::new(InlineTheme::default(), None, 12);
738        session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
739        session.push_queued_input("line one\nline two".to_string());
740
741        TranscriptWidget::new(&mut session).render(area, &mut buf);
742
743        let overlay_text: String = (inner.y..inner.bottom()).map(|row| row_text(&buf, inner, row)).collect();
744        assert!(
745            overlay_text.contains("line one") && overlay_text.contains("line two"),
746            "both lines of the queued entry must be visible, got: {overlay_text:?}"
747        );
748        assert!(
749            overlay_text.contains("⏎"),
750            "newlines must be flattened into a visible separator, got: {overlay_text:?}"
751        );
752    }
753
754    #[test]
755    fn render_clears_stale_queue_overlay_rows_when_queue_is_removed() {
756        let area = Rect::new(0, 0, 20, 6);
757        let inner = area;
758        let mut buf = Buffer::empty(area);
759        let mut session = Session::new(InlineTheme::default(), None, 12);
760        session.push_line(InlineMessageKind::Agent, vec![segment("alpha")]);
761        session.push_queued_input("queued follow-up".to_string());
762
763        TranscriptWidget::new(&mut session).render(area, &mut buf);
764        let has_queued_before = (inner.y..inner.bottom()).any(|row| row_text(&buf, inner, row).contains("queued"));
765        assert!(has_queued_before, "queued should be visible before pop");
766
767        let _ = session.pop_latest_queued_input();
768
769        TranscriptWidget::new(&mut session).render(area, &mut buf);
770
771        let has_queued_after = (inner.y..inner.bottom()).any(|row| row_text(&buf, inner, row).contains("queued"));
772        assert!(!has_queued_after, "queued should be cleared after pop");
773    }
774
775    #[test]
776    fn resize_larger_keeps_existing_transcript_lines_visible() {
777        let small_area = Rect::new(0, 0, 20, 4);
778        let large_area = Rect::new(0, 0, 20, 10);
779        let small_inner = small_area;
780        let large_inner = large_area;
781        let mut small_buf = Buffer::empty(small_area);
782        let mut large_buf = Buffer::empty(large_area);
783        let mut session = Session::new(InlineTheme::default(), None, 12);
784
785        for index in 0..6 {
786            session.push_line(InlineMessageKind::Agent, vec![segment(&format!("line {index}"))]);
787        }
788
789        TranscriptWidget::new(&mut session).render(small_area, &mut small_buf);
790        let small_rendered: Vec<String> = (small_inner.y..small_inner.bottom())
791            .map(|row| row_text(&small_buf, small_inner, row).trim().to_string())
792            .filter(|row| !row.is_empty())
793            .collect();
794        TranscriptWidget::new(&mut session).render(large_area, &mut large_buf);
795
796        let rendered: Vec<String> = (large_inner.y..large_inner.bottom())
797            .map(|row| row_text(&large_buf, large_inner, row).trim().to_string())
798            .filter(|row| !row.is_empty())
799            .collect();
800
801        assert!(rendered.len() > small_rendered.len());
802        assert!(rendered.iter().any(|row| row == "line 1"));
803        assert!(rendered.iter().any(|row| row == "line 5"));
804    }
805
806    #[test]
807    fn width_resize_keeps_transcript_visible() {
808        let wide_area = Rect::new(0, 0, 28, 8);
809        let narrow_area = Rect::new(0, 0, 16, 8);
810        let narrow_inner = narrow_area;
811        let mut wide_buf = Buffer::empty(wide_area);
812        let mut narrow_buf = Buffer::empty(narrow_area);
813        let mut session = Session::new(InlineTheme::default(), None, 12);
814
815        for index in 0..4 {
816            session.push_line(InlineMessageKind::Agent, vec![segment(&format!("line {index}"))]);
817        }
818
819        TranscriptWidget::new(&mut session).render(wide_area, &mut wide_buf);
820        TranscriptWidget::new(&mut session).render(narrow_area, &mut narrow_buf);
821
822        let rendered: Vec<String> = (narrow_inner.y..narrow_inner.bottom())
823            .map(|row| row_text(&narrow_buf, narrow_inner, row).trim().to_string())
824            .filter(|row| !row.is_empty())
825            .collect();
826
827        assert!(!rendered.is_empty());
828        assert!(rendered.iter().any(|row| row == "line 1"));
829        assert!(rendered.iter().any(|row| row == "line 3"));
830    }
831
832    fn shimmer_session_with_status(status: &str) -> Session {
833        let mut session = Session::new(InlineTheme::default(), None, 12);
834        session.input_status_left = Some(status.to_string());
835        session
836    }
837
838    fn shimmer_line_text(line: &Line) -> String {
839        line.spans.iter().map(|span| span.content.as_ref()).collect()
840    }
841
842    #[test]
843    fn indicator_shimmer_preserves_text_and_restyles_active_row() {
844        use ratatui::text::Line as RatLine;
845        let session = shimmer_session_with_status("Running tool: edit_file");
846        let original = RatLine::from("❋ Editing vtcode.toml...");
847        let mut lines = vec![original.clone()];
848        apply_active_file_operation_spinner(&session, &mut lines);
849
850        assert_eq!(shimmer_line_text(&lines[0]), "❋ Editing vtcode.toml...");
851        assert_ne!(lines[0].spans, original.spans, "active row must shimmer");
852    }
853
854    #[test]
855    fn indicator_shimmer_sweep_moves_with_phase() {
856        use ratatui::text::Line as RatLine;
857        let mut session = shimmer_session_with_status("Drafting plan... (42 chars)");
858        // The sweep head starts off-text (10 chars of padding), so advance
859        // until it has travelled into the line. Bounded: ~12 ticks minimum.
860        for _ in 0..40 {
861            std::thread::sleep(std::time::Duration::from_millis(40));
862            session.handle_tick();
863            if session.shimmer_state.phase() >= 0.2 {
864                break;
865            }
866        }
867        assert!(
868            session.shimmer_state.phase() >= 0.2,
869            "phase must advance while drafting, got {}",
870            session.shimmer_state.phase()
871        );
872
873        let mut advanced = vec![RatLine::from("❋ Drafting plan — researching codebase...")];
874        apply_active_file_operation_spinner(&session, &mut advanced);
875
876        let fresh = shimmer_session_with_status("Drafting plan... (42 chars)");
877        let mut at_zero = vec![RatLine::from("❋ Drafting plan — researching codebase...")];
878        apply_active_file_operation_spinner(&fresh, &mut at_zero);
879
880        assert_eq!(shimmer_line_text(&advanced[0]), "❋ Drafting plan — researching codebase...");
881        assert_ne!(advanced[0].spans, at_zero[0].spans, "sweep position must follow the shared shimmer phase");
882    }
883
884    #[test]
885    fn indicator_shimmer_animates_only_the_newest_row() {
886        use ratatui::text::Line as RatLine;
887        let session = shimmer_session_with_status("Validating plan...");
888        let first = RatLine::from("❋ Drafting plan — researching codebase...");
889        let second = RatLine::from("❋ Validating plan...");
890        let mut lines = vec![first.clone(), second.clone()];
891        apply_active_file_operation_spinner(&session, &mut lines);
892
893        assert_eq!(lines[0].spans, first.spans, "older row must stay static");
894        assert_ne!(lines[1].spans, second.spans, "newest row must shimmer");
895    }
896
897    #[test]
898    fn indicator_shimmer_stays_static_without_live_status() {
899        use ratatui::text::Line as RatLine;
900        for status in ["Running tool: code_search", "main*", "Ready"] {
901            let session = shimmer_session_with_status(status);
902            let original = RatLine::from("❋ Drafting plan — researching codebase...");
903            let mut lines = vec![original.clone()];
904            apply_active_file_operation_spinner(&session, &mut lines);
905            assert_eq!(lines[0].spans, original.spans, "stale row must not shimmer for {status:?}");
906        }
907
908        let mut session = shimmer_session_with_status("Drafting plan... (42 chars)");
909        session.appearance.reduce_motion_mode = true;
910        let original = RatLine::from("❋ Drafting plan — researching codebase...");
911        let mut lines = vec![original.clone()];
912        apply_active_file_operation_spinner(&session, &mut lines);
913        assert_eq!(lines[0].spans, original.spans, "reduced motion must keep the row static");
914    }
915}