Vectrum
Sign In
364 lines · 22.3 KB

Spec: inline UI, diagrams and mini apps in the transcript

Status: ready to build. Written for an agent that has not seen the discussion behind it. Repo: Vectrum Code (/home/joni/Documents/DATA/Work/Coding/rust/code), a GPUI desktop client for Claude Code, Pi and jcode. Read README.md first for the app's shape.

1. Goal

Agents can put rich, live output into their answers, like Claude's artifacts or OpenUI: diagrams, charts, cards, tables, small forms and test UIs, and whole HTML mini apps. The agent writes them as fenced code blocks in its normal Markdown answer. The app renders them natively in the transcript (MetalUI look, no webview), and interactive ones can talk back to the agent.

Four block kinds, all plain fenced code blocks, so they degrade to readable code anywhere else:

FenceRenders asPriority
```mermaidDiagram image (flowchart, sequence, class, state, ER, pie, gantt, mindmap...)P0
```openuiNative interactive UI from an OpenUI Lang program (section 5)P0
```htmlCode block as today, plus a Preview button that opens it as a mini appP1
```svgThe SVG drawn as an image, with a "Show source" toggleP2

Every rendered block keeps a small header row with Copy source and Show source (toggles back to the plain code block), so a bad render never hides the content.

Non-goals: arbitrary JS in the transcript, OpenUI Query/Mutation (tool calls from UI), React/Zod component libraries, the OpenUI Gateway or Cloud.

2. Deliver in phases, commit after each

  1. Mermaid (smallest visible win, proves the markdown hook).
  2. OpenUI Lang parser + evaluator, pure Rust, no GPUI, with unit tests.
  3. OpenUI renderer (static components first, then state, inputs, actions).
  4. HTML preview and SVG.
  5. Agent instructions + setting + README.

Each phase must compile (cargo check), pass cargo test, and be committed with a message in the repo's style ("Render mermaid blocks in answers as diagrams"). Do not batch phases.

3. Environment and build facts

  • Toolchain is not on PATH: export PATH=$HOME/.cargo/bin:$PATH.
  • Cold cargo check takes about 7 minutes on this laptop (i7-8550U). Incremental checks are much faster. Run long builds in the background and keep working.
  • Binary: cargo run (debug) or ./build.sh (release). The desktop is Hyprland (Omarchy), grim can screenshot for visual checks.
  • Tests: GPUI exports its own test attribute. Don't glob-import into test modules, or use #[::core::prelude::v1::test] as src/ui/workspace.rs does.
  • Style: doc comments in plain prose, explain why. No em dashes in comments, docs or UI text.
  • Deps: gpui-kit = "=0.7.1" (re-exports gpui, gpui_kit::component = gpui-component, gpui_kit::base = gpui-base). Sources are in ~/.cargo/registry/src/*/gpui-*-0.7.1/ and gpui-pre-0.3.8 for core GPUI. Read them when unsure; don't guess APIs.

4. Where it hooks in (verified)

4.1 The Markdown view

  • Answers render through pub(super) fn markdown(state, p) -> TextView in src/ui/chat.rs (around line 1993). It already adds a copy button per code block with .code_block_actions(...). Its only transcript caller is render_assistant in src/ui/chat/timeline.rs (around line 576/598). Plans use TextView::markdown(...) at timeline.rs ~917 and ~1165 (optional to hook).
  • The state is Entity<TextViewState> per answer, created in src/session.rs (push_assistant_text ~3779, item_from_saved ~4210). Streaming text is pushed with AssistantText::show.
  • gpui_kit::component::text::TextView has the hooks needed:
    • .markdown_block_parser(|node: &markdown_ast::Node, cx: &MarkdownParseContext| -> Option<MarkdownNode>)
    • .markdown_block_renderer(name, |node: &MarkdownNode, &mut Window, &mut App| -> impl IntoElement)
    • or a MarkdownPlugin impl with is_block() -> true applied via .plugin(...).
    • Types are re-exported from gpui_kit::component::text: MarkdownNode, MarkdownParseContext, MarkdownPlugin, MarkdownExtensions, markdown_ast (mdast).
  • The parser sees mdast nodes. A fence is markdown_ast::Node::Code(Code { value, lang, meta, .. }). Match on lang (mermaid, openui, svg). Build the node with MarkdownNode::new("openui", data).text(source).markdown(source) where source is cx.node_source(node), so selection and copy still yield the original fence. Read the data back in the renderer with node.data::<T>(). node.source_range() gives the byte range in the document.
  • Constraints (from gpui-base-0.7.1/src/text/markdown_ext.rs):
    • Parsers and renderers must be Send + Sync + 'static. Parsers run on a background thread with no Window/App: do pure parsing only (the OpenUI parse is ideal here, Mermaid layout too).
    • Hooks are re-registered every frame by the builder. That is fine: the document is only reparsed when the parser configuration changes (number of parsers/renderers, their names, parser_revision). Keep the set of parsers and names constant.
    • Custom blocks are wrapped by the view in div().pb(margin).
  • Renderers get no context about which thread they're in. Pass it in: change markdown() to also take the session's EntityId (Copy, Send) and capture it in the renderer closure. render_item in timeline.rs has the session: &Entity<Session>.
  • Streaming: with the default "paragraphs" streaming (Streaming::Paragraphs, finished_prefix in session.rs) whole code blocks tend to arrive at once, but with token streaming the fence arrives partially (an unclosed fence parses as code to the end). Renderers must accept partial input: render what resolves, show skeletons for the rest, never panic, and only show a parse error once the fence is closed.
  • The transcript is a virtual list (MessageScroller, src/ui/chat.rs ~1452). If a block changes height after an interaction or an async render, call scroller.remeasure(cx) (see timeline.rs ~542 for the pattern), or the next frame may jump.

4.2 Per-block state and re-rendering

  • Do not rely on window.use_keyed_state alone for values: virtual list rows that scroll out of view drop their element state. Keep UI values in a GPUI Global, e.g. InlineUiStore { blocks: HashMap<(EntityId, u64 /* hash of source range + source */), BlockState> }. use_keyed_state is fine for widget entities (InputState, SliderState) as long as they are re-seeded from the store when recreated.
  • After a store change in an event handler, call window.refresh() so the custom block re-renders. If the TextViewState view is cached and does not repaint, also cx.notify(text_view_state_entity_id).
  • Optional: persist block state with the thread (SavedItem in session.rs) so forms keep values after restart. Not required for v1.

4.3 Talking back to the agent and opening previews

  • A renderer only has &mut App. Add a Global hub, set up once in Workspace::new (src/ui/workspace.rs), holding WeakEntity<Workspace>, and a map EntityId -> WeakEntity<Session> (or look sessions up through the workspace).
  • Sending a message: mirror ChatView::send (src/ui/chat.rs ~520-550): if the session is working, queue_message vs send_message per the follow_up setting, then scroll to end. Session::send_message(text, Vec::new(), cx) is at session.rs ~1253, queue_message ~1444.
  • Message format from UI actions should be explicit so the agent knows it came from its UI, e.g. [UI] Submitted "signup": {"name":"Ada","email":"ada@x.io"} or the literal text of @ToAssistant("...").
  • Browser panel: Workspace::open_surface(Surface::Browser, window, cx) (src/ui/workspace/surfaces.rs ~62), panels made by shown_browser (workspace.rs ~1786), BrowserPanel (src/ui/browser.rs) owns a BrowserSession with open(url) (src/browser.rs ~522). Needs agent-browser installed (browser::find_binary()). Fallback: workspace::open_url(url) (xdg-open, workspace.rs ~2362).

4.4 Telling the agents

  • Claude and Pi: AgentProcess::spawn in src/claude.rs (~174-200) passes --append-system-prompt <prompt> only for the browser prompt today. Build one combined prompt (browser prompt if any + inline UI prompt if enabled) and pass the flag once.
  • jcode: system prompt can't be extended. Instructions ride along with the first message as system_reminder, see src/session.rs ~1600 (orchestrator::INSTRUCTIONS and browser_note are joined there). Add the inline UI prompt to that list.
  • Put the prompt text in a pub const INSTRUCTIONS: &str in the new module (pattern: src/orchestrator.rs ~44). Keep it short, under ~60 lines: when to use each block, the component signatures, two examples, and the rule "plain Markdown first, UI only when it helps".

4.5 Theming

  • metal::palette(cx) -> &'static Palette (src/ui/metal.rs): page, s_hi, s, s_lo, well_top, well_bot, ink, ink2, ink3, rule, primary, green_ink, red, amber, blue_ink, tints[6], dark, shadow recipes. Surface helpers via MetalStyled: .plate(p), .raised(p), .well(p), .region(p), .engraved(p). Also metal::tag, metal::rule, metal::led, metal::toggle, metal::segmented, MetalButton::new(id) .primary()/.ghost()/.danger()/.compact()/.mini()/.label()/.icon()/.on_click(), and .dropdown_menu(|menu, _, _| menu.item(PopupMenuItem::new(..).checked(..).on_click(..))).
  • Fonts: metal::SANS ("Geist"), metal::MONO ("Geist Mono").
  • Code wells look: see markdown_style in chat.rs ~1973. UI blocks should sit on a .raised(p) or .region(p) card with 14px radius so they read as objects, not prose.

5. Block kinds in detail

5.1 Mermaid (P0)

  • Dependency: mermaid-rs-renderer = { version = "0.3.1", default-features = false } (pure Rust, 23 diagram types; default features pull clap and a second resvg version, so keep them off).
  • API: mermaid_rs_renderer::render_with_options(src, RenderOptions { theme, layout }) -> anyhow::Result<String> (SVG text). Theme has public String color fields (font_family, font_size, primary_color, primary_text_color, primary_border_color, line_color, secondary_color, tertiary_color, edge_label_background, cluster_background, cluster_border, background, text_color, sequence_*, pie_colors[12], ...). Start from Theme::modern() (light) or Theme::dark() and override from the palette (convert Hsla to hex via gpui::Rgba::from(hsla)). Make the background transparent or the palette's surface color.
  • Fonts: the crate measures text with system fonts (fontdb), and GPUI rasterizes SVG with system fonts plus bundled assets. Use a family that exists on the system for both (e.g. "DejaVu Sans, sans-serif"), not Geist, or labels overflow their boxes.
  • Rendering: do parse + layout + SVG generation off the main thread (cx.background_spawn), the first call loads the system font DB and is slow. Rasterize with cx.svg_renderer().render_single_frame(svg_bytes, window.scale_factor()) -> Arc<RenderImage>, and draw with img(render_image). ImageFormat::Svg through Image::from_bytes renders at scale 1.0 and looks blurry on HiDPI, avoid it.
  • Cache rendered images in a Global keyed by (hash(src), dark, scale). RenderImage ids are unique per render, so never re-render per frame. Drop evicted images with window.drop_image(...)/cx.drop_image(...).
  • Size: intrinsic width from the SVG (width/height attrs or mermaid_rs_renderer::measure), max_w_full(), keep aspect ratio (.aspect_ratio(w / h)). Center small diagrams. Wide diagrams: horizontal scroll, or click to open full size in an overlay (nice to have).
  • While rendering: skeleton of a guessed height. On error: the code block plus a one-line error in p.red.

5.2 OpenUI Lang (P0)

Follow OpenUI Lang v0.5 (spec: https://www.openui.com/docs/openui-lang/specification-v05.mdx, syntax: https://www.openui.com/docs/openui-lang/syntax.mdx) so models that know OpenUI write valid blocks. Implement a subset.

Module layout (keep the language pure, it gets the unit tests):

  • src/inline_ui/lang.rs: lexer + parser -> Program { statements: Vec<(Ident, Expr)> }.
  • src/inline_ui/eval.rs: evaluator -> Value tree, given state.
  • src/ui/inline/mod.rs: markdown hooks, globals (store, hub, cache).
  • src/ui/inline/mermaid.rs, src/ui/inline/openui.rs, src/ui/inline/html.rs.

Grammar (one statement per line, identifier = Expression):

  • Statements: component/value name = Expr, state $name = default.
  • Expressions: component call Type(args...) (positional only), builtin @Name(args), string "..." (with \" \n escapes), number, true/false/null, array [..], object {key: value}, reference ident, state ref $ident, member access a.b.c (on arrays it plucks the field from every element), ternary c ? a : b, binary + - * / % == != > < >= <= && ||, unary ! -. Precedence as in C/JS.
  • // line comments. Blank lines ignored. Forward references allowed.
  • Root: the statement named root if present, else the first statement.
  • Streaming tolerance: drop the last line if it doesn't parse and the fence is still open. An unresolved reference evaluates to Value::Pending and renders as a skeleton. Cycles evaluate to an error value, not a stack overflow (depth limit ~64).

Values: Null, Bool, Number(f64), String, Array(Vec), Object(BTreeMap), Component { name, args: Vec<Value> }, Action(Vec<Step>), State(name), Pending, Error(msg). Evaluate the whole program on every render. Programs are small, keep it simple.

Builtins: @Count @Sum @Avg @Min @Max @First @Last @Filter(array, field, op, value) @Sort(array, field, dir?) @Round(n, decimals?) @Abs @Floor @Ceil @Each(array, varName, template).

Actions: Action([steps]) where steps are @Set($var, value), @Reset($a, $b), @ToAssistant("message"), @OpenUrl("url"). Also accept v0.1 string actions on buttons: "submit:<form>" (send the form's values to the agent), "action:<name>" (send [UI] action: <name> to the agent). Query, Mutation and @Run are not supported: render a small "not supported here" notice in their place.

Components (positional args in this order, optional ones may be omitted from the end). Names and orders match OpenUI's default library where it has them:

ComponentSignatureRender
Stack(children, direction?="column"|"row", gap?="s"|"m"|"l", align?, justify?, wrap?)flex
Card(children, variant?="raised"|"well"|"plain", direction?, gap?)MetalUI card
CardHeader(title, subtitle?)
TextContent(text, size?="small"|"default"|"large"|"large-heavy")
Markdown(text)nested TextView
Callout(variant="info"|"success"|"warning"|"error", title, description?)
Tag(text, variant?)metal::tag
Separator()metal::rule
Progress(value 0..1 or 0..100, label?)
Stat(label, value, delta?)KPI tile
Image(src, alt?)http(s) or data URI only
CodeBlock(language, code)
Table(columns) with Col(label, data, type?="text"|"number"|"tag")rows = longest column
BarChart(labels, series, variant?="grouped"|"stacked", xLabel?, yLabel?) with Series(name, values)gpui chart
LineChart / AreaChart(labels, series, xLabel?, yLabel?)
PieChart(labels, values)
Tabs(items) with TabItem(value, trigger, content)metal::segmented + content
Form(name, fields, buttons)collects field values by name
FormControl(label, input, hint?)
Input(name or $var, placeholder?, type?="text"|"email"|"number"|"password", rules?)
TextArea(name or $var, placeholder?, rows?)
Select(name or $var, items, placeholder?), items = strings or {label, value}MetalButton + dropdown
Checkbox / Switch(name or $var, label)
Slider(name or $var, min, max, step?)
Buttons(buttons, direction?)
Button(label, action, variant?="primary"|"secondary"|"danger"|"ghost")MetalButton

Binding: a $var in an input's first argument binds it two-way; a plain string name keeps the value in the enclosing Form. Expressions referencing $vars re-evaluate on change.

Unknown component: render its name as a tag and its children, so new names degrade softly.

GPUI building blocks (verified to exist in 0.7.1):

  • Charts: gpui_kit::component::chart::{BarChart, LineChart, AreaChart, PieChart}. BarChart::new(data).band(|d| label).value(|d| v), AreaChart takes several .y(..) for multi-series, PieChart::new(data).value(..).color(..).label(..). PlotValue is implemented for f32/f64. Charts default their ElementId to the call site, so several charts share hover state: always call .id(...) with a per-block id. Give charts a fixed height (e.g. 220px).
  • Inputs: gpui_kit::component::input::{Input, InputState, InputEvent, Textarea, TextareaState}. InputState::new(window, cx).placeholder(..).default_value(..), .value(), .set_value(v, window, cx), subscribe to InputEvent (PressEnter, change). The app draws inputs inside its own well with Input::new(&state).appearance(false) (src/ui/browser.rs ~393).
  • Slider: gpui_kit::component::slider::{Slider, SliderState, SliderEvent, SliderValue}. SliderState::new().min(..).max(..).step(..).default_value(..), SliderEvent::Change(SliderValue::Single(f32)).
  • Checkbox/Switch: gpui_kit::component::checkbox::Checkbox::new(id).label(..).checked(..) .on_click(|checked: &bool, window, cx| ..), or metal::toggle(...) for the app's look.

Safety: a block can only compose these components, open http(s) URLs after a click, and send text to the agent after a click. No file or network access on render. Images from remote URLs load only on click ("Load image") to avoid tracking pixels. Cap: 500 statements, 2000 rendered nodes, 10k table cells; beyond that, render an error.

5.3 HTML mini apps (P1)

  • Extend the existing code_block_actions in markdown() (chat.rs): for lang == "html" add a Preview button next to Copy.
  • On click: write the code to settings::data_dir()/artifacts/<sha-or-hash>.html (settings::data_dir() is in src/settings.rs), then open file://... in the thread's browser panel (section 4.3), falling back to open_url if agent-browser isn't installed.
  • Preview is user-driven in v1. Don't tell the agent about the artifacts folder.

5.4 SVG (P2)

lang == "svg": rasterize with cx.svg_renderer().render_single_frame like Mermaid, cap size at 1MB of source, show source on error.

6. Setting

  • inline_ui: bool, default true, in Settings (src/settings.rs, struct ~438, defaults ~577, the changed-settings labels list ~681). Device preference, not an environment key.
  • Row in Settings → General → Behavior next to "Rich text composer" (src/ui/settings/general.rs ~384, use row(...)/switch(...)/reset(...) like it), search entry in src/ui/settings/search.rs (~83).
  • Off: no agent instructions, and blocks render as plain code blocks.
  • Description: "Let agents draw diagrams, charts and small interactive UIs in their answers. Applies to the agent instructions when a thread's agent next starts."

7. Agent instructions (draft, refine while testing)

## Inline UI in Vectrum Code

Your Markdown answers are shown in Vectrum Code, which renders some fenced blocks live:
- ```mermaid: diagrams (flowchart, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, gantt, pie, mindmap).
- ```openui: native UI written in OpenUI Lang. One statement per line, `name = Component(args)`, positional args, `root = ...` first. State: `$name = default`. Actions: `Button("Go", Action([@ToAssistant("...")]))`, `@Set($x, v)`, `@Reset($x)`, `@OpenUrl("https://...")`.
  Components: Stack, Card, CardHeader, TextContent, Markdown, Callout, Tag, Separator, Progress, Stat, Image, CodeBlock, Table/Col, BarChart/LineChart/AreaChart(labels, [Series(name, values)]), PieChart(labels, values), Tabs/TabItem, Form, FormControl, Input, TextArea, Select, Checkbox, Switch, Slider, Buttons, Button.
- ```html: a self-contained page (inline CSS/JS); the user can open it with Preview.
Use them when a picture, table or control says it better than prose: architecture and flows, comparisons, results data, choices for the user to make. Keep prose for everything else. When the user acts on your UI you receive a message starting with [UI].

Example:
```openui
root = Card([header, form])
header = CardHeader("Deploy", "Pick a target")
form = Form("deploy", [FormControl("Environment", Select("env", ["staging", "production"]))], Buttons([Button("Deploy", "submit:deploy", "primary")]))
```

8. Acceptance checks

Run the app (cargo run), start a Claude thread, and send each prompt. Screenshot each result (grim) in both Bone and Graphite themes and look at it.

  1. "Draw a mermaid flowchart of a login flow with 6 steps." -> crisp diagram, readable labels, colors fit the theme, no blur at the display scale.
  2. "Show our 4 quarterly revenues as an openui bar chart and a table." -> chart + table in a card.
  3. "Give me an openui form to pick a deploy target and confirm." -> select + button; clicking sends a [UI] Submitted "deploy": {...} message and the agent replies.
  4. "Make an openui counter with + and - buttons using state." -> clicks update the number without a message to the agent; scrolling it away and back keeps the value.
  5. "Write a small HTML snake game." -> Preview opens it in the browser panel (or the system browser without agent-browser).
  6. Token streaming on (Settings → General → Response streaming by token): blocks build up without flicker, errors, or panics while the fence is incomplete.
  7. A deliberately broken openui and mermaid block -> source shown with a one-line error.
  8. Setting off -> plain code blocks, and the next agent start has no UI instructions.
  9. Old threads restored from disk with these blocks render them too.

Unit tests (pure, in src/inline_ui): parse every example in this file and in the OpenUI syntax page; operator precedence; member pluck on arrays; builtins; forward refs; cycles; partial last line; @Each; actions; error values.

9. Done means

All phases committed, cargo test green, the 9 checks pass with screenshots looked at, README "Features" gains a bullet describing inline diagrams, UI blocks and HTML previews.