vtcode_core/utils/
ansi.rs

1use crate::config::loader::SyntaxHighlightingConfig;
2use crate::ui::iocraft::{
3    IocraftHandle, IocraftSegment, convert_style as convert_to_iocraft_style, theme_from_styles,
4};
5use crate::ui::markdown::{MarkdownLine, MarkdownSegment, render_markdown_to_lines};
6use crate::ui::theme;
7use crate::utils::transcript;
8use anstream::{AutoStream, ColorChoice};
9use anstyle::{Reset, Style};
10use anstyle_query::{clicolor, clicolor_force, no_color, term_supports_color};
11use anyhow::{Result, anyhow};
12use std::io::{self, Write};
13
14/// Styles available for rendering messages
15#[derive(Clone, Copy)]
16pub enum MessageStyle {
17    Info,
18    Error,
19    Output,
20    Response,
21    Tool,
22    User,
23    Reasoning,
24}
25
26impl MessageStyle {
27    pub fn style(self) -> Style {
28        let styles = theme::active_styles();
29        match self {
30            Self::Info => styles.info,
31            Self::Error => styles.error,
32            Self::Output => styles.output,
33            Self::Response => styles.response,
34            Self::Tool => styles.tool,
35            Self::User => styles.user,
36            Self::Reasoning => styles.reasoning,
37        }
38    }
39
40    pub fn indent(self) -> &'static str {
41        match self {
42            Self::Response | Self::Tool | Self::Reasoning => "  ",
43            _ => "",
44        }
45    }
46}
47
48/// Renderer with deferred output buffering
49pub struct AnsiRenderer {
50    writer: AutoStream<io::Stdout>,
51    buffer: String,
52    color: bool,
53    sink: Option<IocraftSink>,
54    last_line_was_empty: bool,
55    highlight_config: SyntaxHighlightingConfig,
56}
57
58impl AnsiRenderer {
59    /// Create a new renderer for stdout
60    pub fn stdout() -> Self {
61        let color =
62            clicolor_force() || (!no_color() && clicolor().unwrap_or_else(term_supports_color));
63        let choice = if color {
64            ColorChoice::Auto
65        } else {
66            ColorChoice::Never
67        };
68        Self {
69            writer: AutoStream::new(std::io::stdout(), choice),
70            buffer: String::new(),
71            color,
72            sink: None,
73            last_line_was_empty: false,
74            highlight_config: SyntaxHighlightingConfig::default(),
75        }
76    }
77
78    /// Create a renderer that forwards output to an iocraft session handle
79    pub fn with_iocraft(handle: IocraftHandle, highlight_config: SyntaxHighlightingConfig) -> Self {
80        let mut renderer = Self::stdout();
81        renderer.highlight_config = highlight_config;
82        renderer.sink = Some(IocraftSink::new(handle));
83        renderer.last_line_was_empty = false;
84        renderer
85    }
86
87    /// Override the syntax highlighting configuration.
88    pub fn set_highlight_config(&mut self, config: SyntaxHighlightingConfig) {
89        self.highlight_config = config;
90    }
91
92    /// Check if the last line rendered was empty
93    pub fn was_previous_line_empty(&self) -> bool {
94        self.last_line_was_empty
95    }
96
97    pub fn supports_streaming_markdown(&self) -> bool {
98        self.sink.is_some()
99    }
100
101    /// Push text into the buffer
102    pub fn push(&mut self, text: &str) {
103        self.buffer.push_str(text);
104    }
105
106    /// Flush the buffer with the given style
107    pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
108        if let Some(sink) = &mut self.sink {
109            let indent = style.indent();
110            let line = self.buffer.clone();
111            // Track if this line is empty
112            self.last_line_was_empty = line.is_empty() && indent.is_empty();
113            sink.write_line(style.style(), indent, &line)?;
114            self.buffer.clear();
115            return Ok(());
116        }
117        let style = style.style();
118        if self.color {
119            writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
120        } else {
121            writeln!(self.writer, "{}", self.buffer)?;
122        }
123        self.writer.flush()?;
124        transcript::append(&self.buffer);
125        // Track if this line is empty
126        self.last_line_was_empty = self.buffer.is_empty();
127        self.buffer.clear();
128        Ok(())
129    }
130
131    /// Convenience for writing a single line
132    pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
133        if matches!(style, MessageStyle::Response) {
134            return self.render_markdown(style, text);
135        }
136        let indent = style.indent();
137
138        if let Some(sink) = &mut self.sink {
139            sink.write_multiline(style.style(), indent, text)?;
140            return Ok(());
141        }
142
143        if text.contains('\n') {
144            let trailing_newline = text.ends_with('\n');
145            for line in text.lines() {
146                self.buffer.clear();
147                if !indent.is_empty() && !line.is_empty() {
148                    self.buffer.push_str(indent);
149                }
150                self.buffer.push_str(line);
151                self.flush(style)?;
152            }
153            if trailing_newline {
154                self.buffer.clear();
155                if !indent.is_empty() {
156                    self.buffer.push_str(indent);
157                }
158                self.flush(style)?;
159            }
160            Ok(())
161        } else {
162            self.buffer.clear();
163            if !indent.is_empty() && !text.is_empty() {
164                self.buffer.push_str(indent);
165            }
166            self.buffer.push_str(text);
167            self.flush(style)
168        }
169    }
170
171    /// Write styled text without a trailing newline
172    pub fn inline_with_style(&mut self, style: Style, text: &str) -> Result<()> {
173        if let Some(sink) = &mut self.sink {
174            sink.write_inline(style, text);
175            return Ok(());
176        }
177        if self.color {
178            write!(self.writer, "{style}{}{Reset}", text)?;
179        } else {
180            write!(self.writer, "{}", text)?;
181        }
182        self.writer.flush()?;
183        Ok(())
184    }
185
186    /// Write a line with an explicit style
187    pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
188        if let Some(sink) = &mut self.sink {
189            sink.write_multiline(style, "", text)?;
190            return Ok(());
191        }
192        if self.color {
193            writeln!(self.writer, "{style}{}{Reset}", text)?;
194        } else {
195            writeln!(self.writer, "{}", text)?;
196        }
197        self.writer.flush()?;
198        transcript::append(text);
199        Ok(())
200    }
201
202    /// Write an empty line only if the previous line was not empty
203    pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
204        if !self.was_previous_line_empty() {
205            self.line(style, "")
206        } else {
207            Ok(())
208        }
209    }
210
211    /// Write a raw line without styling
212    pub fn raw_line(&mut self, text: &str) -> Result<()> {
213        writeln!(self.writer, "{}", text)?;
214        self.writer.flush()?;
215        transcript::append(text);
216        Ok(())
217    }
218
219    fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
220        let styles = theme::active_styles();
221        let base_style = style.style();
222        let indent = style.indent();
223        let highlight_cfg = if self.highlight_config.enabled {
224            Some(&self.highlight_config)
225        } else {
226            None
227        };
228        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
229        if lines.is_empty() {
230            lines.push(MarkdownLine::default());
231        }
232        for line in lines {
233            self.write_markdown_line(style, indent, line)?;
234        }
235        Ok(())
236    }
237
238    pub fn stream_markdown_response(
239        &mut self,
240        text: &str,
241        previous_line_count: usize,
242    ) -> Result<usize> {
243        let styles = theme::active_styles();
244        let style = MessageStyle::Response;
245        let base_style = style.style();
246        let indent = style.indent();
247        let highlight_cfg = if self.highlight_config.enabled {
248            Some(&self.highlight_config)
249        } else {
250            None
251        };
252        let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
253        if lines.is_empty() {
254            lines.push(MarkdownLine::default());
255        }
256
257        if let Some(sink) = &mut self.sink {
258            let mut plain_lines = Vec::with_capacity(lines.len());
259            let mut prepared = Vec::with_capacity(lines.len());
260            for mut line in lines {
261                if !indent.is_empty() && !line.segments.is_empty() {
262                    line.segments
263                        .insert(0, MarkdownSegment::new(base_style, indent));
264                }
265                plain_lines.push(
266                    line.segments
267                        .iter()
268                        .map(|segment| segment.text.clone())
269                        .collect::<String>(),
270                );
271                prepared.push(line.segments);
272            }
273            sink.replace_lines(previous_line_count, &prepared, &plain_lines);
274            self.last_line_was_empty = prepared
275                .last()
276                .map(|segments| segments.is_empty())
277                .unwrap_or(true);
278            return Ok(prepared.len());
279        }
280
281        Err(anyhow!("stream_markdown_response requires an iocraft sink"))
282    }
283
284    fn write_markdown_line(
285        &mut self,
286        style: MessageStyle,
287        indent: &str,
288        mut line: MarkdownLine,
289    ) -> Result<()> {
290        if !indent.is_empty() && !line.segments.is_empty() {
291            line.segments
292                .insert(0, MarkdownSegment::new(style.style(), indent));
293        }
294
295        if let Some(sink) = &mut self.sink {
296            sink.write_segments(&line.segments)?;
297            self.last_line_was_empty = line.is_empty();
298            return Ok(());
299        }
300
301        let mut plain = String::new();
302        if self.color {
303            for segment in &line.segments {
304                write!(
305                    self.writer,
306                    "{style}{}{Reset}",
307                    segment.text,
308                    style = segment.style
309                )?;
310                plain.push_str(&segment.text);
311            }
312            writeln!(self.writer)?;
313        } else {
314            for segment in &line.segments {
315                write!(self.writer, "{}", segment.text)?;
316                plain.push_str(&segment.text);
317            }
318            writeln!(self.writer)?;
319        }
320        self.writer.flush()?;
321        transcript::append(&plain);
322        self.last_line_was_empty = plain.trim().is_empty();
323        Ok(())
324    }
325}
326
327struct IocraftSink {
328    handle: IocraftHandle,
329}
330
331impl IocraftSink {
332    fn new(handle: IocraftHandle) -> Self {
333        Self { handle }
334    }
335
336    fn style_to_segment(&self, style: Style, text: &str) -> IocraftSegment {
337        let mut text_style = convert_to_iocraft_style(style);
338        if text_style.color.is_none() {
339            let theme = theme_from_styles(&theme::active_styles());
340            text_style = text_style.merge_color(theme.foreground);
341        }
342        IocraftSegment {
343            text: text.to_string(),
344            style: text_style,
345        }
346    }
347
348    fn write_multiline(&mut self, style: Style, indent: &str, text: &str) -> Result<()> {
349        if text.is_empty() {
350            self.handle.append_line(Vec::new());
351            crate::utils::transcript::append("");
352            return Ok(());
353        }
354
355        let mut lines = text.split('\n').peekable();
356        let ends_with_newline = text.ends_with('\n');
357
358        while let Some(line) = lines.next() {
359            let mut content = String::new();
360            if !indent.is_empty() && !line.is_empty() {
361                content.push_str(indent);
362            }
363            content.push_str(line);
364            if content.is_empty() {
365                self.handle.append_line(Vec::new());
366                crate::utils::transcript::append("");
367            } else {
368                let segment = self.style_to_segment(style, &content);
369                self.handle.append_line(vec![segment]);
370                crate::utils::transcript::append(&content);
371            }
372        }
373
374        if ends_with_newline {
375            self.handle.append_line(Vec::new());
376            crate::utils::transcript::append("");
377        }
378
379        Ok(())
380    }
381
382    fn write_line(&mut self, style: Style, indent: &str, text: &str) -> Result<()> {
383        if text.is_empty() {
384            self.handle.append_line(Vec::new());
385            crate::utils::transcript::append("");
386            return Ok(());
387        }
388        let mut content = String::new();
389        if !indent.is_empty() {
390            content.push_str(indent);
391        }
392        content.push_str(text);
393        let segment = self.style_to_segment(style, &content);
394        self.handle.append_line(vec![segment]);
395        crate::utils::transcript::append(&content);
396        Ok(())
397    }
398
399    fn write_inline(&mut self, style: Style, text: &str) {
400        if text.is_empty() {
401            return;
402        }
403        let segment = self.style_to_segment(style, text);
404        self.handle.inline(segment);
405    }
406
407    fn write_segments(&mut self, segments: &[MarkdownSegment]) -> Result<()> {
408        let converted = self.convert_segments(segments);
409        let plain = segments
410            .iter()
411            .map(|segment| segment.text.clone())
412            .collect::<String>();
413        self.handle.append_line(converted);
414        crate::utils::transcript::append(&plain);
415        Ok(())
416    }
417
418    fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<IocraftSegment> {
419        if segments.is_empty() {
420            return Vec::new();
421        }
422
423        let mut converted = Vec::with_capacity(segments.len());
424        for segment in segments {
425            if segment.text.is_empty() {
426                continue;
427            }
428            converted.push(self.style_to_segment(segment.style, &segment.text));
429        }
430        converted
431    }
432
433    fn replace_lines(&mut self, count: usize, lines: &[Vec<MarkdownSegment>], plain: &[String]) {
434        let mut converted = Vec::with_capacity(lines.len());
435        for segments in lines {
436            converted.push(self.convert_segments(segments));
437        }
438        self.handle.replace_last(count, converted);
439        crate::utils::transcript::replace_last(count, plain);
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_styles_construct() {
449        let info = MessageStyle::Info.style();
450        assert_eq!(info, MessageStyle::Info.style());
451        let resp = MessageStyle::Response.style();
452        assert_eq!(resp, MessageStyle::Response.style());
453        let tool = MessageStyle::Tool.style();
454        assert_eq!(tool, MessageStyle::Tool.style());
455        let reasoning = MessageStyle::Reasoning.style();
456        assert_eq!(reasoning, MessageStyle::Reasoning.style());
457    }
458
459    #[test]
460    fn test_renderer_buffer() {
461        let mut r = AnsiRenderer::stdout();
462        r.push("hello");
463        assert_eq!(r.buffer, "hello");
464    }
465}