From d4427e1a837825e966226021a76569f41bda5162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 2 Oct 2026 22:45:33 -0300 Subject: [PATCH 1/4] Render Mermaid diagrams in chat replies Assistant replies now draw ```mermaid fences as diagrams with the file preview's engine, fence frame, source toggle, Copy action and lightbox. Inline images in chat keep their text rendering. Streaming never renders a fence that may still be growing: only blocks a later row of the same reply follows, or blocks of a completed reply, request a diagram, so per-token commits start no render work. Until a diagram is ready the fence shows its source, and a failed render keeps the source with the engine's diagnostic in a header marker. Rows request their fences while laying out, so only painted diagrams are rendered, one at a time off the UI thread; requests whose rows scrolled away are dropped. Results are retained under a byte budget, least recently painted first out. A swap remeasures only the rows painting it and uses the existing layout signals, so the bottom pin follows and the own-turn runway absorbs the change without moving the sent prompt. --- crates/ui/src/files/markdown_preview.rs | 152 ++++--- crates/ui/src/image_media.rs | 30 ++ crates/ui/src/markdown/mermaid_cache.rs | 361 +++++++++++++++++ crates/ui/src/markdown/mod.rs | 1 + crates/ui/src/markdown/render.rs | 87 +++- crates/ui/src/transcript.rs | 512 +++++++++++++++++++++++- docs/markdown-preview.md | 16 +- 7 files changed, 1056 insertions(+), 103 deletions(-) create mode 100644 crates/ui/src/markdown/mermaid_cache.rs diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index 51fc1975d3..c4d4a0451b 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -968,36 +968,18 @@ impl MarkdownPreview { name: String, weak: gpui::WeakEntity, ) -> AnyElement { - use gpui::StyledImage as _; let preview = crate::attachments::PreviewImage::new(name, loaded.image.clone()); let source = loaded.clone(); - div() - .id(id) - .w_full() - .max_w(px(loaded.width)) - .mx_auto() - .max_h(px(480.0)) - .aspect_ratio(loaded.width / loaded.height) - .cursor_pointer() - .role(gpui::Role::Button) - .aria_label("Enlarge image") - .on_click(move |_, window, cx| { - cx.stop_propagation(); - let _ = weak.update(cx, |view, cx| { - view.close_media_preview(cx); - view.zoom_source = Some(source.clone()); - preview.viewer.reset(); - view.preview_image = Some(preview.clone()); - window.focus(&view.preview_focus, cx); - cx.notify(); - }); - }) - .child( - gpui::img(loaded.image.clone()) - .size_full() - .object_fit(gpui::ObjectFit::Contain), - ) - .into_any_element() + crate::image_media::preview_element(loaded, id, move |window, cx| { + let _ = weak.update(cx, |view, cx| { + view.close_media_preview(cx); + view.zoom_source = Some(source.clone()); + preview.viewer.reset(); + view.preview_image = Some(preview.clone()); + window.focus(&view.preview_focus, cx); + cx.notify(); + }); + }) } #[cfg(test)] @@ -1147,7 +1129,7 @@ impl MarkdownPreview { }) .into_any_element(), }; - render::DiagramUi { + render::DiagramView::Diagram(render::DiagramUi { body, show_source: source_shown, toggle_source: Rc::new(move |_, cx| { @@ -1159,16 +1141,13 @@ impl MarkdownPreview { cx.notify(); }); }), - } + }) })), - image: Rc::new(move |image, id, theme| match images.get(&image.source) { - Some(Ok(loaded)) => { - let mut el = - div() - .flex() - .flex_col() - .gap(px(4.0)) - .child(Self::media_element( + image: Some(Rc::new(move |image, id, theme| { + match images.get(&image.source) { + Some(Ok(loaded)) => { + let mut el = div().flex().flex_col().gap(px(4.0)).child( + Self::media_element( loaded, id, if image.alt.is_empty() { @@ -1177,11 +1156,15 @@ impl MarkdownPreview { image.alt.clone() }, image_owner.clone(), - )); - if let Some(target) = image.link.clone() { - let link = image_link.clone(); - el = el.child( - super::toolbar_button("markdown-image-link", "Open image link") + ), + ); + if let Some(target) = image.link.clone() { + let link = image_link.clone(); + el = el.child( + super::toolbar_button( + "markdown-image-link", + "Open image link", + ) .on_click(move |_, window, cx| { render::activate_link( render::LinkTarget::new(&target, &target), @@ -1196,51 +1179,52 @@ impl MarkdownPreview { .size(px(crate::surface_chrome::ICON_SIZE)) .text_color(theme.text_muted), ), - ); + ); + } + el.into_any_element() } - el.into_any_element() - } - state => { - let text = if image.source.starts_with("https://") - || image.source.starts_with("http://") - { - format!("{} — {}", image.alt, image.source) - } else { - format!( - "{} — {}", - image.alt, - state - .and_then(|s| s.as_ref().err()) - .map(String::as_str) - .unwrap_or(if image_allowed.contains(&image.source) { - "Loading image…" - } else { - "Document image preview limit reached" + state => { + let text = if image.source.starts_with("https://") + || image.source.starts_with("http://") + { + format!("{} — {}", image.alt, image.source) + } else { + format!( + "{} — {}", + image.alt, + state + .and_then(|s| s.as_ref().err()) + .map(String::as_str) + .unwrap_or(if image_allowed.contains(&image.source) { + "Loading image…" + } else { + "Document image preview limit reached" + }) + ) + }; + let target = image.source.clone(); + let external = + target.starts_with("https://") || target.starts_with("http://"); + div() + .id(id) + .text_color(theme.text_muted) + .child(text) + .when(external, |el| { + let link = image_link.clone(); + el.cursor_pointer().on_click(move |_, window, cx| { + render::activate_link( + render::LinkTarget::new(&target, &target), + render::LinkAction::Primary, + Some(&link), + window, + cx, + ); }) - ) - }; - let target = image.source.clone(); - let external = - target.starts_with("https://") || target.starts_with("http://"); - div() - .id(id) - .text_color(theme.text_muted) - .child(text) - .when(external, |el| { - let link = image_link.clone(); - el.cursor_pointer().on_click(move |_, window, cx| { - render::activate_link( - render::LinkTarget::new(&target, &target), - render::LinkAction::Primary, - Some(&link), - window, - cx, - ); }) - }) - .into_any_element() + .into_any_element() + } } - }), + })), }); opts.link = Some(link.clone()); opts.copy = Some(self.copy_ui_for(&opts.row_key, cx)); diff --git a/crates/ui/src/image_media.rs b/crates/ui/src/image_media.rs index f89605df8e..506d6fbd3c 100644 --- a/crates/ui/src/image_media.rs +++ b/crates/ui/src/image_media.rs @@ -117,6 +117,36 @@ impl MediaImage { } } +/// Prepared media centered at its natural size within the reading column, +/// capped at 480px tall. A click requests the enlarged lightbox. +pub(crate) fn preview_element( + loaded: &MediaImage, + id: gpui::SharedString, + on_click: impl Fn(&mut gpui::Window, &mut gpui::App) + 'static, +) -> gpui::AnyElement { + use gpui::{InteractiveElement as _, IntoElement as _, StyledImage as _, prelude::*}; + gpui::div() + .id(id) + .w_full() + .max_w(gpui::px(loaded.width)) + .mx_auto() + .max_h(gpui::px(480.0)) + .aspect_ratio(loaded.width / loaded.height) + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label("Enlarge image") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + on_click(window, cx); + }) + .child( + gpui::img(loaded.image.clone()) + .size_full() + .object_fit(gpui::ObjectFit::Contain), + ) + .into_any_element() +} + pub(crate) fn svg_options() -> usvg::Options<'static> { static FONTS: OnceLock> = OnceLock::new(); let fonts = FONTS diff --git a/crates/ui/src/markdown/mermaid_cache.rs b/crates/ui/src/markdown/mermaid_cache.rs new file mode 100644 index 0000000000..b01b3a5c64 --- /dev/null +++ b/crates/ui/src/markdown/mermaid_cache.rs @@ -0,0 +1,361 @@ +//! Lazily rendered Mermaid diagrams for a surface that discovers its fences +//! while painting (the agent transcript). +//! +//! Rows request their fences as they lay out, so only painted diagrams cost +//! anything. The owner renders one requested source at a time on a background +//! executor and drops requests whose rows left the viewport before their turn. +//! Results are retained under a byte budget, least recently painted first out; +//! an evicted diagram simply renders again when its row returns. Diagrams +//! painted in the latest two passes are never evicted, so the budget is soft +//! only when more diagrams than it admits are on screen at once. +use crate::image_media::MediaImage; +use gpui::SharedString; +use std::collections::{HashMap, HashSet}; + +pub(crate) const MAX_RETAINED_BYTES: usize = 64 * 1024 * 1024; +const MAX_ENTRIES: usize = 64; + +pub(crate) enum Lookup { + Pending, + Ready(MediaImage), + Failed(SharedString), +} + +enum State { + Pending, + Ready(MediaImage), + Failed(SharedString), +} + +struct Entry { + state: State, + /// Paint pass that last requested this source. + used: u64, + /// Rows that painted this source, remeasured when its result lands. + rows: Vec, +} + +#[derive(Default)] +pub(crate) struct MermaidCache { + entries: HashMap, + frame: u64, + style: Option, + view: Option<((f32, f32), f32)>, + new_requests: bool, + /// Diagram frames switched to their source, keyed by frame id. + source_visible: HashSet, +} + +/// The renderer keys a diagram frame `"{row_key}-mermaid-{ix}"`. +pub(crate) fn frame_row(frame_id: &str) -> &str { + frame_id + .rsplit_once("-mermaid-") + .map_or(frame_id, |(row, _)| row) +} + +impl MermaidCache { + /// Start a paint pass. A theme change discards every diagram, since the + /// palette is baked into the generated SVG; the media is returned for + /// release. + pub(crate) fn begin_frame(&mut self, style: u32) -> Vec { + self.frame += 1; + if self.style == Some(style) { + return Vec::new(); + } + self.style = Some(style); + self.drain() + } + + /// Rasterize retained diagrams for the column width and display density. + /// Returns superseded rasters for release. + pub(crate) fn set_view(&mut self, target: (f32, f32), scale: f32) -> Vec { + if self.view == Some((target, scale)) { + return Vec::new(); + } + self.view = Some((target, scale)); + let mut retired = Vec::new(); + for entry in self.entries.values_mut() { + if let State::Ready(media) = &mut entry.state { + let next = media.preview_for_view(target, scale); + if !std::sync::Arc::ptr_eq(&media.image, &next.image) { + retired.push(std::mem::replace(media, next)); + } + } + } + retired + } + + /// Look up a fence painted in the current pass, queueing it if unseen. + pub(crate) fn request(&mut self, code: &str, frame_id: &str) -> Lookup { + let frame = self.frame; + let entry = match self.entries.get_mut(code) { + Some(entry) => entry, + None => { + self.new_requests = true; + self.entries.entry(code.to_owned()).or_insert(Entry { + state: State::Pending, + used: frame, + rows: Vec::new(), + }) + } + }; + entry.used = frame; + let row = frame_row(frame_id); + if !entry.rows.iter().any(|known| known == row) { + entry.rows.push(row.to_owned().into()); + } + match &entry.state { + State::Pending => Lookup::Pending, + State::Ready(media) => Lookup::Ready(media.clone()), + State::Failed(reason) => Lookup::Failed(reason.clone()), + } + } + + /// Whether a fence was requested since the last call. + pub(crate) fn take_new_requests(&mut self) -> bool { + std::mem::take(&mut self.new_requests) + } + + /// The next source to render. Requests not repainted in the latest two + /// passes belong to rows that scrolled away; they are forgotten and + /// requested again if their row returns. Two passes, because a request + /// can be made while the current pass has only laid out some of its rows. + pub(crate) fn next_job(&mut self) -> Option { + let frame = self.frame; + self.entries.retain(|_, entry| { + !matches!(entry.state, State::Pending) || frame.saturating_sub(entry.used) <= 1 + }); + self.entries + .iter() + .filter(|(_, entry)| matches!(entry.state, State::Pending)) + .max_by_key(|(_, entry)| entry.used) + .map(|(code, _)| code.clone()) + } + + /// Store a finished render. Returns the rows that painted it (to + /// remeasure) and evicted media (to release); `None` when the result is + /// obsolete because the theme changed while it rendered. + pub(crate) fn finish( + &mut self, + code: String, + style: u32, + result: Result, + ) -> Option<(Vec, Vec)> { + if self.style != Some(style) { + return None; + } + let state = match result { + Ok(media) => State::Ready(match self.view { + Some((target, scale)) => media.preview_for_view(target, scale), + None => media, + }), + Err(reason) => State::Failed(reason.into()), + }; + let frame = self.frame; + let entry = self.entries.entry(code).or_insert(Entry { + state: State::Pending, + used: frame, + rows: Vec::new(), + }); + entry.state = state; + let rows = entry.rows.clone(); + Some((rows, self.evict())) + } + + /// Release settled diagrams least recently painted first until the + /// retained memory and entry count fit their limits. + fn evict(&mut self) -> Vec { + let mut released = Vec::new(); + let frame = self.frame; + while self.retained_bytes() > MAX_RETAINED_BYTES || self.entries.len() > MAX_ENTRIES { + let Some(code) = self + .entries + .iter() + .filter(|(_, entry)| { + !matches!(entry.state, State::Pending) && frame.saturating_sub(entry.used) > 1 + }) + .min_by_key(|(_, entry)| entry.used) + .map(|(code, _)| code.clone()) + else { + break; + }; + if let Some(Entry { + state: State::Ready(media), + .. + }) = self.entries.remove(&code) + { + released.push(media); + } + } + released + } + + pub(crate) fn retained_bytes(&self) -> usize { + self.entries + .values() + .filter_map(|entry| match &entry.state { + State::Ready(media) => Some(media.bytes), + _ => None, + }) + .sum() + } + + /// Forget every diagram, returning retained media for release. + pub(crate) fn drain(&mut self) -> Vec { + self.entries + .drain() + .filter_map(|(_, entry)| match entry.state { + State::Ready(media) => Some(media), + _ => None, + }) + .collect() + } + + pub(crate) fn source_visible(&self, frame_id: &str) -> bool { + self.source_visible.contains(frame_id) + } + + pub(crate) fn toggle_source(&mut self, frame_id: &SharedString) { + if !self.source_visible.remove(frame_id) { + self.source_visible.insert(frame_id.clone()); + } + } + + #[cfg(test)] + pub(crate) fn ready_count(&self) -> usize { + self.entries + .values() + .filter(|entry| matches!(entry.state, State::Ready(_))) + .count() + } + + #[cfg(test)] + pub(crate) fn ready_media(&self) -> Option { + self.entries.values().find_map(|entry| match &entry.state { + State::Ready(media) => Some(media.clone()), + _ => None, + }) + } + + pub(crate) fn has_source_toggles(&self) -> bool { + !self.source_visible.is_empty() + } + + /// Keep source toggles only for frames whose row still exists. + pub(crate) fn retain_rows(&mut self, rows: &HashSet<&str>) { + self.source_visible + .retain(|frame_id| rows.contains(frame_row(frame_id))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn media(width: u32) -> MediaImage { + crate::image_media::decode_image( + "image/svg+xml", + format!( + r#""# + ) + .into_bytes(), + ) + .unwrap() + } + + #[test] + fn requests_queue_once_and_track_their_rows() { + let mut cache = MermaidCache::default(); + assert!(cache.begin_frame(1).is_empty()); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.0-mermaid-3"), + Lookup::Pending + )); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.2-mermaid-0"), + Lookup::Pending + )); + assert!(cache.take_new_requests()); + assert!(!cache.take_new_requests()); + let code = cache.next_job().unwrap(); + let (rows, released) = cache.finish(code, 1, Ok(media(80))).unwrap(); + assert_eq!(rows, ["chat#p.0", "chat#p.2"]); + assert!(released.is_empty()); + assert!(matches!( + cache.request("graph TD; A-->B", "chat#p.0-mermaid-3"), + Lookup::Ready(_) + )); + assert!(!cache.take_new_requests()); + assert!(cache.next_job().is_none()); + } + + #[test] + fn requests_that_scrolled_away_are_dropped_before_rendering() { + let mut cache = MermaidCache::default(); + cache.begin_frame(1); + cache.request("a", "row-mermaid-0"); + cache.begin_frame(1); + cache.request("b", "row2-mermaid-0"); + // `a` was painted in the previous pass: still eligible. + assert!(cache.next_job().is_some()); + cache.begin_frame(1); + cache.request("b", "row2-mermaid-0"); + assert_eq!(cache.next_job().as_deref(), Some("b")); + cache.finish("b".into(), 1, Err("bad".into())); + assert!(cache.next_job().is_none()); + assert!(matches!( + cache.request("b", "row2-mermaid-0"), + Lookup::Failed(_) + )); + } + + #[test] + fn theme_changes_discard_diagrams_and_obsolete_results() { + let mut cache = MermaidCache::default(); + cache.begin_frame(1); + cache.request("a", "row-mermaid-0"); + cache.finish("a".into(), 1, Ok(media(80))); + assert_eq!(cache.begin_frame(2).len(), 1); + assert!(matches!( + cache.request("a", "row-mermaid-0"), + Lookup::Pending + )); + assert!(cache.finish("a".into(), 1, Ok(media(80))).is_none()); + assert!(matches!( + cache.request("a", "row-mermaid-0"), + Lookup::Pending + )); + } + + #[test] + fn eviction_spares_diagrams_painted_in_the_latest_passes() { + let mut cache = MermaidCache::default(); + for ix in 0..MAX_ENTRIES { + cache.begin_frame(1); + let code = format!("graph {ix}"); + cache.request(&code, &format!("row{ix}-mermaid-0")); + cache.finish(code, 1, Err("bad".into())); + } + cache.begin_frame(1); + cache.begin_frame(1); + cache.request("graph 0", "row0-mermaid-0"); + cache.request("fresh", "fresh-mermaid-0"); + cache.finish("fresh".into(), 1, Ok(media(80))); + assert_eq!(cache.entries.len(), MAX_ENTRIES); + assert!(cache.entries.contains_key("graph 0")); + assert!(cache.entries.contains_key("fresh")); + assert!(!cache.entries.contains_key("graph 1")); + } + + #[test] + fn source_toggles_follow_their_rows() { + let mut cache = MermaidCache::default(); + let frame: SharedString = "chat#p.0-mermaid-2".into(); + cache.toggle_source(&frame); + assert!(cache.source_visible(&frame)); + cache.retain_rows(&HashSet::from(["chat#p.0"])); + assert!(cache.source_visible(&frame)); + cache.retain_rows(&HashSet::from(["chat#p.1"])); + assert!(!cache.source_visible(&frame)); + } +} diff --git a/crates/ui/src/markdown/mod.rs b/crates/ui/src/markdown/mod.rs index 11c952ff40..f7302481d8 100644 --- a/crates/ui/src/markdown/mod.rs +++ b/crates/ui/src/markdown/mod.rs @@ -27,3 +27,4 @@ pub mod veil; pub use parser::{Block, BlockTree, IncrementalParser, InlineRun, InlineStyle, parse_full}; pub mod mermaid; +pub(crate) mod mermaid_cache; diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 7faa45f52e..ceb642fa21 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -109,8 +109,18 @@ pub struct TaskUi { #[derive(Clone)] pub struct MediaUi { - pub diagram: Option DiagramUi>>, - pub image: Rc AnyElement>, + pub diagram: Option DiagramView>>, + /// `None` keeps inline images in their ordinary text rendering. + pub image: Option AnyElement>>, +} + +/// How a Mermaid fence presents itself on the owning surface. +pub enum DiagramView { + /// The ordinary source fence, unchanged (no diagram is available yet). + Source, + /// The source fence, flagged with the reason it cannot be drawn. + Failed(SharedString), + Diagram(DiagramUi), } pub struct DiagramUi { @@ -366,7 +376,7 @@ impl Render for CodeScrollbarDragGhost { } } -struct CodeBlockTooltip(&'static str); +struct CodeBlockTooltip(SharedString); impl Render for CodeBlockTooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -379,9 +389,10 @@ impl Render for CodeBlockTooltip { .border_color(theme.border_strong) .bg(theme.surface_raised) .shadow_md() + .max_w(px(360.0)) .text_size(px(11.0)) .text_color(theme.text) - .child(self.0) + .child(self.0.clone()) } } @@ -917,7 +928,10 @@ fn render_table( TableAlign::Center => cell.text_center(), TableAlign::Right => cell.text_right(), }; - if opts.media.is_some() + if opts + .media + .as_ref() + .is_some_and(|media| media.image.is_some()) && all[r] .get(c) .is_some_and(|runs| runs.iter().any(|run| run.style.image.is_some())) @@ -1805,7 +1819,7 @@ fn text_element( opts: &RenderOptions, theme: &Theme, ) -> AnyElement { - if let Some(media) = &opts.media { + if let Some(image_ui) = opts.media.as_ref().and_then(|media| media.image.as_ref()) { if runs.iter().any(|run| run.style.image.is_some()) { let mut elements = Vec::new(); let mut start = 0; @@ -1823,7 +1837,7 @@ fn text_element( theme, )); } - elements.push((media.image)( + elements.push(image_ui( image, format!("{}-image-{ix}-{index}", opts.row_key).into(), theme, @@ -2020,7 +2034,27 @@ fn render_code_block( if language.is_some_and(|l| l.eq_ignore_ascii_case("mermaid")) { if let Some(handler) = opts.media.as_ref().and_then(|media| media.diagram.as_ref()) { let frame_id: SharedString = format!("{}-mermaid-{ix}", opts.row_key).into(); - let diagram = handler(code, frame_id.clone(), theme); + let diagram = match handler(code, frame_id.clone(), theme) { + DiagramView::Source => { + return render_code_block_source( + language, code, top_ix, ix, opts, theme, highlight, + ); + } + DiagramView::Failed(reason) => { + let notice = code_notice(format!("{frame_id}-failure").into(), reason, theme); + return render_code_block_source_with_actions( + language, + code, + top_ix, + ix, + opts, + theme, + highlight, + vec![notice], + ); + } + DiagramView::Diagram(diagram) => diagram, + }; let toggle = diagram.toggle_source.clone(); let toggle_action = code_icon_action( format!("{frame_id}-source-toggle").into(), @@ -2084,7 +2118,7 @@ fn code_icon_action( cx.stop_propagation(); handler(window, cx); }) - .tooltip(move |_, cx| cx.new(move |_| CodeBlockTooltip(label)).into()) + .tooltip(move |_, cx| cx.new(move |_| CodeBlockTooltip(label.into())).into()) .child( crate::icons::icon(icon_path) .size(px(13.0)) @@ -2093,6 +2127,26 @@ fn code_icon_action( .into_any_element() } +/// A passive header marker whose tooltip explains why a fence stays source. +fn code_notice(id: SharedString, message: SharedString, theme: &Theme) -> AnyElement { + div() + .id(id) + .size(px(CODE_ACTION_SIZE)) + .flex() + .items_center() + .justify_center() + .tooltip(move |_, cx| { + let message = message.clone(); + cx.new(move |_| CodeBlockTooltip(message)).into() + }) + .child( + crate::icons::icon(crate::icons::DANGER_TRIANGLE) + .size(px(13.0)) + .text_color(theme.warning_muted), + ) + .into_any_element() +} + fn code_copy_button( code: &str, ix: usize, @@ -2126,7 +2180,7 @@ fn code_copy_button( cx.stop_propagation(); handler(ix, code_text.clone(), window, cx); }) - .tooltip(|_, cx| cx.new(|_| CodeBlockTooltip("Copy code")).into()) + .tooltip(|_, cx| cx.new(|_| CodeBlockTooltip("Copy code".into())).into()) .child( crate::icons::icon(if copied { crate::icons::CHECK @@ -2314,11 +2368,14 @@ fn render_code_block_source_with_actions( }) .tooltip(move |_, cx| { cx.new(move |_| { - CodeBlockTooltip(if fit_content { - "Use horizontal scrolling" - } else { - "Fit content" - }) + CodeBlockTooltip( + if fit_content { + "Use horizontal scrolling" + } else { + "Fit content" + } + .into(), + ) }) .into() }) diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index eb03fc3f7e..a4a5167d07 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -42,6 +42,7 @@ use gpui::{ use zeron_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, SubagentStatus}; use zeron_proto::ToolCall; +use crate::markdown::mermaid_cache::{self, MermaidCache}; use crate::markdown::parser::{ Block, BlockTree, IncrementalParser, InlineRun, InlineStyle, parse_full, }; @@ -3260,6 +3261,17 @@ pub struct Transcript { /// Focused while the lightbox is open so Escape reaches it. attachment_preview_focus: gpui::FocusHandle, attachment_preview_return_focus: Option, + /// Mermaid fences drawn as diagrams in settled Markdown blocks. Each + /// row's media handler requests its fences while laying out, so only + /// painted diagrams are rendered (see [`MermaidCache`]). + diagrams: Rc>, + /// The media handler over `diagrams`, built once and shared by every row. + diagram_media: Option, + /// The single serialized diagram render loop, while requests remain. + diagram_worker: Option>, + /// Prepared diagram shown in the lightbox: its natural size frames the + /// enlarged raster, which is released when the lightbox closes. + diagram_zoom: Option, /// In-flight ReadAttachmentChunk loads, keyed by device, path and validation /// policy; results land in the global attachment cache. attachment_loads: HashMap>, @@ -3435,6 +3447,11 @@ impl Transcript { .ok(); }); let observe = cx.observe(&state, |this: &mut Self, _, cx| this.sync(cx)); + cx.on_release(|this: &mut Self, cx| { + this.close_diagram_zoom(cx); + crate::image_media::release_media(this.diagrams.borrow_mut().drain(), cx); + }) + .detach(); let text_changes = cx.subscribe( &state, |this: &mut Self, state, event: &crate::state::TranscriptTextChanged, cx| { @@ -3535,6 +3552,10 @@ impl Transcript { attachment_preview: None, attachment_preview_focus: cx.focus_handle(), attachment_preview_return_focus: None, + diagrams: Rc::default(), + diagram_media: None, + diagram_worker: None, + diagram_zoom: None, attachment_loads: HashMap::new(), attachment_retries: HashMap::new(), blob_details: HashMap::new(), @@ -4918,6 +4939,11 @@ impl Transcript { .collect(); self.code_fences .retain(|key, _| active_code_fences.contains(key)); + // Diagram source toggles follow the same stable row identity. + if self.diagrams.borrow().has_source_toggles() { + let row_ids: HashSet<&str> = new_rows.iter().map(|row| row.id.as_ref()).collect(); + self.diagrams.borrow_mut().retain_rows(&row_ids); + } // Text already streamed before this (re)attach is the veil BASELINE: // its rows' veils seed instead of fading (render creates them from @@ -5822,6 +5848,180 @@ impl Transcript { .into_any_element() } + /// Media wiring for settled Markdown: Mermaid fences become diagrams + /// once rendered and keep their source until then (or on failure). + /// Inline images keep their ordinary chat rendering. + fn diagram_media(&mut self, cx: &Context) -> render::MediaUi { + let cache = self.diagrams.clone(); + let owner = cx.weak_entity(); + let media = self.diagram_media.get_or_insert_with(|| render::MediaUi { + image: None, + diagram: Some(Rc::new(move |code, id, _theme| { + let lookup = cache.borrow_mut().request(code, &id); + match lookup { + mermaid_cache::Lookup::Pending => render::DiagramView::Source, + mermaid_cache::Lookup::Failed(reason) => render::DiagramView::Failed(reason), + mermaid_cache::Lookup::Ready(media) => { + let show_source = cache.borrow().source_visible(&id); + let body = if show_source { + gpui::Empty.into_any_element() + } else { + let open = owner.clone(); + let source = media.clone(); + crate::image_media::preview_element( + &media, + format!("{id}-image").into(), + move |window, cx| { + let _ = open.update(cx, |this, cx| { + this.open_diagram_preview(source.clone(), window, cx) + }); + }, + ) + }; + let toggle = owner.clone(); + render::DiagramView::Diagram(render::DiagramUi { + body, + show_source, + toggle_source: Rc::new(move |_, cx| { + let _ = toggle.update(cx, |this, cx| { + this.diagrams.borrow_mut().toggle_source(&id); + let row = mermaid_cache::frame_row(&id).to_owned(); + this.diagram_layout_changed(&[row.into()], cx); + }); + }), + }) + } + } + })), + }); + media.clone() + } + + /// Render requested diagrams one at a time off the UI thread. The loop + /// picks its next source between renders, so rows that scrolled away in + /// the meantime are skipped rather than queued. + fn ensure_diagram_worker(&mut self, cx: &mut Context) { + if self.diagram_worker.is_some() || !self.diagrams.borrow_mut().take_new_requests() { + return; + } + self.diagram_worker = Some(cx.spawn(async move |this, cx| { + loop { + let job = this.update(cx, |this, cx| { + let job = this.diagrams.borrow_mut().next_job(); + if job.is_none() { + this.diagram_worker = None; + } + job.map(|code| { + ( + code, + crate::markdown::mermaid::Palette::from_theme(Theme::of(cx)), + crate::theme::style_generation(), + ) + }) + }); + let Ok(Some((code, palette, style))) = job else { + return; + }; + let source = code.clone(); + let result = cx + .background_executor() + .spawn(async move { + let svg = crate::markdown::mermaid::render(&source, &palette)?; + crate::image_media::decode_image("image/svg+xml", svg.into_bytes()) + }) + .await; + if this + .update(cx, |this, cx| this.finish_diagram(code, style, result, cx)) + .is_err() + { + return; + } + } + })); + } + + fn finish_diagram( + &mut self, + code: String, + style: u32, + result: Result, + cx: &mut Context, + ) { + let Some((rows, released)) = self.diagrams.borrow_mut().finish(code, style, result) else { + return; + }; + crate::image_media::release_media(released, cx); + self.diagram_layout_changed(&rows, cx); + } + + /// A diagram replaced its source (or the reverse): remeasure the rows + /// painting it and let the bottom pin and the own-turn runway absorb the + /// height change, exactly like any other layout-affecting row update. + fn diagram_layout_changed(&mut self, rows: &[SharedString], cx: &mut Context) { + let mut changed = false; + for (ix, row) in self.rows.iter().enumerate() { + if rows.contains(&row.id) { + self.list.remeasure_items(ix..ix + 1); + changed = true; + } + } + if !changed { + return; + } + self.viewport_layout_revision = self.viewport_layout_revision.wrapping_add(1); + if self.pinned { + self.wake_spring(); + } + if self.own_turn.is_some() { + self.own_turn_kick = true; + } + cx.notify(); + } + + /// Open a diagram in the shared lightbox, rasterized for the window + /// within the memory the retained diagrams leave available. + fn open_diagram_preview( + &mut self, + source: crate::image_media::MediaImage, + window: &mut Window, + cx: &mut Context, + ) { + self.close_diagram_zoom(cx); + let viewport = window.viewport_size(); + let available = mermaid_cache::MAX_RETAINED_BYTES + .saturating_sub(self.diagrams.borrow().retained_bytes()); + let enlarged = source.enlarged( + ( + f32::from(viewport.width) * 0.9, + f32::from(viewport.height) * 0.85, + ), + window.scale_factor(), + available, + None, + ); + self.attachment_preview_return_focus = window.focused(cx); + self.attachment_preview = Some(crate::attachments::PreviewImage::new( + "Mermaid diagram", + enlarged.image, + )); + self.diagram_zoom = Some(source); + window.focus(&self.attachment_preview_focus, cx); + cx.notify(); + } + + /// Release a diagram lightbox's dedicated raster. The row's own preview + /// stays with the diagram cache. + fn close_diagram_zoom(&mut self, cx: &mut gpui::App) { + let Some(source) = self.diagram_zoom.take() else { + return; + }; + if let Some(preview) = self.attachment_preview.take() + && !Arc::ptr_eq(&preview.image, &source.image) + { + cx.defer(move |cx| gpui::ImageSource::Image(preview.image).evict(None, cx)); + } + } + fn render_generated_image( &mut self, row_id: &SharedString, @@ -6494,7 +6694,7 @@ impl Transcript { let code = self.code_uis_for(&row.id, &top.block, *block_ix, cx); let opts = RenderOptions { tasks: None, - media: None, + media: Some(self.diagram_media(cx)), row_key: row.id.clone(), veil: None, cache: (!render_cache_disabled()).then(|| self.render_cache.clone()), @@ -6553,9 +6753,18 @@ impl Transcript { }) .clone() }); + // A streaming fence may still be growing, so only blocks the + // reply has already moved past draw diagrams: a later row of + // the same entry proves this block is complete. The tail + // keeps its source until the next block or completion, and + // per-token commits never start a render. + let settled = self + .rows + .get(ix + 1) + .is_some_and(|next| next.entry_id == row.entry_id); let opts = RenderOptions { tasks: None, - media: None, + media: settled.then(|| self.diagram_media(cx)), row_key: row.id.clone(), veil: veil.clone(), cache: (!render_cache_disabled()).then(|| self.render_cache.clone()), @@ -6575,6 +6784,8 @@ impl Transcript { let seed_opts = RenderOptions { cache: None, link: None, + // Seed fences as source text, never request diagrams. + media: None, ..opts.clone() }; let _ = render::render_block( @@ -6642,6 +6853,8 @@ impl Transcript { RowKind::ErrorChip { message } => error_chip(message.clone(), &theme), RowKind::ForkMarker { source_title, .. } => fork_marker(source_title.clone(), &theme), }; + // Diagram fences this row just requested start rendering after layout. + self.ensure_diagram_worker(cx); // Hover-revealed metadata strip: a RESERVED 32px lane under the // entry's last row. Timestamp, copy action, and copied feedback only @@ -8900,6 +9113,23 @@ impl Render for Transcript { // Release gpui-side decoded copies of any images the attachment LRU // evicted since the last frame (no-op when nothing was evicted). crate::attachments::flush_evicted(Some(window), cx); + // Diagram requests are scoped to paint passes; rasters follow the + // reading column and display density (a no-op while both hold). + let released_diagrams = { + let mut diagrams = self.diagrams.borrow_mut(); + let mut released = diagrams.begin_frame(crate::theme::style_generation()); + let list_width = f32::from(self.list.viewport_bounds().size.width); + let column = if list_width > 0.0 { + self.content_width.min(list_width) + } else { + self.content_width + }; + released.extend(diagrams.set_view((column.max(1.0), 480.0), window.scale_factor())); + released + }; + if !released_diagrams.is_empty() { + crate::image_media::release_media(released_diagrams, cx); + } // Own-turn driver: measurements are only authoritative after layout, // so reservation sizing, the send glide, and the outgrown-handoff // each advance at most once per requested frame. Scheduled on every @@ -9046,12 +9276,19 @@ impl Render for Transcript { // (AttachmentPreviewDialog: bare lightbox, click closes). if let Some(preview) = self.attachment_preview.clone() { let weak = cx.weak_entity(); - return root.child(crate::attachments::lightbox( + // A diagram's enlarged raster is framed by its natural size. + let natural_size = self + .diagram_zoom + .as_ref() + .map(|source| size(px(source.width), px(source.height))); + return root.child(crate::attachments::lightbox_with_size( window, &preview, &self.attachment_preview_focus, + natural_size, move |window, cx| { if let Ok(focus) = weak.update(cx, |this, cx| { + this.close_diagram_zoom(cx); this.attachment_preview = None; cx.notify(); this.attachment_preview_return_focus.take() @@ -13072,6 +13309,275 @@ mod tests { }); } + /// Run the next queued diagram synchronously, as the worker would. + fn render_next_diagram(transcript: &Entity, cx: &mut gpui::App) -> bool { + transcript.update(cx, |this, cx| { + let Some(code) = this.diagrams.borrow_mut().next_job() else { + return false; + }; + let palette = crate::markdown::mermaid::Palette::from_theme(Theme::of(cx)); + let result = crate::markdown::mermaid::render(&code, &palette).and_then(|svg| { + crate::image_media::decode_image("image/svg+xml", svg.into_bytes()) + }); + assert!(result.is_ok(), "fixture diagram must render"); + this.finish_diagram(code, crate::theme::style_generation(), result, cx); + true + }) + } + + const MERMAID_FENCE: &str = + "```mermaid\nflowchart TD\n A[Request] --> B[Plan]\n B --> C[Done]\n```\n"; + + #[test] + fn mermaid_streaming_tail_keeps_source_until_the_reply_moves_past_it() { + with_window(|transcript, window, cx| { + let reply = |status, text: String| { + vec![ + prompt("prompt"), + assistant("reply", status, vec![text_part("text", &text)]), + ] + }; + let mut text = String::from("Intro.\n\n"); + // Token-sized commits through the open fence, then the closed + // fence while it is still the reply's tail. + for token in MERMAID_FENCE.split_inclusive([' ', '\n']) { + text.push_str(token); + transcript.update(cx, |this, cx| { + this.rail_enabled = false; + feed(this, reply(MessageStatus::Streaming, text.clone()), cx) + }); + tick(&transcript, window, cx); + let this = transcript.read(cx); + assert!( + this.diagram_worker.is_none(), + "a streaming tail fence must not start a render" + ); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + } + // Enough reply after the fence that the viewport can anchor on + // it without clamping back to the end. + text.push_str(&format!( + "\nAfter the diagram. {}", + "More detail. ".repeat(300) + )); + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Streaming, text.clone()), cx) + }); + draw(window, cx); + assert!( + transcript.read(cx).diagram_worker.is_some(), + "a fence followed by more reply renders" + ); + // A pinned end is glued, where rows report no bounds; read + // them with the viewport anchored at the diagram row instead. + transcript.update(cx, |this, cx| { + this.pinned = false; + let ix = this + .rows + .iter() + .position(|row| row.id.as_ref() == "reply#text.1") + .unwrap(); + this.list.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + }); + draw(window, cx); + let fence_height = |cx: &gpui::App| { + let this = transcript.read(cx); + let ix = this + .rows + .iter() + .position(|row| row.id.as_ref() == "reply#text.1") + .unwrap(); + this.list.bounds_for_item(ix).unwrap().size.height + }; + let source_height = fence_height(cx); + assert!(render_next_diagram(&transcript, cx)); + assert!( + !render_next_diagram(&transcript, cx), + "one request per source" + ); + draw(window, cx); + assert_ne!( + fence_height(cx), + source_height, + "the diagram replaced its source" + ); + let frame: SharedString = "reply#text.1-mermaid-1".into(); + transcript.update(cx, |this, cx| { + assert_eq!(this.diagrams.borrow().ready_count(), 1); + this.diagrams.borrow_mut().toggle_source(&frame); + this.diagram_layout_changed(&["reply#text.1".into()], cx); + }); + draw(window, cx); + assert_eq!( + fence_height(cx), + source_height, + "the toggle restores the source" + ); + // Completion keeps row identity, the rendered diagram and + // the reader's source toggle; nothing renders again. + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Complete, text.clone()), cx) + }); + draw(window, cx); + let this = transcript.read(cx); + assert!(this.diagrams.borrow().source_visible(&frame)); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + }); + } + + #[test] + fn mermaid_diagram_swap_keeps_a_pinned_stream_at_the_bottom() { + with_window(|transcript, window, cx| { + let intro = "A paragraph that explains the result in detail. ".repeat(12); + let reply = |status, tail: &str| { + let mut text = String::new(); + for section in 0..8 { + text.push_str(&format!("Section {section}. {intro}\n\n")); + } + text.push_str(MERMAID_FENCE); + text.push_str(tail); + vec![ + prompt("prompt"), + assistant("reply", status, vec![text_part("text", &text)]), + ] + }; + transcript.update(cx, |this, cx| { + this.rail_enabled = false; + feed( + this, + reply(MessageStatus::Streaming, "\nAfter the diagram."), + cx, + ) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned); + assert!( + this.list.max_offset_for_scrollbar().y > px(100.0), + "must overflow" + ); + assert!(this.distance_from_bottom() <= 1.0); + assert!(render_next_diagram(&transcript, cx)); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned, "a diagram swap is not user input"); + assert!(this.distance_from_bottom() <= 1.0, "pin follows the swap"); + // More streaming after the swap keeps following. + transcript.update(cx, |this, cx| { + feed( + this, + reply( + MessageStatus::Streaming, + &format!("\nAfter the diagram.\n\n{intro}\n\n{intro}"), + ), + cx, + ) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let this = transcript.read(cx); + assert!(this.pinned); + assert!(this.distance_from_bottom() <= 1.0); + assert!(this.diagrams.borrow_mut().next_job().is_none()); + }); + } + + #[test] + fn mermaid_completion_renders_the_tail_without_moving_the_runway_prompt() { + with_window(|transcript, window, cx| { + transcript.update(cx, |this, cx| { + feed(this, vec![prompt("prompt")], cx); + this.rail_enabled = false; + }); + draw(window, cx); + transcript.update(cx, |this, cx| { + this.on_own_send("chat".into(), "prompt".into(), cx) + }); + draw(window, cx); + for _ in 0..80 { + tick(&transcript, window, cx); + } + let reply = |status| { + vec![ + prompt("prompt"), + assistant( + "reply", + status, + vec![text_part( + "text", + &format!("Here it is:\n\n{MERMAID_FENCE}"), + )], + ), + ] + }; + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Streaming), cx) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + assert!(transcript.read(cx).diagram_worker.is_none()); + transcript.update(cx, |this, cx| { + feed(this, reply(MessageStatus::Complete), cx) + }); + for _ in 0..50 { + tick(&transcript, window, cx); + } + let prompt_top = |cx: &gpui::App| { + let this = transcript.read(cx); + assert!(this.own_turn.is_some(), "a short reply keeps its runway"); + this.list.bounds_for_item(0).unwrap().top() + }; + let before = prompt_top(cx); + assert!(render_next_diagram(&transcript, cx)); + // No controller tick between the swap and this paint. + draw(window, cx); + assert_eq!(prompt_top(cx), before, "the diagram swap moved the prompt"); + let this = transcript.read(cx); + assert!( + this.list.max_offset_for_scrollbar().y <= px(2.5), + "the swap opened blank space below the runway" + ); + for _ in 0..50 { + tick(&transcript, window, cx); + } + assert_eq!(prompt_top(cx), before); + + // The lightbox frames an enlarged raster by the diagram's + // natural size and releases it on close. + let media = transcript + .read(cx) + .diagrams + .borrow() + .ready_media() + .expect("rendered diagram"); + cx.update_window(window.into(), |_, window, cx| { + transcript.update(cx, |this, cx| { + this.open_diagram_preview(media.clone(), window, cx) + }); + }) + .unwrap(); + draw(window, cx); + transcript.update(cx, |this, cx| { + assert!(this.attachment_preview.is_some()); + assert!(this.diagram_zoom.is_some()); + this.close_diagram_zoom(cx); + assert!(this.attachment_preview.is_none()); + assert!(this.diagram_zoom.is_none()); + }); + draw(window, cx); + }); + } + #[test] fn send_glide_never_crosses_the_prompt_during_remeasurement() { with_window(|transcript, window, cx| { diff --git a/docs/markdown-preview.md b/docs/markdown-preview.md index b6662e958a..255267bc8b 100644 --- a/docs/markdown-preview.md +++ b/docs/markdown-preview.md @@ -10,7 +10,7 @@ Task lists use the existing GPUI base checkbox with Zeron theme colors and icons Hovering a Markdown block exposes the same add-comment button used in diffs. The shared inline draft and comment cards appear below the block across the full preview panel width, with their location, input, text and actions aligned to the centered reading column. Each card retains its original file and line reference. Comments use the existing file-review staging, removal and editor anchors; they join the composer without modifying the Markdown. A paragraph, list, table or fence is one comment target, cited at its first source line. Existing notes on inner lines appear below their containing block. Cancel or Escape dismisses the draft. Truncated and non-editable previews cannot start comments, and stale preview offsets are rejected. -Workspace-relative images are loaded from the device owning the checkout. HTTP(S) images remain links. HTML and MDX are not executed. Images and diagrams open in the shared centered lightbox with trackpad pinch, Ctrl + wheel zoom and pan; see [image preview and zoom](image-preview.md). Mermaid in chat is not enabled by this change. +Workspace-relative images are loaded from the device owning the checkout. HTTP(S) images remain links. HTML and MDX are not executed. Images and diagrams open in the shared centered lightbox with trackpad pinch, Ctrl + wheel zoom and pan; see [image preview and zoom](image-preview.md). Chat renders Mermaid with the same engine; see [Mermaid in chat](#mermaid-in-chat). Mermaid diagrams use the same fence frame, header metrics, border, background and code actions as ordinary fenced code blocks. Switching between diagram and source replaces the body inside that single frame. @@ -38,6 +38,16 @@ Mermaid source is limited to 16 KiB, 256 lines and 2048 lexical segments; genera Manual validation on macOS and a second physical remote device must be recorded separately from headless Linux tests. Automated tests cannot establish platform-specific focus, GPU rendering or real network behavior by themselves. +## Mermaid in chat + +Assistant replies render ```` ```mermaid ```` fences as diagrams, sharing the file preview's engine, fence frame, source toggle, Copy action and lightbox. Inline images in chat keep their existing text rendering. + +Streaming never renders a fence that may still be growing. Only blocks that a later row of the same reply follows, or blocks of a completed reply, request a diagram. The streaming tail keeps its source; per-token commits start no render work. Until a diagram is ready, the fence shows its ordinary source, so completion changes the row height at most once. A failed render keeps the source and shows the engine's diagnostic in a warning marker in the fence header. + +Rows request their fences while they lay out, so only painted diagrams cost anything. One serialized loop per transcript renders them off the UI thread, choosing its next source between renders and dropping requests whose rows scrolled away. Retained diagrams share a 64 MiB budget and are evicted least recently painted first, with a 64-entry cap; an evicted diagram renders again when its row returns. Diagrams painted in the latest two passes are never evicted. Theme changes discard every diagram, and results computed under the previous theme are rejected. Rasters follow the conversation column width and display density. + +A diagram swap remeasures only the rows painting it and uses the same layout signals as other row-height changes. The bottom pin glides to the new end, and the own-turn runway reservation absorbs the change in the same layout without moving the sent prompt. Source toggles keep the stable row identity across streaming completion. The lightbox enlarges the diagram within the memory the retained diagrams leave available and releases that raster when it closes. + ## Implementation validation The implementation was checked on Linux with the following commands: @@ -58,3 +68,7 @@ Formatting checks pass for all changed Rust files, and `git diff --check` passes ### Browser link follow-up The file preview's session Browser routing was checked on Linux with `cargo test -p zeron-ui -- --test-threads=1` (918 passed), `cargo build -p zeron`, Rustfmt for changed modules, and `git diff --check`. New tests cover the preview-to-Files event, source-session ownership, complete destinations and clipboard content, rejected URLs, mail handling, preview suspension, and the Shell subscriptions for both Files and standalone file tabs. The first parallel suite run failed the existing `active_reply_text_selection_survives_streaming_and_completion` test; it passed in isolation and in the complete sequential run. This follow-up did not repeat native visual verification on Linux or macOS. + +### Mermaid in chat follow-up + +Chat Mermaid rendering was checked on Linux with `cargo test -p zeron-ui --lib -- --test-threads=1` (1528 passed), `cargo check -p zeron`, Rustfmt for changed modules and `git diff --check`. Cache unit tests cover single queueing per source, row tracking, dropping requests that scrolled away, theme invalidation of retained and in-flight results, eviction ordering that spares recently painted diagrams, and source toggles following their rows. Transcript tests stream a fence token by token and verify that the tail starts no render. They also verify that the following block renders it once, that the row height changes and the toggle restores the source, and that completion keeps the diagram and toggle. Other transcript tests check that a pinned overflowing stream stays at the bottom through the swap and further streaming. They also check that rendering a completed tail neither moves the own-turn prompt nor opens blank space below the runway, and that the diagram lightbox opens and releases its raster. In one parallel suite run, the existing `markdown_drag_tracks_each_table_column_and_wrapped_cell` and `rendered_truncation_resizes_and_selects_the_original_url` tests failed. Both passed in isolation and in the complete sequential run. Native visual verification on Linux or macOS was not performed. From 608812e5b789e09f95fa1b6a87d9b635d5d4c610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 2 Oct 2026 23:03:22 -0300 Subject: [PATCH 2/4] Account prepared SVGs at their current raster, not the largest possible decode_image reserved the largest preview any view could request (900x480 at 4x density) for every SVG and Mermaid diagram, so a 177x348 class diagram counted 7.6 MiB against the 64 MiB media budgets. The file preview stopped drawing diagrams after about eight per document, the chat cache retained about eight, and the lightbox had little room left to enlarge. Media now counts its source plus the CPU and GPU copies of the raster it holds. Since admission no longer reserves headroom, the file preview and the chat diagram cache re-check their budget before a larger re-raster (MediaImage::preview_within); a variant that does not fit keeps the current raster. --- crates/ui/src/files/markdown_preview.rs | 17 +++++++- crates/ui/src/image_media.rs | 58 +++++++++++++++++++++---- crates/ui/src/markdown/mermaid_cache.rs | 6 ++- docs/markdown-preview.md | 4 +- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index c4d4a0451b..e33cf746e1 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -239,14 +239,29 @@ impl MarkdownPreview { 480.0, ); let mut retired = Vec::new(); + // Admission accounts current rasters, so a sharper one must still fit + // the document budget. + let mut used: usize = self + .images + .values() + .chain(self.diagrams.values()) + .filter_map(|m| m.as_ref().ok()) + .map(|m| m.bytes) + .sum(); for media in self .images .values_mut() .chain(self.diagrams.values_mut()) .filter_map(|m| m.as_mut().ok()) { - let next = media.preview_for_view(target, window.scale_factor()); + let others = used - media.bytes; + let next = media.preview_within( + target, + window.scale_factor(), + MAX_MEDIA_BYTES.saturating_sub(others), + ); if !Arc::ptr_eq(&media.image, &next.image) { + used = others + next.bytes; retired.push(std::mem::replace(media, next)); self.media_dirty = true; } diff --git a/crates/ui/src/image_media.rs b/crates/ui/src/image_media.rs index 506d6fbd3c..7f862be142 100644 --- a/crates/ui/src/image_media.rs +++ b/crates/ui/src/image_media.rs @@ -60,6 +60,12 @@ fn raster_size( size } +/// Memory a prepared SVG holds: its source and wrapper copies, plus both the +/// CPU pixels and GPU texture of one raster. +fn svg_retained_bytes(svg: &str, raster: (u32, u32)) -> usize { + svg.len() * 2 + 1024 + raster.0 as usize * raster.1 as usize * 8 +} + impl MediaImage { /// Preserve the sanitized vector source; only the outer raster viewport changes. pub(crate) fn for_view(&self, viewport: (f32, f32), dpi: f32, pixels: usize) -> Self { @@ -82,14 +88,25 @@ impl MediaImage { image: Arc::new(Image::from_bytes(ImageFormat::Svg, wrapper.into_bytes())), width: self.width, height: self.height, - bytes: self - .bytes - .max(svg.len() * 2 + 1024 + size.0 as usize * size.1 as usize * 8), + bytes: svg_retained_bytes(svg, size), svg: self.svg.clone(), raster_size: Some(size), } } + /// Re-rasterize for a new view only when the variant fits the memory the + /// owner can still spend (`available`, excluding this media). A larger + /// raster that does not fit keeps the current one: slightly softer, never + /// over budget. + pub(crate) fn preview_within(&self, viewport: (f32, f32), dpi: f32, available: usize) -> Self { + let next = self.preview_for_view(viewport, dpi); + if next.bytes > self.bytes && next.bytes > available { + self.clone() + } else { + next + } + } + pub(crate) fn preview_for_view(&self, viewport: (f32, f32), dpi: f32) -> Self { self.for_view(viewport, dpi, PREVIEW_PIXELS) } @@ -187,15 +204,14 @@ pub(crate) fn decode_image(mime: &str, bytes: Vec) -> Result zeron_proto::MAX_WORKSPACE_IMAGE_BYTES { return Err("Prepared SVG exceeds preview size limit".into()); } - let maximum = raster_size(width, height, (900.0, 480.0), 4.0, PREVIEW_PIXELS); - // Reserve the largest admitted preview across supported display densities, - // including both CPU pixels and GPU texture, plus source and wrapper. - let retained = svg.len() * 2 + 1024 + maximum.0 as usize * maximum.1 as usize * 8; + // Account the raster that exists, not the largest one any view could + // request: owners re-check their budget before a larger re-raster + // (`MediaImage::preview_within`). let media = MediaImage { image: Arc::new(Image::from_bytes(ImageFormat::Svg, Vec::new())), width, height, - bytes: retained, + bytes: svg_retained_bytes(&svg, (0, 0)), svg: Some(Arc::from(svg)), raster_size: None, }; @@ -381,6 +397,32 @@ mod tests { } } + #[test] + fn svg_accounting_follows_the_current_raster_and_upgrades_respect_budget() { + let media = decode_image( + "image/svg+xml", + br#""#.to_vec(), + ) + .unwrap(); + let exact = |m: &MediaImage| { + let (w, h) = m.raster_size.unwrap(); + m.svg.as_ref().unwrap().len() * 2 + 1024 + w as usize * h as usize * 8 + }; + // A small diagram no longer reserves the largest preview any view + // could ask for (900x480 at 4x). + assert_eq!(media.bytes, exact(&media)); + assert!(media.bytes < 1024 * 1024); + let sharper = media.preview_within((900.0, 480.0), 4.0, usize::MAX); + assert!(sharper.bytes > media.bytes); + assert_eq!(sharper.bytes, exact(&sharper)); + // Without room for the larger raster, the current one stays. + let held = media.preview_within((900.0, 480.0), 4.0, media.bytes); + assert!(Arc::ptr_eq(&held.image, &media.image)); + // A smaller raster frees memory and is always taken. + let smaller = sharper.preview_within((100.0, 50.0), 1.0, 0); + assert!(smaller.bytes < sharper.bytes); + } + #[test] fn extreme_aspect_ratios_stay_inside_the_pixel_budget() { for (w, h) in [(1e20, 1.0), (1.0, 1e20)] { diff --git a/crates/ui/src/markdown/mermaid_cache.rs b/crates/ui/src/markdown/mermaid_cache.rs index b01b3a5c64..0f9245a09c 100644 --- a/crates/ui/src/markdown/mermaid_cache.rs +++ b/crates/ui/src/markdown/mermaid_cache.rs @@ -74,10 +74,14 @@ impl MermaidCache { } self.view = Some((target, scale)); let mut retired = Vec::new(); + let mut used = self.retained_bytes(); for entry in self.entries.values_mut() { if let State::Ready(media) = &mut entry.state { - let next = media.preview_for_view(target, scale); + let others = used - media.bytes; + let next = + media.preview_within(target, scale, MAX_RETAINED_BYTES.saturating_sub(others)); if !std::sync::Arc::ptr_eq(&media.image, &next.image) { + used = others + next.bytes; retired.push(std::mem::replace(media, next)); } } diff --git a/docs/markdown-preview.md b/docs/markdown-preview.md index 255267bc8b..be30dc2e97 100644 --- a/docs/markdown-preview.md +++ b/docs/markdown-preview.md @@ -32,7 +32,7 @@ Preview parsing is debounced by 120ms and limited to the first 2 MiB of Markdown Source revisions reject obsolete async results. Image watcher events invalidate both completed and pending loads; changing theme regenerates diagrams. Switching back to Code releases the preview's derived state while preserving the editor and preview scroll position. Closing a preview schedules image asset and atlas eviction. Image/diagram completion remeasures rows with an absolute scroll anchor. -Image reads are limited to 8 MiB, with 384 KiB binary chunks and content-hash validation between chunks. Raster images are decoded with 4096px dimension and 64 MiB allocation limits, then flattened to a static PNG, including the first frame of animations. SVG sources are parsed and reserialized with embedded/external image resolution disabled; the prepared vector source is retained (up to 8 MiB) regardless of its natural dimensions. A bounded outer SVG viewport preserves the complete original coordinate system. Preview rasters adapt to the panel and display density (up to 4×), capped at 1,048,576 pixels and 4096 pixels per side. The lightbox uses its own viewport and up to 2,097,152 pixels within the remaining document memory budget, reusing the preview when there is no room for another raster. CPU pixels, GPU textures and retained SVG bytes are budgeted. Enlarged variants are evicted on close, replacement, source changes and preview disposal. Unsupported SVG content may be omitted. +Image reads are limited to 8 MiB, with 384 KiB binary chunks and content-hash validation between chunks. Raster images are decoded with 4096px dimension and 64 MiB allocation limits, then flattened to a static PNG, including the first frame of animations. SVG sources are parsed and reserialized with embedded/external image resolution disabled; the prepared vector source is retained (up to 8 MiB) regardless of its natural dimensions. A bounded outer SVG viewport preserves the complete original coordinate system. Preview rasters adapt to the panel and display density (up to 4×), capped at 1,048,576 pixels and 4096 pixels per side. The lightbox uses its own viewport and up to 2,097,152 pixels within the remaining document memory budget, reusing the preview when there is no room for another raster. CPU pixels, GPU textures and retained SVG bytes are budgeted. An SVG or diagram is accounted at the raster it currently holds, not the largest raster any view could request. A re-raster at a higher width or density happens only when the larger variant fits the remaining budget; otherwise the current raster stays. Enlarged variants are evicted on close, replacement, source changes and preview disposal. Unsupported SVG content may be omitted. Mermaid source is limited to 16 KiB, 256 lines and 2048 lexical segments; generated SVG is limited to 2 MiB. The native engine has no cooperative cancellation or hard execution deadline. Its CPU work is serialized across previews and runs off the UI thread; obsolete results must be rejected by their owning view. These limits bound admitted work but do not constitute a strict wall-clock guarantee. @@ -44,7 +44,7 @@ Assistant replies render ```` ```mermaid ```` fences as diagrams, sharing the fi Streaming never renders a fence that may still be growing. Only blocks that a later row of the same reply follows, or blocks of a completed reply, request a diagram. The streaming tail keeps its source; per-token commits start no render work. Until a diagram is ready, the fence shows its ordinary source, so completion changes the row height at most once. A failed render keeps the source and shows the engine's diagnostic in a warning marker in the fence header. -Rows request their fences while they lay out, so only painted diagrams cost anything. One serialized loop per transcript renders them off the UI thread, choosing its next source between renders and dropping requests whose rows scrolled away. Retained diagrams share a 64 MiB budget and are evicted least recently painted first, with a 64-entry cap; an evicted diagram renders again when its row returns. Diagrams painted in the latest two passes are never evicted. Theme changes discard every diagram, and results computed under the previous theme are rejected. Rasters follow the conversation column width and display density. +Rows request their fences while they lay out, so only painted diagrams cost anything. One serialized loop per transcript renders them off the UI thread, choosing its next source between renders and dropping requests whose rows scrolled away. Retained diagrams share a 64 MiB budget and are evicted least recently painted first, with a 64-entry cap; an evicted diagram renders again when its row returns. Diagrams painted in the latest two passes are never evicted. Theme changes discard every diagram, and results computed under the previous theme are rejected. Rasters follow the conversation column width and display density, under the same budget check before a larger re-raster. A diagram swap remeasures only the rows painting it and uses the same layout signals as other row-height changes. The bottom pin glides to the new end, and the own-turn runway reservation absorbs the change in the same layout without moving the sent prompt. Source toggles keep the stable row identity across streaming completion. The lightbox enlarges the diagram within the memory the retained diagrams leave available and releases that raster when it closes. From fa8d68b9d787314d1c326a0303c6faf4bd08cf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 3 Oct 2026 00:06:44 -0300 Subject: [PATCH 3/4] Draw Mermaid diagrams in Zeron's style Render diagrams on a transparent canvas so they sit on the fence body, with rounded hairline cards for nodes, muted labels and connectors, and a soft accent tint on default decisions, sequence notes and activations. Gantt bars take their hues from the accent and gridlines use the theme border. Explicit style and classDef colors are kept. Tighter spacing keeps labels larger once a diagram is scaled to the reading column, and the lightbox draws diagrams on a plate in the fence body color. --- crates/ui/src/attachments.rs | 19 +- crates/ui/src/files/image_preview.rs | 1 + crates/ui/src/files/markdown_preview.rs | 6 +- crates/ui/src/image_viewer.rs | 13 ++ crates/ui/src/markdown/mermaid.rs | 255 +++++++++++++++++++++--- crates/ui/src/transcript.rs | 8 +- docs/markdown-preview.md | 6 + 7 files changed, 275 insertions(+), 33 deletions(-) diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs index 6692feb720..cd4e5f5856 100644 --- a/crates/ui/src/attachments.rs +++ b/crates/ui/src/attachments.rs @@ -919,6 +919,8 @@ pub struct PreviewImage { pub name: SharedString, pub image: Arc, pub(crate) viewer: crate::image_viewer::ImageView, + /// Fill behind an image drawn on a transparent canvas (diagrams). + pub(crate) plate: Option, } impl PreviewImage { @@ -927,8 +929,14 @@ impl PreviewImage { name: name.into(), image, viewer: Default::default(), + plate: None, } } + + pub(crate) fn with_plate(mut self, plate: gpui::Hsla) -> Self { + self.plate = Some(plate); + self + } } /// Shared image viewer over a dim scrim. Escape and a click close it; @@ -969,9 +977,14 @@ pub(crate) fn lightbox_with_size( }) }); let content = match natural_size { - Some(natural) => preview - .viewer - .render(preview.image.clone(), natural, None, window, cx), + Some(natural) => preview.viewer.render( + preview.image.clone(), + natural, + None, + preview.plate, + window, + cx, + ), None => div() .text_color(ink(0.6)) .child("Loading image…") diff --git a/crates/ui/src/files/image_preview.rs b/crates/ui/src/files/image_preview.rs index d85ebf1c02..88d23e9dfb 100644 --- a/crates/ui/src/files/image_preview.rs +++ b/crates/ui/src/files/image_preview.rs @@ -205,6 +205,7 @@ impl Render for ImagePreview { display.image, gpui::size(px(source.width), px(source.height)), None, + None, window, cx, )); diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index e33cf746e1..dfe7a7ab51 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -981,9 +981,11 @@ impl MarkdownPreview { loaded: &crate::image_media::MediaImage, id: gpui::SharedString, name: String, + plate: Option, weak: gpui::WeakEntity, ) -> AnyElement { - let preview = crate::attachments::PreviewImage::new(name, loaded.image.clone()); + let mut preview = crate::attachments::PreviewImage::new(name, loaded.image.clone()); + preview.plate = plate; let source = loaded.clone(); crate::image_media::preview_element(loaded, id, move |window, cx| { let _ = weak.update(cx, |view, cx| { @@ -1126,6 +1128,7 @@ impl MarkdownPreview { loaded, format!("{id}-image").into(), "Mermaid diagram".into(), + Some(crate::markdown::mermaid::Palette::plate(theme)), diagram_owner.clone(), ), Some(Err(error)) => div() @@ -1170,6 +1173,7 @@ impl MarkdownPreview { } else { image.alt.clone() }, + None, image_owner.clone(), ), ); diff --git a/crates/ui/src/image_viewer.rs b/crates/ui/src/image_viewer.rs index b36b337557..10bbbda8a3 100644 --- a/crates/ui/src/image_viewer.rs +++ b/crates/ui/src/image_viewer.rs @@ -207,6 +207,7 @@ impl ImageView { image: Arc, natural: Size, on_image_click: Option, + plate: Option, _window: &mut Window, _cx: &mut App, ) -> AnyElement { @@ -283,6 +284,18 @@ impl ImageView { on_click(window, cx); } }) + .when_some(plate, |viewport, plate| { + viewport.child( + div() + .absolute() + .left(px(origin.x)) + .top(px(origin.y)) + .w(px(natural.width * geometry.scale)) + .h(px(natural.height * geometry.scale)) + .rounded(px(10.0)) + .bg(plate), + ) + }) .child( gpui::img(image) .absolute() diff --git a/crates/ui/src/markdown/mermaid.rs b/crates/ui/src/markdown/mermaid.rs index 9cce173a6c..a22b6ea4df 100644 --- a/crates/ui/src/markdown/mermaid.rs +++ b/crates/ui/src/markdown/mermaid.rs @@ -8,16 +8,23 @@ pub const MAX_SOURCE_BYTES: usize = 16 * 1024; // CPU work across previews; UI owners discard results from superseded revisions. static RENDER_LOCK: Mutex<()> = Mutex::new(()); +/// Zeron's diagram style. The canvas is left transparent so the diagram sits +/// on its fence body; `canvas` is that body's opaque approximation, used where +/// the engine needs a solid mask (edge label pills, hollow markers). #[derive(Clone)] pub struct Palette { dark: bool, font: String, - background: String, - raised: String, + canvas: String, + node: String, + group: String, text: String, - muted: String, + label: String, + line: String, border: String, - accent: String, + grid: String, + accent_line: String, + accent_wash: String, } fn color(color: gpui::Hsla, background: gpui::Hsla) -> String { @@ -36,17 +43,36 @@ fn color(color: gpui::Hsla, background: gpui::Hsla) -> String { impl Palette { pub fn from_theme(theme: &Theme) -> Self { + let dark = theme.appearance.is_dark(); + let canvas = Self::plate(theme); + // Nodes are cards lifted off the fence: white in light, one ink step + // up in dark, where the panel is already the deepest plane. + let node = if dark { + canvas.blend(theme.ink(0.06)) + } else { + theme.bg + }; Self { - dark: theme.appearance.is_dark(), + dark, font: theme.font_sans.to_string(), - background: color(theme.surface, theme.bg), - raised: color(theme.surface_raised, theme.bg), - text: color(theme.text, theme.bg), - muted: color(theme.text_muted, theme.bg), - border: color(theme.border_strong, theme.bg), - accent: color(theme.accent, theme.bg), + canvas: color(canvas, theme.bg), + node: color(node, canvas), + group: color(theme.ink(0.03), canvas), + text: color(theme.text, node), + label: color(theme.text_muted, canvas), + line: color(theme.text_faint, canvas), + border: color(theme.border_strong, canvas), + grid: color(theme.border, canvas), + accent_line: color(theme.accent.opacity(0.6), canvas), + accent_wash: color(theme.accent.opacity(0.12), node), } } + + /// The fence body, an ink wash over the panel (see `code_block_frame`). + /// Also the fill behind a diagram shown off its fence, as in the lightbox. + pub fn plate(theme: &Theme) -> gpui::Hsla { + theme.bg.blend(theme.ink(0.035)) + } } pub fn render(source: &str, palette: &Palette) -> Result { @@ -67,29 +93,42 @@ pub fn render(source: &str, palette: &Palette) -> Result { } else { mermaid_rs_renderer::Theme::modern() }; + // A denser layout than the engine's default: diagrams are scaled to the + // reading column, so slack spacing shrinks the labels. + options.layout.node_spacing = 36.0; + options.layout.rank_spacing = 40.0; + options.layout.node_padding_x = 18.0; + options.layout.node_padding_y = 10.0; let theme = &mut options.theme; theme.font_family = palette.font.clone(); theme.font_size = 14.0; - theme.background = palette.background.clone(); - theme.primary_color = palette.raised.clone(); + theme.background = palette.canvas.clone(); + theme.primary_color = palette.node.clone(); theme.primary_text_color = palette.text.clone(); theme.primary_border_color = palette.border.clone(); - theme.text_color = palette.text.clone(); - theme.line_color = palette.muted.clone(); - theme.secondary_color = palette.raised.clone(); - theme.tertiary_color = palette.raised.clone(); - theme.edge_label_background = palette.background.clone(); - theme.cluster_background = palette.background.clone(); + theme.text_color = palette.label.clone(); + theme.line_color = palette.line.clone(); + theme.secondary_color = palette.node.clone(); + theme.tertiary_color = palette.group.clone(); + theme.edge_label_background = palette.canvas.clone(); + theme.cluster_background = palette.group.clone(); theme.cluster_border = palette.border.clone(); - theme.sequence_actor_fill = palette.raised.clone(); + theme.sequence_actor_fill = palette.node.clone(); theme.sequence_actor_border = palette.border.clone(); - theme.sequence_actor_line = palette.muted.clone(); - theme.sequence_note_fill = palette.raised.clone(); - theme.sequence_note_border = palette.border.clone(); - theme.sequence_activation_fill = palette.accent.clone(); - theme.sequence_activation_border = palette.border.clone(); + theme.sequence_actor_line = palette.border.clone(); + theme.sequence_note_fill = palette.accent_wash.clone(); + theme.sequence_note_border = palette.accent_line.clone(); + theme.sequence_activation_fill = palette.accent_wash.clone(); + theme.sequence_activation_border = palette.accent_line.clone(); + if diagram_keyword(source) == Some("gantt") { + // Gantt derives its bar hues from this color; a neutral gray would + // turn every section red. The softened accent keeps bar labels + // legible in both appearances. + theme.primary_border_color = palette.accent_line.clone(); + } let svg = mermaid_rs_renderer::render_with_options(source, options).map_err(|e| e.to_string())?; + let svg = restyle(svg, palette); if svg.len() > 2 * 1024 * 1024 { return Err("Diagram output exceeds preview size limit".into()); } @@ -98,6 +137,128 @@ pub fn render(source: &str, palette: &Palette) -> Result { .unwrap_or_else(|_| Err("Diagram could not be rendered".into())) } +/// The diagram type: the first word after any front matter and comments. +fn diagram_keyword(source: &str) -> Option<&str> { + let mut lines = source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .peekable(); + if lines.next_if_eq(&"---").is_some() { + lines.by_ref().find(|line| *line == "---"); + } + lines + .find(|line| !line.starts_with("%%")) + .and_then(|line| line.split_whitespace().next()) +} + +/// Finishing touches the engine's theme cannot express, applied to its +/// pinned output: a transparent canvas, softer node corners, cards for +/// boxes the engine fills with its background, decisions in the accent and +/// themed Gantt gridlines. Only elements still carrying the palette's own +/// colors change, so explicit `style`/`classDef` colors survive. +fn restyle(svg: String, palette: &Palette) -> String { + let canvas_fill = format!(" fill=\"{}\"", palette.canvas); + let node_fill = format!(" fill=\"{}\"", palette.node); + let border_stroke = format!(" stroke=\"{}\"", palette.border); + let mut out = String::with_capacity(svg.len()); + let mut rest = svg.as_str(); + while let Some(start) = rest.find('<') { + out.push_str(&rest[..start]); + rest = &rest[start..]; + let Some(end) = rest.find('>') else { break }; + let tag = &rest[..=end]; + rest = &rest[end + 1..]; + if is_canvas(tag, palette) { + continue; + } + if tag.starts_with(" Vec<(&str, &str)> { + let parts: Vec<_> = tag.split('"').collect(); + parts + .as_chunks::<2>() + .0 + .iter() + .filter_map(|[name, value]| { + let name = name.trim_end().strip_suffix('=')?; + Some((name.rsplit(' ').next()?, *value)) + }) + .collect() +} + +/// A full-bleed background: a plain rectangle in the canvas color. +fn is_canvas(tag: &str, palette: &Palette) -> bool { + if !tag.starts_with(" {} + "fill" => fill = Some(value), + _ => return false, + } + } + fill == Some(palette.canvas.as_str()) +} + +fn is_default_diamond(tag: &str, palette: &Palette) -> bool { + let attrs = attributes(tag); + let get = |key: &str| attrs.iter().find(|(name, _)| *name == key).map(|(_, v)| *v); + if get("fill") != Some(palette.node.as_str()) || get("stroke") != Some(palette.border.as_str()) + { + return false; + } + let Some(points) = get("points") else { + return false; + }; + let points: Vec<(f32, f32)> = points + .split_whitespace() + .filter_map(|point| { + let (x, y) = point.split_once(',')?; + Some((x.parse().ok()?, y.parse().ok()?)) + }) + .collect(); + let [top, right, bottom, left] = points[..] else { + return false; + }; + (top.0 - bottom.0).abs() < 0.05 + && (left.1 - right.1).abs() < 0.05 + && left.0 < top.0 + && top.0 < right.0 + && top.1 < left.1 + && left.1 < bottom.1 +} + #[cfg(test)] mod tests { use super::*; @@ -189,4 +350,48 @@ mod tests { assert!(render(&"x".repeat(MAX_SOURCE_BYTES + 1), &palette).is_err()); assert!(render("flowchart TD\nA[Hola
mundo] --> B[Fin]", &palette).is_ok()); } + + #[test] + fn diagrams_take_zeron_style_and_keep_explicit_colors() { + for theme in [Theme::light(), Theme::dark()] { + let palette = Palette::from_theme(&theme); + let svg = render( + "flowchart TD\nA[Inicio] --> B{¿Listo?}\nB --> C[Fin]\nB --> D{Otra}\nstyle D fill:#dbeafe,stroke:#2563eb", + &palette, + ) + .unwrap(); + // The fence body shows through: no full-bleed background remains. + assert!(!svg.contains(&format!("fill=\"{}\"/>", palette.canvas))); + assert!(svg.contains(" rx=\"8\" ry=\"8\" ")); + assert!(!svg.contains(" rx=\"3\" ry=\"3\" ")); + let diamonds: Vec<_> = svg.match_indices("= 2); + assert_eq!(accented, 1, "only the default-colored decision is tinted"); + assert!(svg.contains("fill=\"#dbeafe\" stroke=\"#2563eb\"")); + + let gantt = render( + "%% plan\ngantt\ndateFormat YYYY-MM-DD\nsection A\nTask :2026-09-08, 2d", + &palette, + ) + .unwrap(); + assert!(!gantt.contains("#E2E8F0")); + } + } + + #[test] + fn diagram_keyword_skips_front_matter_and_comments() { + assert_eq!(diagram_keyword("gantt\n title X"), Some("gantt")); + assert_eq!( + diagram_keyword("---\ntitle: Plan\n---\n%% note\n\n gantt"), + Some("gantt") + ); + assert_eq!(diagram_keyword("flowchart LR; A-->B"), Some("flowchart")); + assert_eq!(diagram_keyword(" \n%% only"), None); + } } diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index a4a5167d07..56072f2ce6 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -6000,10 +6000,10 @@ impl Transcript { None, ); self.attachment_preview_return_focus = window.focused(cx); - self.attachment_preview = Some(crate::attachments::PreviewImage::new( - "Mermaid diagram", - enlarged.image, - )); + self.attachment_preview = Some( + crate::attachments::PreviewImage::new("Mermaid diagram", enlarged.image) + .with_plate(crate::markdown::mermaid::Palette::plate(Theme::of(cx))), + ); self.diagram_zoom = Some(source); window.focus(&self.attachment_preview_focus, cx); cx.notify(); diff --git a/docs/markdown-preview.md b/docs/markdown-preview.md index be30dc2e97..246b35d48d 100644 --- a/docs/markdown-preview.md +++ b/docs/markdown-preview.md @@ -14,6 +14,8 @@ Workspace-relative images are loaded from the device owning the checkout. HTTP(S Mermaid diagrams use the same fence frame, header metrics, border, background and code actions as ordinary fenced code blocks. Switching between diagram and source replaces the body inside that single frame. +Diagrams are drawn in Zeron's own style rather than the engine's stock theme. The canvas is transparent, so the diagram sits directly on the fence body under any surface treatment. Nodes are cards with 8px corners and a hairline border: white in light mode, one ink step above the fence in dark mode. Groups are a faint wash. Edge and group labels use muted text, and connectors use faint text. Decisions, sequence notes and activations carry a soft tint of the selected accent, and Gantt bars derive their hues from it. Gantt gridlines use the theme border. Colors set explicitly with `style` or `classDef` are kept. The layout uses tighter node and rank spacing than the engine default, so diagrams scaled to the reading column keep larger labels. In the lightbox, the diagram is drawn on a rounded plate in the fence body color so it remains legible over the scrim. + Source fences use the same interaction model as chat code blocks: long lines have an independent horizontal scroll plane and a hover scrollbar, Copy changes to a transient `Copied` confirmation, and the Fit content control wraps lines to the available width. The Fit content choice is the same device-level persisted setting used by chat, so changing it in either surface updates both; horizontal offsets remain local to each fence. ## Mermaid engine decision @@ -72,3 +74,7 @@ The file preview's session Browser routing was checked on Linux with `cargo test ### Mermaid in chat follow-up Chat Mermaid rendering was checked on Linux with `cargo test -p zeron-ui --lib -- --test-threads=1` (1528 passed), `cargo check -p zeron`, Rustfmt for changed modules and `git diff --check`. Cache unit tests cover single queueing per source, row tracking, dropping requests that scrolled away, theme invalidation of retained and in-flight results, eviction ordering that spares recently painted diagrams, and source toggles following their rows. Transcript tests stream a fence token by token and verify that the tail starts no render. They also verify that the following block renders it once, that the row height changes and the toggle restores the source, and that completion keeps the diagram and toggle. Other transcript tests check that a pinned overflowing stream stays at the bottom through the swap and further streaming. They also check that rendering a completed tail neither moves the own-turn prompt nor opens blank space below the runway, and that the diagram lightbox opens and releases its raster. In one parallel suite run, the existing `markdown_drag_tracks_each_table_column_and_wrapped_cell` and `rendered_truncation_resizes_and_selects_the_original_url` tests failed. Both passed in isolation and in the complete sequential run. Native visual verification on Linux or macOS was not performed. + +### Diagram style follow-up + +The Zeron diagram style was checked with `cargo test -p zeron-ui --lib -- --test-threads=1` (1531 passed), Rustfmt for changed modules and Clippy, which reports no new warnings in changed code. New unit tests cover the transparent canvas, rounded node corners, accent-tinted default decisions alongside an explicitly styled one that keeps its colors, themed Gantt gridlines, and diagram type detection past front matter and comments. The six-fixture corpus was rendered with `ZERON_MERMAID_ARTIFACTS` in light, dark and Geist Mono variants. It was composited onto the fence body color at the chat column size and inspected visually. Native visual verification in the running application was not performed. From 0d37e717608309889e46a0431d343e5d999eea31 Mon Sep 17 00:00:00 2001 From: Wing Date: Fri, 2 Oct 2026 22:45:52 -0700 Subject: [PATCH 4/4] Gate Linux-only Mermaid cache test helpers ready_count and ready_media serve the transcript tests, which only compile on Linux, so they were dead code (with a warning) on macOS and Windows. Co-Authored-By: Claude Opus 5.5 --- crates/ui/src/markdown/mermaid_cache.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/markdown/mermaid_cache.rs b/crates/ui/src/markdown/mermaid_cache.rs index 0f9245a09c..b64f5c27e1 100644 --- a/crates/ui/src/markdown/mermaid_cache.rs +++ b/crates/ui/src/markdown/mermaid_cache.rs @@ -225,7 +225,8 @@ impl MermaidCache { } } - #[cfg(test)] + // Used by the transcript tests, which only run on Linux. + #[cfg(all(test, target_os = "linux"))] pub(crate) fn ready_count(&self) -> usize { self.entries .values() @@ -233,7 +234,7 @@ impl MermaidCache { .count() } - #[cfg(test)] + #[cfg(all(test, target_os = "linux"))] pub(crate) fn ready_media(&self) -> Option { self.entries.values().find_map(|entry| match &entry.state { State::Ready(media) => Some(media.clone()),