/answer: answer the questions in Claude's last reply in Claude Code's native question dialog

Claude forgot the question dialog? Open one yourself.
Sometimes Claude asks questions in plain prose instead of opening its built-in question dialog. Run /answer to pick options or write your own responses, review them, and send everything back in one organized reply.
A Claude Code mod to answer every question in Claude Code's native question dialog, all at once, instead of retyping them in the prompt.
Install from the marketplace, inside Claude Code:
/plugin marketplace add PeteChu/cc-answer
/plugin install answer@cc-answer
/reload-plugins
A mod runs inside Claude Code with the same access Claude Code has. Read the code before installing.
Or load a local clone directly:
git clone https://github.com/PeteChu/cc-answer
claude --plugin-dir ./cc-answer
For persistent loading, set CLAUDE_CODE_PLUGIN_DIRS=/path/to/cc-answer in your environment or in the env block of ~/.claude/settings.json.
/answer takes Claude's latest reply that has text. A small model (haiku by default) pulls out the questions, each with a short header, 2–4 options with one-line descriptions, and single or multi-select. Open-ended questions get a few suggested answers.I answered your questions in the following way:, followed by Q:/A: pairs. Unanswered questions are left out.Extraction uses haiku by default. Change the model option in /config, or set pluginConfigs.answer.options.model in settings to another model alias or ID.
$.model.complete): the prompt is the full text of the latest non-empty assistant reply, unchanged. The system prompt is SYSTEM_PROMPT in hooks/lib.mjs: it instructs the model to extract user-facing questions in order as JSON, include essential context, use headers of at most 12 characters, provide 2–4 concise options with descriptions (Yes/No for confirmations and suggestions for open-ended questions), omit an "Other" option, mark multi-select only for compatible choices, and return an empty questions array when none are found. No other conversation messages, files, or tool results are included by the mod.Your reply must be exactly one JSON object and nothing else: no prose, no code fences, no commentary. Escape quotes and newlines inside strings.$.prompt.submit): after all dialog rounds complete, the mod submits a new user message (asUser: true) beginning exactly with I answered your questions in the following way:, then a blank line and Q: <extracted question> / A: <your answer> blocks separated by blank lines. Questions are model-extracted and may be rephrased; answers are selected option labels or your custom text, with whitespace normalized. Multiple selections are joined with ,. Unanswered questions are omitted. Cancellation or no answers means no prompt is submitted. No additional instructions are appended.With --debug, the command output also contains the extraction model name, raw model replies, and parsed questions/options; Claude can read that output. Extraction failures may include a snippet of the model reply even without --debug.
The mod changes only AskUserQuestion, through its tool.call hook. /answer calls $.ui.ask with the placeholder Answer Claude's questions?, header Answer, and options Answer / Cancel. While a round is pending, the hook matches that placeholder as the first question and calls next({ ...e, questions }), replacing the entire questions array with up to four extracted questions. Each has question, header, options (labels and descriptions), and multiSelect; all other event fields are preserved. This lets the native dialog display the extracted questions instead of the placeholder. The hook reads the returned answers or cancellation and returns the tool result unchanged.
If no round is pending or the first question does not match the placeholder, the hook calls next(e) unchanged. Ordinary Claude-generated AskUserQuestion calls pass through under that rule; all other tools are outside the hook's filter and are not modified.
Custom text entry is available on terminal and desktop surfaces. On mobile, answers are limited to the extracted options.
I answered your questions in the following way:
Q: Which database should we use?
A: PostgreSQL
Q: What should the service be called?
A: billing-api
/answer --debug/answer --debug ends with an extraction report: the model used, its raw reply (and the retry's, when the first one did not parse), and the questions it parsed with their options. It shows straight away when extraction fails, or after the dialog closes. The report is part of the command's output, so Claude can read it too.
Extraction tolerates the usual model slips: prose or code fences around the JSON, trailing commas, raw newlines in strings, smart quotes, and a reply cut off partway (the questions that arrived whole are kept). If the reply still can't be read, /answer retries once with a stricter instruction and more room. If that fails too, it says what the model replied.
claude plugin validate . # Check the manifest and hooks module
claude plugin test . # Run unit and end-to-end tests
Layout:
hooks/register.mjs: the /answer command (extraction, one retry, the --debug report) and the tool.call hook that fills the native dialoghooks/lib.mjs: the extraction prompt, JSON repair and recovery from replies cut off partway, fitting questions to the dialog's limits (header ≤ 12 chars, 2–4 options, ≤ 4 questions per round), and compiling the answerstests/answer.test.ts: tests, with the model and the dialog faked beneath the plugin: JSON repairs, rounds of four, the submitted prompt, Esc and "Chat about this", the retry, and the --debug reportMIT. See LICENSE.
hooks/register.mjs 137 lines1import { SUBMIT_PREFIX, SYSTEM_PROMPT, answerFor, batches, compileAnswers, parseExtraction } from './lib.mjs'
2
3/**
4 * `$.ui.ask` opens the engine's own AskUserQuestion dialog but takes a single
5 * question of bare labels. This plugin's `tool.call` hook sees that call and
6 * swaps in a whole round of extracted questions, so the native dialog draws
7 * them as tabs with descriptions, multi-select, "Type something" and its own
8 * review screen. The placeholder question is how the hook knows the call is ours.
9 */
10const PLACEHOLDER = "Answer Claude's questions?"
11
12const LIMITS = { maxTokens: 8192, effort: 'low', timeoutMs: 120_000 }
13const STRICT =
14 'Your reply must be exactly one JSON object and nothing else: no prose, no code fences, no commentary. Escape quotes and newlines inside strings.'
15
16/** The start of a reply, on one line, for an error message. */
17const snippet = text => {
18 const line = text.replace(/\s+/g, ' ').trim()
19 return line ? `"${line.length > 160 ? `${line.slice(0, 160)}…` : line}" (${text.length} chars)` : '(empty)'
20}
21
22/** What `/answer --debug` prints before the dialog: the model, its raw reply per attempt, and what was parsed. */
23function debugReport(model, input, first, retry, questions) {
24 const raw = r => (r.isAnswered ? `${r.text.length} chars\n${r.text}` : `no reply (${r.reason})`)
25 const lines = [
26 `answer --debug · model ${model} · last reply ${input.length} chars`,
27 '',
28 '── raw reply ──',
29 raw(first),
30 ]
31 if (retry !== first) lines.push('', '── retry (first reply did not parse) ──', raw(retry))
32 lines.push('', '── parsed ──')
33 if (questions === null) lines.push('nothing: no "questions" array could be read')
34 else if (questions.length === 0) lines.push('no questions')
35 for (const [i, q] of (questions ?? []).entries()) {
36 lines.push(`${i + 1}. [${q.header}]${q.multiSelect ? ' (multi-select)' : ''} ${q.question}`)
37 for (const o of q.options) lines.push(` - ${o.label}${o.description ? ` — ${o.description}` : ''}`)
38 }
39 return lines.join('\n')
40}
41
42/** @type {import('./lib.mjs').AskQuestion[] | null} */
43let round = null
44/** @type {{ answers: unknown } | { cancel: 'dismissed' | 'clarify' } | null} */
45let outcome = null
46
47/** @type {import('claude-code').Register} */
48export const register = (on, options) => {
49 const model = typeof options.model === 'string' && options.model.trim() ? options.model.trim() : 'haiku'
50
51 on('session.start', async ($, e, next) => {
52 const r = await next(e)
53 await $.command.register({
54 name: 'answer',
55 description: "Answer the questions in Claude's last reply in the question dialog (--debug shows the extraction)",
56 })
57 return r
58 })
59
60 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
61 const questions = round
62 // Claude's own questions, and anything else not opened by /answer, pass untouched.
63 if (!questions || e.questions[0]?.question !== PLACEHOLDER) return next(e)
64 round = null
65 const ran = await next({ ...e, questions })
66 const result = ran.result
67 if (!ran.isError && result && typeof result === 'object' && 'answers' in result) {
68 outcome = { answers: result.answers }
69 } else {
70 // Esc rejects the call; "Chat about this" rejects it asking to clarify.
71 outcome = { cancel: /clarify/i.test(ran.text ?? '') ? 'clarify' : 'dismissed' }
72 }
73 return ran
74 })
75
76 on('command.run', { command: 'answer' }, async ($, e) => {
77 const isDebug = /(^|\s)--debug(\s|$)/.test(e.args)
78 const messages = await $.session.messages()
79 const last = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim().length > 0)
80 if (!last) return { text: 'no assistant reply with text to answer yet.' }
81
82 $.ui.status(`extracting questions with ${model}…`)
83 const first = await $.model.complete({ model, system: SYSTEM_PROMPT, prompt: last.text, ...LIMITS })
84 let questions = first.isAnswered ? parseExtraction(first.text) : null
85 let reply = first
86 if (first.isAnswered && questions === null) {
87 // One retry with a stricter instruction and more room: the usual failure is prose or a reply cut short.
88 reply = await $.model.complete({
89 model,
90 system: `${SYSTEM_PROMPT}\n\n${STRICT}`,
91 prompt: last.text,
92 ...LIMITS,
93 maxTokens: 16_000,
94 })
95 questions = reply.isAnswered ? parseExtraction(reply.text) : null
96 }
97 $.ui.status(undefined)
98 // With --debug, every way out of the command ends with the extraction report.
99 const report = isDebug ? debugReport(model, last.text, first, reply, questions) : ''
100 const done = text => {
101 const out = [text, report].filter(Boolean).join('\n\n')
102 return out ? { text: out } : {}
103 }
104 if (!reply.isAnswered) return done(`extraction with ${model} failed (${reply.reason}).`)
105 if (questions === null) {
106 $.ui.log(`extraction reply from ${model}:\n${reply.text}`, { to: 'debug' })
107 return done(`${model} did not return the questions as JSON. It replied: ${snippet(reply.text)}`)
108 }
109 if (questions.length === 0) return done('no questions found in the last reply.')
110
111 // The dialog holds up to four questions; more come as further rounds.
112 const answers = []
113 for (const batch of batches(questions)) {
114 round = batch
115 outcome = null
116 try {
117 await $.ui.ask(PLACEHOLDER, { options: ['Answer', 'Cancel'], header: 'Answer' })
118 } catch {
119 // The placeholder itself is never answered, so the ask rejects; the hook kept the outcome.
120 }
121 round = null
122 // Set by the tool.call hook while the ask ran.
123 const got = outcome
124 if (!got || 'cancel' in got) {
125 return done(got?.cancel === 'clarify' ? 'cancelled. Tell Claude what you would like to clarify.' : 'cancelled.')
126 }
127 for (const q of batch) answers.push(answerFor(got.answers, q.question))
128 }
129
130 const text = compileAnswers(questions, answers)
131 if (!text) return done('no answers provided.')
132 // Submitting waits for the session to be idle, which this command holds: submit once it returns.
133 $.clock.after(0, () => void $.prompt.submit({ text: `${SUBMIT_PREFIX}\n\n${text}`, asUser: true }))
134 return done()
135 })
136}
137hooks/lib.mjs 232 lines1/**
2 * One question in the shape the built-in AskUserQuestion dialog draws.
3 * @typedef {{ question: string, header: string, options: { label: string, description: string }[], multiSelect: boolean }} AskQuestion
4 */
5
6export const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract every question that needs an answer from the user, shaped for a multiple-choice dialog.
7
8Output ONLY a JSON object with this structure, no prose:
9{
10 "questions": [
11 {
12 "question": "Which database should the service use?",
13 "header": "Database",
14 "multiSelect": false,
15 "options": [
16 { "label": "PostgreSQL", "description": "Mature relational option with a strong ecosystem" },
17 { "label": "SQLite", "description": "Embedded, zero setup" }
18 ]
19 }
20 ]
21}
22
23Rules:
24- Extract all questions that require user input, in the order they appeared
25- "question" is clear, self-contained and ends with a question mark; fold in any context essential to answer it
26- "header" is a very short chip label, at most 12 characters (e.g. "Database", "Auth method")
27- Give 2 to 4 options per question; the dialog always adds its own free-text "Type something" row, so never add an "Other" option
28- Extract every concrete choice stated or clearly implied by the text; for yes/no, confirmation or permission questions use "Yes" and "No"
29- For open-ended questions (a name, a description), offer 2 to 4 sensible suggestions the text supports
30- Option labels are concise (1-5 words) and fully answer the question on their own; each description is one short sentence
31- Set "multiSelect": true only when the choices are not mutually exclusive
32- If no questions are found, return {"questions": []}`
33
34export const SUBMIT_PREFIX = 'I answered your questions in the following way:'
35
36/** The dialog's limits, which it enforces: questions per dialog, options per question, header length. */
37export const MAX_QUESTIONS = 4
38const MAX_OPTIONS = 4
39const MIN_OPTIONS = 2
40const MAX_HEADER = 12
41
42/** Pads a question that came back with fewer than two options; "Type something" still takes free text. */
43const PADDING = [
44 { label: 'No preference', description: 'Go with whatever you think is best' },
45 { label: 'Not sure yet', description: 'I need more information before deciding' },
46]
47
48/** @returns {string} */
49function str(value) {
50 return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
51}
52
53/**
54 * Coerces the extractor's output into questions the dialog accepts, with unique texts.
55 * @returns {AskQuestion[]}
56 */
57export function normalizeQuestions(raw) {
58 if (!Array.isArray(raw)) return []
59 const out = []
60 for (const q of raw) {
61 if (!q || typeof q !== 'object') continue
62 let question = str(q.question)
63 if (!question) continue
64 // The dialog answers by question text, so texts must be unique.
65 while (out.some(one => one.question === question)) question = `${question} (${out.length + 1})`
66
67 const options = []
68 if (Array.isArray(q.options)) {
69 for (const opt of q.options) {
70 const record = opt && typeof opt === 'object' ? opt : {}
71 const label = typeof opt === 'string' ? str(opt) : str(record.label)
72 if (!label || /^(other|type something)\b/i.test(label) || options.some(o => o.label === label)) continue
73 options.push({ label, description: str(record.description) })
74 if (options.length === MAX_OPTIONS) break
75 }
76 }
77 for (const pad of PADDING) {
78 if (options.length >= MIN_OPTIONS) break
79 if (!options.some(o => o.label === pad.label)) options.push(pad)
80 }
81
82 const header = (str(q.header) || `Q${out.length + 1}`).slice(0, MAX_HEADER).trim()
83 out.push({ question, header, options, multiSelect: q.multiSelect === true })
84 }
85 return out
86}
87
88/**
89 * Repairs what models commonly get wrong in JSON: raw newlines and tabs inside
90 * strings, smart quotes used as delimiters, and trailing commas.
91 * @param {string} text
92 */
93export function repairJson(text) {
94 let out = ''
95 let inString = false
96 // A string a smart quote opened is closed by a smart quote too.
97 let isSmart = false
98 for (let i = 0; i < text.length; i++) {
99 const ch = text[i]
100 if (inString) {
101 if (ch === '\\') {
102 out += ch + (text[i + 1] ?? '')
103 i++
104 } else if (ch === '"' || (isSmart && (ch === '“' || ch === '”'))) {
105 inString = false
106 out += '"'
107 } else if (ch === '"' || ch === '“' || ch === '”') out += ch
108 else if (ch === '\n') out += '\\n'
109 else if (ch === '\r') out += '\\r'
110 else if (ch === '\t') out += '\\t'
111 else out += ch
112 continue
113 }
114 if (ch === '"' || ch === '“' || ch === '”') {
115 inString = true
116 isSmart = ch !== '"'
117 out += '"'
118 } else if (ch === ',') {
119 // Drop a comma that only precedes a closing bracket.
120 const rest = text.slice(i + 1).match(/^\s*([}\]])/)
121 if (!rest) out += ch
122 } else out += ch
123 }
124 return out
125}
126
127/** The end (exclusive) of the balanced {...} or [...] starting at `start`, or -1 when it never closes. */
128function balancedEnd(text, start) {
129 let depth = 0
130 let inString = false
131 for (let i = start; i < text.length; i++) {
132 const ch = text[i]
133 if (inString) {
134 if (ch === '\\') i++
135 else if (ch === '"') inString = false
136 } else if (ch === '"') inString = true
137 else if (ch === '{' || ch === '[') depth++
138 else if (ch === '}' || ch === ']') {
139 depth--
140 if (depth === 0) return i + 1
141 }
142 }
143 return -1
144}
145
146function parseJson(text) {
147 try {
148 return JSON.parse(text)
149 } catch {
150 return undefined
151 }
152}
153
154/**
155 * Reads the extractor's reply into raw question objects: the whole reply, the
156 * first balanced object holding "questions", and failing those, each complete
157 * element of the "questions" array, so a reply cut off mid-way keeps the
158 * questions that did arrive whole. Null when no "questions" array is found.
159 * @param {string} reply
160 * @returns {unknown[] | null}
161 */
162export function readQuestions(reply) {
163 const text = repairJson(reply.replace(/```(?:json)?/g, ''))
164 const whole = parseJson(text.trim())
165 if (whole && Array.isArray(whole.questions)) return whole.questions
166
167 for (let at = text.indexOf('{'); at >= 0; at = text.indexOf('{', at + 1)) {
168 const end = balancedEnd(text, at)
169 if (end < 0) break
170 const found = parseJson(text.slice(at, end))
171 if (found && Array.isArray(found.questions)) return found.questions
172 }
173
174 const key = text.search(/"questions"\s*:\s*\[/)
175 if (key < 0) return null
176 const items = []
177 let at = text.indexOf('[', key) + 1
178 for (;;) {
179 const open = text.indexOf('{', at)
180 if (open < 0) break
181 const end = balancedEnd(text, open)
182 if (end < 0) break
183 const item = parseJson(text.slice(open, end))
184 if (item !== undefined) items.push(item)
185 at = end
186 }
187 return items
188}
189
190/**
191 * The extractor's reply as dialog-ready questions, or null when it holds no "questions" array.
192 * @param {string} text
193 * @returns {AskQuestion[] | null}
194 */
195export function parseExtraction(text) {
196 const raw = readQuestions(text)
197 return raw === null ? null : normalizeQuestions(raw)
198}
199
200/**
201 * Splits questions into dialogs of at most four.
202 * @template T
203 * @param {readonly T[]} items
204 * @returns {T[][]}
205 */
206export function batches(items, size = MAX_QUESTIONS) {
207 const out = []
208 for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
209 return out
210}
211
212/** Reads one answer from the dialog's `answers` map (question text -> answer). */
213export function answerFor(answers, question) {
214 if (!answers || typeof answers !== 'object') return ''
215 const value = answers[question]
216 if (Array.isArray(value)) return value.map(str).filter(Boolean).join(', ')
217 return str(value)
218}
219
220/**
221 * Q/A blocks for every answered question; unanswered ones are left out.
222 * @param {readonly AskQuestion[]} questions
223 * @param {readonly string[]} answers
224 */
225export function compileAnswers(questions, answers) {
226 return questions
227 .map((q, i) => ({ q, a: (answers[i] ?? '').trim() }))
228 .filter(({ a }) => a.length > 0)
229 .map(({ q, a }) => `Q: ${q.question}\nA: ${a}`)
230 .join('\n\n')
231}
232