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