From b52e2ba2feae5fecf8a5f9a44ae726e98db091cf Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:36:23 +0200 Subject: [PATCH] feat: add compare tool for side-by-side A/B(/C) picks (#23) Adds a standalone `compare` MCP tool alongside confirm/ask/form/gallery: render 2+ full-content variants (Markdown text and/or image/video) as equal-width panes and let the user click one to pick, instead of a small thumbnail (ask) or an independent per-item verdict (gallery). - New Compare.svelte widget with sync_scroll (locked scroll across panes) and a shared max_height so panes stay visually equal. - Rust: compare case in the spec validator (>=2 variants, non-empty value) and the window-size estimator (scales with variant count, grows for image/video variants). - Mirrored compare tool in the Python aiui-mcp package for remote/headless sessions. - Extracted the Markdown-to-sanitized-HTML renderer out of Form.svelte into companion/src/lib/markdown.ts so both widgets share one DOMPurify allowlist instead of drifting. - docs/skill.md and CHANGELOG.md updated. --- CHANGELOG.md | 16 ++ companion/src-tauri/src/dialog.rs | 89 ++++++++ companion/src-tauri/src/http.rs | 87 ++++++- companion/src-tauri/src/mcp.rs | 63 +++++ companion/src/lib/DialogShell.svelte | 3 + companion/src/lib/markdown.ts | 32 +++ companion/src/lib/widgets/Compare.svelte | 278 +++++++++++++++++++++++ companion/src/lib/widgets/Form.svelte | 28 +-- docs/skill.md | 67 +++++- python/src/aiui_mcp/server.py | 74 ++++++ python/tests/test_resolve_local_paths.py | 22 ++ 11 files changed, 727 insertions(+), 32 deletions(-) create mode 100644 companion/src/lib/markdown.ts create mode 100644 companion/src/lib/widgets/Compare.svelte 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 ` + +
+
+ {#if spec.header}{spec.header}{/if} + {#if spec.title}

{spec.title}

{/if} + {#if spec.description}

{spec.description}

{/if} + +
+ {#each spec.variants as v, i (v.value)} +
pick(e, v.value)} + onkeydown={(e) => onKey(e, v.value)} + > +
+ + {v.label ?? defaultLabel(i)} +
+ +
onBodyScroll(i)} + > + {#if v.src} +
+ {#if isVideo(v.src)} + + + {:else} + {v.alt + {/if} +
+ {/if} + {#if v.content} +
+ + {@html renderMarkdown(v.content)} +
+ {/if} +
+ + {#if v.detail}
{v.detail}
{/if} +
+ {/each} +
+
+ +
+ + +
+
+ + diff --git a/companion/src/lib/widgets/Form.svelte b/companion/src/lib/widgets/Form.svelte index 9d31574..9d35fae 100644 --- a/companion/src/lib/widgets/Form.svelte +++ b/companion/src/lib/widgets/Form.svelte @@ -1,7 +1,6 @@
@@ -478,7 +456,7 @@ {:else if f.kind === "markdown"}
- {@html renderMd(f.text)} + {@html renderMarkdown(f.text)}
{: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")