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
14pub struct TranscriptWidget<'a> {
30 session: &'a mut Session,
31 show_scrollbar: bool,
32 custom_style: Option<Style>,
33}
34
35impl<'a> TranscriptWidget<'a> {
36 pub fn new(session: &'a mut Session) -> Self {
38 Self { session, show_scrollbar: false, custom_style: None }
39 }
40
41 #[must_use]
43 pub fn show_scrollbar(mut self, show: bool) -> Self {
44 self.show_scrollbar = show;
45 self
46 }
47
48 #[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 let cached_lines = self
92 .session
93 .collect_transcript_window_cached(content_width, visible_start, viewport_rows);
94
95 let needs_mutation = !self.session.queued_inputs.is_empty();
99
100 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 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 if self.session.transcript_clear_required {
139 Clear.render(scroll_area, buf);
140 self.session.transcript_clear_required = false;
141 }
142 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 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
197fn 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
221fn 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 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
284fn 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
316fn line_background(line: &Line<'_>) -> Option<Color> {
325 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 if has_uncolored_divider {
351 return None;
352 }
353 marker_background.or(first_background)
354}
355
356fn 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 if cell.bg == Color::Reset || Some(cell.bg) == default_bg {
382 cell.bg = *bg;
383 }
384 }
385 }
386}
387
388#[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 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 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 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 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 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}