From d034209dedaa807d46ebc3ca6d12c2394f0dfd57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 00:06:41 -0300 Subject: [PATCH 1/4] Markdown preview: run zeron-visual fences as native visuals Fenced ```zeron-visual blocks in Markdown documents run as Luau visuals in the Files preview, with a Show source / Show visual toggle, a cap of 8 live visuals per document, and a Run visual button for documents outside the workspace. Add `zeron visual check `, which runs a document's fences (or a .luau script) with the preview's seeds and maps errors to document lines, backed by a line scanner in zeron_visual::document. --- Cargo.lock | 1 + apps/zeron/Cargo.toml | 1 + apps/zeron/src/main.rs | 25 ++ apps/zeron/src/visual_cli.rs | 133 ++++++++ crates/mcp/src/jsonrpc.rs | 6 +- crates/mcp/src/tools.rs | 4 + crates/ui/src/files/markdown_preview.rs | 431 +++++++++++++++++++++++- crates/ui/src/markdown/render.rs | 292 +++++++++++++--- crates/ui/src/pull_request_media.rs | 1 + crates/ui/src/transcript.rs | 1 + crates/ui/src/visuals.rs | 108 +++++- crates/visual/src/document.rs | 296 ++++++++++++++++ crates/visual/src/lib.rs | 9 + docs/mcp.md | 46 +++ 14 files changed, 1275 insertions(+), 79 deletions(-) create mode 100644 apps/zeron/src/visual_cli.rs create mode 100644 crates/visual/src/document.rs diff --git a/Cargo.lock b/Cargo.lock index 07b24331ba..b25210d9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13569,6 +13569,7 @@ dependencies = [ "zeron-rpc", "zeron-ui", "zeron-update", + "zeron-visual", ] [[package]] diff --git a/apps/zeron/Cargo.toml b/apps/zeron/Cargo.toml index 8ee7a430fa..cfad155e7a 100644 --- a/apps/zeron/Cargo.toml +++ b/apps/zeron/Cargo.toml @@ -15,6 +15,7 @@ zeron-rpc.workspace = true zeron-ui.workspace = true zeron-update.workspace = true zeron-mcp.workspace = true +zeron-visual.workspace = true tokio.workspace = true clap.workspace = true anyhow.workspace = true diff --git a/apps/zeron/src/main.rs b/apps/zeron/src/main.rs index 7744301d0c..185e18e5f2 100644 --- a/apps/zeron/src/main.rs +++ b/apps/zeron/src/main.rs @@ -8,6 +8,7 @@ mod auth_cli; mod daemon; mod paths; mod update_cli; +mod visual_cli; use clap::{Parser, Subcommand}; @@ -48,6 +49,11 @@ enum Command { /// proxying to the running engine's IPC. Agents use it to create, read, /// and message chats. Logs go to stderr; stdout is the protocol. Mcp, + /// Agent visuals (Luau scripts Zeron draws natively). + Visual { + #[command(subcommand)] + command: VisualCommand, + }, /// Manage `zeron headless` as a background service (launchd / systemd --user). Daemon { #[command(subcommand)] @@ -61,6 +67,17 @@ enum Command { }, } +#[derive(Subcommand)] +enum VisualCommand { + /// Run every visual in a file the way Zeron will: each ```zeron-visual + /// fence of a Markdown document, or a whole `.luau` script. Errors point + /// at the file's lines; exits 1 when any visual fails. + Check { + #[arg(value_name = "FILE")] + path: std::path::PathBuf, + }, +} + #[derive(Subcommand)] enum DaemonCommand { /// Install, enable, and start the service (captures ZERON_* env). @@ -267,6 +284,14 @@ fn main() -> anyhow::Result<()> { .build()?; runtime.block_on(zeron_mcp::run(zeron_mcp::McpConfig::from_env())) } + Some(Command::Visual { + command: VisualCommand::Check { path }, + }) => { + if !visual_cli::check(&path)? { + std::process::exit(1); + } + Ok(()) + } #[cfg(target_os = "linux")] Some(Command::Appshot) => { zeron_ui::appshots::request_running_appshot(&engine_config_from_env().data_dir) diff --git a/apps/zeron/src/visual_cli.rs b/apps/zeron/src/visual_cli.rs new file mode 100644 index 0000000000..0fe8d7b5e4 --- /dev/null +++ b/apps/zeron/src/visual_cli.rs @@ -0,0 +1,133 @@ +//! `zeron visual check`: run a file's visuals the way Zeron will, without +//! Zeron open. An agent that wrote ```zeron-visual fences into a Markdown +//! document can't see the preview, so this is how it learns that a visual +//! fails, and at which line of the document. + +use std::path::Path; + +use anyhow::Context as _; +use zeron_visual::document; + +/// Check every visual in `path` and print one line per visual. False when +/// any of them fails. +pub fn check(path: &Path) -> anyhow::Result { + let text = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let name = path.display().to_string(); + let markdown = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown")); + let (lines, ok) = if markdown { + check_markdown(&name, &text) + } else { + check_script(&name, &text) + }; + for line in lines { + println!("{line}"); + } + Ok(ok) +} + +fn check_markdown(name: &str, text: &str) -> (Vec, bool) { + let fences = document::fences(text); + if fences.is_empty() { + return ( + vec![format!("{name}: no ```{} fences", document::FENCE_LANGUAGE)], + true, + ); + } + let mut out = Vec::new(); + let mut ok = true; + for (ix, fence) in fences.iter().enumerate() { + // The seed the preview runs this fence with: same random data. + let seed = zeron_visual::seed_for_document(&fence.code, fence.occurrence); + ok &= report( + &mut out, + name, + &format!("visual {}", ix + 1), + fence.line, + zeron_visual::check(&fence.code, seed), + ); + } + (out, ok) +} + +fn check_script(name: &str, text: &str) -> (Vec, bool) { + // A fence's code has no trailing newline; seed the script as that fence. + let script = text.strip_suffix('\n').unwrap_or(text); + let script = script.strip_suffix('\r').unwrap_or(script); + let seed = zeron_visual::seed_for_document(script, 0); + let mut out = Vec::new(); + let ok = report( + &mut out, + name, + "visual", + 0, + zeron_visual::check(script, seed), + ); + (out, ok) +} + +fn report( + out: &mut Vec, + name: &str, + label: &str, + first_line: usize, + result: Result, +) -> bool { + match result { + Ok(report) => { + out.push(format!( + "{name}:{}: {label} ok ({} interactions)", + first_line.max(1), + report.interactions + )); + out.extend(report.logs.iter().map(|log| format!(" print: {log}"))); + true + } + Err(error) => { + let (line, message) = document::locate(&error.message, first_line); + out.push(format!("{name}:{}: {label}: {message}", line.max(1))); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_errors_point_at_document_lines() { + let doc = "# Report\n\nIntro.\n\n```zeron-visual\n--! title: Fine\nprint(\"hello\")\nreturn function()\n return ui.text \"ok\"\nend\n```\n\n- A list\n\n ~~~zeron-visual\n local x = 1\n return function()\n return ui.nope {}\n end\n ~~~\n"; + let (lines, ok) = check_markdown("doc.md", doc); + assert!(!ok); + assert_eq!(lines[0], "doc.md:5: visual 1 ok (0 interactions)"); + assert_eq!(lines[1], " print: hello"); + // `ui.nope` is on script line 3 of the fence opened on line 15. + assert!( + lines[2].starts_with("doc.md:18: visual 2: "), + "{}", + lines[2] + ); + } + + #[test] + fn documents_without_visuals_pass() { + let (lines, ok) = check_markdown("plain.md", "# Nothing\n\n```lua\nx()\n```\n"); + assert!(ok); + assert_eq!(lines, ["plain.md: no ```zeron-visual fences"]); + } + + #[test] + fn scripts_report_their_own_lines() { + let (lines, ok) = check_script( + "v.luau", + "local a = 1\nreturn function()\n error(\"boom\")\nend\n", + ); + assert!(!ok); + assert!(lines[0].starts_with("v.luau:3: visual: "), "{}", lines[0]); + assert!(lines[0].contains("boom"), "{}", lines[0]); + } +} diff --git a/crates/mcp/src/jsonrpc.rs b/crates/mcp/src/jsonrpc.rs index 01b0c9e6c6..d5c6230b76 100644 --- a/crates/mcp/src/jsonrpc.rs +++ b/crates/mcp/src/jsonrpc.rs @@ -50,7 +50,11 @@ When a chart, table, diagram, dashboard, or 3D view would say more than prose an your final reply. Zeron renders it with its own native interface and enforces its style: \ content sits directly on the chat with no cards or boxed panels, text uses the interface font \ and type scale, and colors are theme tokens that work on frosted glass or opaque, light or dark. \ -The reader sees the visual above that reply, so don't announce or restate it."; +The reader sees the visual above that reply, so don't announce or restate it.\n\ +\n\ +The same Luau visual script can live in a Markdown document as a ```zeron-visual fenced block \ +(optional first line `--! title: ...`; no assets); Zeron's Markdown preview runs it. Run \ +`zeron visual check ` after editing such a document and fix any errors it reports."; const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6d4b87d801..68536815b1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -278,6 +278,10 @@ end\n\ Zeron runs the script before showing it (setup, render, every draw, and each tab, toggle, slider, and \ button once) and returns the first error with its line; fix it and call again.\n\ \n\ +The same script also works in a Markdown document as a ```zeron-visual fenced block, which Zeron's \ +Markdown preview runs (title it with a first line `--! title: ...`; fences can't use assets). Check a \ +document's visuals with `zeron visual check `, which reports errors at the document's lines.\n\ +\n\ Nodes are props tables: children are the array part, strings are plain text, nil and false children \ are skipped, and lists of nodes flatten (so ui.map results can be children). Unknown props are errors.\n\ - ui.column { gap = 8, align = \"start|center|end\", ... }, ui.row { gap = 12, align, justify = \ diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index dfe7a7ab51..e44f3d6d01 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -22,6 +22,9 @@ use std::{ const MAX_MEDIA_BYTES: usize = 64 * 1024 * 1024; const MAX_MEDIA_ENTRIES: usize = 32; const MAX_MARKDOWN_BYTES: usize = 2 * 1024 * 1024; +/// Live visuals per document. Their Luau runs on the main thread, so a +/// document past this many shows the rest as code. +const MAX_VISUALS: usize = 8; const MAX_PREVIEW_CONTENT_WIDTH: f32 = 900.0; const PREVIEW_VERTICAL_PADDING: f32 = 16.0; @@ -83,6 +86,19 @@ fn web_target(target: &str) -> bool { && url::Url::parse(target).is_ok_and(|url| url.scheme() == "mailto")) } +/// A document visual: its fence's code and its position among fences with +/// identical code. Independent of where the fence sits, so text added above +/// it keeps the visual running. +type VisualKey = (String, usize); + +/// How one visual fence of a row renders this frame. +enum RowVisual { + Live(gpui::Entity), + /// An outside document's visual, waiting for "Run visual". + Gated(VisualKey), + Blocked(&'static str), +} + fn comment_block(lines: &[u32], line: u32) -> Option { (!lines.is_empty()).then(|| { lines @@ -201,6 +217,15 @@ pub(super) struct MarkdownPreview { diagram_task: Option>, diagram_style: u32, source_visible: HashSet, + /// Running ```` ```zeron-visual ```` fences, created when their row + /// first renders and kept while their code stays in the document. + visuals: HashMap>, + /// Each fence's key by its frame id (`{row}-visual-{ix}`), per parse. + visual_slots: HashMap, + /// The first [`MAX_VISUALS`] fences in document order: the ones that run. + visual_allowed: HashSet, + /// Visuals of an outside document the reader chose to run. + visual_runnable: HashSet, preview_image: Option, zoom_source: Option, zoom_render: Option, @@ -396,6 +421,7 @@ impl MarkdownPreview { ) -> Self { cx.on_release(|view, cx| { view.close_media_preview(cx); + view.clear_visuals(); render::clear_selection_surface(&view.scope); let media = view .images @@ -431,6 +457,10 @@ impl MarkdownPreview { diagram_task: None, diagram_style: 0, source_visible: HashSet::new(), + visuals: HashMap::new(), + visual_slots: HashMap::new(), + visual_allowed: HashSet::new(), + visual_runnable: HashSet::new(), preview_image: None, zoom_source: None, zoom_render: None, @@ -560,6 +590,7 @@ impl MarkdownPreview { view.block_lines = block_lines; view.highlights = highlights; view.anchors = anchors; + view.sync_visuals(); view.cache.borrow_mut().clear(); view.loading = false; view.load_images(cx); @@ -750,6 +781,123 @@ impl MarkdownPreview { }) } + fn clear_visuals(&mut self) { + // A frame frees its GPU images on release. + self.visuals.clear(); + self.visual_slots.clear(); + self.visual_allowed.clear(); + self.visual_runnable.clear(); + } + + /// Map the parsed tree's visual fences to their keys, and drop the + /// visuals whose fence left the document or changed its code. + fn sync_visuals(&mut self) { + self.visual_slots.clear(); + self.visual_allowed.clear(); + let mut seen: HashMap = HashMap::new(); + for (top_ix, top) in self.tree.blocks.iter().enumerate() { + for (ix, code) in render::visual_fences(&top.block, top_ix) { + let occurrence = seen.entry(code.clone()).or_default(); + let key = (code, *occurrence); + *occurrence += 1; + if self.visual_allowed.len() < MAX_VISUALS { + self.visual_allowed.insert(key.clone()); + } + self.visual_slots + .insert(format!("{}{top_ix}-visual-{ix}", self.scope), key); + } + } + if self.truncated { + self.visuals.clear(); + } + let present: HashSet<&VisualKey> = self.visual_slots.values().collect(); + self.visuals.retain(|key, _| present.contains(key)); + self.visual_runnable.retain(|key| present.contains(key)); + } + + /// How each visual fence of row `top_ix` renders, by frame id. Starts + /// the visuals that may run and have none yet: only rendered rows get + /// here, so a long document starts its visuals as they scroll in. + fn row_visuals( + &mut self, + block: &Block, + top_ix: usize, + link: &LinkUi, + cx: &mut Context, + ) -> HashMap { + let outside = super::path_is_outside(&self.path); + let mut out = HashMap::new(); + for (ix, _) in render::visual_fences(block, top_ix) { + let id = format!("{}{top_ix}-visual-{ix}", self.scope); + let Some(key) = self.visual_slots.get(&id).cloned() else { + continue; + }; + let slot = if self.truncated { + RowVisual::Blocked("Visuals don't run in a truncated preview") + } else if !self.visual_allowed.contains(&key) { + RowVisual::Blocked("Document visual preview limit reached") + } else if let Some(frame) = self.visuals.get(&key) { + RowVisual::Live(frame.clone()) + } else if outside && !self.visual_runnable.contains(&key) { + RowVisual::Gated(key) + } else { + RowVisual::Live(self.start_visual(key, cx)) + }; + if let RowVisual::Live(frame) = &slot { + frame.update(cx, |frame, _| frame.set_link_ui(Some(link.clone()))); + } + out.insert(id, slot); + } + out + } + + fn start_visual( + &mut self, + key: VisualKey, + cx: &mut Context, + ) -> gpui::Entity { + let (code, occurrence) = &key; + let seed = zeron_visual::seed_for_document(code, *occurrence); + // Stable while the fence moves, so its element state stays put. + let id: SharedString = format!("{}visual-{seed:016x}", self.scope).into(); + let title = crate::visuals::visual_title(code).unwrap_or_else(|| "Visual".into()); + let frame = + cx.new(|cx| crate::visuals::VisualFrame::from_source(id, title, code, seed, cx)); + // A tab switch or a failure resizes the row, like a diagram loading. + cx.subscribe( + &frame, + |view, _, _: &crate::visuals::VisualLayoutChanged, cx| { + view.list.remeasure_items(0..view.tree.len()); + cx.notify(); + }, + ) + .detach(); + self.visuals.insert(key, frame.clone()); + frame + } + + fn run_visual(&mut self, key: VisualKey, cx: &mut Context) { + self.visual_runnable.insert(key); + self.list.remeasure_items(0..self.tree.len()); + cx.notify(); + } + + /// Flip a diagram or visual fence between its drawing and its source. + fn source_toggle( + owner: gpui::WeakEntity, + id: String, + ) -> Rc { + Rc::new(move |_, cx| { + let _ = owner.update(cx, |view, cx| { + if !view.source_visible.remove(&id) { + view.source_visible.insert(id.clone()); + } + view.list.remeasure_items(0..view.tree.len()); + cx.notify(); + }); + }) + } + pub fn activate( &mut self, path: &str, @@ -767,6 +915,8 @@ impl MarkdownPreview { self.images.drain().filter_map(|(_, result)| result.ok()), cx, ); + // Another document: nothing of this one's visuals carries over. + self.clear_visuals(); self.media_dirty = true; } if self.suspended { @@ -790,6 +940,7 @@ impl MarkdownPreview { self.copied_clear = None; self.copied_code = None; self.code_fences.clear(); + self.clear_visuals(); self.close_media_preview(cx); self.image_snapshot = Rc::default(); self.diagram_snapshot = Rc::default(); @@ -1116,13 +1267,46 @@ impl MarkdownPreview { let source_visible = self.source_visible.clone(); let image_allowed = self.image_allowed.clone(); let diagram_allowed = self.diagram_allowed.clone(); + let visuals = self.row_visuals(&top.block, ix, &link, cx); + let visual_owner = cx.weak_entity(); + let visual_source_visible = source_visible.clone(); opts.media = Some(render::MediaUi { + visual: (!visuals.is_empty()).then(|| { + Rc::new(move |_: &str, id: SharedString, _: &Theme| { + let owner = visual_owner.clone(); + match visuals.get(id.as_ref()) { + None => render::DiagramView::Source, + Some(RowVisual::Blocked(reason)) => { + render::DiagramView::Failed((*reason).into()) + } + Some(RowVisual::Gated(key)) => { + let key = key.clone(); + render::DiagramView::Gated(Rc::new(move |_, cx| { + let _ = owner.update(cx, |view, cx| { + view.run_visual(key.clone(), cx) + }); + })) + } + Some(RowVisual::Live(frame)) => { + render::DiagramView::Diagram(render::DiagramUi { + body: div() + .w_full() + .child(frame.clone()) + .into_any_element(), + show_source: visual_source_visible.contains(id.as_ref()), + toggle_source: Self::source_toggle(owner, id.to_string()), + }) + } + } + }) + as Rc render::DiagramView> + }), diagram: Some(Rc::new(move |code, id, theme| { let state = diagrams.get(code); let allowed = diagram_allowed.contains(code); let source_shown = source_visible.contains(id.as_ref()) || !allowed; - let owner = diagram_owner.clone(); - let toggle_id = id.to_string(); + let toggle_source = + Self::source_toggle(diagram_owner.clone(), id.to_string()); let body = match state { Some(Ok(loaded)) => Self::media_element( loaded, @@ -1150,15 +1334,7 @@ impl MarkdownPreview { render::DiagramView::Diagram(render::DiagramUi { body, show_source: source_shown, - toggle_source: Rc::new(move |_, cx| { - let _ = owner.update(cx, |view, cx| { - if !view.source_visible.remove(&toggle_id) { - view.source_visible.insert(toggle_id.clone()); - } - view.list.remeasure_items(0..view.tree.len()); - cx.notify(); - }); - }), + toggle_source, }) })), image: Some(Rc::new(move |image, id, theme| { @@ -1465,6 +1641,34 @@ mod tests { assert_eq!(comment_block(&lines, 11), Some(3)); assert_eq!(comment_block(&[], 1), None); } + /// `zeron visual check` finds fences with a line scanner; it must see the + /// same code as the preview's parser, or the seeds would differ. + #[test] + fn visual_check_scanner_matches_the_parser() { + for source in [ + "# Doc\n\n```zeron-visual\nlocal a = 1\n\n indented()\n```\n\nText\n\n~~~zeron-visual\ntilde()\n~~~\n", + "- item\n\n ```zeron-visual\n listed()\n deeper()\n ```\n\n1. ```zeron-visual\n numbered()\n ```\n", + "> ```zeron-visual\n> quoted()\n>\n> more()\n> ```\n", + "```zeron-visual\r\ncrlf()\r\n\r\nend\r\n```\r\n", + "````markdown\n```zeron-visual\nhidden()\n```\n````\n```zeron-visual\nopen()\n", + "```zeron-visual\n```\n\n```zeron-visual\n\n\n```\n", + ] { + let tree = parser::parse_full(source); + let parsed: Vec = tree + .blocks + .iter() + .enumerate() + .flat_map(|(ix, top)| render::visual_fences(&top.block, ix)) + .map(|(_, code)| code) + .collect(); + let scanned: Vec = zeron_visual::document::fences(source) + .into_iter() + .map(|fence| fence.code) + .collect(); + assert_eq!(scanned, parsed, "{source:?}"); + } + } + #[test] fn markdown_paths_and_links() { assert!(is_markdown("docs/README.MD")); @@ -1879,6 +2083,72 @@ mod layout_tests { }); } + #[test] + fn rendered_visual_draws_lazily_and_toggles_source() { + gpui_platform::headless().run(|cx| { + cx.set_global(Theme::dark()); + let fence = "```zeron-visual\n--! title: Tabs\nlocal s = ui.state { tab = \"a\" }\nreturn function()\n return ui.column { gap = 8,\n ui.tabs { options = { \"a\", \"b\" }, value = s.tab, on_change = function(v) s.tab = v end },\n ui.chart { type = \"bar\", height = 200, x = { \"x\", \"y\" }, series = { { name = \"n\", values = { 1, 2 } } } },\n }\nend\n```\n"; + // Far below the fold, so it starts only when scrolled to. + let source = format!("{}{fence}", "Paragraph.\n\n".repeat(80)); + let window = cx + .open_window( + gpui::WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(Bounds::new( + Point::default(), + gpui::size(px(900.0), px(600.0)), + ))), + ..Default::default() + }, + |_, cx| { + cx.new(|cx| { + let mut view = + MarkdownPreview::new("README.md".into(), Rc::new(|_, _| {}), cx); + view.tree = parser::parse_full(&source); + view.list.reset(view.tree.len()); + view.sync_visuals(); + view.diagram_style = crate::theme::style_generation(); + view + }) + }, + ) + .unwrap(); + let view = window.entity(cx).unwrap(); + let draw = |cx: &mut gpui::App| { + cx.update_window(window.into(), |_, window, cx| { + window.refresh(); + let _ = window.draw(cx); + }) + .unwrap(); + }; + draw(cx); + assert!(view.read(cx).visuals.is_empty(), "an unseen visual doesn't run"); + let last = view.read(cx).tree.len() - 1; + view.update(cx, |view, _| { + view.list.scroll_to(ListOffset { + item_ix: last, + offset_in_item: px(0.0), + }) + }); + draw(cx); + let frame = view.read(cx).visuals.values().next().unwrap().clone(); + assert_eq!(frame.read(cx).test_status(), Ok(())); + let drawn = view.read(cx).test_block_bounds(last).size.height; + assert!(drawn > px(200.0), "the chart sets the row height: {drawn:?}"); + + let id = format!("{}{last}-visual-{last}", view.read(cx).scope); + view.update(cx, |view, _| { + view.source_visible.insert(id); + view.list.remeasure_items(0..view.tree.len()); + }); + draw(cx); + assert_eq!(view.read(cx).visuals.len(), 1, "source view keeps the visual"); + let code = view.read(cx).test_block_bounds(last).size.height; + assert!(code < drawn, "the source is shorter than the chart: {code:?}"); + + cx.spawn(async move |cx| cx.update(|cx| cx.quit())).detach(); + }); + } + #[test] fn rendered_mermaid_opens_lightbox_and_toggles_source() { gpui_platform::headless().run(|cx| { @@ -2042,6 +2312,145 @@ mod async_tests { assert!(weak.upgrade().is_none()); } + const SCRIPT: &str = "--! title: Test\nreturn function()\n return ui.text \"hi\"\nend"; + + fn fence(code: &str) -> String { + format!("```zeron-visual\n{code}\n```\n\n") + } + + fn show(view: &mut MarkdownPreview, source: &str) { + view.tree = parser::parse_full(source); + view.list.reset(view.tree.len()); + view.sync_visuals(); + } + + /// Every row's visual fences as the next frame renders them. + fn render_visuals( + view: &mut MarkdownPreview, + cx: &mut Context, + ) -> Vec { + let link = view.link_ui(cx); + let blocks = view.tree.blocks.clone(); + let mut out = Vec::new(); + for (ix, top) in blocks.iter().enumerate() { + let mut row: Vec<_> = view + .row_visuals(&top.block, ix, &link, cx) + .into_iter() + .collect(); + row.sort_by(|a, b| a.0.cmp(&b.0)); + out.extend(row.into_iter().map(|(_, slot)| slot)); + } + out + } + + fn live(slot: &RowVisual) -> gpui::Entity { + match slot { + RowVisual::Live(frame) => frame.clone(), + RowVisual::Gated(_) => panic!("gated"), + RowVisual::Blocked(reason) => panic!("blocked: {reason}"), + } + } + + #[gpui::test] + fn document_visuals_follow_their_fence_code(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let view = cx.new(|cx| MarkdownPreview::new("README.md".into(), Rc::new(|_, _| {}), cx)); + view.update(cx, |view, cx| { + show(view, &fence(SCRIPT)); + let first = live(&render_visuals(view, cx)[0]); + assert_eq!(first.read(cx).test_status(), Ok(())); + assert_eq!(view.visuals.len(), 1); + + // Text above moves the fence to another row; the visual stays. + show(view, &format!("Intro paragraph.\n\n{}", fence(SCRIPT))); + assert_eq!(live(&render_visuals(view, cx)[0]), first); + + // Editing the fence starts it again. + let edited = SCRIPT.replace("hi", "edited"); + show(view, &format!("Intro paragraph.\n\n{}", fence(&edited))); + assert!(view.visuals.is_empty(), "the old visual is dropped"); + let second = live(&render_visuals(view, cx)[0]); + assert_ne!(second, first); + assert_eq!(view.visuals.len(), 1); + + // Identical fences are separate visuals with their own seeds. + show(view, &format!("{}{}", fence(&edited), fence(&edited))); + let slots = render_visuals(view, cx); + let (a, b) = (live(&slots[0]), live(&slots[1])); + assert_ne!(a, b); + assert_ne!(a.read(cx).test_id(), b.read(cx).test_id()); + + let broken = live(&{ + show(view, &fence("return function( end")); + render_visuals(view, cx).remove(0) + }); + let status = broken.read(cx).test_status().unwrap_err(); + assert!(status.starts_with("Visual failed: visual:1:"), "{status}"); + + view.suspend(cx); + assert!(view.visuals.is_empty()); + assert!(view.visual_slots.is_empty()); + assert!(view.visual_allowed.is_empty()); + }); + } + + #[gpui::test] + fn documents_run_a_bounded_number_of_visuals(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let view = cx.new(|cx| MarkdownPreview::new("README.md".into(), Rc::new(|_, _| {}), cx)); + view.update(cx, |view, cx| { + let source: String = (0..=MAX_VISUALS) + .map(|i| fence(&format!("{SCRIPT}\n-- {i}"))) + .collect(); + show(view, &source); + let slots = render_visuals(view, cx); + assert_eq!(slots.len(), MAX_VISUALS + 1); + for slot in &slots[..MAX_VISUALS] { + live(slot); + } + assert!(matches!( + slots[MAX_VISUALS], + RowVisual::Blocked("Document visual preview limit reached") + )); + assert_eq!(view.visuals.len(), MAX_VISUALS); + + view.truncated = true; + view.sync_visuals(); + assert!(view.visuals.is_empty()); + assert!( + render_visuals(view, cx) + .iter() + .all(|slot| matches!(slot, RowVisual::Blocked(_))) + ); + }); + } + + #[gpui::test] + fn outside_documents_run_visuals_only_when_asked(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let view = + cx.new(|cx| MarkdownPreview::new("/tmp/notes.md".into(), Rc::new(|_, _| {}), cx)); + view.update(cx, |view, cx| { + show(view, &format!("{}{}", fence(SCRIPT), fence("return nil"))); + let slots = render_visuals(view, cx); + let RowVisual::Gated(key) = &slots[0] else { + panic!("an outside visual must wait"); + }; + assert!(view.visuals.is_empty()); + view.run_visual(key.clone(), cx); + let slots = render_visuals(view, cx); + live(&slots[0]); + assert!( + matches!(slots[1], RowVisual::Gated(_)), + "only the one asked" + ); + // Another document forgets what the reader allowed. + view.activate("/tmp/other.md", &(None, "checkout".into()), cx); + assert!(view.visual_runnable.is_empty()); + assert!(view.visuals.is_empty()); + }); + } + #[gpui::test] fn raster_admission_accounts_for_cpu_pixels_and_gpu_texture(cx: &mut TestAppContext) { cx.update(|cx| cx.set_global(Theme::dark())); diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 19ee50019a..7f18885b94 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -110,19 +110,29 @@ pub struct TaskUi { #[derive(Clone)] pub struct MediaUi { pub diagram: Option DiagramView>>, + /// Runs ```` ```zeron-visual ```` fences. `None` shows them as code. + pub visual: 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. +/// How a Mermaid or visual 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), + /// The source fence with a button that draws it on request (a visual in + /// a document from outside the workspace runs only when asked). + Gated(Rc), Diagram(DiagramUi), } +/// Whether a fence's language marks a Luau visual. +pub fn is_visual_language(language: &str) -> bool { + zeron_visual::document::is_fence_language(language) +} + pub struct DiagramUi { pub body: AnyElement, pub show_source: bool, @@ -549,6 +559,38 @@ pub(crate) fn code_block_indices(block: &Block, ix: usize) -> Vec { indices } +/// The ```` ```zeron-visual ```` fences below `block` with the element +/// discriminator each one renders with (the same as [`code_block_indices`]). +pub(crate) fn visual_fences(block: &Block, ix: usize) -> Vec<(usize, String)> { + fn collect(block: &Block, ix: usize, out: &mut Vec<(usize, String)>) { + match block { + Block::CodeBlock { language, code } => { + if language.as_deref().is_some_and(is_visual_language) { + out.push((ix, code.clone())); + } + } + Block::BlockQuote { children } => { + for (child_ix, child) in children.iter().enumerate() { + collect(child, quote_child_ix(ix, child_ix), out); + } + } + Block::List { items, .. } => { + for (item_ix, item) in items.iter().enumerate() { + for (child_ix, child) in item.iter().enumerate() { + collect(child, list_child_ix(ix, item_ix, child_ix), out); + } + } + } + Block::Paragraph { .. } | Block::Heading { .. } | Block::Table { .. } | Block::Rule => { + } + } + } + + let mut fences = Vec::new(); + collect(block, ix, &mut fences); + fences +} + /// Render one block (top-level or nested). `top_ix` is the enclosing top-level /// block index (cache invalidation scope); `ix` the per-element discriminator. #[allow(clippy::too_many_arguments)] @@ -2045,65 +2087,111 @@ fn render_code_block( theme: &Theme, highlight: CodeHighlight, ) -> AnyElement { - 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 = 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(), - if diagram.show_source { - "Show diagram" - } else { - "Show source" - }, - if diagram.show_source { - crate::icons::EYE + let media = opts.media.as_ref(); + let (handler, kind, shown) = match language { + Some(l) if l.eq_ignore_ascii_case("mermaid") => ( + media.and_then(|m| m.diagram.as_ref()), + "mermaid", + "Show diagram", + ), + Some(l) if is_visual_language(l) => ( + media.and_then(|m| m.visual.as_ref()), + "visual", + "Show visual", + ), + _ => (None, "", ""), + }; + let Some(handler) = handler else { + return render_code_block_source(language, code, top_ix, ix, opts, theme, highlight); + }; + let frame_id: SharedString = format!("{}-{kind}-{ix}", opts.row_key).into(); + 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::Gated(run) => { + let action = code_icon_action( + format!("{frame_id}-run").into(), + if kind == "visual" { + "Run visual" } else { - crate::icons::FILE_CODE + "Render diagram" }, - Rc::new(move |window, cx| toggle(window, cx)), + crate::icons::ACTION_PLAY, + Rc::new(move |window, cx| run(window, cx)), theme, ); - if diagram.show_source { - return render_code_block_source_with_actions( - language, - code, - top_ix, - ix, - opts, - theme, - highlight, - vec![toggle_action], - ); - } - let mut actions = vec![toggle_action]; - actions.extend(code_copy_button(code, ix, opts, theme)); - return code_block_frame(frame_id, language, actions, diagram.body, theme) - .into_any_element(); + return render_code_block_source_with_actions( + language, + code, + top_ix, + ix, + opts, + theme, + highlight, + vec![action], + ); } + DiagramView::Diagram(diagram) => diagram, + }; + let toggle = diagram.toggle_source.clone(); + let toggle_action = code_icon_action( + format!("{frame_id}-source-toggle").into(), + if diagram.show_source { + shown + } else { + "Show source" + }, + if diagram.show_source { + crate::icons::EYE + } else { + crate::icons::FILE_CODE + }, + Rc::new(move |window, cx| toggle(window, cx)), + theme, + ); + if diagram.show_source { + return render_code_block_source_with_actions( + language, + code, + top_ix, + ix, + opts, + theme, + highlight, + vec![toggle_action], + ); + } + if kind == "visual" { + // Visuals carry no box or fill (their style rules forbid them): only + // a quiet row with the source toggle sits above. + return div() + .id(frame_id) + .w_full() + .min_w_0() + .flex() + .flex_col() + .gap(px(4.0)) + .child(div().flex().flex_row().justify_end().child(toggle_action)) + .child(diagram.body) + .into_any_element(); } - render_code_block_source(language, code, top_ix, ix, opts, theme, highlight) + let mut actions = vec![toggle_action]; + actions.extend(code_copy_button(code, ix, opts, theme)); + code_block_frame(frame_id, language, actions, diagram.body, theme).into_any_element() } fn code_icon_action( @@ -3223,6 +3311,102 @@ mod tests { assert_eq!(code_block_indices(&top_level.blocks[1].block, 1), vec![1]); } + #[test] + fn visual_fences_share_code_block_indices() { + let source = "```zeron-visual\ntop()\n```\n\n\ + > ```Zeron-Visual\n> quoted()\n> ```\n\n\ + - item\n\n ```rust\n let x = 1;\n ```\n\n ```zeron-visual\n listed()\n ```\n"; + let tree = parse_full(source); + let found: Vec<(usize, String)> = tree + .blocks + .iter() + .enumerate() + .flat_map(|(ix, top)| visual_fences(&top.block, ix)) + .collect(); + assert_eq!( + found, + [ + (0, "top()".to_string()), + (100, "quoted()".to_string()), + (202, "listed()".to_string()), + ] + ); + for (ix, top) in tree.blocks.iter().enumerate() { + let indices = code_block_indices(&top.block, ix); + for (fence_ix, _) in visual_fences(&top.block, ix) { + assert!(indices.contains(&fence_ix), "{fence_ix} in {indices:?}"); + } + } + assert!(is_visual_language("ZERON-VISUAL")); + assert!(!is_visual_language("zeron-visuals")); + } + + struct VisualFenceHarness { + handler: bool, + calls: Rc>>, + } + + impl Render for VisualFenceHarness { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + let mut opts = RenderOptions::settled("visual-test".into()); + let calls = self.calls.clone(); + opts.media = Some(MediaUi { + diagram: None, + visual: self.handler.then(|| { + Rc::new(move |_: &str, id: SharedString, _: &Theme| { + calls.borrow_mut().push(id.to_string()); + DiagramView::Diagram(DiagramUi { + body: div().h(px(40.0)).child("drawn").into_any_element(), + show_source: false, + toggle_source: Rc::new(|_, _| {}), + }) + }) as Rc DiagramView> + }), + image: None, + }); + div() + .size_full() + .child(selection_frame_reset()) + .child(render_tree( + &parse_full("```zeron-visual\nreturn nil\n```\n"), + &opts, + &theme, + window, + &|_| None, + )) + } + } + + #[gpui::test] + fn visual_fences_render_as_code_without_a_handler(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let registered = |key: &str| { + REGISTRY.with(|registry| registry.borrow().iter().any(|e| e.key.as_ref() == key)) + }; + for handler in [false, true] { + let calls = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view(|_, _| VisualFenceHarness { + handler, + calls: calls.clone(), + }); + cx.simulate_resize(size(px(640.0), px(240.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + assert_eq!(registered("visual-test-code0-line0"), !handler); + // Each layout pass asks again, always for the same frame id. + let calls = calls.borrow(); + assert_eq!(!calls.is_empty(), handler); + assert!( + calls.iter().all(|id| id == "visual-test-visual-0"), + "{calls:?}" + ); + } + } + #[test] fn sole_workspace_link_gets_a_file_path() { let runs = vec![InlineRun { diff --git a/crates/ui/src/pull_request_media.rs b/crates/ui/src/pull_request_media.rs index 224ac941b1..bdf33ca18a 100644 --- a/crates/ui/src/pull_request_media.rs +++ b/crates/ui/src/pull_request_media.rs @@ -340,6 +340,7 @@ pub(super) fn media( let pr_url = pr_url.to_owned(); MediaUi { diagram: None, + visual: None, image: image_renderer(move |image, id, theme| { let Some(source) = image_url(&image.source, &pr_url) else { return placeholder( diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 515d6c65c3..0dbf5c9a07 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -6459,6 +6459,7 @@ impl Transcript { let owner = cx.weak_entity(); let media = self.diagram_media.get_or_insert_with(|| render::MediaUi { image: None, + visual: None, diagram: Some(Rc::new(move |code, id, _theme| { let lookup = cache.borrow_mut().request(code, &id); match lookup { diff --git a/crates/ui/src/visuals.rs b/crates/ui/src/visuals.rs index 56e5446d3a..956fcce51f 100644 --- a/crates/ui/src/visuals.rs +++ b/crates/ui/src/visuals.rs @@ -180,12 +180,9 @@ impl VisualFrame { target_device_id: Option, cx: &mut Context, ) -> Self { - // Its drawings leave every window's sprite atlas with it. - cx.on_release(|this, cx| { - let images = this.local.borrow_mut().prune(None); - free_images(images, cx); - }) - .detach(); + // The row id ends with the part's visual id, which the engine + // seeded its check with: the reader sees the same random data. + let seed = zeron_visual::seed_for(id.rsplit('#').next().unwrap_or(&id)); let mut tasks = Vec::new(); let load = match engine { Some(engine) => { @@ -229,7 +226,7 @@ impl VisualFrame { } let assets = zeron_visual::Assets::new(files); let _ = this.update(cx, |this, cx| match script { - Some(script) => this.start(&script, assets, cx), + Some(script) => this.start(&script, assets, seed, cx), None => { this.load = Load::Failed("Visual unavailable".into()); cx.notify(); @@ -240,6 +237,32 @@ impl VisualFrame { } None => Load::Failed("Visual unavailable".into()), }; + let mut frame = Self::empty(id, title, load, cx); + frame._tasks = tasks; + frame + } + + /// A visual whose script is already at hand (a Markdown document's + /// ```` ```zeron-visual ```` fence), run now with `seed` and no assets. + pub fn from_source( + id: SharedString, + title: String, + script: &str, + seed: u64, + cx: &mut Context, + ) -> Self { + let mut frame = Self::empty(id, title, Load::Loading, cx); + frame.start(script, zeron_visual::Assets::default(), seed, cx); + frame + } + + fn empty(id: SharedString, title: String, load: Load, cx: &mut Context) -> Self { + // Its drawings leave every window's sprite atlas with it. + cx.on_release(|this, cx| { + let images = this.local.borrow_mut().prune(None); + free_images(images, cx); + }) + .detach(); Self { id, title: title.into(), @@ -251,7 +274,7 @@ impl VisualFrame { link_ui: None, local: Rc::default(), started: Instant::now(), - _tasks: tasks, + _tasks: Vec::new(), } } @@ -259,13 +282,30 @@ impl VisualFrame { self.link_ui = link_ui; } + /// Whether the script rendered, or why it didn't. + #[cfg(test)] + pub(crate) fn test_status(&self) -> Result<(), String> { + match (&self.load, &self.tree) { + (Load::Failed(message), _) => Err(message.to_string()), + (Load::Ready, Some(_)) => Ok(()), + _ => Err("loading".into()), + } + } + + #[cfg(test)] + pub(crate) fn test_id(&self) -> &SharedString { + &self.id + } + /// Run a script's setup and first render (the engine already ran the /// same script before publishing it, so failures here are rare). - fn start(&mut self, script: &str, assets: zeron_visual::Assets, cx: &mut Context) { - // The row id ends with the part's visual id, which the engine - // seeded its check with: the reader sees the same random data. - let visual_id = self.id.rsplit('#').next().unwrap_or(&self.id); - let seed = zeron_visual::seed_for(visual_id); + fn start( + &mut self, + script: &str, + assets: zeron_visual::Assets, + seed: u64, + cx: &mut Context, + ) { match zeron_visual::Visual::load(script, seed) { Ok(visual) => { let visual = visual.with_assets(assets); @@ -510,6 +550,8 @@ impl Render for VisualFrame { let root = self.id.to_string(); self.node(&tree, &root, &theme, &palette, window, cx) } + // The first render failed: the error below says why. + (Load::Ready, None) if self.error.is_some() => gpui::Empty.into_any_element(), _ => status_line("Loading visual…".into(), theme.text_faint), }; let error = self @@ -534,6 +576,25 @@ impl Render for VisualFrame { } } +/// The title a document visual names in its first line, `--! title: ...`, +/// trimmed and cut like a `show_visual` title. +pub fn visual_title(script: &str) -> Option { + let title = script + .lines() + .next()? + .trim() + .strip_prefix("--!")? + .trim_start() + .strip_prefix("title:")? + .trim(); + let title: String = title + .chars() + .take(zeron_proto::VISUAL_MAX_TITLE_CHARS) + .collect(); + let title = title.trim_end(); + (!title.is_empty()).then(|| title.to_owned()) +} + fn status_line(message: SharedString, color: Hsla) -> AnyElement { div() .text_size(crate::typography::ui_rems(12.0)) @@ -2773,6 +2834,27 @@ mod tests { } } + #[test] + fn document_visuals_name_themselves_in_their_first_line() { + assert_eq!( + visual_title("--! title: Latencia por región\nreturn nil").as_deref(), + Some("Latencia por región") + ); + assert_eq!( + visual_title(" --!title: Spaced ").as_deref(), + Some("Spaced") + ); + assert_eq!(visual_title("--! title: \nx"), None); + assert_eq!(visual_title("local x = 1\n--! title: Late"), None); + assert_eq!(visual_title("-- title: Plain comment"), None); + assert_eq!(visual_title(""), None); + let long = format!("--! title: {}", "é".repeat(500)); + assert_eq!( + visual_title(&long).unwrap().chars().count(), + zeron_proto::VISUAL_MAX_TITLE_CHARS + ); + } + #[test] fn domains_include_zero_for_bars_and_land_on_ticks() { let (lo, hi, ticks) = domain(12.0, 87.0, true); diff --git a/crates/visual/src/document.rs b/crates/visual/src/document.rs new file mode 100644 index 0000000000..62f6969a37 --- /dev/null +++ b/crates/visual/src/document.rs @@ -0,0 +1,296 @@ +//! Visuals embedded in Markdown documents as ```` ```zeron-visual ```` fences. +//! +//! Zeron's Markdown preview runs these fences with its full parser; this is +//! the light line scanner `zeron visual check` uses to find them, so an agent +//! can check a document's visuals and map each error back to a line of the +//! `.md` without a running Zeron. + +/// The fence language that marks a visual (case-insensitive). +pub const FENCE_LANGUAGE: &str = "zeron-visual"; + +pub fn is_fence_language(language: &str) -> bool { + language.eq_ignore_ascii_case(FENCE_LANGUAGE) +} + +/// One visual fence in a document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fence { + /// The 1-based line of the opening fence: script line `n` sits on + /// document line `line + n`. + pub line: usize, + pub code: String, + /// Its position among fences with identical code (see + /// [`crate::seed_for_document`]). + pub occurrence: usize, +} + +struct Open { + marker: char, + len: usize, + /// What precedes the fence on its line: list indentation and quote markers. + prefix: String, + /// The fence's own indentation, stripped from its content lines. + indent: usize, + visual: Option, +} + +/// Every visual fence in `markdown`, in document order. Other fences are +/// skipped whole, so a visual quoted inside a ```` ```markdown ```` fence is +/// not one. +pub fn fences(markdown: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut open: Option = None; + let finish = |fence: Open, out: &mut Vec| { + if let Some(mut visual) = fence.visual { + visual.occurrence = out.iter().filter(|f| f.code == visual.code).count(); + out.push(visual); + } + }; + for (ix, raw) in markdown.lines().enumerate() { + let line = raw.strip_suffix('\r').unwrap_or(raw); + if let Some(fence) = &mut open { + match strip_container(line, &fence.prefix) { + Some(content) if !closes(content, fence.marker, fence.len) => { + if let Some(visual) = &mut fence.visual { + // `ix` is the 0-based index, so the first content + // line has `ix == visual.line`. + if ix > visual.line { + visual.code.push('\n'); + } + visual.code.push_str(strip_spaces(content, fence.indent)); + } + continue; + } + content => { + finish(open.take().expect("open fence"), &mut out); + if content.is_some() { + continue; + } + // The quote holding the fence ended, so this line may + // open a fence of its own. + } + } + } + open = opening(line, ix + 1); + } + if let Some(fence) = open { + finish(fence, &mut out); + } + out +} + +/// A fence opened by `line`, if it opens one. +fn opening(line: &str, number: usize) -> Option { + let body_start = container_len(line); + let (prefix, body) = line.split_at(body_start); + let indent = body.len() - body.trim_start_matches(' ').len(); + let body = &body[indent..]; + let marker = body.chars().next().filter(|c| matches!(c, '`' | '~'))?; + let len = body.chars().take_while(|c| *c == marker).count(); + if len < 3 { + return None; + } + let info = body[len..].trim(); + if marker == '`' && info.contains('`') { + return None; + } + let language = info.split_whitespace().next().unwrap_or(""); + Some(Open { + marker, + len, + prefix: prefix.to_owned(), + indent, + visual: is_fence_language(language).then(|| Fence { + // Content starts on the next line; `line + n` is script line n. + line: number, + code: String::new(), + occurrence: 0, + }), + }) +} + +/// The length of a line's container markers: list bullets and numbers, quote +/// markers, and the spaces between them, up to where the block itself starts. +fn container_len(line: &str) -> usize { + let bytes = line.as_bytes(); + let mut ix = 0; + loop { + let start = ix; + while ix < bytes.len() && bytes[ix] == b' ' { + ix += 1; + } + match bytes.get(ix) { + Some(b'>') => { + ix += 1; + if bytes.get(ix) == Some(&b' ') { + ix += 1; + } + } + Some(b'-' | b'*' | b'+') if bytes.get(ix + 1) == Some(&b' ') => ix += 2, + Some(b'0'..=b'9') => { + let digits = bytes[ix..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + match (bytes.get(ix + digits), bytes.get(ix + digits + 1)) { + (Some(b'.' | b')'), Some(b' ')) => ix += digits + 2, + _ => return start, + } + } + _ => return start, + } + } +} + +/// A content line with the opening line's container removed: its quote +/// markers must be there (`None` when the quote ended); list indentation is +/// removed where present. +fn strip_container<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + let mut rest = line; + let mut prefix = prefix; + while !prefix.is_empty() { + let spaces = prefix.len() - prefix.trim_start_matches(' ').len(); + prefix = &prefix[spaces..]; + match prefix.chars().next() { + Some('>') => { + rest = rest.trim_start_matches(' ').strip_prefix('>')?; + rest = rest.strip_prefix(' ').unwrap_or(rest); + prefix = prefix[1..].strip_prefix(' ').unwrap_or(&prefix[1..]); + } + Some(_) => { + // A list marker: its content lines are indented by its width. + let marker = prefix.find(' ').map_or(prefix.len(), |at| at + 1); + rest = strip_spaces(rest, spaces + marker); + prefix = &prefix[marker..]; + } + None => rest = strip_spaces(rest, spaces), + } + } + Some(rest) +} + +fn strip_spaces(line: &str, max: usize) -> &str { + let spaces = line.len() - line.trim_start_matches(' ').len(); + &line[spaces.min(max)..] +} + +fn closes(content: &str, marker: char, len: usize) -> bool { + let body = content.trim_start_matches(' '); + let run = body.chars().take_while(|c| *c == marker).count(); + content.len() - body.len() <= 3 && run >= len && body[run..].trim().is_empty() +} + +/// Where a script error points in the document: the line of its first +/// `visual:N:` position (the fence line when it has none) and the message +/// without that position. A Luau traceback only lends its position: the +/// message stops where the traceback starts. +pub fn locate(message: &str, fence_line: usize) -> (usize, String) { + let (head, traceback) = message + .split_once("\nstack traceback:") + .unwrap_or((message, "")); + if let Some((at, len, line)) = position(head) { + let after = head[at + len..].trim_start(); + return (fence_line + line, format!("{}{after}", &head[..at])); + } + let line = position(traceback).map_or(0, |(_, _, line)| line); + (fence_line + line, head.trim_end().to_owned()) +} + +/// The first `visual:N:` in `text`: its offset, length, and N. +fn position(text: &str) -> Option<(usize, usize, usize)> { + let mut search = 0; + while let Some(found) = text[search..].find("visual:") { + let at = search + found; + let rest = &text[at + "visual:".len()..]; + let digits = rest.chars().take_while(char::is_ascii_digit).count(); + if digits > 0 + && rest[digits..].starts_with(':') + && let Ok(line) = rest[..digits].parse::() + { + return Some((at, "visual:".len() + digits + 1, line)); + } + search = at + "visual:".len(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_backtick_and_tilde_fences_with_their_lines() { + let doc = "# Doc\n\n```zeron-visual\nlocal a = 1\nreturn a\n```\n\ntext\n\n~~~ Zeron-Visual extra\nline 1\n~~~\n"; + let found = fences(doc); + assert_eq!(found.len(), 2); + assert_eq!(found[0].line, 3); + assert_eq!(found[0].code, "local a = 1\nreturn a"); + assert_eq!(found[1].line, 10); + assert_eq!(found[1].code, "line 1"); + } + + #[test] + fn other_fences_hide_what_they_contain() { + let doc = "````markdown\n```zeron-visual\nnot a visual\n```\n````\n```lua\nx\n```\n"; + assert!(fences(doc).is_empty()); + } + + #[test] + fn nested_fences_lose_their_container_indentation() { + let doc = "- item\n\n ```zeron-visual\n local a = 1\n nested()\n ```\n\n> ```zeron-visual\n> quoted()\n> ```\n"; + let found = fences(doc); + assert_eq!(found.len(), 2); + assert_eq!(found[0].line, 3); + assert_eq!(found[0].code, "local a = 1\n nested()"); + assert_eq!(found[1].line, 8); + assert_eq!(found[1].code, "quoted()"); + } + + #[test] + fn list_markers_on_the_fence_line_are_containers() { + let doc = "1. ```zeron-visual\n one()\n ```\n"; + let found = fences(doc); + assert_eq!(found[0].code, "one()"); + } + + #[test] + fn crlf_blank_lines_and_unclosed_fences() { + let doc = "```zeron-visual\r\n\r\nfirst()\r\n```\r\n```zeron-visual\nlast()"; + let found = fences(doc); + assert_eq!(found[0].code, "\nfirst()"); + assert_eq!(found[1].code, "last()"); + assert_eq!(found[1].line, 5); + } + + #[test] + fn identical_fences_count_occurrences() { + let doc = "```zeron-visual\nx\n```\n```zeron-visual\ny\n```\n```zeron-visual\nx\n```\n"; + let found = fences(doc); + let occurrences: Vec = found.iter().map(|f| f.occurrence).collect(); + assert_eq!(occurrences, [0, 0, 1]); + } + + #[test] + fn errors_map_to_document_lines() { + assert_eq!( + locate("visual:3: attempt to call a nil value", 10), + (13, "attempt to call a nil value".into()) + ); + assert_eq!( + locate("after selecting tab 'b': visual:5: boom", 2), + (7, "after selecting tab 'b': boom".into()) + ); + assert_eq!( + locate("the script used more than 64 MB", 4), + (4, "the script used more than 64 MB".into()) + ); + assert_eq!(locate("visual:x: odd", 1), (1, "visual:x: odd".into())); + assert_eq!( + locate( + "attempt to call a nil value\nstack traceback:\n\t[C]: in ?\n\tvisual:4: in function ", + 31 + ), + (35, "attempt to call a nil value".into()) + ); + } +} diff --git a/crates/visual/src/lib.rs b/crates/visual/src/lib.rs index 1a4ac13f89..6ce61b86c5 100644 --- a/crates/visual/src/lib.rs +++ b/crates/visual/src/lib.rs @@ -18,6 +18,7 @@ //! script's random data is the same on every render and every device. pub mod assets; +pub mod document; mod draw; pub mod image; mod model; @@ -616,5 +617,13 @@ pub fn seed_for(id: &str) -> u64 { }) } +/// The seed of a ```` ```zeron-visual ```` fence in a Markdown document: +/// its code and its position among fences with identical code, so the +/// Markdown preview and `zeron visual check` draw the same random data and a +/// fence keeps it when the document around it changes. +pub fn seed_for_document(code: &str, occurrence: usize) -> u64 { + seed_for(&format!("{:016x}:{occurrence}", seed_for(code))) +} + #[cfg(test)] mod tests; diff --git a/docs/mcp.md b/docs/mcp.md index 9b3021ba6c..173304cf0f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -226,6 +226,52 @@ the glass composer, and takes input like any other row, identically on Linux, macOS, and Windows, and it follows theme, appearance, and text-size changes without rerunning the script. +#### Visuals in Markdown documents + +The same script can live in a Markdown file as a fenced block whose language +is `zeron-visual` (case-insensitive), and the desktop's Markdown preview runs +it in place of the code. No MCP call is involved: the agent edits the file with +its own tools, Zeron reloads it when it changes on disk, and the preview parses +it again. + +````markdown +```zeron-visual +--! title: Latency by region +local s = ui.state { metric = "p95" } +return function() + return ui.column { gap = 16, ui.text "..." } +end +``` +```` + +- The optional first line `--! title: ...` names the visual (a Luau comment, + since the parser keeps only the info string's first word); without one it is + "Visual". +- `math.random` is seeded from the fence's code and its position among fences + with identical code (`zeron_visual::seed_for_document`), so a fence keeps its + data and its live state when text around it changes; editing its code starts + it again. +- Fences take no assets: a script that uses `s:model`, `texture`, or + `ui.shader` textures fails with the runtime's usual error. +- Lua runs on the desktop's main thread, so a document runs at most 8 visuals + (its first 8 fences; the rest show as code with a notice), each starts only + when its row first renders, and they are dropped when the preview suspends, + switches documents, or the fence leaves the document. A truncated preview + runs none. A document outside the workspace runs nothing on its own: each + fence shows its code with a "Run visual" button. +- The fence sits on the document without a code-block frame; a quiet row above + it toggles "Show source" / "Show visual". Elsewhere (GitHub, other editors, + chat replies, iOS) the fence is ordinary code. + +An agent can't see the preview, so `zeron visual check ` runs a file's +visuals the way the preview will (`zeron_visual::check` with the same seed): +every fence of a `.md`/`.markdown`, or a whole `.luau` script. It prints +`file.md:LINE: visual K ok (N interactions)` plus the script's `print()` +output, or `file.md:LINE: visual K: message` with the error's script line +mapped to the document's, and exits 1 when any visual fails. It finds fences +with a line scanner (`zeron_visual::document`) that understands backtick and +tilde fences, list indentation, and quotes. + ## Selecting a host and project `list_projects {device?}` filters by device id or exact name; without arguments From c2dec7fdf8152ab9b4196bbbd0f55fa3fc579f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 01:03:18 -0300 Subject: [PATCH 2/4] Fix nested Markdown visual identifier collisions --- crates/ui/src/files/markdown_preview.rs | 18 ++ crates/ui/src/markdown/render.rs | 230 ++++++++++++++---------- 2 files changed, 150 insertions(+), 98 deletions(-) diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index e44f3d6d01..d7e799c182 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -2394,6 +2394,24 @@ mod async_tests { }); } + #[gpui::test] + fn nested_visuals_keep_independent_frames(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let view = cx.new(|cx| MarkdownPreview::new("README.md".into(), Rc::new(|_, _| {}), cx)); + view.update(cx, |view, cx| { + let source = "> > Intro\n> >\n> > ```zeron-visual\n> > return ui.text \"A\"\n> > ```\n>\n> ```zeron-visual\n> return ui.text \"B\"\n> ```\n"; + show(view, source); + let slots = render_visuals(view, cx); + assert_eq!(slots.len(), 2); + let a = live(&slots[0]); + let b = live(&slots[1]); + assert_ne!(a, b); + assert_eq!(a.read(cx).test_status(), Ok(())); + assert_eq!(b.read(cx).test_status(), Ok(())); + assert_eq!(view.visuals.len(), 2); + }); + } + #[gpui::test] fn documents_run_a_bounded_number_of_visuals(cx: &mut TestAppContext) { cx.update(|cx| cx.set_global(Theme::dark())); diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 7f18885b94..29c2cf6407 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -501,16 +501,17 @@ pub fn render_tree( window: &Window, highlight: &dyn Fn(usize) -> Option>, ) -> AnyElement { + let mut next_ix = 0; div() .flex() .flex_col() .gap(px(MD_BLOCK_GAP)) .children(tree.blocks.iter().enumerate().map(|(ix, top)| { let document = highlight(ix); - render_block( + render_block_next( &top.block, ix, - ix, + &mut next_ix, opts, theme, window, @@ -522,72 +523,51 @@ pub fn render_tree( .into_any_element() } -fn quote_child_ix(ix: usize, child_ix: usize) -> usize { - ix * 100 + child_ix -} - -fn list_child_ix(ix: usize, item_ix: usize, child_ix: usize) -> usize { - ix * 100 + item_ix * 10 + child_ix -} - -/// Element discriminators for every code block below `block`. Transcript rows -/// use this to provision one independent scroll handle per nested fence before -/// the renderer recursively reaches it. -pub(crate) fn code_block_indices(block: &Block, ix: usize) -> Vec { - fn collect(block: &Block, ix: usize, out: &mut Vec) { - match block { - Block::CodeBlock { .. } => out.push(ix), - Block::BlockQuote { children } => { - for (child_ix, child) in children.iter().enumerate() { - collect(child, quote_child_ix(ix, child_ix), out); - } - } - Block::List { items, .. } => { - for (item_ix, item) in items.iter().enumerate() { - for (child_ix, child) in item.iter().enumerate() { - collect(child, list_child_ix(ix, item_ix, child_ix), out); - } - } +/// Visit blocks in preorder, assigning each block one distinct index. Shared +/// with rendering so nested fences get the same identity in every caller. +fn visit_blocks(block: &Block, next_ix: &mut usize, visit: &mut impl FnMut(usize, &Block)) { + let ix = *next_ix; + *next_ix += 1; + visit(ix, block); + match block { + Block::BlockQuote { children } => { + for child in children { + visit_blocks(child, next_ix, visit); } - Block::Paragraph { .. } | Block::Heading { .. } | Block::Table { .. } | Block::Rule => { + } + Block::List { items, .. } => { + for child in items.iter().flatten() { + visit_blocks(child, next_ix, visit); } } + _ => {} } +} +/// Element discriminators for every code block below `block`. Transcript rows +/// use this to provision one independent scroll handle per nested fence before +/// the renderer recursively reaches it. +pub(crate) fn code_block_indices(block: &Block, mut ix: usize) -> Vec { let mut indices = Vec::new(); - collect(block, ix, &mut indices); + visit_blocks(block, &mut ix, &mut |ix, block| { + if matches!(block, Block::CodeBlock { .. }) { + indices.push(ix); + } + }); indices } /// The ```` ```zeron-visual ```` fences below `block` with the element /// discriminator each one renders with (the same as [`code_block_indices`]). -pub(crate) fn visual_fences(block: &Block, ix: usize) -> Vec<(usize, String)> { - fn collect(block: &Block, ix: usize, out: &mut Vec<(usize, String)>) { - match block { - Block::CodeBlock { language, code } => { - if language.as_deref().is_some_and(is_visual_language) { - out.push((ix, code.clone())); - } - } - Block::BlockQuote { children } => { - for (child_ix, child) in children.iter().enumerate() { - collect(child, quote_child_ix(ix, child_ix), out); - } - } - Block::List { items, .. } => { - for (item_ix, item) in items.iter().enumerate() { - for (child_ix, child) in item.iter().enumerate() { - collect(child, list_child_ix(ix, item_ix, child_ix), out); - } - } - } - Block::Paragraph { .. } | Block::Heading { .. } | Block::Table { .. } | Block::Rule => { - } - } - } - +pub(crate) fn visual_fences(block: &Block, mut ix: usize) -> Vec<(usize, String)> { let mut fences = Vec::new(); - collect(block, ix, &mut fences); + visit_blocks(block, &mut ix, &mut |ix, block| { + if let Block::CodeBlock { language, code } = block + && language.as_deref().is_some_and(is_visual_language) + { + fences.push((ix, code.clone())); + } + }); fences } @@ -597,12 +577,27 @@ pub(crate) fn visual_fences(block: &Block, ix: usize) -> Vec<(usize, String)> { pub fn render_block( block: &Block, top_ix: usize, - ix: usize, + mut ix: usize, opts: &RenderOptions, theme: &Theme, window: &Window, highlight: CodeHighlight, ) -> AnyElement { + render_block_next(block, top_ix, &mut ix, opts, theme, window, highlight) +} + +#[allow(clippy::too_many_arguments)] +fn render_block_next( + block: &Block, + top_ix: usize, + next_ix: &mut usize, + opts: &RenderOptions, + theme: &Theme, + window: &Window, + highlight: CodeHighlight, +) -> AnyElement { + let ix = *next_ix; + *next_ix += 1; match block { Block::Paragraph { runs } => text_element( runs, @@ -627,33 +622,27 @@ pub fn render_block( theme, highlight, ), - Block::BlockQuote { children } => div() - // Accent-tinted quote: indigo rail + a whisper of the same hue - // behind it (the inline-code treatment, dialed down). - .border_l_2() - .border_color(theme.accent.opacity(0.6)) - .bg(theme.accent.opacity(0.05)) - .rounded_tr(px(6.0)) - .rounded_br(px(6.0)) - .pl(px(12.0)) - .pr(px(10.0)) - .py(px(6.0)) - .flex() - .flex_col() - .gap(px(8.0)) - .text_color(theme.text_muted) - .children(children.iter().enumerate().map(|(ci, child)| { - render_block( - child, - top_ix, - quote_child_ix(ix, ci), - opts, - theme, - window, - None, - ) - })) - .into_any_element(), + Block::BlockQuote { children } => { + div() + // Accent-tinted quote: indigo rail + a whisper of the same hue + // behind it (the inline-code treatment, dialed down). + .border_l_2() + .border_color(theme.accent.opacity(0.6)) + .bg(theme.accent.opacity(0.05)) + .rounded_tr(px(6.0)) + .rounded_br(px(6.0)) + .pl(px(12.0)) + .pr(px(10.0)) + .py(px(6.0)) + .flex() + .flex_col() + .gap(px(8.0)) + .text_color(theme.text_muted) + .children(children.iter().map(|child| { + render_block_next(child, top_ix, next_ix, opts, theme, window, None) + })) + .into_any_element() + } Block::List { ordered_start, items, @@ -780,15 +769,7 @@ pub fn render_block( } else { child }; - render_block( - child, - top_ix, - list_child_ix(ix, item_ix, ci), - opts, - theme, - window, - None, - ) + render_block_next(child, top_ix, next_ix, opts, theme, window, None) })), ) })) @@ -3302,15 +3283,35 @@ mod tests { #[test] fn code_block_indices_include_nested_quotes_and_lists() { let quoted = parse_full("> ```rust\n> let x = 1;\n> ```\n"); - assert_eq!(code_block_indices("ed.blocks[0].block, 0), vec![0]); + assert_eq!(code_block_indices("ed.blocks[0].block, 0), vec![1]); let listed = parse_full("- Result:\n\n ```json\n {\"ok\":true}\n ```\n"); - assert_eq!(code_block_indices(&listed.blocks[0].block, 0), vec![1]); + assert_eq!(code_block_indices(&listed.blocks[0].block, 0), vec![2]); let top_level = parse_full("paragraph\n\n```text\nvalue\n```\n"); assert_eq!(code_block_indices(&top_level.blocks[1].block, 1), vec![1]); } + const NESTED_VISUALS: &str = "> > Intro\n> >\n> > ```zeron-visual\n> > return ui.text \"A\"\n> > ```\n>\n> ```zeron-visual\n> return ui.text \"B\"\n> ```\n"; + + #[test] + fn nested_block_indices_are_unique() { + // The old base-100 arithmetic gave both of these fences index 1. + let quoted = parse_full(NESTED_VISUALS); + let indices = code_block_indices("ed.blocks[0].block, 0); + assert_eq!(indices.len(), 2); + assert_ne!(indices[0], indices[1]); + + // List item 0's child 10 also used to collide with item 1's child 0. + let mut listed = String::from("- First\n\n"); + listed.push_str(&" Paragraph.\n\n".repeat(9)); + listed.push_str(" ```zeron-visual\n return ui.text \"A\"\n ```\n\n- ```zeron-visual\n return ui.text \"B\"\n ```\n"); + let tree = parse_full(&listed); + let indices = code_block_indices(&tree.blocks[0].block, 0); + assert_eq!(indices.len(), 2); + assert_ne!(indices[0], indices[1]); + } + #[test] fn visual_fences_share_code_block_indices() { let source = "```zeron-visual\ntop()\n```\n\n\ @@ -3327,8 +3328,8 @@ mod tests { found, [ (0, "top()".to_string()), - (100, "quoted()".to_string()), - (202, "listed()".to_string()), + (2, "quoted()".to_string()), + (5, "listed()".to_string()), ] ); for (ix, top) in tree.blocks.iter().enumerate() { @@ -3344,6 +3345,7 @@ mod tests { struct VisualFenceHarness { handler: bool, calls: Rc>>, + source: &'static str, } impl Render for VisualFenceHarness { @@ -3369,7 +3371,7 @@ mod tests { .size_full() .child(selection_frame_reset()) .child(render_tree( - &parse_full("```zeron-visual\nreturn nil\n```\n"), + &parse_full(self.source), &opts, &theme, window, @@ -3390,6 +3392,7 @@ mod tests { let (_, cx) = cx.add_window_view(|_, _| VisualFenceHarness { handler, calls: calls.clone(), + source: "```zeron-visual\nreturn nil\n```\n", }); cx.simulate_resize(size(px(640.0), px(240.0))); cx.update(|window, cx| { @@ -3407,6 +3410,37 @@ mod tests { } } + #[gpui::test] + fn nested_visuals_render_with_distinct_frame_ids(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let calls = Rc::new(RefCell::new(Vec::new())); + let (_, cx) = cx.add_window_view(|_, _| VisualFenceHarness { + handler: true, + calls: calls.clone(), + source: NESTED_VISUALS, + }); + cx.simulate_resize(size(px(640.0), px(480.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + let tree = parse_full(NESTED_VISUALS); + let expected: std::collections::HashSet<_> = visual_fences(&tree.blocks[0].block, 0) + .into_iter() + .map(|(ix, _)| format!("visual-test-visual-{ix}")) + .collect(); + assert_eq!(expected.len(), 2); + assert_eq!( + calls + .borrow() + .iter() + .cloned() + .collect::>(), + expected + ); + } + #[test] fn sole_workspace_link_gets_a_file_path() { let runs = vec![InlineRun { From 4ec901c5a1dd885be541313caccabb27e04f5b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 01:06:13 -0300 Subject: [PATCH 3/4] Release Markdown visuals outside the preview limit --- crates/ui/src/files/markdown_preview.rs | 59 +++++++++++++++++++++++-- docs/mcp.md | 6 +-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index d7e799c182..0e48b93784 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -218,7 +218,8 @@ pub(super) struct MarkdownPreview { diagram_style: u32, source_visible: HashSet, /// Running ```` ```zeron-visual ```` fences, created when their row - /// first renders and kept while their code stays in the document. + /// first renders and kept while their code stays among the first + /// [`MAX_VISUALS`] fences in the document. visuals: HashMap>, /// Each fence's key by its frame id (`{row}-visual-{ix}`), per parse. visual_slots: HashMap, @@ -790,7 +791,7 @@ impl MarkdownPreview { } /// Map the parsed tree's visual fences to their keys, and drop the - /// visuals whose fence left the document or changed its code. + /// visuals whose fence left the allowed set or changed its code. fn sync_visuals(&mut self) { self.visual_slots.clear(); self.visual_allowed.clear(); @@ -810,8 +811,9 @@ impl MarkdownPreview { if self.truncated { self.visuals.clear(); } + self.visuals + .retain(|key, _| self.visual_allowed.contains(key)); let present: HashSet<&VisualKey> = self.visual_slots.values().collect(); - self.visuals.retain(|key, _| present.contains(key)); self.visual_runnable.retain(|key| present.contains(key)); } @@ -2443,6 +2445,57 @@ mod async_tests { }); } + #[gpui::test] + fn document_edits_release_visuals_that_leave_the_preview_limit(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let view = cx.new(|cx| MarkdownPreview::new("README.md".into(), Rc::new(|_, _| {}), cx)); + let mut source: String = (0..MAX_VISUALS) + .map(|i| fence(&format!("{SCRIPT}\n-- initial {i}"))) + .collect(); + let mut previous: Vec<_> = view.update(cx, |view, cx| { + show(view, &source); + render_visuals(view, cx) + .iter() + .map(|slot| live(slot).downgrade()) + .collect() + }); + cx.run_until_parked(); + + for edit in 0..3 { + source = format!("{}{source}", fence(&format!("{SCRIPT}\n-- added {edit}"))); + view.update(cx, |view, _| { + show(view, &source); + assert_eq!(view.visuals.len(), MAX_VISUALS - 1); + assert!( + previous[..MAX_VISUALS - 1] + .iter() + .all(|frame| frame.upgrade().is_some()), + "allowed frames keep their state" + ); + }); + // GPUI defers entity releases until the update's effects finish. + cx.run_until_parked(); + assert!( + previous.last().unwrap().upgrade().is_none(), + "the displaced frame is released" + ); + previous = view.update(cx, |view, cx| { + let slots = render_visuals(view, cx); + assert!( + slots[MAX_VISUALS..] + .iter() + .all(|slot| matches!(slot, RowVisual::Blocked(_))) + ); + assert_eq!(view.visuals.len(), MAX_VISUALS); + slots[..MAX_VISUALS] + .iter() + .map(|slot| live(slot).downgrade()) + .collect() + }); + cx.run_until_parked(); + } + } + #[gpui::test] fn outside_documents_run_visuals_only_when_asked(cx: &mut TestAppContext) { cx.update(|cx| cx.set_global(Theme::dark())); diff --git a/docs/mcp.md b/docs/mcp.md index 173304cf0f..c967e63a38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -256,9 +256,9 @@ end - Lua runs on the desktop's main thread, so a document runs at most 8 visuals (its first 8 fences; the rest show as code with a notice), each starts only when its row first renders, and they are dropped when the preview suspends, - switches documents, or the fence leaves the document. A truncated preview - runs none. A document outside the workspace runs nothing on its own: each - fence shows its code with a "Run visual" button. + switches documents, or the fence leaves the document's first 8 fences. A + truncated preview runs none. A document outside the workspace runs nothing + on its own: each fence shows its code with a "Run visual" button. - The fence sits on the document without a code-block frame; a quiet row above it toggles "Show source" / "Show visual". Elsewhere (GitHub, other editors, chat replies, iOS) the fence is ordinary code. From 6395fb7a1fb764a86c3f2f10a6a9d9f6f0449b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 15 Oct 2026 01:10:45 -0300 Subject: [PATCH 4/4] Use the preview Markdown parser for visual checks --- Cargo.lock | 1 + apps/zeron/src/visual_cli.rs | 34 ++++ crates/markdown/src/parser.rs | 109 ++++++++++--- crates/ui/src/files/markdown_preview.rs | 12 +- crates/visual/Cargo.toml | 1 + crates/visual/src/document.rs | 206 ++++++------------------ docs/mcp.md | 6 +- 7 files changed, 182 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b25210d9d5..9034c0db39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14009,6 +14009,7 @@ dependencies = [ "mlua", "naga", "thiserror 2.0.20", + "zeron-markdown", ] [[package]] diff --git a/apps/zeron/src/visual_cli.rs b/apps/zeron/src/visual_cli.rs index 0fe8d7b5e4..24543ed3e1 100644 --- a/apps/zeron/src/visual_cli.rs +++ b/apps/zeron/src/visual_cli.rs @@ -120,6 +120,40 @@ mod tests { assert_eq!(lines, ["plain.md: no ```zeron-visual fences"]); } + #[test] + fn markdown_checks_visuals_in_tab_indented_lists() { + let (lines, ok) = check_markdown( + "tabbed.md", + "- item\n\n\t```zeron-visual\n\treturn ui.nope {}\n\t```\n", + ); + assert!(!ok); + assert!(lines[0].starts_with("tabbed.md:4: visual 1: "), "{lines:?}"); + } + + #[test] + fn markdown_ignores_visual_source_in_html_and_indented_code() { + for source in [ + "\n", + "
\n```zeron-visual\nreturn ui.nope {}\n```\n
\n", + " ```zeron-visual\n return ui.nope {}\n ```\n", + ] { + let (lines, ok) = check_markdown("plain.md", source); + assert!(ok, "{lines:?}"); + assert_eq!(lines, ["plain.md: no ```zeron-visual fences"]); + } + } + + #[test] + fn markdown_checks_the_visual_after_an_unclosed_list_fence() { + let (lines, ok) = check_markdown( + "list.md", + "- ```zeron-visual\n return ui.text \"first\"\n\n```zeron-visual\nreturn ui.nope {}\n```\n", + ); + assert!(!ok); + assert!(lines[0].starts_with("list.md:1: visual 1 ok"), "{lines:?}"); + assert!(lines[1].starts_with("list.md:5: visual 2: "), "{lines:?}"); + } + #[test] fn scripts_report_their_own_lines() { let (lines, ok) = check_script( diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 1bb9ef122a..12159bda85 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -140,7 +140,78 @@ pub fn parse_full(source: &str) -> BlockTree { parse_at(source, 0) } +/// A fenced code block with its opening line in the original source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FencedCodeBlock { + pub line: usize, + pub language: Option, + pub code: String, +} + +/// Extract fences using the same parser, options, and code normalization as +/// [`parse_full`]. Container indentation and HTML follow Markdown semantics; +/// indented code blocks are excluded. +pub fn fenced_code_blocks(source: &str) -> Vec { + let line_starts: Vec<_> = std::iter::once(0) + .chain(source.match_indices('\n').map(|(offset, _)| offset + 1)) + .collect(); + with_events(source, 0, |cur| { + let mut fences = Vec::new(); + while let Some((event, range)) = cur.peek() { + if matches!( + event, + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) + ) { + let line = line_starts.partition_point(|start| *start <= range.start); + for block in parse_started_block(cur) { + if let Block::CodeBlock { language, code } = block { + fences.push(FencedCodeBlock { + line, + language, + code, + }); + } + } + } else { + cur.bump(); + } + } + fences + }) +} + fn parse_at(source: &str, offset: usize) -> BlockTree { + with_events(source, offset, |cur| { + let mut blocks = Vec::new(); + while let Some((event, range)) = cur.peek() { + let range = range.clone(); + match event { + Event::Rule => { + cur.bump(); + blocks.push(Arc::new(TopBlock { + range, + block: Block::Rule, + })); + } + Event::Start(_) => { + for block in parse_started_block(cur) { + blocks.push(Arc::new(TopBlock { + range: range.clone(), + block, + })); + } + } + // Stray inline events at top level (shouldn't happen): skip. + _ => cur.bump(), + } + } + BlockTree { blocks } + }) +} + +/// Run a parser over the same prepared source and original byte offsets used +/// by the block tree, including absolute-link destination rewriting. +fn with_events(source: &str, offset: usize, parse: impl FnOnce(&mut Cursor<'_, '_>) -> T) -> T { let (text, insertions) = match rewrite_absolute_destinations(source) { Some((text, insertions)) => (Cow::Owned(text), Some(insertions)), None => (Cow::Borrowed(source), None), @@ -161,30 +232,7 @@ fn parse_at(source: &str, offset: usize) -> BlockTree { events: &events, ix: 0, }; - let mut blocks = Vec::new(); - while let Some((event, range)) = cur.peek() { - let range = range.clone(); - match event { - Event::Rule => { - cur.bump(); - blocks.push(Arc::new(TopBlock { - range, - block: Block::Rule, - })); - } - Event::Start(_) => { - for block in parse_started_block(&mut cur) { - blocks.push(Arc::new(TopBlock { - range: range.clone(), - block, - })); - } - } - // Stray inline events at top level (shouldn't happen): skip. - _ => cur.bump(), - } - } - BlockTree { blocks } + parse(&mut cur) } /// Byte positions, in the rewritten text, of the angle brackets the rewrite @@ -1678,6 +1726,19 @@ mod destination_rewrite_tests { assert_eq!(text(&runs), "[x](/tmp/a b.md)"); } + #[test] + fn fenced_code_extraction_keeps_original_lines_and_normalized_code() { + let source = "[x](/tmp/Some Folder/x.md)\r\n\r\n- item\r\n\r\n\t~~~RuSt extra\r\n\tfirst()\r\n\t~~~\r\n"; + assert_eq!( + fenced_code_blocks(source), + vec![FencedCodeBlock { + line: 5, + language: Some("RuSt".into()), + code: "first()".into(), + }] + ); + } + #[test] fn unclosed_destinations_wait_for_their_closing_paren() { let mut parser = IncrementalParser::new(); diff --git a/crates/ui/src/files/markdown_preview.rs b/crates/ui/src/files/markdown_preview.rs index 0e48b93784..8b94611d15 100644 --- a/crates/ui/src/files/markdown_preview.rs +++ b/crates/ui/src/files/markdown_preview.rs @@ -1643,10 +1643,10 @@ mod tests { assert_eq!(comment_block(&lines, 11), Some(3)); assert_eq!(comment_block(&[], 1), None); } - /// `zeron visual check` finds fences with a line scanner; it must see the - /// same code as the preview's parser, or the seeds would differ. + /// `zeron visual check` and the preview share the Markdown parser. Their + /// extraction must also keep code and occurrences identical for seeding. #[test] - fn visual_check_scanner_matches_the_parser() { + fn visual_check_parser_matches_the_preview() { for source in [ "# Doc\n\n```zeron-visual\nlocal a = 1\n\n indented()\n```\n\nText\n\n~~~zeron-visual\ntilde()\n~~~\n", "- item\n\n ```zeron-visual\n listed()\n deeper()\n ```\n\n1. ```zeron-visual\n numbered()\n ```\n", @@ -1654,6 +1654,12 @@ mod tests { "```zeron-visual\r\ncrlf()\r\n\r\nend\r\n```\r\n", "````markdown\n```zeron-visual\nhidden()\n```\n````\n```zeron-visual\nopen()\n", "```zeron-visual\n```\n\n```zeron-visual\n\n\n```\n", + "- item\n\n\t```zeron-visual\n\treturn ui.nope {}\n\t```\n", + "\n\n```zeron-visual\nshown()\n```\n", + "
\n```zeron-visual\nhidden()\n```\n
\n", + " ```zeron-visual\n hidden()\n ```\n", + "- ```zeron-visual\n first()\n\n```zeron-visual\nsecond()\n```\n", + "[x](/tmp/Some Folder/x.md)\r\n\r\n> ```zeron-visual\r\n> quoted()\r\n> ```\r\n", ] { let tree = parser::parse_full(source); let parsed: Vec = tree diff --git a/crates/visual/Cargo.toml b/crates/visual/Cargo.toml index 1b19b58662..6c0dbb0d94 100644 --- a/crates/visual/Cargo.toml +++ b/crates/visual/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true description = "Sandboxed Luau runtime for agent visuals: scripts build a native element tree, canvas drawings, and 3D scenes" [dependencies] +zeron-markdown.workspace = true mlua = { version = "0.12", features = ["luau"] } # The GLSL compiler wgpu uses, so a shader is validated exactly as it will run. naga = { version = "29", features = ["glsl-in"] } diff --git a/crates/visual/src/document.rs b/crates/visual/src/document.rs index 62f6969a37..c2729376d0 100644 --- a/crates/visual/src/document.rs +++ b/crates/visual/src/document.rs @@ -1,9 +1,7 @@ //! Visuals embedded in Markdown documents as ```` ```zeron-visual ```` fences. //! -//! Zeron's Markdown preview runs these fences with its full parser; this is -//! the light line scanner `zeron visual check` uses to find them, so an agent -//! can check a document's visuals and map each error back to a line of the -//! `.md` without a running Zeron. +//! Uses the Markdown preview's parser so `zeron visual check` finds the same +//! scripts, seeds them identically, and maps errors back to document lines. /// The fence language that marks a visual (case-insensitive). pub const FENCE_LANGUAGE: &str = "zeron-visual"; @@ -24,160 +22,25 @@ pub struct Fence { pub occurrence: usize, } -struct Open { - marker: char, - len: usize, - /// What precedes the fence on its line: list indentation and quote markers. - prefix: String, - /// The fence's own indentation, stripped from its content lines. - indent: usize, - visual: Option, -} - -/// Every visual fence in `markdown`, in document order. Other fences are -/// skipped whole, so a visual quoted inside a ```` ```markdown ```` fence is -/// not one. +/// Every visual fence in `markdown`, in document order, with the same code +/// as the Markdown preview. Other fences, HTML, and indented code are handled +/// by the shared Markdown parser. pub fn fences(markdown: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut open: Option = None; - let finish = |fence: Open, out: &mut Vec| { - if let Some(mut visual) = fence.visual { - visual.occurrence = out.iter().filter(|f| f.code == visual.code).count(); - out.push(visual); - } - }; - for (ix, raw) in markdown.lines().enumerate() { - let line = raw.strip_suffix('\r').unwrap_or(raw); - if let Some(fence) = &mut open { - match strip_container(line, &fence.prefix) { - Some(content) if !closes(content, fence.marker, fence.len) => { - if let Some(visual) = &mut fence.visual { - // `ix` is the 0-based index, so the first content - // line has `ix == visual.line`. - if ix > visual.line { - visual.code.push('\n'); - } - visual.code.push_str(strip_spaces(content, fence.indent)); - } - continue; - } - content => { - finish(open.take().expect("open fence"), &mut out); - if content.is_some() { - continue; - } - // The quote holding the fence ended, so this line may - // open a fence of its own. - } - } - } - open = opening(line, ix + 1); - } - if let Some(fence) = open { - finish(fence, &mut out); - } - out -} - -/// A fence opened by `line`, if it opens one. -fn opening(line: &str, number: usize) -> Option { - let body_start = container_len(line); - let (prefix, body) = line.split_at(body_start); - let indent = body.len() - body.trim_start_matches(' ').len(); - let body = &body[indent..]; - let marker = body.chars().next().filter(|c| matches!(c, '`' | '~'))?; - let len = body.chars().take_while(|c| *c == marker).count(); - if len < 3 { - return None; - } - let info = body[len..].trim(); - if marker == '`' && info.contains('`') { - return None; - } - let language = info.split_whitespace().next().unwrap_or(""); - Some(Open { - marker, - len, - prefix: prefix.to_owned(), - indent, - visual: is_fence_language(language).then(|| Fence { - // Content starts on the next line; `line + n` is script line n. - line: number, - code: String::new(), - occurrence: 0, - }), - }) -} - -/// The length of a line's container markers: list bullets and numbers, quote -/// markers, and the spaces between them, up to where the block itself starts. -fn container_len(line: &str) -> usize { - let bytes = line.as_bytes(); - let mut ix = 0; - loop { - let start = ix; - while ix < bytes.len() && bytes[ix] == b' ' { - ix += 1; - } - match bytes.get(ix) { - Some(b'>') => { - ix += 1; - if bytes.get(ix) == Some(&b' ') { - ix += 1; - } - } - Some(b'-' | b'*' | b'+') if bytes.get(ix + 1) == Some(&b' ') => ix += 2, - Some(b'0'..=b'9') => { - let digits = bytes[ix..] - .iter() - .take_while(|b| b.is_ascii_digit()) - .count(); - match (bytes.get(ix + digits), bytes.get(ix + digits + 1)) { - (Some(b'.' | b')'), Some(b' ')) => ix += digits + 2, - _ => return start, - } - } - _ => return start, - } - } -} - -/// A content line with the opening line's container removed: its quote -/// markers must be there (`None` when the quote ended); list indentation is -/// removed where present. -fn strip_container<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { - let mut rest = line; - let mut prefix = prefix; - while !prefix.is_empty() { - let spaces = prefix.len() - prefix.trim_start_matches(' ').len(); - prefix = &prefix[spaces..]; - match prefix.chars().next() { - Some('>') => { - rest = rest.trim_start_matches(' ').strip_prefix('>')?; - rest = rest.strip_prefix(' ').unwrap_or(rest); - prefix = prefix[1..].strip_prefix(' ').unwrap_or(&prefix[1..]); - } - Some(_) => { - // A list marker: its content lines are indented by its width. - let marker = prefix.find(' ').map_or(prefix.len(), |at| at + 1); - rest = strip_spaces(rest, spaces + marker); - prefix = &prefix[marker..]; - } - None => rest = strip_spaces(rest, spaces), - } - } - Some(rest) -} - -fn strip_spaces(line: &str, max: usize) -> &str { - let spaces = line.len() - line.trim_start_matches(' ').len(); - &line[spaces.min(max)..] -} - -fn closes(content: &str, marker: char, len: usize) -> bool { - let body = content.trim_start_matches(' '); - let run = body.chars().take_while(|c| *c == marker).count(); - content.len() - body.len() <= 3 && run >= len && body[run..].trim().is_empty() + let mut seen = std::collections::HashMap::::new(); + zeron_markdown::parser::fenced_code_blocks(markdown) + .into_iter() + .filter(|fence| fence.language.as_deref().is_some_and(is_fence_language)) + .map(|fence| { + let occurrence = seen.entry(fence.code.clone()).or_default(); + let visual = Fence { + line: fence.line, + code: fence.code, + occurrence: *occurrence, + }; + *occurrence += 1; + visual + }) + .collect() } /// Where a script error points in the document: the line of its first @@ -253,6 +116,35 @@ mod tests { assert_eq!(found[0].code, "one()"); } + #[test] + fn tab_indented_list_fences_are_visuals() { + let found = fences("- item\n\n\t```zeron-visual\n\treturn ui.nope {}\n\t```\n"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].line, 3); + assert_eq!(found[0].code, "return ui.nope {}"); + } + + #[test] + fn html_and_indented_code_do_not_open_visuals() { + for source in [ + "\n", + "
\n```zeron-visual\nreturn nil\n```\n
\n", + " ```zeron-visual\n return nil\n ```\n", + ] { + assert!(fences(source).is_empty(), "{source:?}"); + } + } + + #[test] + fn a_list_ending_also_ends_its_unclosed_fence() { + let found = fences("- ```zeron-visual\n first()\n\n```zeron-visual\nsecond()\n```\n"); + assert_eq!(found.len(), 2); + assert_eq!(found[0].line, 1); + assert_eq!(found[0].code, "first()\n"); + assert_eq!(found[1].line, 4); + assert_eq!(found[1].code, "second()"); + } + #[test] fn crlf_blank_lines_and_unclosed_fences() { let doc = "```zeron-visual\r\n\r\nfirst()\r\n```\r\n```zeron-visual\nlast()"; diff --git a/docs/mcp.md b/docs/mcp.md index c967e63a38..80b8bd4821 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -268,9 +268,9 @@ visuals the way the preview will (`zeron_visual::check` with the same seed): every fence of a `.md`/`.markdown`, or a whole `.luau` script. It prints `file.md:LINE: visual K ok (N interactions)` plus the script's `print()` output, or `file.md:LINE: visual K: message` with the error's script line -mapped to the document's, and exits 1 when any visual fails. It finds fences -with a line scanner (`zeron_visual::document`) that understands backtick and -tilde fences, list indentation, and quotes. +mapped to the document's, and exits 1 when any visual fails. Fence extraction +(`zeron_visual::document`) uses the preview's shared Markdown parser, including +its handling of lists, tabs, quotes, HTML, and code indentation. ## Selecting a host and project