Skip to main content

vtcode_ui/tui/ui/markdown/
mod.rs

1//! Markdown rendering utilities for terminal output with syntax highlighting support.
2
3mod code_blocks;
4mod links;
5mod parsing;
6mod tables;
7
8use crate::tui::config::loader::SyntaxHighlightingConfig;
9use crate::tui::ui::theme::{self, ThemeStyles};
10use anstyle::Style;
11use code_blocks::{CodeBlockRenderEnv, CodeBlockState, finalize_unclosed_code_block, handle_code_block_event};
12use parsing::{
13    LinkState, ListState, MarkdownContext, append_text, handle_end_tag, handle_start_tag, inline_code_style,
14    push_blank_line, trim_trailing_blank_lines,
15};
16use pulldown_cmark::{Event, Options, Parser};
17use tables::TableBuffer;
18use unicode_width::UnicodeWidthStr;
19
20pub(crate) use code_blocks::render_diff_content_segments;
21pub use code_blocks::{
22    HighlightedSegment, highlight_code_to_ansi, highlight_code_to_segments, highlight_line_for_diff,
23};
24
25pub(crate) const LIST_INDENT_WIDTH: usize = 2;
26pub(crate) const CODE_LINE_NUMBER_MIN_WIDTH: usize = 3;
27
28/// A styled text segment.
29///
30/// Keep field-compatible with the headless `vtcode_commons::ui_protocol::MarkdownSegment`
31/// (this TUI variant is used when the `tui` feature is on); both are serialized into
32/// the same inline-stream shapes.
33#[derive(Clone, Debug)]
34pub struct MarkdownSegment {
35    pub style: Style,
36    pub text: String,
37    pub link_target: Option<Box<str>>,
38}
39
40impl MarkdownSegment {
41    fn new(style: Style, text: impl Into<String>) -> Self {
42        Self { style, text: text.into(), link_target: None }
43    }
44
45    fn with_link(style: Style, text: impl Into<String>, link_target: Option<impl Into<Box<str>>>) -> Self {
46        Self {
47            style,
48            text: text.into(),
49            link_target: link_target.map(Into::into),
50        }
51    }
52}
53
54/// A rendered line composed of styled segments.
55#[derive(Clone, Debug, Default)]
56pub struct MarkdownLine {
57    pub segments: Vec<MarkdownSegment>,
58    pub line_background: Option<anstyle::Color>,
59}
60
61impl MarkdownLine {
62    fn set_line_background(&mut self, color: Option<anstyle::Color>) {
63        self.line_background = color;
64    }
65
66    fn push_segment(&mut self, style: Style, text: &str) {
67        self.push_segment_with_link(style, text, None::<String>);
68    }
69
70    fn push_segment_with_link(&mut self, style: Style, text: &str, link_target: Option<impl Into<Box<str>>>) {
71        let link_target = link_target.map(Into::into);
72        if text.is_empty() {
73            return;
74        }
75        if let Some(last) = self.segments.last_mut()
76            && last.style == style
77            && last.link_target == link_target
78        {
79            last.text.push_str(text);
80            return;
81        }
82        self.segments.push(MarkdownSegment::with_link(style, text, link_target));
83    }
84
85    pub fn is_empty(&self) -> bool {
86        self.segments.iter().all(|segment| segment.text.trim().is_empty())
87    }
88
89    fn width(&self) -> usize {
90        self.segments.iter().map(|seg| UnicodeWidthStr::width(seg.text.as_str())).sum()
91    }
92}
93
94#[derive(Debug, Clone, Copy, Default)]
95pub struct RenderMarkdownOptions {
96    pub preserve_code_indentation: bool,
97    pub disable_code_block_table_reparse: bool,
98    /// Available content width for tables. Headered tables keep a padded,
99    /// width-aware grid while cells remain readable, then fall back to aligned
100    /// labeled records when the grid becomes too cramped. Headerless tables
101    /// retain their grid layout and scale columns to fit.
102    pub table_max_width: Option<usize>,
103}
104
105/// Render markdown text to styled lines that can be written to the terminal renderer.
106fn render_markdown_to_lines(
107    source: &str,
108    base_style: Style,
109    theme_styles: &ThemeStyles,
110    highlight_config: Option<&SyntaxHighlightingConfig>,
111) -> Vec<MarkdownLine> {
112    render_markdown_to_lines_with_options(
113        source,
114        base_style,
115        theme_styles,
116        highlight_config,
117        RenderMarkdownOptions::default(),
118    )
119}
120
121pub fn render_markdown_to_lines_with_options(
122    source: &str,
123    base_style: Style,
124    theme_styles: &ThemeStyles,
125    highlight_config: Option<&SyntaxHighlightingConfig>,
126    render_options: RenderMarkdownOptions,
127) -> Vec<MarkdownLine> {
128    // Plan wrappers are control markup rather than user-visible prose. Remove
129    // them only outside fenced code blocks so ordinary markdown and code
130    // examples remain lossless.
131    let preprocessed = preprocess_plan_wrappers(source);
132    let parser_options =
133        Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_FOOTNOTES;
134
135    let parser = Parser::new_ext(&preprocessed, parser_options);
136
137    // Output lines track the source roughly 1:1, so size from the source line
138    // count to avoid reallocations during per-message rendering.
139    let mut lines = Vec::with_capacity(source.lines().count());
140    let mut current_line = MarkdownLine::default();
141    let mut style_stack = vec![base_style];
142    let mut blockquote_depth = 0usize;
143    // List nesting is shallow in practice; bound the stack up front.
144    let mut list_stack: Vec<ListState> = Vec::with_capacity(4);
145    let mut list_continuation_prefix = String::new();
146    let mut pending_list_prefix: Option<String> = None;
147    let mut code_block: Option<CodeBlockState> = None;
148    let mut active_table: Option<TableBuffer> = None;
149    let mut link_state: Option<LinkState> = None;
150
151    for event in parser {
152        let mut code_block_env = code_block_render_env(
153            &mut lines,
154            &mut current_line,
155            blockquote_depth,
156            &list_continuation_prefix,
157            &mut pending_list_prefix,
158            base_style,
159            theme_styles,
160            highlight_config,
161            render_options,
162        );
163        if handle_code_block_event(&event, &mut code_block, &mut code_block_env) {
164            continue;
165        }
166
167        let mut ctx = MarkdownContext {
168            style_stack: &mut style_stack,
169            blockquote_depth: &mut blockquote_depth,
170            list_stack: &mut list_stack,
171            pending_list_prefix: &mut pending_list_prefix,
172            list_continuation_prefix: &mut list_continuation_prefix,
173            lines: &mut lines,
174            current_line: &mut current_line,
175            theme_styles,
176            base_style,
177            code_block: &mut code_block,
178            active_table: &mut active_table,
179            link_state: &mut link_state,
180            table_max_width: render_options.table_max_width,
181        };
182
183        match event {
184            Event::Start(ref tag) => handle_start_tag(tag, &mut ctx),
185            Event::End(tag) => handle_end_tag(tag, &mut ctx),
186            Event::Text(text) => append_text(&text, &mut ctx),
187            Event::Code(code) => {
188                ctx.ensure_prefix();
189                ctx.current_line.push_segment_with_link(
190                    inline_code_style(theme_styles, base_style),
191                    &code,
192                    ctx.active_link_target(),
193                );
194            }
195            Event::SoftBreak | Event::HardBreak => ctx.flush_line(),
196            Event::Rule => {
197                ctx.flush_line();
198                let mut line = MarkdownLine::default();
199                line.push_segment(base_style.dimmed(), &"―".repeat(32));
200                ctx.lines.push(line);
201                push_blank_line(ctx.lines);
202            }
203            Event::TaskListMarker(checked) => {
204                ctx.ensure_prefix();
205                ctx.current_line.push_segment(base_style, if checked { "[x] " } else { "[ ] " });
206            }
207            Event::Html(html) | Event::InlineHtml(html) => {
208                // Keep the event-level guard for split or oddly-cased tags;
209                // other HTML is preserved as text.
210                if !is_plan_markup_html(&html) {
211                    append_text(&html, &mut ctx);
212                }
213            }
214            Event::FootnoteReference(r) => append_text(&format!("[^{r}]"), &mut ctx),
215            Event::InlineMath(m) => append_text(&format!("${m}$"), &mut ctx),
216            Event::DisplayMath(m) => append_text(&format!("$$\n{m}\n$$"), &mut ctx),
217        }
218    }
219
220    let mut code_block_env = code_block_render_env(
221        &mut lines,
222        &mut current_line,
223        blockquote_depth,
224        &list_continuation_prefix,
225        &mut pending_list_prefix,
226        base_style,
227        theme_styles,
228        highlight_config,
229        render_options,
230    );
231    finalize_unclosed_code_block(&mut code_block, &mut code_block_env);
232
233    if !current_line.segments.is_empty() {
234        lines.push(current_line);
235    }
236
237    trim_trailing_blank_lines(&mut lines);
238    lines
239}
240
241/// Convenience helper that renders markdown using the active theme without emitting output.
242pub(crate) fn render_markdown(source: &str) -> Vec<MarkdownLine> {
243    let styles = theme::active_styles();
244    render_markdown_to_lines(source, Style::default(), &styles, None)
245}
246
247fn code_block_render_env<'a>(
248    lines: &'a mut Vec<MarkdownLine>,
249    current_line: &'a mut MarkdownLine,
250    blockquote_depth: usize,
251    list_continuation_prefix: &'a str,
252    pending_list_prefix: &'a mut Option<String>,
253    base_style: Style,
254    theme_styles: &'a ThemeStyles,
255    highlight_config: Option<&'a SyntaxHighlightingConfig>,
256    render_options: RenderMarkdownOptions,
257) -> CodeBlockRenderEnv<'a> {
258    CodeBlockRenderEnv {
259        lines,
260        current_line,
261        blockquote_depth,
262        list_continuation_prefix,
263        pending_list_prefix,
264        base_style,
265        theme_styles,
266        highlight_config,
267        render_options,
268    }
269}
270
271/// Plan wrappers that must never appear literally in rendered output.
272const PLAN_MARKUP_TAGS: &[&str] = &["<proposed_plan>", "</proposed_plan>", "<plan>", "</plan>"];
273
274fn is_plan_markup_html(html: &str) -> bool {
275    let normalized: String = html.chars().filter(|ch| !ch.is_whitespace()).collect();
276    let lowered = normalized.to_ascii_lowercase();
277    PLAN_MARKUP_TAGS.iter().any(|tag| lowered.contains(tag))
278}
279
280fn strip_plan_markup_tags(text: &str, inline_code_ticks: &mut Option<usize>) -> String {
281    let mut out = String::with_capacity(text.len());
282    let mut cursor = 0;
283
284    while cursor < text.len() {
285        let remainder = &text[cursor..];
286        if remainder.starts_with('`') {
287            let run_length = remainder.bytes().take_while(|byte| *byte == b'`').count();
288            out.push_str(&remainder[..run_length]);
289            if inline_code_ticks.is_some_and(|ticks| ticks == run_length) {
290                *inline_code_ticks = None;
291            } else if inline_code_ticks.is_none() {
292                *inline_code_ticks = Some(run_length);
293            }
294            cursor += run_length;
295            continue;
296        }
297
298        if inline_code_ticks.is_none()
299            && let Some(tag) = PLAN_MARKUP_TAGS.iter().find(|tag| {
300                remainder
301                    .get(..tag.len())
302                    .is_some_and(|prefix| prefix.eq_ignore_ascii_case(tag))
303            })
304        {
305            cursor += tag.len();
306            continue;
307        }
308
309        let character = remainder.chars().next().expect("cursor is on a character boundary");
310        out.push(character);
311        cursor += character.len_utf8();
312    }
313
314    out
315}
316
317fn preprocess_plan_wrappers(source: &str) -> String {
318    let mut out = String::with_capacity(source.len());
319    let mut may_have_plan_tags = None;
320    let mut in_fenced_code = false;
321    let mut inline_code_ticks = None;
322
323    for (index, line) in source.lines().enumerate() {
324        if index > 0 {
325            out.push('\n');
326        }
327
328        let is_fence = is_fence_delimiter(line);
329        if in_fenced_code || is_fence || !*may_have_plan_tags.get_or_insert_with(|| source.contains('<')) {
330            out.push_str(line);
331        } else {
332            out.push_str(&strip_plan_markup_tags(line, &mut inline_code_ticks));
333        }
334
335        if is_fence {
336            in_fenced_code = !in_fenced_code;
337            inline_code_ticks = None;
338        }
339    }
340
341    if source.ends_with('\n') && !out.ends_with('\n') {
342        out.push('\n');
343    }
344    out
345}
346
347fn is_fence_delimiter(line: &str) -> bool {
348    vtcode_commons::formatting::is_markdown_fence_delimiter(line)
349}
350
351#[cfg(test)]
352mod tests;