//! 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<Session>,
pub ix: usize,
pub scroller: WeakEntity<MessageScrollerState>,
}
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<Self> {
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<Arc<Program>>,
}
/// 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<Workspace>);
impl Global for Hub {}
pub fn set_workspace(workspace: WeakEntity<Workspace>, 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::<Hub>().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<MarkdownNode> {
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<Key, BlockState>,
}
/// 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::<Fence>() 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::<Store>().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::<Store>().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<RenderImage>, Size<Pixels>)> {
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("<svg")?;
let tag = &svg[start..start + svg[start..].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<f32> {
let value = value.trim().trim_end_matches("px");
value.parse::<f32>().ok().filter(|value| value.is_finite() && *value > 0.)
};
let view_box = attr("viewBox").and_then(|value| {
let numbers: Vec<f32> = 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#"<svg xmlns="x" width="120" height="80">"#), Some((120., 80.)));
assert_eq!(svg_size(r#"<svg viewBox="0 0 300 150" width="600px">"#), Some((600., 300.)));
assert_eq!(svg_size(r#"<?xml?><svg stroke-width="2" viewBox='0,0,40,20'>"#), Some((40., 20.)));
assert_eq!(svg_size(r#"<svg width="100%" viewBox="0 0 40 20">"#), Some((40., 20.)));
assert_eq!(svg_size("<svg>"), 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<svg/>\n~~~"));
assert!(!is_closed("```mermaid\ngraph TD"));
assert!(!is_closed("```mermaid"));
assert!(!is_closed("````mermaid\ngraph TD\n```"));
}
}