Question card: HTML option previews (Claude Code previewFormat html), drawn in a frame that runs nothing

Merged
TE
13d ago
+849-51

Leo's ask, in his words: "ask user question tool and its various states need looking at allegedly there's some sort of vibe-coded thing that agents can do to have prettier ask question situations...? idk but we could look into that/research that then support it..."

Leo-ask: ask-427cc7d8f5

What the "prettier ask question" thing is

Claude Code's AskUserQuestion lets each option carry a preview. The host decides its format; the model does not. I read this from the tool's own prompt and schema in the Claude Code 2.1.307 binary.

  • markdown is the default, and what cmux gets today. The prompt asks for "ASCII mockups of UI layouts or components, code snippets ... rendered as markdown in a monospace box". The card shows the focused option's preview beside the list.
  • html is the thing Leo half-remembers. A host turns it on with the Agent SDK option toolConfig.askUserQuestion.previewFormat: "html", which reaches the CLI as CLAUDE_CODE_QUESTION_PREVIEW_FORMAT=html. The model is then told to send "HTML mockups of UI layouts or components, formatted code snippets ..., visual comparisons or diagrams ... a self-contained HTML fragment (no <html>/<body> wrapper, no <script> or <style> tags; use inline style attributes instead)". Claude Code validates every preview before the card is shown, and refuses one with script or style tags, a full document, or no tag at all.
  • The same option block has extendedQuestions, which adds a title, text and number questions, and follow-up rounds. It also has optionalDescriptions. Neither is in this PR; extended questions come with the elicitation PR.

The full research and the state inventory are in leo-workspace scratch/transcript-parity/askquestion-research.md. It covers Codex request_user_input, ACP permission requests, MCP elicitation (acpmux declines it today), what acpmux drops, and how answers come back from the CLI, MCP and the phone.

What changed

This PR is HTML previews, end to end. It is one of four pieces. The split with #24325 (affordance-clarity, which has landed) is agreed, and this branch is up to date with it.

  • acpmux:
    • agent::preview_format(profile) decides the format once per launch from the launch's own profile (family defaults, preset and session env applied). It resolves a ${env:…} or ${keychain:…} reference the way the launch does. Only a Claude Code (ClaudeStdio) profile whose value is html counts.
    • record_spawn_inputs stores that decision in the session record (SessionMeta.htmlPreviews, persisted like steering). An agent the daemon adopts after a restart keeps its launch's format; adoption doesn't recompute it. Forks copy it, and older records without the field read as text, which is the safe direction.
    • hub/questions.rs labels each normalized preview format: "html" or "monospace" from that record.
    • harness_command pins CLAUDE_CODE_QUESTION_PREVIEW_FORMAT to the same value on every Claude Code it starts, set last after all other env, so the hub's label and what the agent writes can't disagree. The pinned value is markdown unless the profile says html, and markdown is already Claude Code's default for this entrypoint, so nothing changes for anyone today.
    • store.rs stays the same size: its line_rev_tests module moves to store/line_rev_tests.rs. hub/mod.rs shrinks by one line.
  • Models: Preview.format gains html in the web model (model.ts) and the Swift model (CmuxAgentQuestion). A Swift card that doesn't draw HTML yet shows the fragment as text, as before. New shared fixture: pending-with-html-preview.
  • Native pane: AgentPaneNavigation.decision used to cancel every subframe except the render frame and loopback previews. It now also allows exactly about:srcdoc in a subframe, so the preview frame loads in the app. The main frame, about:srcdoc?x, other about: pages and data: documents stay cancelled.
  • Web card (question/HtmlPreview.tsx, drawn from OptionPreview in every #24325 layout): an html preview draws as a picture.
    • It goes in an srcdoc frame sandboxed without allow-scripts, so nothing in the fragment runs, and the frame can't submit forms, open popups or navigate the pane.
    • The frame's document carries default-src 'none' ahead of the fragment: no network, no remote image or font. It also inherits the pane's CSP.
    • The fragment is cleaned first, as a second line of defence. The cleaning drops script, frames, embeds, meta, base, link, style and SVG animation (set, animate*, discard), along with event-handler attributes, every URL attribute except # links and data: images, and link targets. The policy is what stops a remote url() in an inline style.
    • The frame gets the pane's theme and base styles (renderBase.ts, the same ones render cards use).
    • Its height follows the picture's own box, from 80 to 420 px, and shrinks again for a shorter picture. A taller picture scrolls.
    • allow-same-origin is set only so the card can measure that height. Without scripts, the frame has no code that could use it.
    • Monospace previews are unchanged.

Do not touch: other row kinds, the preview layouts and hover (#24325), the answered state (#24325). No default flips: nobody's Claude Code writes HTML until a profile opts in.

Gallery

agent-pane.question-card-html-preview shows three looks:

  • framed (the default and my pick): the mockup sits in the card's preview box, like a monospace preview.
  • canvas: an inset artboard in the page colour, with an edge.
  • bleed: no box.

It also shows the arrow-to-next-option state and the unchanged monospace preview. I picked framed because it keeps the card's one preview vocabulary, and a mockup with its own background still reads on it.

Before/after (synthetic fixtures only), built at 757827f99396: https://cmux-lawrences-mac-mini.tail137216.ts.net:18796/compare/tp-askquestion-states/?entry=question-card (tailnet). The gallery advisory run attaches the stills.

No capture: the visible change is webview-only. The native change is a navigation-policy decision, covered by a unit test.

Test

  • question/HtmlPreview.test.tsx (jsdom):
    • An html preview draws in a frame with sandbox allow-same-origin (no scripts) and the load-nothing policy, never as <pre> text.
    • A monospace preview still draws as text.
    • The frame document drops onclick, <script>, <meta refresh>, <base>, <link>, <iframe>, javascript:, remote URLs and SVG <set>/<animate>. It keeps inline styles, # links and data: images.
  • model.test.ts maps the new fixture; the Swift AgentQuestionMappingTests read the same files.
  • AgentPaneHtmlPreviewFrameTests (Swift): about:srcdoc loads only as a subframe; look-alikes and data: stay cancelled.
  • Rust:
    • hub::questions: monospace unless the launch says html; only a Claude launch counts; a normalized question keeps its format.
    • agent::command claude_code_is_pinned_to_the_preview_format_the_hub_records: the pinned env equals preview_format, an unresolved reference is not HTML, and a non-Claude harness is not HTML.

Observed / Not run

  • huge-blue, at 757827f99396465eae0a3c7633a7e7aef74ba195:
    • bun test src/agent-session/acpmux/question: 22 pass, 0 fail.
    • bun run check: one failure, a format issue in src/pages/settings/components/HostSections.tsx. This PR doesn't touch that file, it fails the same way at the base tip 0cb8ff22e8f, and #24440 (another lane) fixes it.
  • huge-blue, at aef94d783d95: cargo test -p acpmux --lib for preview, line_rev and hub::questions: 6 pass. The only later Rust change is the lock in hub/tools.rs, and rustfmt is clean on it.
  • Fleet, at aef94d783d95: the Swift suites AgentPaneHtmlPreviewFrameTests and AgentPaneAppLinkTests pass. Fleet, at fc14da04: compile-check --package CmuxAgentQuestion --app passes. Both are re-running at 757827f99396; results will follow in a comment.
  • The crash ratchet adds no acpmux hits; it still reports the base's cmux-tui-core count.
  • Not run: a live Claude Code with previewFormat html through acpmux, and an in-app capture of the frame. The fixture follows Claude Code's schema rather than a captured run.

Changelog

Added: An agent's question can show each option's HTML mockup as a picture (Claude Code's HTML previews), drawn in a frame that runs no script and loads nothing.

Agent session: cosmic-gray

🤖 Generated with Claude Code