Skip to main content

ui/components/ai/
agent_markdown.rs

1use std::ops::Range;
2
3use gpui::{AnyElement, FontWeight, HighlightStyle, SharedString, StyledText, UnderlineStyle};
4
5use crate::CodeEditor;
6use crate::prelude::*;
7
8/// Renders a single agent chat message body as a minimal Markdown subset.
9///
10/// Supported blocks: headings (`#` .. `######`), unordered lists (`- ` / `* `),
11/// ordered lists (`1. `), fenced code blocks (```` ```lang ```` .. ```` ``` ````)
12/// and paragraphs. Supported inline spans (within paragraphs/list items/
13/// headings): `**bold**`, `` `inline code` `` and `[text](url)`.
14///
15/// Non-goals (intentionally unsupported): tables, blockquotes, nested lists,
16/// images, raw HTML passthrough. Links are display-only — the URL is
17/// discarded after parsing and never auto-navigated or executed.
18#[derive(IntoElement, RegisterComponent)]
19pub struct AgentMarkdown {
20    id: ElementId,
21    text: SharedString,
22}
23
24impl AgentMarkdown {
25    pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
26        Self {
27            id: id.into(),
28            text: text.into(),
29        }
30    }
31}
32
33impl RenderOnce for AgentMarkdown {
34    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
35        let blocks = parse_blocks(&self.text);
36        v_flex()
37            .id(self.id)
38            .gap_2()
39            .children(blocks.into_iter().map(|block| render_block(block, cx)))
40    }
41}
42
43// ---------------------------------------------------------------------
44// Block-level parsing
45// ---------------------------------------------------------------------
46
47#[derive(Debug, Clone, PartialEq)]
48enum Block {
49    Heading { level: u8, text: String },
50    UnorderedList(Vec<String>),
51    OrderedList(Vec<String>),
52    CodeFence { lang: Option<String>, code: String },
53    Paragraph(String),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57enum ListKind {
58    Unordered,
59    Ordered,
60}
61
62fn heading_level(line: &str) -> Option<u8> {
63    let hashes = line.chars().take_while(|&c| c == '#').count();
64    if hashes >= 1 && hashes <= 6 && line.as_bytes().get(hashes) == Some(&b' ') {
65        Some(hashes as u8)
66    } else {
67        None
68    }
69}
70
71fn ordered_list_item(line: &str) -> Option<String> {
72    let dot = line.find(". ")?;
73    let (num, rest) = line.split_at(dot);
74    if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
75        Some(rest[2..].to_string())
76    } else {
77        None
78    }
79}
80
81fn parse_blocks(input: &str) -> Vec<Block> {
82    let mut blocks = Vec::new();
83    let mut paragraph_buf: Vec<&str> = Vec::new();
84    let mut list_buf: Vec<String> = Vec::new();
85    let mut list_kind: Option<ListKind> = None;
86
87    let flush_paragraph = |blocks: &mut Vec<Block>, buf: &mut Vec<&str>| {
88        if !buf.is_empty() {
89            blocks.push(Block::Paragraph(buf.join(" ")));
90            buf.clear();
91        }
92    };
93    let flush_list =
94        |blocks: &mut Vec<Block>, kind: &mut Option<ListKind>, buf: &mut Vec<String>| {
95            if let Some(kind) = kind.take() {
96                let items = std::mem::take(buf);
97                blocks.push(match kind {
98                    ListKind::Unordered => Block::UnorderedList(items),
99                    ListKind::Ordered => Block::OrderedList(items),
100                });
101            }
102        };
103
104    let mut lines = input.lines().peekable();
105    while let Some(line) = lines.next() {
106        let trimmed = line.trim_start();
107
108        if let Some(fence_lang) = trimmed.strip_prefix("```") {
109            flush_paragraph(&mut blocks, &mut paragraph_buf);
110            flush_list(&mut blocks, &mut list_kind, &mut list_buf);
111            let lang = (!fence_lang.trim().is_empty()).then(|| fence_lang.trim().to_string());
112            let mut code_lines = Vec::new();
113            for code_line in lines.by_ref() {
114                if code_line.trim_start().starts_with("```") {
115                    break;
116                }
117                code_lines.push(code_line);
118            }
119            blocks.push(Block::CodeFence {
120                lang,
121                code: code_lines.join("\n"),
122            });
123            continue;
124        }
125
126        if trimmed.is_empty() {
127            flush_paragraph(&mut blocks, &mut paragraph_buf);
128            flush_list(&mut blocks, &mut list_kind, &mut list_buf);
129            continue;
130        }
131
132        if let Some(level) = heading_level(trimmed) {
133            flush_paragraph(&mut blocks, &mut paragraph_buf);
134            flush_list(&mut blocks, &mut list_kind, &mut list_buf);
135            let text = trimmed[level as usize + 1..].trim().to_string();
136            blocks.push(Block::Heading { level, text });
137            continue;
138        }
139
140        if let Some(item) = trimmed
141            .strip_prefix("- ")
142            .or_else(|| trimmed.strip_prefix("* "))
143        {
144            flush_paragraph(&mut blocks, &mut paragraph_buf);
145            if list_kind != Some(ListKind::Unordered) {
146                flush_list(&mut blocks, &mut list_kind, &mut list_buf);
147                list_kind = Some(ListKind::Unordered);
148            }
149            list_buf.push(item.to_string());
150            continue;
151        }
152
153        if let Some(item) = ordered_list_item(trimmed) {
154            flush_paragraph(&mut blocks, &mut paragraph_buf);
155            if list_kind != Some(ListKind::Ordered) {
156                flush_list(&mut blocks, &mut list_kind, &mut list_buf);
157                list_kind = Some(ListKind::Ordered);
158            }
159            list_buf.push(item);
160            continue;
161        }
162
163        flush_list(&mut blocks, &mut list_kind, &mut list_buf);
164        paragraph_buf.push(trimmed);
165    }
166
167    flush_paragraph(&mut blocks, &mut paragraph_buf);
168    flush_list(&mut blocks, &mut list_kind, &mut list_buf);
169    blocks
170}
171
172// ---------------------------------------------------------------------
173// Inline-level parsing
174// ---------------------------------------------------------------------
175
176#[derive(Debug, Clone, PartialEq)]
177enum InlineStyle {
178    Bold,
179    Code,
180    Link,
181}
182
183/// Strips `**bold**`, `` `code` `` and `[text](url)` markup out of `text`,
184/// returning the display string plus the byte ranges (in that display
185/// string) that need styling. The URL of a link is intentionally not
186/// returned — links are rendered display-only, never navigated.
187fn parse_inline(text: &str) -> (String, Vec<(Range<usize>, InlineStyle)>) {
188    let mut output = String::with_capacity(text.len());
189    let mut spans = Vec::new();
190    let len = text.len();
191    let mut i = 0;
192
193    while i < len {
194        if text[i..].starts_with("**") {
195            if let Some(end) = text[i + 2..].find("**") {
196                let inner = &text[i + 2..i + 2 + end];
197                let start = output.len();
198                output.push_str(inner);
199                spans.push((start..output.len(), InlineStyle::Bold));
200                i += 2 + end + 2;
201                continue;
202            }
203        }
204
205        if text.as_bytes()[i] == b'`' {
206            if let Some(end) = text[i + 1..].find('`') {
207                let inner = &text[i + 1..i + 1 + end];
208                let start = output.len();
209                output.push_str(inner);
210                spans.push((start..output.len(), InlineStyle::Code));
211                i += 1 + end + 1;
212                continue;
213            }
214        }
215
216        if text.as_bytes()[i] == b'[' {
217            if let Some(bracket_rel) = text[i..].find(']') {
218                let bracket_end = i + bracket_rel;
219                if text.as_bytes().get(bracket_end + 1) == Some(&b'(') {
220                    if let Some(paren_rel) = text[bracket_end + 2..].find(')') {
221                        let paren_end = bracket_end + 2 + paren_rel;
222                        let link_text = &text[i + 1..bracket_end];
223                        let start = output.len();
224                        output.push_str(link_text);
225                        spans.push((start..output.len(), InlineStyle::Link));
226                        i = paren_end + 1;
227                        continue;
228                    }
229                }
230            }
231        }
232
233        let ch = text[i..].chars().next().expect("i < len implies a char");
234        output.push(ch);
235        i += ch.len_utf8();
236    }
237
238    (output, spans)
239}
240
241/// Builds a single `StyledText` from a run of Markdown inline text — one
242/// element per paragraph/list item/heading, never one element per character.
243fn render_inline_text(text: &str, cx: &App) -> StyledText {
244    let (stripped, spans) = parse_inline(text);
245    if spans.is_empty() {
246        return StyledText::new(stripped);
247    }
248
249    let buffer_font_family = theme::theme_settings(cx).buffer_font(cx).family.clone();
250    let code_bg = cx.theme().colors().element_background;
251    let link_color = Color::Accent.color(cx);
252
253    let highlights: Vec<(Range<usize>, HighlightStyle)> = spans
254        .iter()
255        .map(|(range, style)| {
256            let highlight = match style {
257                InlineStyle::Bold => HighlightStyle {
258                    font_weight: Some(FontWeight::BOLD),
259                    ..Default::default()
260                },
261                InlineStyle::Code => HighlightStyle {
262                    background_color: Some(code_bg),
263                    ..Default::default()
264                },
265                InlineStyle::Link => HighlightStyle {
266                    color: Some(link_color),
267                    underline: Some(UnderlineStyle::default()),
268                    ..Default::default()
269                },
270            };
271            (range.clone(), highlight)
272        })
273        .collect();
274
275    let font_overrides: Vec<(Range<usize>, SharedString)> = spans
276        .iter()
277        .filter(|(_, style)| *style == InlineStyle::Code)
278        .map(|(range, _)| (range.clone(), buffer_font_family.clone()))
279        .collect();
280
281    StyledText::new(stripped)
282        .with_highlights(highlights)
283        .with_font_family_overrides(font_overrides)
284}
285
286// ---------------------------------------------------------------------
287// Rendering
288// ---------------------------------------------------------------------
289
290/// Maps a fenced code block's language tag to a `CodeEditor` extension.
291/// Unrecognized/absent tags fall back to plain (unhighlighted) rendering.
292fn extension_for_fence_lang(lang: &str) -> Option<&'static str> {
293    match lang.to_ascii_lowercase().as_str() {
294        "rust" | "rs" => Some("rs"),
295        "javascript" | "js" | "jsx" => Some("js"),
296        "typescript" | "ts" | "tsx" => Some("ts"),
297        "json" => Some("json"),
298        "markdown" | "md" => Some("md"),
299        _ => None,
300    }
301}
302
303fn render_block(block: Block, cx: &mut App) -> AnyElement {
304    match block {
305        Block::Heading { level, text } => {
306            let heading = div()
307                .font_weight(FontWeight::BOLD)
308                .child(render_inline_text(&text, cx));
309            match level {
310                1 => heading.text_xl(),
311                2 => heading.text_lg(),
312                _ => heading,
313            }
314            .into_any_element()
315        }
316        Block::UnorderedList(items) => v_flex()
317            .gap_0p5()
318            .children(items.into_iter().map(|item| {
319                h_flex()
320                    .gap_1()
321                    .items_start()
322                    .child(Label::new("•").color(Color::Muted))
323                    .child(div().child(render_inline_text(&item, cx)))
324            }))
325            .into_any_element(),
326        Block::OrderedList(items) => v_flex()
327            .gap_0p5()
328            .children(items.into_iter().enumerate().map(|(index, item)| {
329                h_flex()
330                    .gap_1()
331                    .items_start()
332                    .child(Label::new(format!("{}.", index + 1)).color(Color::Muted))
333                    .child(div().child(render_inline_text(&item, cx)))
334            }))
335            .into_any_element(),
336        Block::CodeFence { lang, code } => {
337            let extension = lang.as_deref().and_then(extension_for_fence_lang);
338            cx.new(|cx| {
339                let mut editor = CodeEditor::new(cx);
340                if let Some(extension) = extension {
341                    editor = editor.language(extension);
342                }
343                editor.set_text(code, cx);
344                editor.read_only(cx, true)
345            })
346            .into_any_element()
347        }
348        Block::Paragraph(text) => div()
349            .child(render_inline_text(&text, cx))
350            .into_any_element(),
351    }
352}
353
354impl Component for AgentMarkdown {
355    fn scope() -> ComponentScope {
356        ComponentScope::Agent
357    }
358
359    fn description() -> Option<&'static str> {
360        Some(
361            "Renders a single agent chat message as a minimal Markdown subset: \
362             headings, lists, fenced code blocks and paragraphs with bold/inline \
363             code/link inline spans.",
364        )
365    }
366
367    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
368        let container = || v_flex().w_96().gap_4();
369
370        Some(
371            container()
372                .child(single_example(
373                    "Full document",
374                    AgentMarkdown::new(
375                        "am-preview-1",
376                        "# Heading One\n\n\
377                         Some **bold** text with `inline code` and a [link](https://example.com).\n\n\
378                         - First item\n\
379                         - Second **bold** item\n\
380                         - Third item with `code`\n\n\
381                         1. Step one\n\
382                         2. Step two\n\n\
383                         ```rust\nfn main() {\n    println!(\"hi\");\n}\n```",
384                    )
385                    .into_any_element(),
386                ))
387                .into_any_element(),
388        )
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn heading_levels_parsed() {
398        let blocks = parse_blocks("# H1\n\n## H2\n\n###### H6");
399        assert_eq!(
400            blocks,
401            vec![
402                Block::Heading {
403                    level: 1,
404                    text: "H1".into()
405                },
406                Block::Heading {
407                    level: 2,
408                    text: "H2".into()
409                },
410                Block::Heading {
411                    level: 6,
412                    text: "H6".into()
413                },
414            ]
415        );
416    }
417
418    #[test]
419    fn hash_without_space_is_paragraph() {
420        let blocks = parse_blocks("#nospace");
421        assert_eq!(blocks, vec![Block::Paragraph("#nospace".into())]);
422    }
423
424    #[test]
425    fn unordered_list_grouped() {
426        let blocks = parse_blocks("- one\n- two\n* three");
427        assert_eq!(
428            blocks,
429            vec![Block::UnorderedList(vec![
430                "one".into(),
431                "two".into(),
432                "three".into(),
433            ])]
434        );
435    }
436
437    #[test]
438    fn ordered_list_grouped() {
439        let blocks = parse_blocks("1. one\n2. two\n10. ten");
440        assert_eq!(
441            blocks,
442            vec![Block::OrderedList(vec![
443                "one".into(),
444                "two".into(),
445                "ten".into(),
446            ])]
447        );
448    }
449
450    #[test]
451    fn fenced_code_with_lang() {
452        let blocks = parse_blocks("```rust\nfn main() {}\n```");
453        assert_eq!(
454            blocks,
455            vec![Block::CodeFence {
456                lang: Some("rust".into()),
457                code: "fn main() {}".into(),
458            }]
459        );
460    }
461
462    #[test]
463    fn fenced_code_without_lang() {
464        let blocks = parse_blocks("```\nplain\ntext\n```");
465        assert_eq!(
466            blocks,
467            vec![Block::CodeFence {
468                lang: None,
469                code: "plain\ntext".into(),
470            }]
471        );
472    }
473
474    #[test]
475    fn paragraph_lines_joined() {
476        let blocks = parse_blocks("line one\nline two\n\nnew paragraph");
477        assert_eq!(
478            blocks,
479            vec![
480                Block::Paragraph("line one line two".into()),
481                Block::Paragraph("new paragraph".into()),
482            ]
483        );
484    }
485
486    #[test]
487    fn mixed_blocks_in_order() {
488        let blocks =
489            parse_blocks("# Title\n\nIntro text.\n\n- item a\n- item b\n\n```js\nlet x = 1;\n```");
490        assert_eq!(
491            blocks,
492            vec![
493                Block::Heading {
494                    level: 1,
495                    text: "Title".into()
496                },
497                Block::Paragraph("Intro text.".into()),
498                Block::UnorderedList(vec!["item a".into(), "item b".into()]),
499                Block::CodeFence {
500                    lang: Some("js".into()),
501                    code: "let x = 1;".into(),
502                },
503            ]
504        );
505    }
506
507    #[test]
508    fn inline_bold() {
509        let (text, spans) = parse_inline("say **hello** now");
510        assert_eq!(text, "say hello now");
511        assert_eq!(spans, vec![(4..9, InlineStyle::Bold)]);
512    }
513
514    #[test]
515    fn inline_code() {
516        let (text, spans) = parse_inline("run `cargo test` please");
517        assert_eq!(text, "run cargo test please");
518        assert_eq!(spans, vec![(4..14, InlineStyle::Code)]);
519    }
520
521    #[test]
522    fn inline_link() {
523        let (text, spans) = parse_inline("see [docs](https://example.com) now");
524        assert_eq!(text, "see docs now");
525        assert_eq!(spans, vec![(4..8, InlineStyle::Link)]);
526    }
527
528    #[test]
529    fn inline_mixed() {
530        let (text, spans) = parse_inline("**bold** and `code` and [link](url)");
531        assert_eq!(text, "bold and code and link");
532        assert_eq!(
533            spans,
534            vec![
535                (0..4, InlineStyle::Bold),
536                (9..13, InlineStyle::Code),
537                (18..22, InlineStyle::Link),
538            ]
539        );
540    }
541
542    #[test]
543    fn inline_plain_text_no_spans() {
544        let (text, spans) = parse_inline("just plain text");
545        assert_eq!(text, "just plain text");
546        assert!(spans.is_empty());
547    }
548
549    #[test]
550    fn extension_mapping() {
551        assert_eq!(extension_for_fence_lang("rust"), Some("rs"));
552        assert_eq!(extension_for_fence_lang("TypeScript"), Some("ts"));
553        assert_eq!(extension_for_fence_lang("cobol"), None);
554    }
555}