Skip to main content

vtcode_core/utils/ansi/
output.rs

1//! Plain output lines, modals, and screen management.
2
3use super::*;
4
5use crate::utils::transcript;
6
7use crate::ui::markdown::{MarkdownLine, RenderMarkdownOptions, render_markdown_to_lines_with_options};
8use crate::ui::theme;
9use crate::ui::tui::{InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind};
10use crate::utils::ansi_capabilities::AnsiCapabilities;
11use anstyle::{Reset, Style};
12use anyhow::{Result, anyhow};
13use std::io::Write;
14use unicode_width::UnicodeWidthStr;
15
16impl AnsiRenderer {
17    pub fn set_table_max_width(&mut self, max_width: Option<usize>) {
18        if let Some(sink) = &mut self.sink {
19            sink.table_max_width = max_width;
20            sink.table_max_width_override = max_width;
21        }
22    }
23
24    pub(super) fn should_render_style(&self, style: MessageStyle) -> bool {
25        self.reasoning_visible || !matches!(style, MessageStyle::Reasoning | MessageStyle::ReasoningEmphasis)
26    }
27
28    fn is_diagnostic_error_style(style: MessageStyle) -> bool {
29        matches!(style, MessageStyle::Error | MessageStyle::ToolError)
30    }
31
32    fn log_transcript_error(text: &str, style: MessageStyle, suppressed_in_tui: bool) {
33        tracing::error!(
34            target: "vtcode_transcript",
35            style = ?style,
36            suppressed_in_tui,
37            message = %text,
38            "diagnostic error output"
39        );
40    }
41
42    pub(super) fn indent_for_style(&self, style: MessageStyle) -> &'static str {
43        if self.screen_reader_mode && matches!(style, MessageStyle::Reasoning | MessageStyle::ReasoningEmphasis) {
44            "  [reasoning] "
45        } else {
46            style.indent()
47        }
48    }
49
50    /// Get the terminal's detected ANSI capabilities
51    pub fn capabilities(&self) -> &AnsiCapabilities {
52        &self.capabilities
53    }
54
55    /// Return the width available to a rendered diff row after the logical
56    /// message indent and, for inline UI output, the transcript frame.
57    ///
58    /// Diff rows use this width only to extend their background with spaces;
59    /// when terminal sizing is unavailable callers retain their bounded
60    /// fallback behavior.
61    pub fn diff_content_width(&self, style: MessageStyle) -> Option<usize> {
62        let indent_width = UnicodeWidthStr::width(self.indent_for_style(style));
63        let kind = Self::message_kind(style);
64        let frame_width = self
65            .sink
66            .as_ref()
67            .map(|sink| transcript_table_frame_width(kind, sink.handle.agent_label_frame_width()))
68            .unwrap_or_default();
69        // An explicit test/configuration width wins. Otherwise prefer a fresh
70        // terminal measurement so a later diff preview follows a resize even
71        // when an earlier Markdown render cached the previous width.
72        let terminal_width = self
73            .sink
74            .as_ref()
75            .and_then(|sink| {
76                sink.table_max_width_override
77                    .or_else(|| crossterm::terminal::size().ok().map(|(width, _)| usize::from(width)))
78                    .or(sink.table_max_width)
79            })
80            .or_else(|| crossterm::terminal::size().ok().map(|(width, _)| usize::from(width)));
81
82        terminal_width.map(|width| width.saturating_sub(indent_width).saturating_sub(frame_width))
83    }
84
85    /// Check if unicode should be used for formatting (tables, boxes, etc.)
86    pub fn should_use_unicode_formatting(&self) -> bool {
87        self.capabilities.should_use_unicode_boxes()
88    }
89
90    /// Check if 256-color output is supported
91    pub fn supports_256_colors(&self) -> bool {
92        self.capabilities.supports_256_colors()
93    }
94
95    /// Check if true color (24-bit) output is supported
96    pub fn supports_true_color(&self) -> bool {
97        self.capabilities.supports_true_color()
98    }
99
100    /// Check if should use unicode characters based on terminal capabilities
101    pub fn should_use_unicode(&self) -> bool {
102        self.capabilities.unicode_support
103    }
104
105    pub fn show_list_modal(
106        &mut self,
107        title: &str,
108        lines: Vec<String>,
109        items: Vec<InlineListItem>,
110        selected: Option<InlineListSelection>,
111        search: Option<InlineListSearchConfig>,
112    ) {
113        if let Some(sink) = &self.sink {
114            sink.show_list_modal(title.into(), lines, items, selected, search);
115        }
116    }
117
118    pub fn show_list_modal_with_footer(
119        &mut self,
120        title: &str,
121        lines: Vec<String>,
122        items: Vec<InlineListItem>,
123        selected: Option<InlineListSelection>,
124        search: Option<InlineListSearchConfig>,
125        footer_hint: Option<String>,
126    ) {
127        self.show_list_modal_with_status(title, lines, items, selected, search, footer_hint, None);
128    }
129
130    /// Show a list modal with an optional status strip (last action feedback).
131    pub fn show_list_modal_with_status(
132        &mut self,
133        title: &str,
134        lines: Vec<String>,
135        items: Vec<InlineListItem>,
136        selected: Option<InlineListSelection>,
137        search: Option<InlineListSearchConfig>,
138        footer_hint: Option<String>,
139        status: Option<vtcode_commons::ui_protocol::InlineStatus>,
140    ) {
141        if let Some(sink) = &self.sink {
142            sink.show_list_modal_with_status(title.into(), lines, items, selected, search, footer_hint, status);
143        }
144    }
145
146    pub fn show_secure_prompt_modal(&mut self, title: &str, lines: Vec<String>, prompt_label: String) {
147        if let Some(sink) = &self.sink {
148            sink.show_secure_prompt_modal(title.into(), lines, prompt_label);
149        }
150    }
151
152    pub fn close_modal(&mut self) {
153        if let Some(sink) = &self.sink {
154            sink.close_modal();
155        }
156    }
157
158    pub fn clear_screen(&mut self) {
159        self.flush_compact_command_group();
160        if let Some(sink) = &self.sink {
161            sink.handle.clear_screen();
162        }
163    }
164
165    /// Push text into the buffer
166    pub fn push(&mut self, text: &str) {
167        self.buffer.push_str(text);
168    }
169
170    /// Flush the buffer with the given style
171    pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
172        self.flush_compact_command_group();
173        if !self.should_render_style(style) {
174            self.buffer.clear();
175            return Ok(());
176        }
177        let indent = self.indent_for_style(style);
178        if let Some(sink) = &mut self.sink {
179            // Track if this line is empty
180            self.last_line_was_empty = self.buffer.is_empty() && indent.is_empty();
181            sink.write_line(style.style(), indent, &self.buffer, Self::message_kind(style))?;
182            self.buffer.clear();
183            return Ok(());
184        }
185        let style = style.style();
186        if self.color {
187            writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
188        } else {
189            writeln!(self.writer, "{}", self.buffer)?;
190        }
191        self.writer.flush()?;
192        transcript::append(&self.buffer);
193        // Track if this line is empty
194        self.last_line_was_empty = self.buffer.is_empty();
195        self.buffer.clear();
196        Ok(())
197    }
198
199    /// Convenience for writing a single line
200    pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
201        self.flush_compact_command_group();
202        if !self.should_render_style(style) {
203            return Ok(());
204        }
205        let suppress_transcript =
206            Self::is_diagnostic_error_style(style) && self.sink.is_some() && !self.show_diagnostics_in_transcript;
207        if Self::is_diagnostic_error_style(style) {
208            Self::log_transcript_error(text, style, self.sink.is_some());
209        }
210        if matches!(style, MessageStyle::Response | MessageStyle::Reasoning | MessageStyle::ReasoningEmphasis) {
211            return self.render_markdown(style, text);
212        }
213        if matches!(style, MessageStyle::Output | MessageStyle::ToolOutput) {
214            let stripped = crate::utils::ansi_parser::strip_ansi(text);
215            self.buffer.clear();
216            if looks_like_diff(&stripped) {
217                self.buffer.push_str("```diff\n");
218            } else {
219                self.buffer.push_str("```\n");
220            }
221            self.buffer.push_str(&stripped);
222            self.buffer.push_str("\n```");
223            let fenced = std::mem::take(&mut self.buffer);
224            return self.render_markdown(style, &fenced);
225        }
226        if matches!(style, MessageStyle::ToolDetail) {
227            if contains_markdown_fence(text) {
228                let stripped = crate::utils::ansi_parser::strip_ansi(text);
229                return self.render_markdown(style, &stripped);
230            }
231            if looks_like_diff(text) {
232                let stripped = crate::utils::ansi_parser::strip_ansi(text);
233                self.buffer.clear();
234                self.buffer.push_str("```diff\n");
235                self.buffer.push_str(&stripped);
236                self.buffer.push_str("\n```");
237                let fenced = std::mem::take(&mut self.buffer);
238                return self.render_markdown(style, &fenced);
239            }
240        }
241        let indent = style.indent();
242        let dont_split = matches!(style, MessageStyle::Tool | MessageStyle::ToolDetail);
243
244        if let Some(sink) = &mut self.sink {
245            sink.write_multiline_with_transcript(
246                style.style(),
247                indent,
248                text,
249                Self::message_kind(style),
250                !suppress_transcript,
251                None,
252            )?;
253            return Ok(());
254        }
255
256        if text.contains('\n') && !dont_split {
257            for line in text.lines() {
258                self.buffer.clear();
259                if !indent.is_empty() && !line.is_empty() {
260                    self.buffer.push_str(indent);
261                }
262                self.buffer.push_str(line);
263                self.flush(style)?;
264            }
265            Ok(())
266        } else {
267            self.buffer.clear();
268            if !indent.is_empty() && !text.is_empty() {
269                self.buffer.push_str(indent);
270            }
271            self.buffer.push_str(text);
272            self.flush(style)
273        }
274    }
275
276    /// Write a continuation line that joins an existing Pty block.
277    ///
278    /// Sends the text as `InlineMessageKind::Pty` through
279    /// `write_multiline_with_transcript` so the TUI reflow renders it
280    /// with the same 2-space block prefix and styling as the PTY output.
281    pub fn pty_continuation_line(&mut self, text: &str) -> Result<()> {
282        self.flush_compact_command_group();
283        let style = MessageStyle::ToolOutput;
284        let indent = style.indent();
285        let kind = Self::message_kind(style);
286        if let Some(sink) = &mut self.sink {
287            sink.write_multiline_with_transcript(style.style(), indent, text, kind, true, None)?;
288            return Ok(());
289        }
290        self.buffer.clear();
291        if !indent.is_empty() && !text.is_empty() {
292            self.buffer.push_str(indent);
293        }
294        self.buffer.push_str(text);
295        self.flush(style)
296    }
297
298    /// Write a URL as a full, clickable line using OSC 8 hyperlinks.
299    ///
300    /// The URL is rendered on its own line so that terminal emulators can
301    /// detect and activate it for click-to-open behaviour.
302    pub fn hyperlink_line(&mut self, style: MessageStyle, url: &str) -> Result<()> {
303        self.flush_compact_command_group();
304        if !self.should_render_style(style) {
305            return Ok(());
306        }
307        let indent = style.indent();
308        if let Some(sink) = &mut self.sink {
309            let linked = format!(
310                "{}{}{}",
311                vtcode_commons::ansi_codes::hyperlink_open(url),
312                url,
313                vtcode_commons::ansi_codes::hyperlink_close(),
314            );
315            sink.write_multiline_with_transcript(
316                style.style(),
317                indent,
318                &linked,
319                Self::message_kind(style),
320                true,
321                None,
322            )?;
323            self.last_line_was_empty = false;
324            return Ok(());
325        }
326        self.buffer.clear();
327        if !indent.is_empty() {
328            self.buffer.push_str(indent);
329        }
330        self.buffer.push_str(&vtcode_commons::ansi_codes::hyperlink_open(url));
331        self.buffer.push_str(url);
332        self.buffer.push_str(&vtcode_commons::ansi_codes::hyperlink_close());
333        let ansi_style = style.style();
334        if self.color {
335            writeln!(self.writer, "{ansi_style}{}{Reset}", self.buffer)?;
336        } else {
337            writeln!(self.writer, "{}", self.buffer)?;
338        }
339        self.writer.flush()?;
340        transcript::append(url);
341        self.last_line_was_empty = false;
342        self.buffer.clear();
343        Ok(())
344    }
345
346    /// Append a large pasted user message as a placeholder in inline UI.
347    pub fn append_paste_placeholder(&mut self, message: &str, line_count: usize) -> Result<()> {
348        self.flush_compact_command_group();
349        if let Some(sink) = &self.sink {
350            sink.handle
351                .append_pasted_message(InlineMessageKind::User, message.to_string(), line_count);
352            transcript::append(message);
353            self.last_line_was_empty = message.trim().is_empty();
354            return Ok(());
355        }
356        self.line(MessageStyle::User, message)
357    }
358
359    /// Write styled text without a trailing newline
360    pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
361        self.flush_compact_command_group();
362        if !self.should_render_style(style) {
363            return Ok(());
364        }
365        if let Some(sink) = &mut self.sink {
366            sink.write_inline(style.style(), text, Self::message_kind(style));
367            return Ok(());
368        }
369        let ansi_style = style.style();
370        if self.color {
371            write!(self.writer, "{ansi_style}{text}{Reset}")?;
372        } else {
373            write!(self.writer, "{text}")?;
374        }
375        self.writer.flush()?;
376        Ok(())
377    }
378
379    /// Write a line with an explicit style
380    pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
381        self.line_with_override_style(MessageStyle::Info, style, text)
382    }
383
384    /// Write a line with a custom style while preserving the logical message kind.
385    pub fn line_with_override_style(&mut self, fallback: MessageStyle, style: Style, text: &str) -> Result<()> {
386        self.flush_compact_command_group();
387        let tool_output_id = self.pending_tool_output_anchor.take();
388        if !self.should_render_style(fallback) {
389            return Ok(());
390        }
391        let suppress_transcript =
392            Self::is_diagnostic_error_style(fallback) && self.sink.is_some() && !self.show_diagnostics_in_transcript;
393        if Self::is_diagnostic_error_style(fallback) {
394            Self::log_transcript_error(text, fallback, self.sink.is_some());
395        }
396        let kind = Self::message_kind(fallback);
397        let indent = self.indent_for_style(fallback);
398        if let Some(sink) = &mut self.sink {
399            sink.write_multiline_with_transcript(style, indent, text, kind, !suppress_transcript, tool_output_id)?;
400            self.last_line_was_empty = text.trim().is_empty();
401            return Ok(());
402        }
403        let mut combined;
404        let display = if !indent.is_empty() && !text.is_empty() {
405            combined = String::with_capacity(indent.len() + text.len());
406            combined.push_str(indent);
407            combined.push_str(text);
408            combined.as_str()
409        } else {
410            text
411        };
412        if self.color {
413            writeln!(self.writer, "{style}{display}{Reset}")?;
414        } else {
415            writeln!(self.writer, "{display}")?;
416        }
417        self.writer.flush()?;
418        transcript::append(display);
419        self.last_line_was_empty = text.trim().is_empty();
420        Ok(())
421    }
422
423    /// Write an empty line only if the previous line was not empty
424    pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
425        if !self.was_previous_line_empty() {
426            self.line(style, "")
427        } else {
428            Ok(())
429        }
430    }
431
432    /// Write a raw line without styling
433    pub fn raw_line(&mut self, text: &str) -> Result<()> {
434        self.flush_compact_command_group();
435        writeln!(self.writer, "{text}")?;
436        self.writer.flush()?;
437        transcript::append(text);
438        Ok(())
439    }
440
441    pub fn render_token_delta(&mut self, delta: &str) -> Result<()> {
442        self.inline_with_style(MessageStyle::Response, delta)
443    }
444
445    pub fn stream_markdown_response(&mut self, text: &str, previous_line_count: usize) -> Result<usize> {
446        // Strip ANSI codes from agent response to prevent interference with markdown rendering
447        let text = crate::utils::ansi_parser::strip_ansi(text);
448        let text = &text;
449
450        let styles = theme::active_styles();
451        let style = MessageStyle::Response;
452        let base_style = style.style();
453        let indent = style.indent();
454        if let Some(sink) = &mut self.sink {
455            // Read terminal width fresh so tables adapt to resizes.
456            if sink.table_max_width_override.is_none()
457                && let Ok((w, _)) = crossterm::terminal::size()
458            {
459                sink.table_max_width = Some(w as usize);
460            }
461            let table_max_width = sink.table_content_width(Self::message_kind(style), indent);
462            let (prepared, plain_lines, last_empty) =
463                sink.prepare_markdown_lines_with_table_width(text, indent, base_style, true, true, table_max_width);
464            let line_count = prepared.len();
465            sink.replace_inline_lines(previous_line_count, prepared, &plain_lines, Self::message_kind(style));
466            self.last_line_was_empty = last_empty;
467            return Ok(line_count);
468        }
469
470        let highlight_cfg = if self.highlight_config.enabled {
471            Some(&self.highlight_config)
472        } else {
473            None
474        };
475        let mut lines = render_markdown_to_lines_with_options(
476            text,
477            base_style,
478            &styles,
479            highlight_cfg,
480            RenderMarkdownOptions::default(),
481        );
482        if lines.is_empty() {
483            lines.push(MarkdownLine::default());
484        }
485
486        Err(anyhow!("stream_markdown_response requires an inline sink"))
487    }
488}