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