Vectrum
Sign In

Preview HTML blocks and draw SVG blocks

An ```html code block gets a Preview button next to Copy: the page is
written to the data folder's artifacts and opened in the thread's
browser panel, or the system browser without agent-browser. An ```svg
block is drawn like a diagram, on a light card since its colors are its
own, with its source a click away; sources over 1 MB stay code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 8d8c1c3commit4a52bd0

Showing 5 changed files with 123 additions and 21 deletions.

+10−5src/ui/browser.rsView File
@@ -120,11 +120,7 @@ impl BrowserPanel {
120120 if matches!(event, InputEvent::PressEnter { .. }) {
121121 let text = url.read(cx).value().to_string();
122122 if let Some(target) = browser::normalize_url(&text) {
123- url.update(cx, |url, cx| url.set_value(target.clone(), window, cx));
124- this.error = None;
125- this.session.open(&target);
126- this.focus_handle.focus(window, cx);
127- cx.notify();
123+ this.navigate(target, window, cx);
128124 }
129125 }
130126 }),
@@ -187,6 +183,15 @@ impl BrowserPanel {
187183 cx.notify();
188184 }
189185
186+ /// Opens `url` in the panel's page, as if typed into its address bar.
187+ pub fn navigate(&mut self, url: String, window: &mut Window, cx: &mut Context<Self>) {
188+ self.url.update(cx, |field, cx| field.set_value(url.clone(), window, cx));
189+ self.error = None;
190+ self.session.open(&url);
191+ self.focus_handle.focus(window, cx);
192+ cx.notify();
193+ }
194+
190195 /// Watches the stream while the panel shows; a hidden panel lets go of it.
191196 pub fn set_visible(&mut self, visible: bool, cx: &mut Context<Self>) {
192197 if self.visible != visible {
+22−6src/ui/chat.rsView File
@@ -2012,12 +2012,28 @@ pub(super) fn markdown(
20122012 code.hash(&mut hasher);
20132013 hasher.finish()
20142014 };
2015- MetalButton::new(ElementId::NamedInteger("copy-code".into(), id))
2016- .ghost()
2017- .mini()
2018- .icon(IconName::Copy)
2019- .tooltip("Copy")
2020- .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string())))
2015+ let html = block.lang().is_some_and(|lang| lang.eq_ignore_ascii_case("html"));
2016+ h_flex()
2017+ .gap(px(2.))
2018+ .when(html && crate::ui::inline::enabled(), |this| {
2019+ let code = code.clone();
2020+ this.child(
2021+ MetalButton::new(ElementId::NamedInteger("preview-code".into(), id))
2022+ .ghost()
2023+ .mini()
2024+ .label("Preview")
2025+ .tooltip("Open as a page")
2026+ .on_click(move |_, window, cx| crate::ui::inline::preview_html(&code, window, cx)),
2027+ )
2028+ })
2029+ .child(
2030+ MetalButton::new(ElementId::NamedInteger("copy-code".into(), id))
2031+ .ghost()
2032+ .mini()
2033+ .icon(IconName::Copy)
2034+ .tooltip("Copy")
2035+ .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string()))),
2036+ )
20212037 })
20222038 }
20232039
+40−10src/ui/inline/mermaid.rsView File
@@ -57,25 +57,55 @@ pub(super) fn render(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut
5757 scale: scale.to_bits(),
5858 };
5959 match cx.default_global::<Cache>().entries.get(&key) {
60- Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, p)),
60+ Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, card(p), p)),
6161 Some(Entry::Failed(error)) => Body::Failed(error.clone()),
6262 Some(Entry::Pending) => Body::Ready(skeleton("Drawing diagram…", guess, p)),
6363 None => {
64- start(key, fence.code.clone(), theme(p), scale, icx.clone(), cx);
64+ let (code, theme) = (fence.code.clone(), theme(p));
65+ start(key, scale, icx.clone(), cx, move || {
66+ mermaid_rs_renderer::render_with_options(&code, RenderOptions { theme, ..RenderOptions::default() })
67+ .map_err(|err| format!("Mermaid: {}", first_line(&err.to_string())))
68+ });
6569 Body::Ready(skeleton("Drawing diagram…", guess, p))
6670 }
6771 }
6872 }
6973
70- /// Lays out and rasterizes a diagram in the background, then stores it and
71- /// redraws the windows.
72- fn start(key: Key, code: String, theme: Theme, scale: f32, icx: InlineCx, cx: &mut App) {
74+ /// ` ```svg ` blocks: the agent's own SVG, drawn the same way. Its colors
75+ /// are its own, so it sits on a light card like a page, whatever the theme.
76+ pub(super) fn render_svg(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut Window, cx: &mut App) -> Body {
77+ const MAX_SOURCE: usize = 1 << 20;
78+ let p = metal::palette(cx);
79+ if fence.code.len() > MAX_SOURCE {
80+ return Body::Failed("The SVG is larger than 1 MB".into());
81+ }
82+ if !fence.closed {
83+ return Body::Ready(skeleton("Drawing image…", px(160.), p));
84+ }
85+ let scale = window.scale_factor();
86+ // Palettes are statics, so 0 never collides with one.
87+ let key = Key { code: hash(&fence.code), palette: 0, scale: scale.to_bits() };
88+ let paper = if p.dark { rgb(0xe9e6df).into() } else { rgb(0xfdfcfa).into() };
89+ match cx.default_global::<Cache>().entries.get(&key) {
90+ Some(Entry::Ready { image, size }) => Body::Ready(diagram(id, image.clone(), *size, paper, p)),
91+ Some(Entry::Failed(error)) => Body::Failed(error.clone()),
92+ Some(Entry::Pending) => Body::Ready(skeleton("Drawing image…", px(160.), p)),
93+ None => {
94+ let code = fence.code.clone();
95+ start(key, scale, icx.clone(), cx, move || Ok(code));
96+ Body::Ready(skeleton("Drawing image…", px(160.), p))
97+ }
98+ }
99+ }
100+
101+ /// Makes SVG with `svg` and rasterizes it in the background, then stores
102+ /// the image and redraws the windows.
103+ fn start(key: Key, scale: f32, icx: InlineCx, cx: &mut App, svg: impl FnOnce() -> Result<String, String> + Send + 'static) {
73104 cx.default_global::<Cache>().entries.insert(key, Entry::Pending);
74105 let renderer = cx.svg_renderer();
75106 let job = cx.background_executor().spawn(async move {
76- let svg = mermaid_rs_renderer::render_with_options(&code, RenderOptions { theme, ..RenderOptions::default() })
77- .map_err(|err| format!("Mermaid: {}", first_line(&err.to_string())))?;
78- super::rasterize(&renderer, &svg, scale).map_err(|err| format!("Couldn't draw the diagram: {err}"))
107+ let svg = svg()?;
108+ super::rasterize(&renderer, &svg, scale).map_err(|err| format!("Couldn't draw it: {err}"))
79109 });
80110 cx.spawn(async move |cx| {
81111 let result = job.await;
@@ -110,7 +140,7 @@ fn first_line(text: &str) -> &str {
110140 /// The diagram on a recessed card, at its own size or shrunk to fit. Shrunk
111141 /// below `MIN_SCALE` its labels get too small to read, so a wider diagram
112142 /// scrolls sideways instead.
113- fn diagram(id: ElementId, image: Arc<RenderImage>, size: Size<Pixels>, p: &Palette) -> AnyElement {
143+ fn diagram(id: ElementId, image: Arc<RenderImage>, size: Size<Pixels>, background: Hsla, p: &Palette) -> AnyElement {
114144 const MIN_SCALE: f32 = 0.6;
115145 let ratio = f32::from(size.width) / f32::from(size.height).max(1.);
116146 div()
@@ -118,7 +148,7 @@ fn diagram(id: ElementId, image: Arc<RenderImage>, size: Size<Pixels>, p: &Palet
118148 .w_full()
119149 .p(px(16.))
120150 .rounded(px(14.))
121- .bg(card(p))
151+ .bg(background)
122152 .shadow(p.region_sh.clone())
123153 .overflow_x_scroll()
124154 .restrict_scroll_to_axis()
+37−0src/ui/inline/mod.rsView File
@@ -23,6 +23,7 @@ use gpui_kit::*;
2323 use crate::inline_ui::lang::{self, Program};
2424 use crate::session::Session;
2525 use crate::ui::icons::Lucide;
26+ use crate::ui::workspace::Workspace;
2627 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
2728
2829 /// The one custom node name every rich block parses into. A constant set of
@@ -55,6 +56,7 @@ impl InlineCx {
5556 enum Lang {
5657 Mermaid,
5758 OpenUi,
59+ Svg,
5860 }
5961
6062 impl Lang {
@@ -62,6 +64,7 @@ impl Lang {
6264 match lang.trim().to_ascii_lowercase().as_str() {
6365 "mermaid" => Some(Self::Mermaid),
6466 "openui" | "openui-lang" => Some(Self::OpenUi),
67+ "svg" => Some(Self::Svg),
6568 _ => None,
6669 }
6770 }
@@ -70,6 +73,7 @@ impl Lang {
7073 match self {
7174 Self::Mermaid => "Diagram",
7275 Self::OpenUi => "Interactive",
76+ Self::Svg => "Image",
7377 }
7478 }
7579
@@ -77,6 +81,7 @@ impl Lang {
7781 match self {
7882 Self::Mermaid => "mermaid",
7983 Self::OpenUi => "openui",
84+ Self::Svg => "svg",
8085 }
8186 }
8287 }
@@ -93,6 +98,37 @@ struct Fence {
9398 program: Option<Arc<Program>>,
9499 }
95100
101+ /// Whether answers render rich blocks at all.
102+ pub fn enabled() -> bool {
103+ true
104+ }
105+
106+ /// The workspace, so a block's buttons can open previews in its panels.
107+ struct Hub(WeakEntity<Workspace>);
108+
109+ impl Global for Hub {}
110+
111+ pub fn set_workspace(workspace: WeakEntity<Workspace>, cx: &mut App) {
112+ cx.set_global(Hub(workspace));
113+ }
114+
115+ /// Opens an HTML block as a page: written to the data folder's
116+ /// `artifacts`, then shown in the thread's browser panel, or the system
117+ /// browser without one.
118+ pub fn preview_html(code: &str, window: &mut Window, cx: &mut App) {
119+ let Some(dir) = crate::settings::data_dir().map(|dir| dir.join("artifacts")) else { return };
120+ let path = dir.join(format!("{:016x}.html", hash(code)));
121+ if let Err(err) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&path, code)) {
122+ eprintln!("vectrum-code: couldn't write the preview {}: {err}", path.display());
123+ return;
124+ }
125+ let url = format!("file://{}", path.display());
126+ match cx.try_global::<Hub>().and_then(|hub| hub.0.upgrade()) {
127+ Some(workspace) => workspace.update(cx, |workspace, cx| workspace.preview(url, window, cx)),
128+ None => crate::ui::workspace::open_url(&url),
129+ }
130+ }
131+
96132 /// Adds the rich block hooks to an answer's Markdown view.
97133 pub fn extend(view: TextView, icx: InlineCx) -> TextView {
98134 view.markdown_block_parser(parse).markdown_block_renderer(NODE, move |node, window, cx| render(node, &icx, window, cx))
@@ -173,6 +209,7 @@ fn render(node: &MarkdownNode, icx: &InlineCx, window: &mut Window, cx: &mut App
173209 match fence.lang {
174210 Lang::Mermaid => mermaid::render(id.clone(), fence, icx, window, cx),
175211 Lang::OpenUi => openui::render(&id, key, fence, icx, window, cx),
212+ Lang::Svg => mermaid::render_svg(id.clone(), fence, icx, window, cx),
176213 }
177214 };
178215 let error = match &body {
+14−0src/ui/workspace.rsView File
@@ -318,6 +318,7 @@ impl Workspace {
318318 pub fn new(initial_project: Option<PathBuf>, window: &mut Window, cx: &mut Context<Self>) -> Self {
319319 let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
320320 let workspace = cx.weak_entity();
321+ crate::ui::inline::set_workspace(workspace.clone(), cx);
321322 let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx));
322323 let subscriptions = vec![
323324 cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event {
@@ -1802,6 +1803,19 @@ impl Workspace {
18021803
18031804 /// The open thread's browser panel while it shows, made on first use.
18041805 /// The others let go of their streams.
1806+ /// Shows `url` in the open thread's browser panel, or in the system
1807+ /// browser when agent-browser isn't installed to drive one.
1808+ pub(crate) fn preview(&mut self, url: String, window: &mut Window, cx: &mut Context<Self>) {
1809+ if browser::find_binary().is_none() || self.active.is_none() {
1810+ open_url(&url);
1811+ return;
1812+ }
1813+ self.open_surface(surfaces::Surface::Browser, window, cx);
1814+ if let Some(panel) = self.shown_browser(window, cx) {
1815+ panel.update(cx, |panel, cx| panel.navigate(url, window, cx));
1816+ }
1817+ }
1818+
18051819 fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> {
18061820 let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open);
18071821 let panel = session.map(|session| {