Question card: HTML option previews (Claude Code previewFormat html), drawn in a frame that runs nothing
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.
markdownis 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.htmlis the thing Leo half-remembers. A host turns it on with the Agent SDK optiontoolConfig.askUserQuestion.previewFormat: "html", which reaches the CLI asCLAUDE_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 hasoptionalDescriptions. 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 ishtmlcounts.record_spawn_inputsstores that decision in the session record (SessionMeta.htmlPreviews, persisted likesteering). 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.rslabels each normalized previewformat: "html"or"monospace"from that record.harness_commandpinsCLAUDE_CODE_QUESTION_PREVIEW_FORMATto 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 ismarkdownunless the profile sayshtml, andmarkdownis already Claude Code's default for this entrypoint, so nothing changes for anyone today.store.rsstays the same size: itsline_rev_testsmodule moves tostore/line_rev_tests.rs.hub/mod.rsshrinks by one line.
- Models:
Preview.formatgainshtmlin 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.decisionused to cancel every subframe except the render frame and loopback previews. It now also allows exactlyabout:srcdocin a subframe, so the preview frame loads in the app. The main frame,about:srcdoc?x, otherabout:pages anddata:documents stay cancelled. - Web card (
question/HtmlPreview.tsx, drawn fromOptionPreviewin every #24325 layout): anhtmlpreview draws as a picture.- It goes in an
srcdocframe sandboxed withoutallow-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 anddata:images, and link targets. The policy is what stops a remoteurl()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-originis set only so the card can measure that height. Without scripts, the frame has no code that could use it.- Monospace previews are unchanged.
- It goes in an
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 anddata:images.
- An html preview draws in a frame with sandbox
model.test.tsmaps the new fixture; the SwiftAgentQuestionMappingTestsread the same files.AgentPaneHtmlPreviewFrameTests(Swift):about:srcdocloads only as a subframe; look-alikes anddata:stay cancelled.- Rust:
hub::questions: monospace unless the launch sayshtml; only a Claude launch counts; a normalized question keeps its format.agent::commandclaude_code_is_pinned_to_the_preview_format_the_hub_records: the pinned env equalspreview_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 insrc/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 --libfor preview, line_rev and hub::questions: 6 pass. The only later Rust change is the lock inhub/tools.rs, and rustfmt is clean on it. - Fleet, at aef94d783d95: the Swift suites
AgentPaneHtmlPreviewFrameTestsandAgentPaneAppLinkTestspass. Fleet, at fc14da04:compile-check --package CmuxAgentQuestion --apppasses. 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 htmlthrough 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