vtcode_core/utils/
ansi.rs

1use crate::config::loader::SyntaxHighlightingConfig;
2use crate::ui::markdown::{MarkdownLine, MarkdownSegment, render_markdown_to_lines};
3use crate::ui::theme;
4use crate::ui::tui::{
5    InlineHandle, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
6    InlineSegment, InlineTextStyle, SecurePromptConfig, convert_style as convert_to_inline_style,
7    theme_from_styles,
8};
9use crate::utils::transcript;
10use anstream::{AutoStream, ColorChoice};
11use anstyle::{Reset, Style};
12use anstyle_query::{clicolor, clicolor_force, no_color, term_supports_color};
13use anyhow::{Result, anyhow};
14use std::io::{self, Write};
15
16/// Styles available for rendering messages
17#[derive(Clone, Copy)]
18pub enum MessageStyle {
19    Info,
20    Error,
21    Output,
22    Response,
23    Tool,
24    ToolDetail,
25    Status,
26    McpStatus,
27    User,
28    Reasoning,
29}
30
31impl MessageStyle {
32    pub fn style(self) -> Style {
33        let styles = theme::active_styles();
34        match self {
35            Self::Info => styles.info,
36            Self::Error => styles.error,
37            Self::Output => styles.output,
38            Self::Response => styles.response,
39            Self::Tool => styles.tool,
40            Self::ToolDetail => styles.tool_detail,
41            Self::Status => styles.status,
42            Self::McpStatus => styles.mcp,
43            Self::User => styles.user,
44            Self::Reasoning => styles.reasoning,
45        }
46    }
47
48    pub fn indent(self) -> &'static str {
49        match self {
50            Self::Response | Self::Tool | Self::Reasoning => "  ",
51            Self::ToolDetail => "    ",
52            _ => "",
53        }
54    }
55}
56
57/// Renderer with deferred output buffering
58pub struct AnsiRenderer {
59    writer: AutoStream<io::Stdout>,
60    buffer: String,
61    color: bool,
62    sink: Option<InlineSink>,
63    last_line_was_empty: bool,
64    highlight_config: SyntaxHighlightingConfig,
65}
66
67impl AnsiRenderer {
68    /// Create a new renderer for stdout
69    pub fn stdout() -> Self {
70        let color =
71            clicolor_force() || (!no_color() && clicolor().unwrap_or_else(term_supports_color));
72        let choice = if color {
73            ColorChoice::Auto
74        } else {
75            ColorChoice::Never
76        };
77        Self {
78            writer: AutoStream::new(std::io::stdout(), choice),
79            buffer: String::new(),
80            color,
81            sink: None,
82            last_line_was_empty: false,
83            highlight_config: SyntaxHighlightingConfig::default(),
84        }
85    }
86
87    /// Create a renderer that forwards output to the inline UI session handle
88    pub fn with_inline_ui(
89        handle: InlineHandle,
90        highlight_config: SyntaxHighlightingConfig,
91    ) -> Self {
92        let mut renderer = Self::stdout();
93        renderer.highlight_config = highlight_config;
94        renderer.sink = Some(InlineSink::new(handle));
95        renderer.last_line_was_empty = false;
96        renderer
97    }
98
99    /// Override the syntax highlighting configuration.
100    pub fn set_highlight_config(&mut self, config: SyntaxHighlightingConfig) {
101        self.highlight_config = config;
102    }
103
104    /// Check if the last line rendered was empty
105    pub fn was_previous_line_empty(&self) -> bool {
106        self.last_line_was_empty
107    }
108
109    fn message_kind(style: MessageStyle) -> InlineMessageKind {
110        match style {
111            MessageStyle::Info => InlineMessageKind::Info,
112            MessageStyle::Error => InlineMessageKind::Error,
113            MessageStyle::Output => InlineMessageKind::Pty,
114            MessageStyle::Response => InlineMessageKind::Agent,
115            MessageStyle::Tool | MessageStyle::ToolDetail => InlineMessageKind::Tool,
116            MessageStyle::Status | MessageStyle::McpStatus => InlineMessageKind::Info,
117            MessageStyle::User => InlineMessageKind::User,
118            MessageStyle::Reasoning => InlineMessageKind::Policy,
119        }
120    }
121
122    pub fn supports_streaming_markdown(&self) -> bool {
123        self.sink.is_some()
124    }
125
126    /// Determine whether the renderer is connected to the inline UI.
127    ///
128    /// Inline rendering uses the terminal session scrollback, so tool output should
129    /// avoid truncation that would otherwise be applied in compact CLI mode.
130    pub fn prefers_untruncated_output(&self) -> bool {
131        self.sink.is_some()
132    }
133
134    pub fn supports_inline_ui(&self) -> bool {
135        self.sink.is_some()
136    }
137
138    pub fn show_list_modal(
139        &mut self,
140        title: &str,
141        lines: Vec<String>,
142        items: Vec<InlineListItem>,
143        selected: Option<InlineListSelection>,
144        search: Option<InlineListSearchConfig>,
145    ) {
146        if let Some(sink) = &self.sink {
147            sink.show_list_modal(title.to_string(), lines, items, selected, search);
148        }
149    }
150
151    pub fn show_secure_prompt_modal(
152        &mut self,
153        title: &str,
154        lines: Vec<String>,
155        prompt_label: String,
156    ) {
157        if let Some(sink) = &self.sink {
158            sink.show_secure_prompt_modal(title.to_string(), lines, prompt_label);
159        }
160    }
161
162    pub fn close_modal(&mut self) {
163        if let Some(sink) = &self.sink {
164            sink.close_modal();
165        }
166    }
167
168    /// Push text into the buffer
169    pub fn push(&mut self, text: &str) {
170        self.buffer.push_str(text);
171    }
172
173    /// Flush the buffer with the given style
174    pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
175        if let Some(sink) = &mut self.sink {
176            let indent = style.indent();
177            let line = self.buffer.clone();
178            // Track if this line is empty
179            self.last_line_was_empty = line.is_empty() && indent.is_empty();
180            sink.write_line(style.style(), indent, &line, Self::message_kind(style))?;
181            self.buffer.clear();
182            return Ok(());
183        }
184        let style = style.style();
185        if self.color {
186            writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
187        } else {
188            writeln!(self.writer, "{}", self.buffer)?;
189        }
190        self.writer.flush()?;
191        transcript::append(&self.buffer);
192        // Track if this line is empty
193        self.last_line_was_empty = self.buffer.is_empty();
194        self.buffer.clear();
195        Ok(())
196    }
197
198    /// Convenience for writing a single line
199    pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
200        if matches!(style, MessageStyle::Response) {
201            return self.render_markdown(style, text);
202        }
203        let indent = style.indent();
204
205        if let Some(sink) = &mut self.sink {
206            sink.write_multiline(style.style(), indent, text, Self::message_kind(style))?;
207            return Ok(());
208        }
209
210        if text.contains('\n') {
211            let trailing_newline = text.ends_with('\n');
212            for line in text.lines() {
213                self.buffer.clear();
214                if !indent.is_empty() && !line.is_empty() {
215                    self.buffer.push_str(indent);
216                }
217                self.buffer.push_str(line);
218                self.flush(style)?;
219            }
220            if trailing_newline {
221                self.buffer.clear();
222                if !indent.is_empty() {
223                    self.buffer.push_str(indent);
224                }
225                self.flush(style)?;
226            }
227            Ok(())
228        } else {
229            self.buffer.clear();
230            if !indent.is_empty() && !text.is_empty() {
231                self.buffer.push_str(indent);
232            }
233            self.buffer.push_str(text);
234            self.flush(style)
235        }
236    }
237
238    /// Write styled text without a trailing newline
239    pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
240        if let Some(sink) = &mut self.sink {
241            sink.write_inline(style.style(), text, Self::message_kind(style));
242            return Ok(());
243        }
244        let ansi_style = style.style();
245        if self.color {
246            write!(self.writer, "{ansi_style}{}{Reset}", text)?;
247        } else {
248            write!(self.writer, "{}", text)?;
249        }
250        self.writer.flush()?;
251        Ok(())
252    }
253
254    /// Write a line with an explicit style
255    pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
256        if let Some(sink) = &mut self.sink {
257            sink.write_multiline(style, "", text, InlineMessageKind::Info)?;
258            return Ok(());
259        }
260        if self.color {
261            writeln!(self.writer, "{style}{}{Reset}", text)?;
262        } else {
263            writeln!(self.writer, "{}", text)?;
264        }
265        self.writer.flush()?;
266        transcript::append(text);
267        Ok(())
268    }
269
270    /// Write an empty line only if the previous line was not empty
271    pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
272        if !self.was_previous_line_empty() {
273            self.line(style, "")
274        } else {
275            Ok(())
276        }
277    }
278
279    /// Write a raw line without styling
280    pub fn raw_line(&mut self, text: &str) -> Result<()> {
281        writeln!(self.writer, "{}", text)?;
282        self.writer.flush()?;
283        transcript::append(text);
284        Ok(())
285    }
286
287    fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
288        let styles = theme::active_styles();
289        let base_style = style.style();
290        let indent = style.indent();
291        let highlight_cfg = if self.highlight_config.enabled {
292            Some(&self.highlight_config)
293        } else {
294            None
295        };
296        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
297        if lines.is_empty() {
298            lines.push(MarkdownLine::default());
299        }
300        for line in lines {
301            self.write_markdown_line(style, indent, line)?;
302        }
303        Ok(())
304    }
305
306    pub fn stream_markdown_response(
307        &mut self,
308        text: &str,
309        previous_line_count: usize,
310    ) -> Result<usize> {
311        let styles = theme::active_styles();
312        let style = MessageStyle::Response;
313        let base_style = style.style();
314        let indent = style.indent();
315        let highlight_cfg = if self.highlight_config.enabled {
316            Some(&self.highlight_config)
317        } else {
318            None
319        };
320        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
321        if lines.is_empty() {
322            lines.push(MarkdownLine::default());
323        }
324
325        if let Some(sink) = &mut self.sink {
326            let mut plain_lines = Vec::with_capacity(lines.len());
327            let mut prepared = Vec::with_capacity(lines.len());
328            for mut line in lines {
329                if !indent.is_empty() && !line.segments.is_empty() {
330                    line.segments
331                        .insert(0, MarkdownSegment::new(base_style, indent));
332                }
333                plain_lines.push(
334                    line.segments
335                        .iter()
336                        .map(|segment| segment.text.clone())
337                        .collect::<String>(),
338                );
339                prepared.push(line.segments);
340            }
341            sink.replace_lines(
342                previous_line_count,
343                &prepared,
344                &plain_lines,
345                Self::message_kind(style),
346            );
347            self.last_line_was_empty = prepared
348                .last()
349                .map(|segments| segments.is_empty())
350                .unwrap_or(true);
351            return Ok(prepared.len());
352        }
353
354        Err(anyhow!("stream_markdown_response requires an inline sink"))
355    }
356
357    fn write_markdown_line(
358        &mut self,
359        style: MessageStyle,
360        indent: &str,
361        mut line: MarkdownLine,
362    ) -> Result<()> {
363        if !indent.is_empty() && !line.segments.is_empty() {
364            line.segments
365                .insert(0, MarkdownSegment::new(style.style(), indent));
366        }
367
368        if let Some(sink) = &mut self.sink {
369            sink.write_segments(&line.segments, Self::message_kind(style))?;
370            self.last_line_was_empty = line.is_empty();
371            return Ok(());
372        }
373
374        let mut plain = String::new();
375        if self.color {
376            for segment in &line.segments {
377                write!(
378                    self.writer,
379                    "{style}{}{Reset}",
380                    segment.text,
381                    style = segment.style
382                )?;
383                plain.push_str(&segment.text);
384            }
385            writeln!(self.writer)?;
386        } else {
387            for segment in &line.segments {
388                write!(self.writer, "{}", segment.text)?;
389                plain.push_str(&segment.text);
390            }
391            writeln!(self.writer)?;
392        }
393        self.writer.flush()?;
394        transcript::append(&plain);
395        self.last_line_was_empty = plain.trim().is_empty();
396        Ok(())
397    }
398}
399
400struct InlineSink {
401    handle: InlineHandle,
402}
403
404impl InlineSink {
405    fn new(handle: InlineHandle) -> Self {
406        Self { handle }
407    }
408
409    fn show_list_modal(
410        &self,
411        title: String,
412        lines: Vec<String>,
413        items: Vec<InlineListItem>,
414        selected: Option<InlineListSelection>,
415        search: Option<InlineListSearchConfig>,
416    ) {
417        self.handle
418            .show_list_modal(title, lines, items, selected, search);
419    }
420
421    fn show_secure_prompt_modal(&self, title: String, lines: Vec<String>, prompt_label: String) {
422        self.handle.show_modal(
423            title,
424            lines,
425            Some(SecurePromptConfig {
426                label: prompt_label,
427            }),
428        );
429    }
430
431    fn close_modal(&self) {
432        self.handle.close_modal();
433    }
434
435    fn resolve_fallback_style(&self, style: Style) -> InlineTextStyle {
436        let mut text_style = convert_to_inline_style(style);
437        if text_style.color.is_none() {
438            let theme = theme_from_styles(&theme::active_styles());
439            text_style = text_style.merge_color(theme.foreground);
440        }
441        text_style
442    }
443
444    fn style_to_segment(&self, style: Style, text: &str) -> InlineSegment {
445        let text_style = self.resolve_fallback_style(style);
446        InlineSegment {
447            text: text.to_string(),
448            style: text_style,
449        }
450    }
451
452    fn convert_plain_lines(
453        &self,
454        text: &str,
455        fallback: &InlineTextStyle,
456    ) -> (Vec<Vec<InlineSegment>>, Vec<String>) {
457        if text.is_empty() {
458            return (vec![Vec::new()], vec![String::new()]);
459        }
460
461        let mut converted_lines = Vec::new();
462        let mut plain_lines = Vec::new();
463
464        for line in text.split('\n') {
465            let mut segments = Vec::new();
466            if !line.is_empty() {
467                segments.push(InlineSegment {
468                    text: line.to_string(),
469                    style: fallback.clone(),
470                });
471            }
472            converted_lines.push(segments);
473            plain_lines.push(line.to_string());
474        }
475
476        if text.ends_with('\n') {
477            converted_lines.push(Vec::new());
478            plain_lines.push(String::new());
479        }
480
481        if converted_lines.is_empty() {
482            converted_lines.push(Vec::new());
483            plain_lines.push(String::new());
484        }
485
486        (converted_lines, plain_lines)
487    }
488
489    fn write_multiline(
490        &mut self,
491        style: Style,
492        indent: &str,
493        text: &str,
494        kind: InlineMessageKind,
495    ) -> Result<()> {
496        if text.is_empty() {
497            self.handle.append_line(kind, Vec::new());
498            crate::utils::transcript::append("");
499            return Ok(());
500        }
501
502        let fallback = self.resolve_fallback_style(style);
503        let (converted_lines, plain_lines) = self.convert_plain_lines(text, &fallback);
504
505        for (mut segments, mut plain) in converted_lines.into_iter().zip(plain_lines.into_iter()) {
506            if !indent.is_empty() && !plain.is_empty() {
507                segments.insert(
508                    0,
509                    InlineSegment {
510                        text: indent.to_string(),
511                        style: fallback.clone(),
512                    },
513                );
514                plain.insert_str(0, indent);
515            }
516
517            if segments.is_empty() {
518                self.handle.append_line(kind, Vec::new());
519            } else {
520                self.handle.append_line(kind, segments);
521            }
522            crate::utils::transcript::append(&plain);
523        }
524
525        Ok(())
526    }
527
528    fn write_line(
529        &mut self,
530        style: Style,
531        indent: &str,
532        text: &str,
533        kind: InlineMessageKind,
534    ) -> Result<()> {
535        self.write_multiline(style, indent, text, kind)
536    }
537
538    fn write_inline(&mut self, style: Style, text: &str, kind: InlineMessageKind) {
539        if text.is_empty() {
540            return;
541        }
542        let fallback = self.resolve_fallback_style(style);
543        let (converted_lines, _) = self.convert_plain_lines(text, &fallback);
544        let line_count = converted_lines.len();
545
546        for (index, segments) in converted_lines.into_iter().enumerate() {
547            let has_next = index + 1 < line_count;
548            if segments.is_empty() {
549                if has_next {
550                    self.handle.inline(
551                        kind,
552                        InlineSegment {
553                            text: "\n".to_string(),
554                            style: fallback.clone(),
555                        },
556                    );
557                }
558                continue;
559            }
560
561            for mut segment in segments {
562                if has_next {
563                    segment.text.push('\n');
564                }
565                self.handle.inline(kind, segment);
566            }
567        }
568    }
569
570    fn write_segments(
571        &mut self,
572        segments: &[MarkdownSegment],
573        kind: InlineMessageKind,
574    ) -> Result<()> {
575        let converted = self.convert_segments(segments);
576        let plain = segments
577            .iter()
578            .map(|segment| segment.text.clone())
579            .collect::<String>();
580        self.handle.append_line(kind, converted);
581        crate::utils::transcript::append(&plain);
582        Ok(())
583    }
584
585    fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<InlineSegment> {
586        if segments.is_empty() {
587            return Vec::new();
588        }
589
590        let mut converted = Vec::with_capacity(segments.len());
591        for segment in segments {
592            if segment.text.is_empty() {
593                continue;
594            }
595            converted.push(self.style_to_segment(segment.style, &segment.text));
596        }
597        converted
598    }
599
600    fn replace_lines(
601        &mut self,
602        count: usize,
603        lines: &[Vec<MarkdownSegment>],
604        plain: &[String],
605        kind: InlineMessageKind,
606    ) {
607        let mut converted = Vec::with_capacity(lines.len());
608        for segments in lines {
609            converted.push(self.convert_segments(segments));
610        }
611        self.handle.replace_last(count, kind, converted);
612        crate::utils::transcript::replace_last(count, plain);
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_styles_construct() {
622        let info = MessageStyle::Info.style();
623        assert_eq!(info, MessageStyle::Info.style());
624        let resp = MessageStyle::Response.style();
625        assert_eq!(resp, MessageStyle::Response.style());
626        let tool = MessageStyle::Tool.style();
627        assert_eq!(tool, MessageStyle::Tool.style());
628        let reasoning = MessageStyle::Reasoning.style();
629        assert_eq!(reasoning, MessageStyle::Reasoning.style());
630    }
631
632    #[test]
633    fn test_renderer_buffer() {
634        let mut r = AnsiRenderer::stdout();
635        r.push("hello");
636        assert_eq!(r.buffer, "hello");
637    }
638}