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>
Showing 5 changed files with 123 additions and 21 deletions.
| @@ -120,11 +120,7 @@ impl BrowserPanel { | ||
| 120 | 120 | if matches!(event, InputEvent::PressEnter { .. }) { |
| 121 | 121 | let text = url.read(cx).value().to_string(); |
| 122 | 122 | 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); | |
| 128 | 124 | } |
| 129 | 125 | } |
| 130 | 126 | }), |
| @@ -187,6 +183,15 @@ impl BrowserPanel { | ||
| 187 | 183 | cx.notify(); |
| 188 | 184 | } |
| 189 | 185 | |
| 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 | + | |
| 190 | 195 | /// Watches the stream while the panel shows; a hidden panel lets go of it. |
| 191 | 196 | pub fn set_visible(&mut self, visible: bool, cx: &mut Context<Self>) { |
| 192 | 197 | if self.visible != visible { |
| @@ -2012,12 +2012,28 @@ pub(super) fn markdown( | ||
| 2012 | 2012 | code.hash(&mut hasher); |
| 2013 | 2013 | hasher.finish() |
| 2014 | 2014 | }; |
| 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 | + ) | |
| 2021 | 2037 | }) |
| 2022 | 2038 | } |
| 2023 | 2039 | |
| @@ -57,25 +57,55 @@ pub(super) fn render(id: ElementId, fence: &Fence, icx: &InlineCx, window: &mut | ||
| 57 | 57 | scale: scale.to_bits(), |
| 58 | 58 | }; |
| 59 | 59 | 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)), | |
| 61 | 61 | Some(Entry::Failed(error)) => Body::Failed(error.clone()), |
| 62 | 62 | Some(Entry::Pending) => Body::Ready(skeleton("Drawing diagram…", guess, p)), |
| 63 | 63 | 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 | + }); | |
| 65 | 69 | Body::Ready(skeleton("Drawing diagram…", guess, p)) |
| 66 | 70 | } |
| 67 | 71 | } |
| 68 | 72 | } |
| 69 | 73 | |
| 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) { | |
| 73 | 104 | cx.default_global::<Cache>().entries.insert(key, Entry::Pending); |
| 74 | 105 | let renderer = cx.svg_renderer(); |
| 75 | 106 | 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}")) | |
| 79 | 109 | }); |
| 80 | 110 | cx.spawn(async move |cx| { |
| 81 | 111 | let result = job.await; |
| @@ -110,7 +140,7 @@ fn first_line(text: &str) -> &str { | ||
| 110 | 140 | /// The diagram on a recessed card, at its own size or shrunk to fit. Shrunk |
| 111 | 141 | /// below `MIN_SCALE` its labels get too small to read, so a wider diagram |
| 112 | 142 | /// 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 { | |
| 114 | 144 | const MIN_SCALE: f32 = 0.6; |
| 115 | 145 | let ratio = f32::from(size.width) / f32::from(size.height).max(1.); |
| 116 | 146 | div() |
| @@ -118,7 +148,7 @@ fn diagram(id: ElementId, image: Arc<RenderImage>, size: Size<Pixels>, p: &Palet | ||
| 118 | 148 | .w_full() |
| 119 | 149 | .p(px(16.)) |
| 120 | 150 | .rounded(px(14.)) |
| 121 | - .bg(card(p)) | |
| 151 | + .bg(background) | |
| 122 | 152 | .shadow(p.region_sh.clone()) |
| 123 | 153 | .overflow_x_scroll() |
| 124 | 154 | .restrict_scroll_to_axis() |
| @@ -23,6 +23,7 @@ use gpui_kit::*; | ||
| 23 | 23 | use crate::inline_ui::lang::{self, Program}; |
| 24 | 24 | use crate::session::Session; |
| 25 | 25 | use crate::ui::icons::Lucide; |
| 26 | + use crate::ui::workspace::Workspace; | |
| 26 | 27 | use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; |
| 27 | 28 | |
| 28 | 29 | /// The one custom node name every rich block parses into. A constant set of |
| @@ -55,6 +56,7 @@ impl InlineCx { | ||
| 55 | 56 | enum Lang { |
| 56 | 57 | Mermaid, |
| 57 | 58 | OpenUi, |
| 59 | + Svg, | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | impl Lang { |
| @@ -62,6 +64,7 @@ impl Lang { | ||
| 62 | 64 | match lang.trim().to_ascii_lowercase().as_str() { |
| 63 | 65 | "mermaid" => Some(Self::Mermaid), |
| 64 | 66 | "openui" | "openui-lang" => Some(Self::OpenUi), |
| 67 | + "svg" => Some(Self::Svg), | |
| 65 | 68 | _ => None, |
| 66 | 69 | } |
| 67 | 70 | } |
| @@ -70,6 +73,7 @@ impl Lang { | ||
| 70 | 73 | match self { |
| 71 | 74 | Self::Mermaid => "Diagram", |
| 72 | 75 | Self::OpenUi => "Interactive", |
| 76 | + Self::Svg => "Image", | |
| 73 | 77 | } |
| 74 | 78 | } |
| 75 | 79 | |
| @@ -77,6 +81,7 @@ impl Lang { | ||
| 77 | 81 | match self { |
| 78 | 82 | Self::Mermaid => "mermaid", |
| 79 | 83 | Self::OpenUi => "openui", |
| 84 | + Self::Svg => "svg", | |
| 80 | 85 | } |
| 81 | 86 | } |
| 82 | 87 | } |
| @@ -93,6 +98,37 @@ struct Fence { | ||
| 93 | 98 | program: Option<Arc<Program>>, |
| 94 | 99 | } |
| 95 | 100 | |
| 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 | + | |
| 96 | 132 | /// Adds the rich block hooks to an answer's Markdown view. |
| 97 | 133 | pub fn extend(view: TextView, icx: InlineCx) -> TextView { |
| 98 | 134 | 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 | ||
| 173 | 209 | match fence.lang { |
| 174 | 210 | Lang::Mermaid => mermaid::render(id.clone(), fence, icx, window, cx), |
| 175 | 211 | Lang::OpenUi => openui::render(&id, key, fence, icx, window, cx), |
| 212 | + Lang::Svg => mermaid::render_svg(id.clone(), fence, icx, window, cx), | |
| 176 | 213 | } |
| 177 | 214 | }; |
| 178 | 215 | let error = match &body { |
| @@ -318,6 +318,7 @@ impl Workspace { | ||
| 318 | 318 | pub fn new(initial_project: Option<PathBuf>, window: &mut Window, cx: &mut Context<Self>) -> Self { |
| 319 | 319 | let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search")); |
| 320 | 320 | let workspace = cx.weak_entity(); |
| 321 | + crate::ui::inline::set_workspace(workspace.clone(), cx); | |
| 321 | 322 | let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx)); |
| 322 | 323 | let subscriptions = vec![ |
| 323 | 324 | cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event { |
| @@ -1802,6 +1803,19 @@ impl Workspace { | ||
| 1802 | 1803 | |
| 1803 | 1804 | /// The open thread's browser panel while it shows, made on first use. |
| 1804 | 1805 | /// 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 | + | |
| 1805 | 1819 | fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> { |
| 1806 | 1820 | let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open); |
| 1807 | 1821 | let panel = session.map(|session| { |