Prints AskUserQuestion option previews as plain transcript text and opens the built-in dialog without them

A Claude Code mod that makes AskUserQuestion previews readable with a screen reader.
When Claude asks a question whose options carry previews (code snippets, mockups), Claude Code normally draws the option list on the left and the preview of the focused option on the right. With a screen reader such as Orca that two-column layout is hard to follow.
This mod changes two things:
Previews:
1. Arrow function:
const add = (a, b) => a + b
2. Function declaration:
function add(a, b) {
return a + b
}
The numbers match the order of the options in the dialog. When one call asks several questions, each block starts with Previews for question N, <header>:. Questions without previews are left untouched, and if the mod fails, the question goes to the built-in dialog unchanged.
At the prompt of a Claude Code terminal session:
/plugin install ask-preview-text --marketplace alex19EP/claude-ask-preview-text
Answer y to add the marketplace, then pick a scope (the user scope is first).
From a shell:
claude plugin install ask-preview-text --marketplace alex19EP/claude-ask-preview-text
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with alex19EP/claude-ask-preview-text, then Add plugin.
Run the tests with claude plugin test . and check the mod with claude plugin validate ..
hooks/register.tsx 65 lines1import type { Register } from 'claude-code'
2
3type Question = {
4 question: string
5 header: string
6 options: readonly { label: string; preview?: string }[]
7}
8
9// All previews of one call as a single block of plain lines, so a screen
10// reader reads them once, top to bottom, with the code on lines of its own.
11export function previewText(questions: readonly Question[]): string | undefined {
12 const withPreviews = questions
13 .map((q, i) => ({ q, i, options: q.options.map((o, j) => ({ ...o, j })).filter(o => o.preview) }))
14 .filter(({ options }) => options.length > 0)
15 if (withPreviews.length === 0) return undefined
16
17 return withPreviews
18 .map(({ q, i, options }) => [
19 questions.length === 1 ? 'Previews:' : `Previews for question ${i + 1}, ${q.header}:`,
20 ...options.map(o => `\n${o.j + 1}. ${o.label}:\n${o.preview}`),
21 ].join('\n'))
22 .join('\n\n')
23}
24
25function isQuestions(input: unknown): input is { questions: readonly Question[] } {
26 return typeof input === 'object' && input !== null && Array.isArray((input as { questions?: unknown }).questions)
27}
28
29export const register: Register = on => {
30 // The block for each call, kept from tool.call because the call's previews
31 // are gone from the input by the time the engine runs it.
32 const blocks = new Map<string, string>()
33
34 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
35 const text = previewText(e.questions)
36 if (text === undefined) return next(e)
37
38 blocks.set(e.tool_use_id, text)
39 // Without previews the engine draws its plain vertical list.
40 return next({
41 ...e,
42 questions: e.questions.map(q => ({
43 ...q,
44 options: q.options.map(({ preview: _, ...o }) => o),
45 })),
46 })
47 }).catch(($, e, next) => next(e))
48
49 // The previews go under the question's own row in the transcript, above the
50 // dialog, where the rest of the conversation is read.
51 on('ui.render', { component: 'ToolUse', props: { tool: 'AskUserQuestion' } }, async ($, e, next) => {
52 const text =
53 blocks.get(e.props.tool_use_id) ?? (isQuestions(e.props.input) ? previewText(e.props.input.questions) : undefined)
54 if (text === undefined) return next(e)
55
56 const { Box, Text } = $.ui.resolve(e)
57 return (
58 <Box flexDirection="column">
59 {await next(e)}
60 <Text>{text}</Text>
61 </Box>
62 )
63 }).catch(($, e, next) => next(e))
64}
65