1use std::ops::Range;
2
3use gpui::{AnyElement, FontWeight, HighlightStyle, SharedString, StyledText, UnderlineStyle};
4
5use crate::CodeEditor;
6use crate::prelude::*;
7
8#[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#[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#[derive(Debug, Clone, PartialEq)]
177enum InlineStyle {
178 Bold,
179 Code,
180 Link,
181}
182
183fn 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
241fn 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
286fn 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}