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, InlineListSelection, InlineMessageKind, InlineSegment,
6    InlineTextStyle, 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    pub fn supports_inline_ui(&self) -> bool {
134        self.sink.is_some()
135    }
136
137    pub fn show_list_modal(
138        &mut self,
139        title: &str,
140        lines: Vec<String>,
141        items: Vec<InlineListItem>,
142        selected: Option<InlineListSelection>,
143    ) {
144        if let Some(sink) = &self.sink {
145            sink.show_list_modal(title.to_string(), lines, items, selected);
146        }
147    }
148
149    pub fn close_modal(&mut self) {
150        if let Some(sink) = &self.sink {
151            sink.close_modal();
152        }
153    }
154
155    /// Push text into the buffer
156    pub fn push(&mut self, text: &str) {
157        self.buffer.push_str(text);
158    }
159
160    /// Flush the buffer with the given style
161    pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
162        if let Some(sink) = &mut self.sink {
163            let indent = style.indent();
164            let line = self.buffer.clone();
165            // Track if this line is empty
166            self.last_line_was_empty = line.is_empty() && indent.is_empty();
167            sink.write_line(style.style(), indent, &line, Self::message_kind(style))?;
168            self.buffer.clear();
169            return Ok(());
170        }
171        let style = style.style();
172        if self.color {
173            writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
174        } else {
175            writeln!(self.writer, "{}", self.buffer)?;
176        }
177        self.writer.flush()?;
178        transcript::append(&self.buffer);
179        // Track if this line is empty
180        self.last_line_was_empty = self.buffer.is_empty();
181        self.buffer.clear();
182        Ok(())
183    }
184
185    /// Convenience for writing a single line
186    pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
187        if matches!(style, MessageStyle::Response) {
188            return self.render_markdown(style, text);
189        }
190        let indent = style.indent();
191
192        if let Some(sink) = &mut self.sink {
193            sink.write_multiline(style.style(), indent, text, Self::message_kind(style))?;
194            return Ok(());
195        }
196
197        if text.contains('\n') {
198            let trailing_newline = text.ends_with('\n');
199            for line in text.lines() {
200                self.buffer.clear();
201                if !indent.is_empty() && !line.is_empty() {
202                    self.buffer.push_str(indent);
203                }
204                self.buffer.push_str(line);
205                self.flush(style)?;
206            }
207            if trailing_newline {
208                self.buffer.clear();
209                if !indent.is_empty() {
210                    self.buffer.push_str(indent);
211                }
212                self.flush(style)?;
213            }
214            Ok(())
215        } else {
216            self.buffer.clear();
217            if !indent.is_empty() && !text.is_empty() {
218                self.buffer.push_str(indent);
219            }
220            self.buffer.push_str(text);
221            self.flush(style)
222        }
223    }
224
225    /// Write styled text without a trailing newline
226    pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
227        if let Some(sink) = &mut self.sink {
228            sink.write_inline(style.style(), text, Self::message_kind(style));
229            return Ok(());
230        }
231        let ansi_style = style.style();
232        if self.color {
233            write!(self.writer, "{ansi_style}{}{Reset}", text)?;
234        } else {
235            write!(self.writer, "{}", text)?;
236        }
237        self.writer.flush()?;
238        Ok(())
239    }
240
241    /// Write a line with an explicit style
242    pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
243        if let Some(sink) = &mut self.sink {
244            sink.write_multiline(style, "", text, InlineMessageKind::Info)?;
245            return Ok(());
246        }
247        if self.color {
248            writeln!(self.writer, "{style}{}{Reset}", text)?;
249        } else {
250            writeln!(self.writer, "{}", text)?;
251        }
252        self.writer.flush()?;
253        transcript::append(text);
254        Ok(())
255    }
256
257    /// Write an empty line only if the previous line was not empty
258    pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
259        if !self.was_previous_line_empty() {
260            self.line(style, "")
261        } else {
262            Ok(())
263        }
264    }
265
266    /// Write a raw line without styling
267    pub fn raw_line(&mut self, text: &str) -> Result<()> {
268        writeln!(self.writer, "{}", text)?;
269        self.writer.flush()?;
270        transcript::append(text);
271        Ok(())
272    }
273
274    fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
275        let styles = theme::active_styles();
276        let base_style = style.style();
277        let indent = style.indent();
278        let highlight_cfg = if self.highlight_config.enabled {
279            Some(&self.highlight_config)
280        } else {
281            None
282        };
283        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
284        if lines.is_empty() {
285            lines.push(MarkdownLine::default());
286        }
287        for line in lines {
288            self.write_markdown_line(style, indent, line)?;
289        }
290        Ok(())
291    }
292
293    pub fn stream_markdown_response(
294        &mut self,
295        text: &str,
296        previous_line_count: usize,
297    ) -> Result<usize> {
298        let styles = theme::active_styles();
299        let style = MessageStyle::Response;
300        let base_style = style.style();
301        let indent = style.indent();
302        let highlight_cfg = if self.highlight_config.enabled {
303            Some(&self.highlight_config)
304        } else {
305            None
306        };
307        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
308        if lines.is_empty() {
309            lines.push(MarkdownLine::default());
310        }
311
312        if let Some(sink) = &mut self.sink {
313            let mut plain_lines = Vec::with_capacity(lines.len());
314            let mut prepared = Vec::with_capacity(lines.len());
315            for mut line in lines {
316                if !indent.is_empty() && !line.segments.is_empty() {
317                    line.segments
318                        .insert(0, MarkdownSegment::new(base_style, indent));
319                }
320                plain_lines.push(
321                    line.segments
322                        .iter()
323                        .map(|segment| segment.text.clone())
324                        .collect::<String>(),
325                );
326                prepared.push(line.segments);
327            }
328            sink.replace_lines(
329                previous_line_count,
330                &prepared,
331                &plain_lines,
332                Self::message_kind(style),
333            );
334            self.last_line_was_empty = prepared
335                .last()
336                .map(|segments| segments.is_empty())
337                .unwrap_or(true);
338            return Ok(prepared.len());
339        }
340
341        Err(anyhow!("stream_markdown_response requires an inline sink"))
342    }
343
344    fn write_markdown_line(
345        &mut self,
346        style: MessageStyle,
347        indent: &str,
348        mut line: MarkdownLine,
349    ) -> Result<()> {
350        if !indent.is_empty() && !line.segments.is_empty() {
351            line.segments
352                .insert(0, MarkdownSegment::new(style.style(), indent));
353        }
354
355        if let Some(sink) = &mut self.sink {
356            sink.write_segments(&line.segments, Self::message_kind(style))?;
357            self.last_line_was_empty = line.is_empty();
358            return Ok(());
359        }
360
361        let mut plain = String::new();
362        if self.color {
363            for segment in &line.segments {
364                write!(
365                    self.writer,
366                    "{style}{}{Reset}",
367                    segment.text,
368                    style = segment.style
369                )?;
370                plain.push_str(&segment.text);
371            }
372            writeln!(self.writer)?;
373        } else {
374            for segment in &line.segments {
375                write!(self.writer, "{}", segment.text)?;
376                plain.push_str(&segment.text);
377            }
378            writeln!(self.writer)?;
379        }
380        self.writer.flush()?;
381        transcript::append(&plain);
382        self.last_line_was_empty = plain.trim().is_empty();
383        Ok(())
384    }
385}
386
387struct InlineSink {
388    handle: InlineHandle,
389}
390
391impl InlineSink {
392    fn new(handle: InlineHandle) -> Self {
393        Self { handle }
394    }
395
396    fn show_list_modal(
397        &self,
398        title: String,
399        lines: Vec<String>,
400        items: Vec<InlineListItem>,
401        selected: Option<InlineListSelection>,
402    ) {
403        self.handle.show_list_modal(title, lines, items, selected);
404    }
405
406    fn close_modal(&self) {
407        self.handle.close_modal();
408    }
409
410    fn resolve_fallback_style(&self, style: Style) -> InlineTextStyle {
411        let mut text_style = convert_to_inline_style(style);
412        if text_style.color.is_none() {
413            let theme = theme_from_styles(&theme::active_styles());
414            text_style = text_style.merge_color(theme.foreground);
415        }
416        text_style
417    }
418
419    fn style_to_segment(&self, style: Style, text: &str) -> InlineSegment {
420        let text_style = self.resolve_fallback_style(style);
421        InlineSegment {
422            text: text.to_string(),
423            style: text_style,
424        }
425    }
426
427    fn convert_plain_lines(
428        &self,
429        text: &str,
430        fallback: &InlineTextStyle,
431    ) -> (Vec<Vec<InlineSegment>>, Vec<String>) {
432        if text.is_empty() {
433            return (vec![Vec::new()], vec![String::new()]);
434        }
435
436        let mut converted_lines = Vec::new();
437        let mut plain_lines = Vec::new();
438
439        for line in text.split('\n') {
440            let mut segments = Vec::new();
441            if !line.is_empty() {
442                segments.push(InlineSegment {
443                    text: line.to_string(),
444                    style: fallback.clone(),
445                });
446            }
447            converted_lines.push(segments);
448            plain_lines.push(line.to_string());
449        }
450
451        if text.ends_with('\n') {
452            converted_lines.push(Vec::new());
453            plain_lines.push(String::new());
454        }
455
456        if converted_lines.is_empty() {
457            converted_lines.push(Vec::new());
458            plain_lines.push(String::new());
459        }
460
461        (converted_lines, plain_lines)
462    }
463
464    fn write_multiline(
465        &mut self,
466        style: Style,
467        indent: &str,
468        text: &str,
469        kind: InlineMessageKind,
470    ) -> Result<()> {
471        if text.is_empty() {
472            self.handle.append_line(kind, Vec::new());
473            crate::utils::transcript::append("");
474            return Ok(());
475        }
476
477        let fallback = self.resolve_fallback_style(style);
478        let (converted_lines, plain_lines) = self.convert_plain_lines(text, &fallback);
479
480        for (mut segments, mut plain) in converted_lines.into_iter().zip(plain_lines.into_iter()) {
481            if !indent.is_empty() && !plain.is_empty() {
482                segments.insert(
483                    0,
484                    InlineSegment {
485                        text: indent.to_string(),
486                        style: fallback.clone(),
487                    },
488                );
489                plain.insert_str(0, indent);
490            }
491
492            if segments.is_empty() {
493                self.handle.append_line(kind, Vec::new());
494            } else {
495                self.handle.append_line(kind, segments);
496            }
497            crate::utils::transcript::append(&plain);
498        }
499
500        Ok(())
501    }
502
503    fn write_line(
504        &mut self,
505        style: Style,
506        indent: &str,
507        text: &str,
508        kind: InlineMessageKind,
509    ) -> Result<()> {
510        self.write_multiline(style, indent, text, kind)
511    }
512
513    fn write_inline(&mut self, style: Style, text: &str, kind: InlineMessageKind) {
514        if text.is_empty() {
515            return;
516        }
517        let fallback = self.resolve_fallback_style(style);
518        let (converted_lines, _) = self.convert_plain_lines(text, &fallback);
519        let line_count = converted_lines.len();
520
521        for (index, segments) in converted_lines.into_iter().enumerate() {
522            let has_next = index + 1 < line_count;
523            if segments.is_empty() {
524                if has_next {
525                    self.handle.inline(
526                        kind,
527                        InlineSegment {
528                            text: "\n".to_string(),
529                            style: fallback.clone(),
530                        },
531                    );
532                }
533                continue;
534            }
535
536            for mut segment in segments {
537                if has_next {
538                    segment.text.push('\n');
539                }
540                self.handle.inline(kind, segment);
541            }
542        }
543    }
544
545    fn write_segments(
546        &mut self,
547        segments: &[MarkdownSegment],
548        kind: InlineMessageKind,
549    ) -> Result<()> {
550        let converted = self.convert_segments(segments);
551        let plain = segments
552            .iter()
553            .map(|segment| segment.text.clone())
554            .collect::<String>();
555        self.handle.append_line(kind, converted);
556        crate::utils::transcript::append(&plain);
557        Ok(())
558    }
559
560    fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<InlineSegment> {
561        if segments.is_empty() {
562            return Vec::new();
563        }
564
565        let mut converted = Vec::with_capacity(segments.len());
566        for segment in segments {
567            if segment.text.is_empty() {
568                continue;
569            }
570            converted.push(self.style_to_segment(segment.style, &segment.text));
571        }
572        converted
573    }
574
575    fn replace_lines(
576        &mut self,
577        count: usize,
578        lines: &[Vec<MarkdownSegment>],
579        plain: &[String],
580        kind: InlineMessageKind,
581    ) {
582        let mut converted = Vec::with_capacity(lines.len());
583        for segments in lines {
584            converted.push(self.convert_segments(segments));
585        }
586        self.handle.replace_last(count, kind, converted);
587        crate::utils::transcript::replace_last(count, plain);
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_styles_construct() {
597        let info = MessageStyle::Info.style();
598        assert_eq!(info, MessageStyle::Info.style());
599        let resp = MessageStyle::Response.style();
600        assert_eq!(resp, MessageStyle::Response.style());
601        let tool = MessageStyle::Tool.style();
602        assert_eq!(tool, MessageStyle::Tool.style());
603        let reasoning = MessageStyle::Reasoning.style();
604        assert_eq!(reasoning, MessageStyle::Reasoning.style());
605    }
606
607    #[test]
608    fn test_renderer_buffer() {
609        let mut r = AnsiRenderer::stdout();
610        r.push("hello");
611        assert_eq!(r.buffer, "hello");
612    }
613}