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