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