diff --git a/CHANGELOG.md b/CHANGELOG.md
index eca6481..8f42d39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,22 @@ All notable changes to this project are documented here.
(`companion/src-tauri/src/{mcp,http}.rs`) and the Python bridge
(`aiui-mcp`, used by remote SSH hosts via `uvx`), plus a new
`/aiui:upload` prompt and an `INSTRUCTIONS` trigger on both.
+- **`compare` tool — side-by-side A/B (or A/B/C) content compare (#23).**
+ New standalone MCP tool (alongside `confirm`/`ask`/`form`/`gallery`) that
+ renders 2+ variants as equal-width panes next to each other and lets the
+ user click one to pick, instead of a small thumbnail (`ask`) or a
+ per-item batch review (`gallery`). Each variant carries a stable `value`
+ plus `content` (Markdown text — drafts, diffs, code) and/or `src`
+ (image/video, the same resolution rules as every other aiui image
+ field). Optional `sync_scroll` locks scroll position across all panes
+ for long-text compares; `max_height` set on any variant caps every
+ pane's height so they stay visually equal. Returns `{cancelled,
+ selected}`. New `Compare.svelte` widget, `compare` case in the Rust
+ spec validator + window-size estimator, and a mirrored `compare` tool
+ in the Python `aiui-mcp` package for remote/headless sessions. Extracted
+ the Markdown-to-sanitized-HTML renderer (previously private to
+ `Form.svelte`) into `companion/src/lib/markdown.ts` so both widgets
+ share one DOMPurify allowlist instead of drifting.
## [0.8.3] — 2026-07-29
diff --git a/companion/src-tauri/src/dialog.rs b/companion/src-tauri/src/dialog.rs
index 59d906e..87bba52 100644
--- a/companion/src-tauri/src/dialog.rs
+++ b/companion/src-tauri/src/dialog.rs
@@ -143,6 +143,46 @@ pub fn estimate_dialog_size(spec: &serde_json::Value) -> (f64, f64) {
return (w.min(MAX_W), needed.clamp(BASE_H, MAX_H));
}
+ // Compare has no `fields` either; it's N equal-width panes shown side
+ // by side. Width scales with the pane count (capped at 4, matching the
+ // frontend's grid cap); height is generous by default since panes
+ // typically hold either a full paragraph of markdown or an image.
+ if spec.get("kind").and_then(|v| v.as_str()) == Some("compare") {
+ let variants = spec
+ .get("variants")
+ .and_then(|v| v.as_array())
+ .map(|a| a.len())
+ .unwrap_or(2)
+ .max(1);
+ let cols = spec
+ .get("columns")
+ .and_then(|v| v.as_u64())
+ .filter(|&c| c > 0)
+ .map(|c| c as usize)
+ .unwrap_or(variants)
+ .clamp(1, 4);
+ let w = match cols {
+ 1 => BASE_W,
+ 2 => 760.0,
+ 3 => 980.0,
+ _ => MAX_W,
+ };
+ let has_media = spec
+ .get("variants")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter().any(|it| {
+ it.get("src")
+ .and_then(|s| s.as_str())
+ .map(|s| !s.is_empty())
+ .unwrap_or(false)
+ })
+ })
+ .unwrap_or(false);
+ let h: f64 = if has_media { 640.0 } else { 560.0 };
+ return (w.min(MAX_W), h.clamp(BASE_H, MAX_H));
+ }
+
if let Some(tabs) = spec.get("tabs").and_then(|v| v.as_array()) {
if !tabs.is_empty() {
content_h += 40.0;
@@ -626,6 +666,55 @@ mod tests {
assert_eq!(w, 520.0, "explicit 1 column → base width");
}
+ #[test]
+ fn estimate_size_compare_scales_with_variant_count() {
+ let two = serde_json::json!({
+ "kind": "compare",
+ "variants": [
+ { "value": "a", "content": "Draft A" },
+ { "value": "b", "content": "Draft B" }
+ ]
+ });
+ let (w2, h2) = estimate_dialog_size(&two);
+ assert_eq!(w2, 760.0, "2 variants → 760 wide");
+ assert_eq!(h2, 560.0, "no media → 560 tall");
+
+ let three = serde_json::json!({
+ "kind": "compare",
+ "variants": [
+ { "value": "a", "content": "A" },
+ { "value": "b", "content": "B" },
+ { "value": "c", "content": "C" }
+ ]
+ });
+ let (w3, _h3) = estimate_dialog_size(&three);
+ assert_eq!(w3, 980.0, "3 variants → 980 wide");
+ }
+
+ #[test]
+ fn estimate_size_compare_grows_taller_with_media() {
+ let spec = serde_json::json!({
+ "kind": "compare",
+ "variants": [
+ { "value": "a", "src": "data:image/png;base64,AAAA" },
+ { "value": "b", "src": "data:image/png;base64,BBBB" }
+ ]
+ });
+ let (_w, h) = estimate_dialog_size(&spec);
+ assert_eq!(h, 640.0, "image variants → taller default than text-only");
+ }
+
+ #[test]
+ fn estimate_size_compare_respects_explicit_columns_cap() {
+ let mut variants = Vec::new();
+ for i in 0..6 {
+ variants.push(serde_json::json!({ "value": format!("v{i}"), "content": "x" }));
+ }
+ let spec = serde_json::json!({ "kind": "compare", "variants": variants });
+ let (w, _h) = estimate_dialog_size(&spec);
+ assert_eq!(w, 1100.0, "variant count above the 4-col cap → MAX_W");
+ }
+
#[test]
fn resolve_start_size_no_hint_equals_estimate() {
let spec = serde_json::json!({ "kind": "confirm", "title": "ok?" });
diff --git a/companion/src-tauri/src/http.rs b/companion/src-tauri/src/http.rs
index 9da3ea7..a4abcdc 100644
--- a/companion/src-tauri/src/http.rs
+++ b/companion/src-tauri/src/http.rs
@@ -784,12 +784,45 @@ const KNOWN_FIELD_KINDS: &[&str] = &[
/// specs.
fn validate_spec(spec: &serde_json::Value) -> Result<(), (String, String)> {
let kind = spec.get("kind").and_then(|v| v.as_str()).unwrap_or("");
- if !matches!(kind, "ask" | "form" | "confirm" | "gallery") {
+ if !matches!(kind, "ask" | "form" | "confirm" | "gallery" | "compare") {
return Err((
- format!("top-level 'kind' must be one of ask|form|confirm|gallery, got '{kind}'"),
- "Use confirm for yes/no, ask for one-of-N, form for ≥2 inputs, gallery for batch image/video review.".into(),
+ format!("top-level 'kind' must be one of ask|form|confirm|gallery|compare, got '{kind}'"),
+ "Use confirm for yes/no, ask for one-of-N, form for ≥2 inputs, gallery for batch image/video review, compare for A/B(/C) side-by-side pick.".into(),
));
}
+ if kind == "compare" {
+ match spec.get("variants").and_then(|v| v.as_array()) {
+ None => {
+ return Err((
+ "compare spec is missing the 'variants' array".into(),
+ "Provide variants: [{value, label?, content?, src?}, …] — at least 2.".into(),
+ ));
+ }
+ Some(arr) if arr.len() < 2 => {
+ return Err((
+ format!("compare 'variants' has {} entr{}, needs at least 2", arr.len(), if arr.len() == 1 { "y" } else { "ies" }),
+ "A/B needs 2 variants, A/B/C needs 3 — compare is for comparing options side by side, not showing one.".into(),
+ ));
+ }
+ Some(arr) => {
+ for (i, it) in arr.iter().enumerate() {
+ let has_value = it
+ .get("value")
+ .and_then(|v| v.as_str())
+ .map(|s| !s.is_empty())
+ .unwrap_or(false);
+ if !has_value {
+ return Err((
+ format!("compare variant #{i} is missing a non-empty 'value'"),
+ "Each variant needs a stable 'value' string — it's returned as 'selected' when picked."
+ .into(),
+ ));
+ }
+ }
+ }
+ }
+ return Ok(());
+ }
if kind == "gallery" {
match spec.get("items").and_then(|v| v.as_array()) {
None => {
@@ -1282,6 +1315,54 @@ mod validate_tests {
assert!(validate_spec(&json!({"title":"x"})).is_err());
}
+ #[test]
+ fn accepts_compare_with_two_variants() {
+ let spec = json!({"kind":"compare","variants":[
+ {"value":"a","content":"Draft A"},
+ {"value":"b","content":"Draft B"}
+ ]});
+ assert!(validate_spec(&spec).is_ok());
+ }
+
+ #[test]
+ fn accepts_compare_with_three_variants() {
+ let spec = json!({"kind":"compare","variants":[
+ {"value":"a","src":"data:image/png;base64,AAAA"},
+ {"value":"b","src":"data:image/png;base64,BBBB"},
+ {"value":"c","src":"data:image/png;base64,CCCC"}
+ ]});
+ assert!(validate_spec(&spec).is_ok());
+ }
+
+ #[test]
+ fn rejects_compare_without_variants() {
+ let err = validate_spec(&json!({"kind":"compare"})).unwrap_err();
+ assert!(err.0.contains("variants"), "got: {}", err.0);
+ }
+
+ #[test]
+ fn rejects_compare_with_fewer_than_two_variants() {
+ let spec = json!({"kind":"compare","variants":[{"value":"a","content":"only one"}]});
+ let err = validate_spec(&spec).unwrap_err();
+ assert!(err.0.contains("at least 2"), "got: {}", err.0);
+ }
+
+ #[test]
+ fn rejects_compare_empty_variants() {
+ let err = validate_spec(&json!({"kind":"compare","variants":[]})).unwrap_err();
+ assert!(err.0.contains("at least 2"), "got: {}", err.0);
+ }
+
+ #[test]
+ fn rejects_compare_variant_without_value() {
+ let spec = json!({"kind":"compare","variants":[
+ {"content":"A"},
+ {"value":"b","content":"B"}
+ ]});
+ let err = validate_spec(&spec).unwrap_err();
+ assert!(err.0.contains("value"), "got: {}", err.0);
+ }
+
#[test]
fn rejects_unknown_field_kind_with_name() {
let spec = json!({"kind":"form","fields":[{"kind":"hologram","name":"h"}]});
diff --git a/companion/src-tauri/src/mcp.rs b/companion/src-tauri/src/mcp.rs
index 4ec32f8..9940d15 100644
--- a/companion/src-tauri/src/mcp.rs
+++ b/companion/src-tauri/src/mcp.rs
@@ -427,6 +427,45 @@ fn tools_list() -> Value {
}
}
},
+ {
+ "name": "compare",
+ "description": "Side-by-side A/B (or A/B/C) compare: render 2+ variants as full-content panes next to each other and let the user click ONE to pick. Use this instead of `ask`+thumbnail (which only shows a small icon per option, not the full content) or `gallery` (per-item batch review — approve/revise/skip each, not a single pick). Fits \"which draft is better\", \"which image edit\", \"before vs. after\", \"which of these three headlines\". Each variant needs a stable `value` (the key returned as `selected`) and at least one of `content` (markdown text — drafts, diffs, code) or `src` (image/video, same resolution rules as elsewhere: data: URL, http(s):// URL, or absolute / `~/`-rooted local path on YOUR host; videos render with native controls). A variant may carry both — e.g. an image plus a caption. Set `sync_scroll: true` when comparing long text so scrolling one pane scrolls all of them together. If `max_height` is set on any variant, it caps every pane's height (equal-height panes read as \"side by side\"; independent per-pane heights don't). Returns {cancelled, selected} — `selected` is the `value` of the picked variant (only set when the user actually submits; Cancel/Escape leaves it absent). For a plain yes/no use `confirm`; for choosing among many small thumbnails use `ask`+thumbnail or `image_grid`; for per-item batch verdicts use `gallery`. **Blocks until the user picks and submits or cancels. Response can take minutes — progress notifications fire every ~10 s.**",
+ "inputSchema": {
+ "type": "object",
+ "required": ["variants"],
+ "properties": {
+ "session": { "type": "string", "description": "Optional short human label for the session this dialog belongs to (project/task name). Shown in the window chrome so the user can tell parallel dialogs apart." },
+ "title": { "type": "string", "description": "What's being compared, e.g. \"Which intro paragraph?\"." },
+ "description": { "type": "string", "description": "One sentence of context shown under the title." },
+ "header": { "type": "string", "description": "Short chip above the title (≤ 14 chars)." },
+ "variants": {
+ "type": "array",
+ "minItems": 2,
+ "description": "The options to compare, rendered as equal-width panes in order. Needs at least 2 (A/B) — 3 for A/B/C.",
+ "items": {
+ "type": "object",
+ "required": ["value"],
+ "properties": {
+ "value": { "type": "string", "description": "Stable id; returned as `selected` when this variant is picked. Must be non-empty." },
+ "label": { "type": "string", "description": "Pane header. Defaults to A / B / C / … by position." },
+ "content": { "type": "string", "description": "Markdown text for a text variant — draft copy, a before/after diff, code." },
+ "src": { "type": "string", "description": "Image or video source: data: URL, http(s):// URL, or absolute / ~/ local path on YOUR host." },
+ "alt": { "type": "string" },
+ "detail": { "type": "string", "description": "Short caption under the pane — source, score, timestamp." },
+ "max_height": { "type": "number", "description": "Cap this pane's height in px. If set on ANY variant, it applies to ALL panes so they stay equal-height." }
+ }
+ }
+ },
+ "sync_scroll": { "type": "boolean", "default": false, "description": "Lock scroll position across all panes — useful when comparing long text side by side." },
+ "columns": { "type": "number", "description": "Override the number of columns. Defaults to variants.length, capped at 4." },
+ "submit_label": { "type": "string" },
+ "cancel_label": { "type": "string" },
+ "size": { "type": "string", "enum": ["s", "m", "l"], "description": "Starting window size hint: s / m / l. Default auto-sizes to variant count and content. Always resizable; never opens smaller than the content needs." },
+ "width": { "type": "number", "description": "Explicit starting window width in logical px (overrides `size`)." },
+ "height": { "type": "number", "description": "Explicit starting window height in logical px (overrides `size`)." }
+ }
+ }
+ },
{
"name": "aiui_health",
"description": "Reachability check against the local aiui companion. Returns version + ready flag if the companion is running and responding.",
@@ -696,6 +735,30 @@ async fn tools_call(
"upload" => Ok(do_upload(&args, cfg, http).await),
+ "compare" => dispatch_render(
+ render_dialog(
+ json!({
+ "kind": "compare",
+ "title": args.get("title"),
+ "description": args.get("description"),
+ "header": args.get("header"),
+ "variants": args.get("variants"),
+ "syncScroll": args.get("sync_scroll").and_then(|v| v.as_bool()).unwrap_or(false),
+ "columns": args.get("columns"),
+ "submitLabel": args.get("submit_label"),
+ "cancelLabel": args.get("cancel_label"),
+ "size": args.get("size"),
+ "width": args.get("width"),
+ "height": args.get("height")
+ }),
+ args.get("session").and_then(|v| v.as_str()).map(String::from),
+ cfg,
+ http,
+ )
+ .await,
+ format_dialog_result,
+ ),
+
"aiui_health" => get_json(http, cfg, "/health").await.map(value_to_tool_text),
"version" => get_json(http, cfg, "/version").await.map(value_to_tool_text),
"update" => post_empty(http, cfg, "/update")
diff --git a/companion/src/lib/DialogShell.svelte b/companion/src/lib/DialogShell.svelte
index 67530fb..a89d599 100644
--- a/companion/src/lib/DialogShell.svelte
+++ b/companion/src/lib/DialogShell.svelte
@@ -7,6 +7,7 @@
import Form from "./widgets/Form.svelte";
import Confirm from "./widgets/Confirm.svelte";
import Gallery from "./widgets/Gallery.svelte";
+ import Compare from "./widgets/Compare.svelte";
type DialogReq = {
id: string;
@@ -351,6 +352,8 @@
{:else if current.spec.kind === "gallery"}
+ {:else if current.spec.kind === "compare"}
+
{:else}
diff --git a/companion/src/lib/markdown.ts b/companion/src/lib/markdown.ts
new file mode 100644
index 0000000..21c39c2
--- /dev/null
+++ b/companion/src/lib/markdown.ts
@@ -0,0 +1,32 @@
+// Shared Markdown → sanitized-HTML renderer, used by every widget that
+// renders a `markdown` field or markdown-flavoured content (Form's
+// `markdown` field, Compare's per-variant `content`). Extracted from
+// Form.svelte (v0.4.10, issue #H-2) so the sanitization allowlist has one
+// definition instead of being copy-pasted per widget — a security-relevant
+// config like this should not drift between call sites.
+//
+// Configured once at module scope, used synchronously (no remote includes,
+// no async resolvers) so callers stay simple. Output is piped through
+// DOMPurify before any `{@html}` use so an MCP caller (potentially a
+// compromised remote host reaching us through the SSH-reverse-tunnel)
+// cannot inject `
+
+
+
{:else if f.kind === "image"}
diff --git a/docs/skill.md b/docs/skill.md
index 85c44bb..a8b9a89 100644
--- a/docs/skill.md
+++ b/docs/skill.md
@@ -1,15 +1,17 @@
---
name: aiui
-description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation.
+description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation, `compare` for A/B(/C) side-by-side picks, `gallery` for batch image/video review.
---
# aiui — Dialog design for Claude agents
-aiui exposes three MCP tools that render native dialogs on the user's machine:
+aiui exposes five MCP tools that render native dialogs on the user's machine:
- `confirm` — irreversible yes/no
- `ask` — single- or multi-choice with descriptions and optional free-text fallback
- `form` — composite window with typed fields and multiple action buttons
+- `gallery` — batch review of images/videos, one decision per item
+- `compare` — side-by-side A/B (or A/B/C) content compare, pick one
## Default to a dialog, not to chat
@@ -44,6 +46,10 @@ instead:
- Any step that asks **"which of these images?"** with 2–6 candidates
→ `ask` with `thumbnail` per option. Use `form` + `image_grid` only
when there are many candidates (≥ 7) or the picker needs multi-select.
+- Any step where the user needs to see **full content side by side**
+ before choosing one — two drafts, three headlines, before/after an
+ edit — → `compare`. Don't reach for `ask`+thumbnail here: a thumbnail
+ is too small to actually compare, `compare` renders the full pane.
## When chat actually wins
@@ -66,6 +72,7 @@ Skip the dialog for content the user reads, doesn't answer:
| Multi-field input, multi-action footer | `form` |
| Pick one of *many* images (e.g. 12 logo variants) | `form` with `image_grid` |
| Per-item verdict on a *batch* of images/videos ("approve/revise/skip each") | `gallery` |
+| Pick one of 2–3 full variants shown side by side (drafts, headlines, before/after) | `compare` |
| Single free-text answer | just ask in chat |
| More than 8 fields | split into multiple `form` calls; do not cram one dialog |
@@ -305,9 +312,59 @@ Blocks until the user picks or dismisses the picker, exactly like the
dialog tools — progress notifications fire every ~10 s while you wait, so
a slow response just means the user is browsing, not that aiui broke.
+## Side-by-side compare: `compare`
+
+A standalone tool (not a `form` field), for an A/B or A/B/C compare:
+render 2 or more full-content **variants** next to each other and let
+the user click ONE to pick. Use it for "which draft is better", "which
+of these three headlines", "before vs. after this edit", "GPT vs.
+Claude's answer" — anywhere the full content, not a thumbnail, needs to
+be visible to decide.
+
+Spec: `variants: [{value, label?, content?, src?, alt?, detail?,
+max_height?}]` (≥ 2 entries), `sync_scroll?`, `columns?` (default
+`variants.length`, capped at 4). Each variant needs a stable `value`
+(the key returned as `selected`) and at least one of:
+
+- `content` — Markdown text: a draft, a diff, a code snippet.
+- `src` — an image or video, same resolution rules as everywhere else
+ in aiui (data:, http(s)://, or absolute/`~/` local path). Videos
+ render with native controls.
+
+A variant may carry both (an image plus a caption). `label` defaults
+to A / B / C / … by position if omitted. `detail` is a short caption
+line under the pane (source, score, timestamp).
+
+```json
+{
+ "title": "Which opening line?",
+ "variants": [
+ {"value": "a", "label": "Direct", "content": "Your invoice is 12 days overdue."},
+ {"value": "b", "label": "Soft", "content": "Just a friendly nudge about invoice #4471."}
+ ]
+}
+```
+
+Result: `{cancelled, selected}` — `selected` is the `value` of the
+picked variant, present only when the user actually submits (Cancel/
+Escape leaves it absent, same as everywhere else in aiui).
+
+**`sync_scroll: true`** locks scroll position across all panes — reach
+for it when comparing long text so the user can scroll once and see
+matching passages line up. Leave it off for short copy or images.
+
+**`max_height` is dialog-wide, not per-variant**: set it on any one
+variant and it caps *every* pane's height, because unequal pane heights
+break the "side by side" framing. Omit it and aiui picks a sensible
+default that grows a little for image/video variants.
+
+Use `compare` instead of `ask`+`thumbnail` (thumbnails are too small to
+actually compare) or `gallery` (per-item batch review with an
+independent verdict per asset, not a single either/or pick).
+
## Starting window size: `size` / `width` / `height`
-`form` and `gallery` accept an optional **`size`** hint — `"s"`, `"m"`, or
+`form`, `gallery`, and `compare` accept an optional **`size`** hint — `"s"`, `"m"`, or
`"l"` — and aiui picks good local defaults for each, clamped to the user's
screen. (Power users can pass explicit `width` / `height` in logical px,
which override `size`; rarely needed.)
@@ -325,13 +382,15 @@ short forms — the auto-estimate already fits those.
## Image sources (`src` / `thumbnail`)
-aiui takes an image source in five places:
+aiui takes an image source in these places:
- `confirm` → `image: {src, alt?, max_height?}` — visual yes/no
- `ask` → `options[].thumbnail` — visual pick-one-of-N
- `form` → `image` field → `src`
- `form` → `image_grid` → `images[].src`
- `form` → `list` → `items[].thumbnail`
+- `gallery` → `items[].src` — batch review, images or videos
+- `compare` → `variants[].src` — side-by-side pick, images or videos
In all of them the same three input formats render correctly:
diff --git a/python/src/aiui_mcp/server.py b/python/src/aiui_mcp/server.py
index c7488d7..2881bb9 100644
--- a/python/src/aiui_mcp/server.py
+++ b/python/src/aiui_mcp/server.py
@@ -1164,6 +1164,80 @@ async def upload(
return result
+@mcp.tool()
+async def compare(
+ variants: list[dict[str, Any]],
+ title: str | None = None,
+ description: str | None = None,
+ header: str | None = None,
+ sync_scroll: bool = False,
+ columns: int | None = None,
+ submit_label: str | None = None,
+ cancel_label: str | None = None,
+ size: str | None = None,
+ width: float | None = None,
+ height: float | None = None,
+ session: str | None = None,
+ ctx: Context | None = None,
+) -> dict[str, Any]:
+ """Side-by-side A/B (or A/B/C) compare: render 2+ variants as full-content
+ panes next to each other and let the user click ONE to pick.
+
+ WHEN TO USE: "which draft is better", "which image edit", "before vs.
+ after", "which of these three headlines". Use this instead of `ask` with
+ `thumbnail` (which only shows a small icon per option, not the full
+ content) or `gallery` (per-item batch review — approve/revise/skip each,
+ not a single pick).
+
+ Each variant needs a stable `value` (the key returned as `selected`) and
+ at least one of `content` (markdown text — drafts, diffs, code) or `src`
+ (image/video, standard aiui resolution rules: data: URL, http(s) URL, or
+ absolute / `~/` local path on YOUR host; videos render with native
+ controls). A variant may carry both — e.g. an image plus a caption.
+
+ Returns `{cancelled, selected}` — `selected` is the `value` of the picked
+ variant (only set when the user actually submits).
+
+ Args:
+ variants: List of `{value, label?, content?, src?, alt?, detail?,
+ max_height?}`. Needs at least 2 entries (A/B) — 3 for A/B/C.
+ `value` must be non-empty. `label` defaults to A / B / C / … by
+ position. `max_height` set on ANY variant caps every pane's
+ height so they stay equal-height and read as side-by-side.
+ title: What's being compared, e.g. "Which intro paragraph?".
+ description: One sentence of context under the title.
+ header: Chip above the title (≤ 14 chars).
+ sync_scroll: Lock scroll position across all panes — useful when
+ comparing long text side by side.
+ columns: Override the number of columns. Defaults to
+ `len(variants)`, capped at 4.
+ submit_label: Footer submit button label.
+ cancel_label: Footer cancel button label.
+ size: Starting window size hint — "s", "m", or "l". Default
+ auto-sizes to variant count and content. Always resizable;
+ never opens smaller than the content needs.
+ width: Explicit starting width in logical px (overrides `size`).
+ height: Explicit starting height in logical px (overrides `size`).
+ session: Short human label for this session, shown in the window
+ chrome so parallel dialogs stay distinguishable.
+ """
+ spec = {
+ "kind": "compare",
+ "title": title,
+ "description": description,
+ "header": header,
+ "variants": variants,
+ "syncScroll": sync_scroll,
+ "columns": columns,
+ "submitLabel": submit_label,
+ "cancelLabel": cancel_label,
+ "size": size,
+ "width": width,
+ "height": height,
+ }
+ return _format_result(await _post_render(spec, ctx, session))
+
+
@mcp.prompt(name="teach")
def teach_prompt() -> str:
"""Brief the agent on aiui. Loads the full widget catalog, design
diff --git a/python/tests/test_resolve_local_paths.py b/python/tests/test_resolve_local_paths.py
index dfead5c..0761f50 100644
--- a/python/tests/test_resolve_local_paths.py
+++ b/python/tests/test_resolve_local_paths.py
@@ -172,6 +172,28 @@ def test_resolve_local_paths_walks_gallery_items(tmp_path: Path) -> None:
assert gallery_spec["items"][3]["src"] == "data:image/png;base64,UNCHANGED"
+def test_resolve_local_paths_walks_compare_variants(tmp_path: Path) -> None:
+ """Compare `variants[].src` must resolve the same generic way as every
+ other `src`/`thumbnail` slot — local image path inlines as data:,
+ remote/data URLs pass through untouched.
+ """
+ img = tmp_path / "draft-a.png"
+ img.write_bytes(b"\x89PNG\r\n\x1a\nfake bytes")
+
+ compare_spec = {
+ "kind": "compare",
+ "variants": [
+ {"value": "a", "src": str(img)},
+ {"value": "b", "src": "https://leave.me/b.png"},
+ {"value": "c", "content": "Just markdown text, no src."},
+ ],
+ }
+ _resolve_local_paths(compare_spec)
+ assert compare_spec["variants"][0]["src"].startswith("data:image/png;base64,")
+ assert compare_spec["variants"][1]["src"] == "https://leave.me/b.png"
+ assert "src" not in compare_spec["variants"][2]
+
+
def test_is_local_video_classifies_correctly() -> None:
assert _is_local_video("/Users/me/clip.mp4")
assert _is_local_video("~/Movies/take.MOV")