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    // Only clear if content actually changed, not on viewport-only scroll
226    // This is a significant optimization: avoids expensive Clear operation on most scrolls
227    // Combined with layout skip above, this reduces render CPU by ~50% during scrolling
228    if session.transcript_content_changed {
229        frame.render_widget(Clear, scroll_area);
230        session.transcript_content_changed = false;
231    }
232    frame.render_widget(paragraph, scroll_area);
233}
234
235fn header_reserved_rows(session: &Session) -> u16 {
236    session.header_rows.max(ui::INLINE_HEADER_HEIGHT)
237}
238
239fn input_reserved_rows(session: &Session) -> u16 {
240    header_reserved_rows(session) + session.input_height
241}
242
243pub fn recalculate_transcript_rows(session: &mut Session) {
244    let reserved = input_reserved_rows(session).saturating_add(2); // account for transcript block borders
245    let available = session.view_rows.saturating_sub(reserved).max(1);
246    apply_transcript_rows(session, available);
247}
248fn wrap_block_lines(
249    session: &Session,
250    first_prefix: &str,
251    continuation_prefix: &str,
252    content: Vec<Span<'static>>,
253    max_width: usize,
254    border_style: Style,
255) -> Vec<Line<'static>> {
256    wrap_block_lines_with_options(
257        session,
258        first_prefix,
259        continuation_prefix,
260        content,
261        max_width,
262        border_style,
263        true,
264    )
265}
266
267fn wrap_block_lines_no_right_border(
268    session: &Session,
269    first_prefix: &str,
270    continuation_prefix: &str,
271    content: Vec<Span<'static>>,
272    max_width: usize,
273    border_style: Style,
274) -> Vec<Line<'static>> {
275    wrap_block_lines_with_options(
276        session,
277        first_prefix,
278        continuation_prefix,
279        content,
280        max_width,
281        border_style,
282        false,
283    )
284}
285
286fn wrap_block_lines_with_options(
287    session: &Session,
288    first_prefix: &str,
289    continuation_prefix: &str,
290    content: Vec<Span<'static>>,
291    max_width: usize,
292    border_style: Style,
293    show_right_border: bool,
294) -> Vec<Line<'static>> {
295    if max_width < 2 {
296        let fallback = if show_right_border {
297            format!("{}││", first_prefix)
298        } else {
299            format!("{}│", first_prefix)
300        };
301        return vec![Line::from(fallback).style(border_style)];
302    }
303
304    let right_border = if show_right_border {
305        ui::INLINE_BLOCK_BODY_RIGHT
306    } else {
307        ""
308    };
309    let first_prefix_width = first_prefix.chars().count();
310    let continuation_prefix_width = continuation_prefix.chars().count();
311    let prefix_width = first_prefix_width.max(continuation_prefix_width);
312    let border_width = right_border.chars().count();
313    let consumed_width = prefix_width.saturating_add(border_width);
314    let content_width = max_width.saturating_sub(consumed_width);
315
316    if max_width == usize::MAX {
317        let mut spans = vec![Span::styled(first_prefix.to_owned(), border_style)];
318        spans.extend(content);
319        if show_right_border {
320            spans.push(Span::styled(right_border.to_owned(), border_style));
321        }
322        return vec![Line::from(spans)];
323    }
324
325    let mut wrapped = wrap_line(session, Line::from(content), content_width);
326    if wrapped.is_empty() {
327        wrapped.push(Line::default());
328    }
329
330    // Add borders to each wrapped line
331    for (idx, line) in wrapped.iter_mut().enumerate() {
332        let line_width = line.spans.iter().map(|s| s.width()).sum::<usize>();
333        let padding = if show_right_border {
334            content_width.saturating_sub(line_width)
335        } else {
336            0
337        };
338
339        let active_prefix = if idx == 0 {
340            first_prefix
341        } else {
342            continuation_prefix
343        };
344        let mut new_spans = vec![Span::styled(active_prefix.to_owned(), border_style)];
345        new_spans.append(&mut line.spans);
346        if padding > 0 {
347            new_spans.push(Span::styled(" ".repeat(padding), Style::default()));
348        }
349        if show_right_border {
350            new_spans.push(Span::styled(right_border.to_owned(), border_style));
351        }
352        line.spans = new_spans;
353    }
354
355    wrapped
356}
357
358fn pty_block_has_content(session: &Session, index: usize) -> bool {
359    if session.lines.is_empty() {
360        return false;
361    }
362
363    let mut start = index;
364    while start > 0 {
365        let Some(previous) = session.lines.get(start - 1) else {
366            break;
367        };
368        if previous.kind != InlineMessageKind::Pty {
369            break;
370        }
371        start -= 1;
372    }
373
374    let mut end = index;
375    while end + 1 < session.lines.len() {
376        let Some(next) = session.lines.get(end + 1) else {
377            break;
378        };
379        if next.kind != InlineMessageKind::Pty {
380            break;
381        }
382        end += 1;
383    }
384
385    if start > end || end >= session.lines.len() {
386        tracing::warn!(
387            "invalid range: start={}, end={}, len={}",
388            start,
389            end,
390            session.lines.len()
391        );
392        return false;
393    }
394
395    for line in &session.lines[start..=end] {
396        if line
397            .segments
398            .iter()
399            .any(|segment| !segment.text.trim().is_empty())
400        {
401            return true;
402        }
403    }
404
405    false
406}
407
408fn reflow_pty_lines(session: &Session, index: usize, width: u16) -> Vec<Line<'static>> {
409    let Some(line) = session.lines.get(index) else {
410        return vec![Line::default()];
411    };
412
413    let max_width = if width == 0 {
414        usize::MAX
415    } else {
416        width as usize
417    };
418
419    if !pty_block_has_content(session, index) {
420        return Vec::new();
421    }
422
423    let mut border_style = ratatui_style_from_inline(
424        &session.styles.tool_border_style(),
425        session.theme.foreground,
426    );
427    border_style = border_style.add_modifier(Modifier::DIM);
428
429    let prev_is_pty = index
430        .checked_sub(1)
431        .and_then(|prev| session.lines.get(prev))
432        .map(|prev| prev.kind == InlineMessageKind::Pty)
433        .unwrap_or(false);
434
435    let is_start = !prev_is_pty;
436
437    let mut lines = Vec::new();
438
439    let mut combined = String::new();
440    for segment in &line.segments {
441        combined.push_str(segment.text.as_str());
442    }
443    if is_start && combined.trim().is_empty() {
444        return Vec::new();
445    }
446
447    // Render body content - strip ANSI codes to ensure plain text output
448    let fallback = text_fallback(session, InlineMessageKind::Pty).or(session.theme.foreground);
449    let mut body_spans = Vec::new();
450    for segment in &line.segments {
451        let stripped_text = strip_ansi_codes(&segment.text);
452        let mut style = ratatui_style_from_inline(&segment.style, fallback);
453        style = style.add_modifier(Modifier::DIM);
454        body_spans.push(Span::styled(stripped_text.into_owned(), style));
455    }
456
457    // Check if this is a thinking spinner line (skip border rendering)
458    let is_thinking_spinner = combined.contains("Thinking...");
459
460    if is_start {
461        // Render body without borders - just indent with spaces for visual separation
462        if is_thinking_spinner {
463            // Render thinking spinner without borders
464            lines.extend(wrap_block_lines_no_right_border(
465                session,
466                "",
467                "",
468                body_spans,
469                max_width,
470                border_style,
471            ));
472        } else {
473            let body_prefix = "  ";
474            let continuation_prefix =
475                text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
476            lines.extend(wrap_block_lines_no_right_border(
477                session,
478                body_prefix,
479                continuation_prefix.as_str(),
480                body_spans,
481                max_width,
482                border_style,
483            ));
484        }
485    } else {
486        let body_prefix = "  ";
487        let continuation_prefix =
488            text_utils::pty_wrapped_continuation_prefix(body_prefix, combined.as_str());
489        lines.extend(wrap_block_lines_no_right_border(
490            session,
491            body_prefix,
492            continuation_prefix.as_str(),
493            body_spans,
494            max_width,
495            border_style,
496        ));
497    }
498
499    if lines.is_empty() {
500        lines.push(Line::default());
501    }
502
503    lines
504}
505
506fn message_divider_line(session: &Session, width: usize, kind: InlineMessageKind) -> Line<'static> {
507    if width == 0 {
508        return Line::default();
509    }
510
511    let content = ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(width);
512    let style = message_divider_style(session, kind);
513    Line::from(content).style(style)
514}
515
516fn message_divider_style(session: &Session, kind: InlineMessageKind) -> Style {
517    session.styles.message_divider_style(kind)
518}
519
520fn wrap_line(_session: &Session, line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
521    text_utils::wrap_line(line, max_width)
522}
523
524fn justify_wrapped_lines(
525    session: &Session,
526    lines: Vec<Line<'static>>,
527    max_width: usize,
528    kind: InlineMessageKind,
529) -> Vec<Line<'static>> {
530    if max_width == 0 {
531        return lines;
532    }
533
534    let total = lines.len();
535    let mut justified = Vec::with_capacity(total);
536    let mut in_fenced_block = false;
537    for (index, line) in lines.into_iter().enumerate() {
538        let is_last = index + 1 == total;
539        let mut next_in_fenced_block = in_fenced_block;
540        let is_fence_line = {
541            let line_text_storage: std::borrow::Cow<'_, str> = if line.spans.len() == 1 {
542                std::borrow::Cow::Borrowed(&*line.spans[0].content)
543            } else {
544                std::borrow::Cow::Owned(
545                    line.spans
546                        .iter()
547                        .map(|span| &*span.content)
548                        .collect::<String>(),
549                )
550            };
551            let line_text: &str = &line_text_storage;
552            let trimmed_start = line_text.trim_start();
553            trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~")
554        };
555        if is_fence_line {
556            next_in_fenced_block = !in_fenced_block;
557        }
558
559        // Extend diff line backgrounds to full width
560        let processed_line = if is_diff_line(session, &line) {
561            pad_diff_line(session, &line, max_width)
562        } else if kind == InlineMessageKind::Agent
563            && !in_fenced_block
564            && !is_fence_line
565            && should_justify_message_line(session, &line, max_width, is_last)
566        {
567            justify_message_line(session, &line, max_width)
568        } else {
569            line
570        };
571
572        justified.push(processed_line);
573        in_fenced_block = next_in_fenced_block;
574    }
575
576    justified
577}
578
579fn should_justify_message_line(
580    _session: &Session,
581    line: &Line<'static>,
582    max_width: usize,
583    is_last: bool,
584) -> bool {
585    if is_last || max_width == 0 {
586        return false;
587    }
588    if line.spans.len() != 1 {
589        return false;
590    }
591    let text: &str = &line.spans[0].content;
592    if text.trim().is_empty() {
593        return false;
594    }
595    if text.starts_with(char::is_whitespace) {
596        return false;
597    }
598    let trimmed = text.trim();
599    if trimmed.starts_with(|ch: char| ['-', '*', '`', '>', '#'].contains(&ch)) {
600        return false;
601    }
602    if trimmed.contains("```") {
603        return false;
604    }
605    let width = UnicodeWidthStr::width(trimmed);
606    if width >= max_width || width < max_width / 2 {
607        return false;
608    }
609
610    justify_plain_text(text, max_width).is_some()
611}
612
613fn justify_message_line(
614    _session: &Session,
615    line: &Line<'static>,
616    max_width: usize,
617) -> Line<'static> {
618    let span = &line.spans[0];
619    if let Some(justified) = justify_plain_text(&span.content, max_width) {
620        Line::from(justified).style(span.style)
621    } else {
622        line.clone()
623    }
624}
625
626fn is_diff_line(_session: &Session, line: &Line<'static>) -> bool {
627    // Detect actual diff lines: must start with +, -, or space (diff markers)
628    // AND have background color styling applied (from git diff coloring)
629    // This avoids false positives from regular text that happens to start with these chars
630    if line.spans.is_empty() {
631        return false;
632    }
633
634    // Check if any span has background color (diff lines from render have colored backgrounds)
635    let has_bg_color = line.spans.iter().any(|span| span.style.bg.is_some());
636    if !has_bg_color {
637        return false;
638    }
639
640    // Must start with a diff marker character in the first span
641    let first_span_char = line.spans[0].content.chars().next();
642    matches!(first_span_char, Some('+') | Some('-') | Some(' '))
643}
644
645fn pad_diff_line(_session: &Session, line: &Line<'static>, max_width: usize) -> Line<'static> {
646    if max_width == 0 || line.spans.is_empty() {
647        return line.clone();
648    }
649
650    // Calculate actual display width using Unicode width rules
651    let line_width: usize = line
652        .spans
653        .iter()
654        .map(|s| {
655            s.content
656                .chars()
657                .map(|ch| unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1))
658                .sum::<usize>()
659        })
660        .sum();
661
662    let padding_needed = max_width.saturating_sub(line_width);
663
664    if padding_needed == 0 {
665        return line.clone();
666    }
667
668    let padding_style = line
669        .spans
670        .iter()
671        .find_map(|span| span.style.bg)
672        .map(|bg| Style::default().bg(bg))
673        .unwrap_or_default();
674
675    let mut new_spans = Vec::with_capacity(line.spans.len() + 1);
676    new_spans.extend(line.spans.iter().cloned());
677    new_spans.push(Span::styled(" ".repeat(padding_needed), padding_style));
678
679    Line::from(new_spans)
680}
681
682fn prepare_transcript_scroll(
683    session: &mut Session,
684    total_rows: usize,
685    viewport_rows: usize,
686) -> (usize, usize) {
687    let viewport = viewport_rows.max(1);
688    let clamped_total = total_rows.max(1);
689    session.scroll_manager.set_total_rows(clamped_total);
690    session.scroll_manager.set_viewport_rows(viewport as u16);
691    let max_offset = session.scroll_manager.max_offset();
692
693    if session.scroll_manager.offset() > max_offset {
694        session.scroll_manager.set_offset(max_offset);
695    }
696
697    let top_offset = max_offset.saturating_sub(session.scroll_manager.offset());
698    (top_offset, clamped_total)
699}
700
701// Delegate to text_utils module
702fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
703    text_utils::justify_plain_text(text, max_width)
704}