Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
89 changes: 89 additions & 0 deletions companion/src-tauri/src/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?" });
Expand Down
87 changes: 84 additions & 3 deletions companion/src-tauri/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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"}]});
Expand Down
63 changes: 63 additions & 0 deletions companion/src-tauri/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions companion/src/lib/DialogShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -351,6 +352,8 @@
<Confirm spec={current.spec} onsubmit={handleSubmit} oncancel={handleCancel} />
{:else if current.spec.kind === "gallery"}
<Gallery spec={current.spec} onsubmit={handleSubmit} oncancel={handleCancel} />
{:else if current.spec.kind === "compare"}
<Compare spec={current.spec} onsubmit={handleSubmit} oncancel={handleCancel} />
{:else}
<main class="window-shell">
<div class="window-scroll">
Expand Down
32 changes: 32 additions & 0 deletions companion/src/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -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 `<script>` or event handlers.
import { marked } from "marked";
import DOMPurify from "dompurify";

marked.setOptions({ gfm: true, breaks: true });

export function renderMarkdown(src: string): string {
let raw: string;
try {
raw = marked.parse(src, { async: false }) as string;
} catch {
raw = `<pre>${src.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c]!))}</pre>`;
}
return DOMPurify.sanitize(raw, {
// No <script>, no event handlers, no javascript: URLs, no <iframe>.
// Keep links + basic markup. Defaults are conservative; we explicitly
// forbid form-related tags to prevent autofill-driven exfiltration.
FORBID_TAGS: ["script", "iframe", "form", "input", "button"],
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus"],
});
}
Loading
Loading