//! Rich blocks in agent answers. Agents write diagrams and small UIs as plain //! fenced code blocks (` ```mermaid `, ` ```openui `, ` ```svg `), so they //! read fine anywhere else; here the Markdown view swaps them for native //! renders. Each keeps a header row with Copy and Show source, so a render //! that goes wrong never hides what the agent wrote. mod mermaid; mod openui; use std::collections::HashMap; use std::sync::Arc; use std::hash::Hasher as _; use gpui_kit::component::{ Icon, IconName, h_flex, message_scroller::MessageScrollerState, text::{MarkdownNode, MarkdownParseContext, TextView, markdown_ast}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::inline_ui::lang::{self, Program}; use crate::session::Session; use crate::ui::icons::Lucide; use crate::ui::workspace::Workspace; use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; /// The one custom node name every rich block parses into. A constant set of /// parsers and names keeps the Markdown view from reparsing every frame. const NODE: &str = "inline-ui"; /// Where a Markdown view sits, so a block's renderer knows which thread it /// belongs to and which transcript row to remeasure when its height changes. #[derive(Clone)] pub struct InlineCx { pub session: WeakEntity, pub ix: usize, pub scroller: WeakEntity, } impl InlineCx { /// Tells the transcript a block changed height, so the virtual list /// doesn't jump on the next frame. fn remeasure(&self, cx: &mut App) { let ix = self.ix; if let Some(scroller) = self.scroller.upgrade() { scroller.update(cx, |scroller, cx| { scroller.remeasure_items(ix..ix + 1, cx); }); } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum Lang { Mermaid, OpenUi, Svg, } impl Lang { fn parse(lang: &str) -> Option { match lang.trim().to_ascii_lowercase().as_str() { "mermaid" => Some(Self::Mermaid), "openui" | "openui-lang" => Some(Self::OpenUi), "svg" => Some(Self::Svg), _ => None, } } fn label(self) -> &'static str { match self { Self::Mermaid => "Diagram", Self::OpenUi => "Interactive", Self::Svg => "Image", } } fn fence(self) -> &'static str { match self { Self::Mermaid => "mermaid", Self::OpenUi => "openui", Self::Svg => "svg", } } } /// A fenced block as the parser hands it to the renderer. Parsing runs on a /// background thread, so this holds only plain data. struct Fence { lang: Lang, code: String, /// Whether the closing fence has arrived. Streaming answers show /// unfinished blocks as skeletons, and never as errors. closed: bool, /// OpenUI's program, parsed here off the main thread. program: Option>, } /// Whether answers render rich blocks at all. pub fn enabled() -> bool { true } /// The workspace, so a block's buttons can open previews in its panels. struct Hub(WeakEntity); impl Global for Hub {} pub fn set_workspace(workspace: WeakEntity, cx: &mut App) { cx.set_global(Hub(workspace)); } /// Opens an HTML block as a page: written to the data folder's /// `artifacts`, then shown in the thread's browser panel, or the system /// browser without one. pub fn preview_html(code: &str, window: &mut Window, cx: &mut App) { let Some(dir) = crate::settings::data_dir().map(|dir| dir.join("artifacts")) else { return }; let path = dir.join(format!("{:016x}.html", hash(code))); if let Err(err) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&path, code)) { eprintln!("vectrum-code: couldn't write the preview {}: {err}", path.display()); return; } let url = format!("file://{}", path.display()); match cx.try_global::().and_then(|hub| hub.0.upgrade()) { Some(workspace) => workspace.update(cx, |workspace, cx| workspace.preview(url, window, cx)), None => crate::ui::workspace::open_url(&url), } } /// Adds the rich block hooks to an answer's Markdown view. pub fn extend(view: TextView, icx: InlineCx) -> TextView { view.markdown_block_parser(parse).markdown_block_renderer(NODE, move |node, window, cx| render(node, &icx, window, cx)) } fn parse(node: &markdown_ast::Node, cx: &MarkdownParseContext) -> Option { let markdown_ast::Node::Code(code) = node else { return None; }; let lang = Lang::parse(code.lang.as_deref()?)?; let source = cx.node_source(node)?; let fence = Fence { lang, code: code.value.clone(), closed: is_closed(source), program: (lang == Lang::OpenUi).then(|| Arc::new(lang::parse(&code.value))), }; Some(MarkdownNode::new(NODE, fence).text(source.to_string()).markdown(source.to_string())) } /// Whether a fence's source ends with a closing fence. An unclosed fence runs /// to the end of the document, which is what a block mid-stream looks like. fn is_closed(source: &str) -> bool { let mut lines = source.trim_end().lines(); let Some(open) = lines.next() else { return false }; let open = open.trim_start(); let Some(marker) = open.chars().next().filter(|c| *c == '`' || *c == '~') else { return false; }; let width = open.chars().take_while(|c| *c == marker).count(); lines.next_back().is_some_and(|close| { let close = close.trim(); close.len() >= width && close.chars().all(|c| c == marker) }) } fn hash(value: impl std::hash::Hash) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); value.hash(&mut hasher); hasher.finish() } /// What blocks remember across frames. Rows of the virtual list drop their /// element state when they scroll away, so this lives in a global. #[derive(Default)] struct Store { blocks: HashMap, } /// A block: its thread, and a hash of where it starts and what it holds. type Key = (EntityId, u64); impl Global for Store {} #[derive(Default)] struct BlockState { show_source: bool, ui: openui::UiState, } /// A rendered block body, or why it couldn't be drawn. enum Body { Ready(AnyElement), Failed(String), } fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App) -> AnyElement { let Some(fence) = node.data::() else { return div().into_any_element(); }; let p = metal::palette(cx); let key = (icx.session.entity_id(), hash((node.source_range().map(|range| range.start), &fence.code))); let id = ElementId::NamedInteger("inline-ui".into(), key.1); let show_source = cx.try_global::().and_then(|store| store.blocks.get(&key)).is_some_and(|block| block.show_source); let body = if show_source { Body::Ready(source_view(id.clone(), fence, p)) } else { match fence.lang { Lang::Mermaid => mermaid::render(id.clone(), fence, icx, window, cx), Lang::OpenUi => openui::render(&id, key, fence, icx, window, cx), Lang::Svg => mermaid::render_svg(id.clone(), fence, icx, window, cx), } }; let error = match &body { Body::Failed(error) => Some(error.clone()), Body::Ready(_) => None, }; let body = match body { Body::Ready(element) => element, Body::Failed(_) => source_view(id.clone(), fence, p), }; v_flex() .w_full() .gap(px(6.)) .child(header(id, fence, key, show_source, error.is_some(), icx, p)) .child(body) .when_some(error, |this, error| { this.child(div().px(px(4.)).text_size(px(12.5)).text_color(p.red).truncate().child(error)) }) .into_any_element() } /// The row over a block: its kind, Show source and Copy. fn header( id: ElementId, fence: &Fence, key: Key, show_source: bool, failed: bool, icx: &InlineCx, p: &Palette, ) -> impl IntoElement { let code = fence.code.clone(); let icx = icx.clone(); h_flex() .w_full() .h(px(22.)) .px(px(4.)) .gap(px(4.)) .child(metal::engraved(fence.lang.label(), p)) .child(div().flex_1()) .when(!failed, |this| { this.child( MetalButton::new(ElementId::Name(format!("{id}-source").into())) .ghost() .mini() .icon(if show_source { Icon::new(IconName::Eye) } else { Icon::new(Lucide::Code) }) .tooltip(if show_source { "Show rendered" } else { "Show source" }) .on_click(move |_, window, cx| { let block = cx.default_global::().blocks.entry(key).or_default(); block.show_source = !block.show_source; icx.remeasure(cx); window.refresh(); }), ) }) .child( MetalButton::new(ElementId::Name(format!("{id}-copy").into())) .ghost() .mini() .icon(IconName::Copy) .tooltip("Copy source") .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.clone()))), ) } /// The block as the plain code it is, in the same well code blocks use. fn source_view(id: ElementId, fence: &Fence, p: &Palette) -> AnyElement { let markdown = format!("```{}\n{}\n```", fence.lang.fence(), fence.code.trim_end_matches('\n')); TextView::markdown(ElementId::Name(format!("{id}-code").into()), markdown) .style(crate::ui::chat::markdown_style(p)) .selectable(true) .into_any_element() } /// Rasterizes SVG at exactly the display's density. GPUI's own /// `render_single_frame` draws at twice that for smoothing, which quadruples /// the memory a large diagram takes for no visible gain at this size. fn rasterize(renderer: &SvgRenderer, svg: &str, scale: f32) -> anyhow::Result<(std::sync::Arc, Size)> { let (width, height) = svg_size(svg).ok_or_else(|| anyhow::anyhow!("the SVG has no size"))?; anyhow::ensure!(width * height * scale * scale <= MAX_PIXELS, "the image is too large"); let parsed = renderer.parse_svg(svg.as_bytes())?; let pixels = size(DevicePixels((width * scale).ceil() as i32), DevicePixels((height * scale).ceil() as i32)); let image = renderer.render_parsed(&parsed, SvgSize::ExactSize(pixels))?; Ok((image, size(px(width), px(height)))) } /// The largest bitmap a block may rasterize to, in device pixels. const MAX_PIXELS: f32 = 6000. * 6000.; /// The width and height an SVG asks for: its `width` and `height` /// attributes, else its `viewBox`. fn svg_size(svg: &str) -> Option<(f32, f32)> { let start = svg.find("')?]; let attr = |name: &str| -> Option<&str> { let mut rest = tag; loop { let at = rest.find(name)?; let before = rest[..at].chars().next_back(); rest = &rest[at + name.len()..]; if before.is_some_and(char::is_whitespace) && rest.trim_start().starts_with('=') { let value = rest.trim_start()[1..].trim_start(); let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?; return value[1..].split(quote).next(); } } }; let length = |value: &str| -> Option { let value = value.trim().trim_end_matches("px"); value.parse::().ok().filter(|value| value.is_finite() && *value > 0.) }; let view_box = attr("viewBox").and_then(|value| { let numbers: Vec = value.split([' ', ',']).filter(|part| !part.is_empty()).filter_map(|part| part.parse().ok()).collect(); (numbers.len() == 4 && numbers[2] > 0. && numbers[3] > 0.).then(|| (numbers[2], numbers[3])) }); let width = attr("width").and_then(length); let height = attr("height").and_then(length); match (width, height, view_box) { (Some(width), Some(height), _) => Some((width, height)), (Some(width), None, Some((w, h))) => Some((width, width * h / w)), (None, Some(height), Some((w, h))) => Some((height * w / h, height)), (_, _, view_box) => view_box, } } /// A placeholder while a block streams in or renders. fn skeleton(label: &'static str, height: Pixels, p: &Palette) -> AnyElement { div() .w_full() .h(height) .flex() .items_center() .justify_center() .rounded(px(14.)) .region(p) .text_size(px(12.5)) .text_color(p.ink3) .child(label) .into_any_element() } #[cfg(test)] mod tests { use super::{is_closed, svg_size}; #[::core::prelude::v1::test] fn svg_sizes() { assert_eq!(svg_size(r#""#), Some((120., 80.))); assert_eq!(svg_size(r#""#), Some((600., 300.))); assert_eq!(svg_size(r#""#), Some((40., 20.))); assert_eq!(svg_size(r#""#), Some((40., 20.))); assert_eq!(svg_size(""), None); } #[::core::prelude::v1::test] fn closed_fences() { assert!(is_closed("```mermaid\ngraph TD\n```")); assert!(is_closed("````mermaid\ngraph TD\n````\n")); assert!(is_closed("~~~svg\n\n~~~")); assert!(!is_closed("```mermaid\ngraph TD")); assert!(!is_closed("```mermaid")); assert!(!is_closed("````mermaid\ngraph TD\n```")); } }