Skip to main content

vtcode_tui/core_tui/session/render/
mod.rs

1#![allow(dead_code)]
2
3use anstyle::Color as AnsiColorEnum;
4use ratatui::{
5    prelude::*,
6    widgets::{Block, Clear, List, ListItem, Paragraph, Wrap},
7};
8use unicode_width::UnicodeWidthStr;
9
10use super::super::style::ratatui_style_from_inline;
11use super::super::types::{InlineMessageKind, InlineTextStyle};
12use super::terminal_capabilities;
13use super::{
14    Session,
15    file_palette::FilePalette,
16    message::MessageLine,
17    modal::{
18        ModalBodyContext, ModalListLayout, ModalRenderStyles, compute_modal_area,
19        render_modal_body, render_wizard_modal_body,
20    },
21    text_utils,
22};
23use crate::config::constants::ui;
24
25mod modal_renderer;
26mod palettes;
27mod spans;
28
29pub use modal_renderer::render_modal;
30pub use palettes::render_file_palette;
31use spans::{accent_style, border_style, default_style, invalidate_scroll_metrics, text_fallback};
32
33pub(super) fn render_message_spans(session: &Session, index: usize) -> Vec<Span<'static>> {
34    spans::render_message_spans(session, index)
35}
36
37pub(super) fn agent_prefix_spans(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
38    spans::agent_prefix_spans(session, line)
39}
40
41pub(super) fn strip_ansi_codes(text: &str) -> std::borrow::Cow<'_, str> {
42    spans::strip_ansi_codes(text)
43}
44
45pub(super) fn render_tool_segments(session: &Session, line: &MessageLine) -> Vec<Span<'static>> {
46    spans::render_tool_segments(session, line)
47}
48
49const USER_PREFIX: &str = "";
50
51#[allow(dead_code)]
52pub fn render(session: &mut Session, frame: &mut Frame<'_>) {
53    let size = frame.area();
54    if size.width == 0 || size.height == 0 {
55        return;
56    }
57
58    // Clear entire frame if modal was just closed to remove artifacts
59    if session.needs_full_clear {
60        frame.render_widget(Clear, size);
61        session.needs_full_clear = false;
62    }
63
64    // Pull any newly forwarded log entries before layout calculations
65    session.poll_log_entries();
66
67    let header_lines = session.header_lines();
68    let header_height = session.header_height_from_lines(size.width, &header_lines);
69    if header_height != session.header_rows {
70        session.header_rows = header_height;
71        recalculate_transcript_rows(session);
72    }
73
74    // Always reserve 1 row for the status line to prevent layout jumping
75    // when status content appears/disappears during agent execution.
76    let status_height = if size.width > 0 { 1 } else { 0 };
77    let inner_width = size
78        .width
79        .saturating_sub(ui::INLINE_INPUT_PADDING_HORIZONTAL.saturating_mul(2));
80    let desired_lines = session.desired_input_lines(inner_width);
81    let block_height = Session::input_block_height_for_lines(desired_lines);
82    let input_height = block_height.saturating_add(status_height);
83    session.apply_input_height(input_height);
84
85    let chunks = Layout::vertical([
86        Constraint::Length(header_height),
87        Constraint::Min(1),
88        Constraint::Length(input_height),
89    ])
90    .split(size);
91
92    let (header_area, transcript_area, input_area) = (chunks[0], chunks[1], chunks[2]);
93
94    // Calculate available height for transcript
95    apply_view_rows(session, transcript_area.height);
96
97    // Render components
98    session.render_header(frame, header_area, &header_lines);
99    if session.show_logs {
100        let split = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
101            .split(transcript_area);
102        render_transcript(session, frame, split[0]);
103        render_log_view(session, frame, split[1]);
104    } else {
105        render_transcript(session, frame, transcript_area);
106    }
107    session.render_input(frame, input_area);
108    render_modal(session, frame, size);
109    super::slash::render_slash_palette(session, frame, size);
110    render_file_palette(session, frame, size);
111}
112
113fn render_log_view(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
114    let block = Block::bordered()
115        .title("Logs")
116        .border_type(terminal_capabilities::get_border_type())
117        .style(default_style(session))
118        .border_style(border_style(session));
119    let inner = block.inner(area);
120    frame.render_widget(block, area);
121    if inner.height == 0 || inner.width == 0 {
122        return;
123    }
124
125    let paragraph = Paragraph::new((*session.log_text()).clone()).wrap(Wrap { trim: false });
126    frame.render_widget(paragraph, inner);
127}
128
129fn modal_list_highlight_style(session: &Session) -> Style {
130    session.styles.modal_list_highlight_style()
131}
132
133pub fn apply_view_rows(session: &mut Session, rows: u16) {
134    let resolved = rows.max(2);
135    if session.view_rows != resolved {
136        session.view_rows = resolved;
137        invalidate_scroll_metrics(session);
138    }
139    recalculate_transcript_rows(session);
140    session.enforce_scroll_bounds();
141}
142
143pub fn apply_transcript_rows(session: &mut Session, rows: u16) {
144    let resolved = rows.max(1);
145    if session.transcript_rows != resolved {
146        session.transcript_rows = resolved;
147        invalidate_scroll_metrics(session);
148    }
149}
150
151pub fn apply_transcript_width(session: &mut Session, width: u16) {
152    if session.transcript_width != width {
153        session.transcript_width = width;
154        invalidate_scroll_metrics(session);
155    }
156}
157
158fn render_transcript(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
159    if area.height == 0 || area.width == 0 {
160        return;
161    }
162    let block = Block::new()
163        .border_type(terminal_capabilities::get_border_type())
164        .style(default_style(session))
165        .border_style(border_style(session));
166    let inner = block.inner(area);
167    frame.render_widget(block, area);
168    if inner.height == 0 || inner.width == 0 {
169        return;
170    }
171
172    apply_transcript_rows(session, inner.height);
173
174    let content_width = inner.width;
175    if content_width == 0 {
176        return;
177    }
178    apply_transcript_width(session, content_width);
179
180    let viewport_rows = inner.height as usize;
181    let padding = usize::from(ui::INLINE_TRANSCRIPT_BOTTOM_PADDING);
182    let effective_padding = padding.min(viewport_rows.saturating_sub(1));
183
184    // Skip expensive total_rows calculation if only scrolling (no content change)
185    // This optimization saves ~30-50% CPU on viewport-only scrolls
186    let total_rows = if session.transcript_content_changed {
187        session.total_transcript_rows(content_width) + effective_padding
188    } else {
189        // Reuse last known total if content unchanged
190        session
191            .scroll_manager
192            .last_known_total()
193            .unwrap_or_else(|| session.total_transcript_rows(content_width) + effective_padding)
194    };
195    let (top_offset, _clamped_total_rows) =
196        session.prepare_transcript_scroll(total_rows, viewport_rows);
197    let vertical_offset = top_offset.min(session.scroll_manager.max_offset());
198    session.transcript_view_top = vertical_offset;
199
200    let visible_start = vertical_offset;
201    let scroll_area = inner;
202
203    // Use cached visible lines to avoid re-cloning on viewport-only scrolls
204    let cached_lines =
205        session.collect_transcript_window_cached(content_width, visible_start, viewport_rows);
206
207    // Only clone if we need to mutate (fill or overlay)
208    let fill_count = viewport_rows.saturating_sub(cached_lines.len());
209    let visible_lines = if fill_count > 0 || !session.queued_inputs.is_empty() {
210        // Need to mutate, so clone from Arc
211        let mut lines = (*cached_lines).clone();
212        if fill_count > 0 {
213            let target_len = lines.len() + fill_count;
214            lines.resize_with(target_len, Line::default);
215        }
216        session.overlay_queue_lines(&mut lines, content_width);
217        lines
218    } else {
219        // No mutation needed, use Arc directly
220        (*cached_lines).clone()
221    };
222
223    let paragraph = Paragraph::new(visible_lines).style(default_style(session));
224
225    // Ratatui recreates a fresh frame buffer for every draw call.
226    // Keep only the state bookkeeping here; explicit clearing is unnecessary.
227    if session.transcript_content_changed {
228        session.transcript_content_changed = false;
229    }
230    frame.render_widget(paragraph, scroll_area);
231}
232
233fn header_reserved_rows(session: &Session) -> u16 {
234    session.header_rows.max(ui::INLINE_HEADER_HEIGHT)
235}
236
237fn input_reserved_rows(session: &Session) -> u16 {
238    header_reserved_rows(session) + session.input_height
239}
240
241pub fn recalculate_transcript_rows(session: &mut Session) {
242    let reserved = input_reserved_rows(session).saturating_add(2); // account for transcript block borders
243    let available = session.view_rows.saturating_sub(reserved).max(1);
244    apply_transcript_rows(session, available);
245}
246fn wrap_block_lines(
247    session: &Session,
248    first_prefix: &str,
249    continuation_prefix: &str,
250    content: Vec<Span<'static>>,
251    max_width: usize,
252    border_style: Style,
253) -> Vec<Line<'static>> {
254    wrap_block_lines_with_options(
255        session,
256        first_prefix,
257        continuation_prefix,
258        content,
259        max_width,
260        border_style,
261        true,
262    )
263}
264
265fn wrap_block_lines_no_right_border(
266    session: &Session,
267    first_prefix: &str,
268    continuation_prefix: &str,
269    content: Vec<Span<'static>>,
270    max_width: usize,
271    border_style: Style,
272) -> Vec<Line<'static>> {
273    wrap_block_lines_with_options(
274        session,
275        first_prefix,
276        continuation_prefix,
277        content,
278        max_width,
279        border_style,
280        false,
281    )
282}
283
284fn wrap_block_lines_with_options(
285    session: &Session,
286    first_prefix: &str,
287    continuation_prefix: &str,
288    content: Vec<Span<'static>>,
289    max_width: usize,
290    border_style: Style,
291    show_right_border: bool,
292) -> Vec<Line<'static>> {
293    if max_width < 2 {
294        let fallback = if show_right_border {
295            format!("{}││", first_prefix)
296        } else {
297            format!("{}│", first_prefix)
298        };
299        return vec![Line::from(fallback).style(border_style)];
300    }
301
302    let right_border = if show_right_border {
303        ui::INLINE_BLOCK_BODY_RIGHT
304    } else {
305        ""
306    };
307    let first_prefix_width = first_prefix.chars().count();
308    let continuation_prefix_width = continuation_prefix.chars().count();
309    let prefix_width = first_prefix_width.max(continuation_prefix_width);
310    let border_width = right_border.chars().count();
311    let consumed_width = prefix_width.saturating_add(border_width);
312    let content_width = max_width.saturating_sub(consumed_width);
313
314    if max_width == usize::MAX {
315        let mut spans = vec![Span::styled(first_prefix.to_owned(), border_style)];
316        spans.extend(content);
317        if show_right_border {
318            spans.push(Span::styled(right_border.to_owned(), border_style));
319        }
320        return vec![Line::from(spans)];
321    }
322
323    let mut wrapped = wrap_line(session, Line::from(content), content_width);
324    if wrapped.is_empty() {
325        wrapped.push(Line::default());
326    }
327
328    // Add borders to each wrapped line
329    for (idx, line) in wrapped.iter_mut().enumerate() {
330        let line_width = line.spans.iter().map(|s| s.width()).sum::<usize>();
331        let padding = if show_right_border {
332            content_width.saturating_sub(line_width)
333        } else {
334            0
335        };
336
337        let active_prefix = if idx == 0 {
338            first_prefix
339        } else {
340            continuation_prefix
341        };
342        let mut new_spans = vec![Span::styled(active_prefix.to_owned(), border_style)];
343        new_spans.append(&mut line.spans);
344        if padding > 0 {
345            new_spans.push(Span::styled(" ".repeat(padding), Style::default()));
346        }
347        if show_right_border {
348            new_spans.push(Span::styled(right_border.to_owned(), border_style));
349        }
350        line.spans = new_spans;
351    }
352
353    wrapped
354}
355
356fn pty_block_has_content(session: &Session, index: usize) -> bool {
357    if session.lines.is_empty() {
358        return false;
359    }
360
361    let mut start = index;
362    while start > 0 {
363        let Some(previous) = session.lines.get(start - 1) else {
364            break;
365        };
366        if previous.kind != InlineMessageKind::Pty {
367            break;
368        }
369        start -= 1;
370    }
371
372    let mut end = index;
373    while end + 1 < session.lines.len() {
374        let Some(next) = session.lines.get(end + 1) else {
375            break;
376        };
377        if next.kind != InlineMessageKind::Pty {
378            break;
379        }
380        end += 1;
381    }
382
383    if start > end || end >= session.lines.len() {
384        tracing::warn!(
385            "invalid range: start={}, end={}, len={}",
386            start,
387            end,
388            session.lines.len()
389        );
390        return false;
391    }
392
393    for line in &session.lines[start..=end] {
394        if line
395            .segments
396            .iter()
397            .any(|segment| !segment.text.trim().is_empty())
398        {
399            return true;
400        }
401    }
402
403    false
404}
405
406fn reflow_pty_lines(session: &Session, index: usize, width: u16) -> Vec<Line<'static>> {
407    let Some(line) = session.lines.get(index) else {
408        return vec![Line::default()];
409    };
410
411    let max_width = if width == 0 {
412        usize::MAX
413    } else {
414        width as usize
415    };
416
417    if !pty_block_has_content(session, index) {
418        return Vec::new();
419    }
420
421    let mut border_style = ratatui_style_from_inline(
422        &session.styles.tool_border_style(),
423        session.theme.foreground,
424    );
425    border_style = border_style.add_modifier(Modifier::DIM);
426
427    let prev_is_pty = index
428        .checked_sub(1)
429        .and_then(|prev| session.lines.get(prev))
430        .map(|prev| prev.kind == InlineMessageKind::Pty)
431        .unwrap_or(false);
432
433    let is_start = !prev_is_pty;
434
435    let mut lines = Vec::new();
436
437    let mut combined = String::new();
438    for segment in &line.segments {
439        combined.push_str(segment.text.as_str());
440    }
441    if is_start && combined.trim().is_empty() {
442        return Vec::new();
443    }
444
445    // Render body content - strip ANSI codes to ensure plain text output
446    let fallback = text_fallback(session, InlineMessageKind::Pty).or(session.theme.foreground);
447    let mut body_spans = Vec::new();
448    for segment in &line.segments {
449        let stripped_text = strip_ansi_codes(&segment.text);
450        let mut style = ratatui_style_from_inline(&segment.style, fallback);
451        style = style.add_modifier(Modifier::DIM);
452        body_spans.push(Span::styled(stripped_text.into_owned(), style));
453    }
454
455    // Check if this is a thinking spinner line (skip border rendering)
456    let is_thinking_spinner = combined.contains("Thinking...");
457
458    if is_start {
459        // Render body without borders - just indent with spaces for visual separation
460        if is_thinking_spinner {
461            // Render thinking spinner without borders
462            lines.extend(wrap_block_lines_no_right_border(
463                session,
464                "",
465                "",
466                body_spans,
467                max_width,
468                border_style,
469            ));
470        } else {
471            let body_prefix = "  ";
472            let continuation_prefix =
473                text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
474            lines.extend(wrap_block_lines_no_right_border(
475                session,
476                body_prefix,
477                continuation_prefix.as_str(),
478                body_spans,
479                max_width,
480                border_style,
481            ));
482        }
483    } else {
484        let body_prefix = "  ";
485        let continuation_prefix =
486            text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
487        lines.extend(wrap_block_lines_no_right_border(
488            session,
489            body_prefix,
490            continuation_prefix.as_str(),
491            body_spans,
492            max_width,
493            border_style,
494        ));
495    }
496
497    if lines.is_empty() {
498        lines.push(Line::default());
499    }
500
501    lines
502}
503
504fn message_divider_line(session: &Session, width: usize, kind: InlineMessageKind) -> Line<'static> {
505    if width == 0 {
506        return Line::default();
507    }
508
509    let content = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(width);
510    let style = message_divider_style(session, kind);
511    Line::from(content).style(style)
512}
513
514fn message_divider_style(session: &Session, kind: InlineMessageKind) -> Style {
515    session.styles.message_divider_style(kind)
516}
517
518fn wrap_line(_session: &Session, line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
519    text_utils::wrap_line(line, max_width)
520}
521
522fn justify_wrapped_lines(
523    session: &Session,
524    lines: Vec<Line<'static>>,
525    max_width: usize,
526    kind: InlineMessageKind,
527) -> Vec<Line<'static>> {
528    if max_width == 0 {
529        return lines;
530    }
531
532    let total = lines.len();
533    let mut justified = Vec::with_capacity(total);
534    let mut in_fenced_block = false;
535    for (index, line) in lines.into_iter().enumerate() {
536        let is_last = index + 1 == total;
537        let mut next_in_fenced_block = in_fenced_block;
538        let is_fence_line = {
539            let line_text_storage: std::borrow::Cow<'_, str> = if line.spans.len() == 1 {
540                std::borrow::Cow::Borrowed(&*line.spans[0].content)
541            } else {
542                std::borrow::Cow::Owned(
543                    line.spans
544                        .iter()
545                        .map(|span| &*span.content)
546                        .collect::<String>(),
547                )
548            };
549            let line_text: &str = &line_text_storage;
550            let trimmed_start = line_text.trim_start();
551            trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~")
552        };
553        if is_fence_line {
554            next_in_fenced_block = !in_fenced_block;
555        }
556
557        // Extend diff line backgrounds to full width
558        let processed_line = if is_diff_line(session, &line) {
559            pad_diff_line(session, &line, max_width)
560        } else if kind == InlineMessageKind::Agent
561            && !in_fenced_block
562            && !is_fence_line
563            && should_justify_message_line(session, &line, max_width, is_last)
564        {
565            justify_message_line(session, &line, max_width)
566        } else {
567            line
568        };
569
570        justified.push(processed_line);
571        in_fenced_block = next_in_fenced_block;
572    }
573
574    justified
575}
576
577fn should_justify_message_line(
578    _session: &Session,
579    line: &Line<'static>,
580    max_width: usize,
581    is_last: bool,
582) -> bool {
583    if is_last || max_width == 0 {
584        return false;
585    }
586    if line.spans.len() != 1 {
587        return false;
588    }
589    let text: &str = &line.spans[0].content;
590    if text.trim().is_empty() {
591        return false;
592    }
593    if text.starts_with(char::is_whitespace) {
594        return false;
595    }
596    let trimmed = text.trim();
597    if trimmed.starts_with(|ch: char| ['-', '*', '`', '>', '#'].contains(&ch)) {
598        return false;
599    }
600    if trimmed.contains("```") {
601        return false;
602    }
603    let width = UnicodeWidthStr::width(trimmed);
604    if width >= max_width || width < max_width / 2 {
605        return false;
606    }
607
608    justify_plain_text(text, max_width).is_some()
609}
610
611fn justify_message_line(
612    _session: &Session,
613    line: &Line<'static>,
614    max_width: usize,
615) -> Line<'static> {
616    let span = &line.spans[0];
617    if let Some(justified) = justify_plain_text(&span.content, max_width) {
618        Line::from(justified).style(span.style)
619    } else {
620        line.clone()
621    }
622}
623
624fn is_diff_line(_session: &Session, line: &Line<'static>) -> bool {
625    // Detect actual diff lines: must start with +, -, or space (diff markers)
626    // AND have background color styling applied (from git diff coloring)
627    // This avoids false positives from regular text that happens to start with these chars
628    if line.spans.is_empty() {
629        return false;
630    }
631
632    // Check if any span has background color (diff lines from render have colored backgrounds)
633    let has_bg_color = line.spans.iter().any(|span| span.style.bg.is_some());
634    if !has_bg_color {
635        return false;
636    }
637
638    // Must start with a diff marker character in the first span
639    let first_span_char = line.spans[0].content.chars().next();
640    matches!(first_span_char, Some('+') | Some('-') | Some(' '))
641}
642
643fn pad_diff_line(_session: &Session, line: &Line<'static>, max_width: usize) -> Line<'static> {
644    if max_width == 0 || line.spans.is_empty() {
645        return line.clone();
646    }
647
648    // Calculate actual display width using Unicode width rules
649    let line_width: usize = line
650        .spans
651        .iter()
652        .map(|s| {
653            s.content
654                .chars()
655                .map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1))
656                .sum::<usize>()
657        })
658        .sum();
659
660    let padding_needed = max_width.saturating_sub(line_width);
661
662    if padding_needed == 0 {
663        return line.clone();
664    }
665
666    let padding_style = line
667        .spans
668        .iter()
669        .find_map(|span| span.style.bg)
670        .map(|bg| Style::default().bg(bg))
671        .unwrap_or_default();
672
673    let mut new_spans = Vec::with_capacity(line.spans.len() + 1);
674    new_spans.extend(line.spans.iter().cloned());
675    new_spans.push(Span::styled(" ".repeat(padding_needed), padding_style));
676
677    Line::from(new_spans)
678}
679
680fn prepare_transcript_scroll(
681    session: &mut Session,
682    total_rows: usize,
683    viewport_rows: usize,
684) -> (usize, usize) {
685    let viewport = viewport_rows.max(1);
686    let clamped_total = total_rows.max(1);
687    session.scroll_manager.set_total_rows(clamped_total);
688    session.scroll_manager.set_viewport_rows(viewport as u16);
689    let max_offset = session.scroll_manager.max_offset();
690
691    if session.scroll_manager.offset() > max_offset {
692        session.scroll_manager.set_offset(max_offset);
693    }
694
695    let top_offset = max_offset.saturating_sub(session.scroll_manager.offset());
696    (top_offset, clamped_total)
697}
698
699// Delegate to text_utils module
700fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
701    text_utils::justify_plain_text(text, max_width)
702}