Ask the user a question in a dedicated pane: show every option on one screen with a summary, single or multi-select, no 4-option limit.

A Claude Code plugin that asks the user a question in a dedicated pane: every option on one screen, with a summary of the question, single or multi-select, and no 4-option limit.
Claude Code's built-in AskUserQuestion accepts at most 4 options and has no room for background text. This plugin registers a pick tool that draws its own pane instead.
Status: early (v0.1.0). It relies on Claude Code's early-access function-hooks plugin API, which may change between releases. Tested on Claude Code 2.1.278 (macOS, terminal).
/plugin marketplace add togishima/better-ask-picker
/plugin install better-ask-picker@better-ask-picker
Start a new session afterwards (tools are registered at session start).
The model calls mcp__better-ask-picker__pick:
| Input | Type | Notes |
|---|---|---|
question | string | Required. Should end with ? |
summary | string | Optional. Background shown above the options (Markdown) |
options | { label, description? }[] | Required, 2 or more, no upper limit |
multiSelect | boolean | true for multiple choice; confirm with y |
questions | { question, summary?, options, multiSelect? }[] | Instead of the four fields above: ask several questions in a row, in one pane. Cannot be combined with question / options |
Returns a JSON string:
{ "selected": ["A", "C"] }
With questions, one entry per question:
{ "answers": [{ "question": "Which DB?", "selected": ["Postgres"] }, { "question": "Which features?", "selected": ["A", "C"] }] }
On cancel: { "selected": [], "cancelled": true } ({ "answers": [], "cancelled": true } with questions).
With questions, the pane shows the progress (1/3). A single-select question advances as soon as you pick; a multi-select question advances with y. b goes back to the previous question, keeping its selection.
b, n, y excluded) toggle (or pick, in single-select) the matching option directly. y confirms / goes next, b goes back, n cancels.A pane a plugin opens on its own is not drawn on terminals narrower than 144 columns. In that case the plugin closes the pane and asks with the native dialog instead (4 options per page; multi-select is asked per chunk of 4). It is a degraded mode: the summary is folded into the question text.
sh, sleep, touch and rm via the plugin API's process runner; Windows is not supported.claude -p (headless) there is nobody to ask and the call fails.questions to ask several questions in one call.hooks/hooks.tsx registers the tool at session.start, serves it in tool.call, and draws the pane in ui.render (component: "Pane").$ calls in flight, so waiting for the person is done with a $.process.run that blocks until a sentinel file appears, not with an awaited Promise.tool.call results carry context as string[].claude plugin validate ., then try them in a new session (claude --debug-file <path> shows pane and hook activity).質問の概要と全選択肢を専用ペインに1画面で表示し、単一選択・複数選択ができる Claude Code プラグインです。ネイティブの AskUserQuestion(選択肢4個まで)の代わりに使えます。
y で決定、n でキャンセル、Esc でも閉じますMIT
hooks/hooks.tsx 432 lines1import type { Register } from 'claude-code';
2
3const TOOL_NAME = 'pick';
4const FULL_TOOL_NAME = 'mcp__better-ask-picker__pick';
5const PANE_ID = 'ask-picker';
6
7// hotkey は数字1桁か小文字1字。y/n/b は決定・キャンセル・戻るに空けておく。
8const HOTKEYS = '123456789acdefghijklmopqrstuvwxz';
9const CONFIRM_HOTKEY = 'y';
10const CANCEL_HOTKEY = 'n';
11const BACK_HOTKEY = 'b';
12
13// 待機の上限($.process.run の timeoutMs 上限は10分)
14const WAIT_TIMEOUT_MS = 600_000;
15
16type Option = { label: string; description?: string };
17
18type Question = {
19 question: string;
20 summary?: string;
21 options: Option[];
22 multiSelect: boolean;
23};
24
25type Item = Question & { selected: Set<number> };
26
27type Session = {
28 items: Item[];
29 // 表示中の設問(items の添字)
30 index: number;
31 // `questions` で渡されたか。true なら戻り値を answers 配列にする
32 isBatch: boolean;
33 status: 'open' | 'done' | 'cancelled';
34 sentinel: string;
35};
36
37// 表示中の質問。ペインは1つだけ(同時に2件は受けない)。
38let session: Session | null = null;
39
40// フックの予算(10秒)は自分のコードの実行時間だけを数え、$ 呼び出しの実行中は止まる。
41// 自作 Promise の await は止まらないので、人の回答待ちは「sentinel が現れるまで
42// 待つ子プロセス」を $.process.run で待つ形にする。
43const waitForAnswer = async ($: any, s: Session) => {
44 await $.process.run(['sh', '-c', 'while [ ! -e "$1" ]; do sleep 0.2; done', 'sh', s.sentinel], {
45 timeoutMs: WAIT_TIMEOUT_MS,
46 });
47};
48
49const finish = async ($: any, s: Session, status: 'done' | 'cancelled') => {
50 if (s.status !== 'open') return;
51 s.status = status;
52 await $.process.run(['touch', s.sentinel]);
53};
54
55const selectedLabels = (it: Item) =>
56 [...it.selected].sort((a, b) => a - b).map((i) => it.options[i]?.label ?? '');
57
58const cancelledAnswer = (s: Session) =>
59 s.isBatch ? { answers: [], cancelled: true } : { selected: [] as string[], cancelled: true };
60
61const answerOf = (s: Session) => {
62 if (s.status !== 'done') return cancelledAnswer(s);
63 if (s.isBatch) {
64 return {
65 answers: s.items.map((it) => ({ question: it.question, selected: selectedLabels(it) })),
66 };
67 }
68 const only = s.items[0];
69 return { selected: only ? selectedLabels(only) : [] };
70};
71
72// ペインを出せない環境(幅が足りない等)向け: ネイティブダイアログで4択ずつ聞く。
73// multiSelect は4個ずつのチャンクをそれぞれ複数選択で聞いて連結する。
74// キャンセル(ダイアログを閉じた)は null。
75// 既知の制限: ラベルにカンマを含むと、複数選択の回答の分割がずれる。
76const askOneWithNativeDialog = async (
77 $: any,
78 q: Question,
79 tag: string
80): Promise<string[] | null> => {
81 const header = `${tag}${q.summary ? `${q.question}\n${q.summary}` : q.question}`;
82 const labels = q.options.map((o) => o.label);
83 try {
84 if (q.multiSelect) {
85 const chunks: string[][] = [];
86 for (let i = 0; i < labels.length; i += 4) chunks.push(labels.slice(i, i + 4));
87 const picked: string[] = [];
88 for (const [n, chunk] of chunks.entries()) {
89 const text = chunks.length > 1 ? `${header} (${n + 1}/${chunks.length})` : header;
90 const answer: string = await $.ui.ask(text, { options: chunk, multiSelect: true });
91 picked.push(
92 ...answer
93 .split(',')
94 .map((t) => t.trim())
95 .filter(Boolean)
96 );
97 }
98 return picked;
99 }
100 const Prev = '← 前へ';
101 const Next = '→ 次へ';
102 const pageCount = Math.ceil(labels.length / 2);
103 let page = 0;
104 while (true) {
105 const start = page * 2;
106 const nav: string[] = [];
107 if (page > 0) nav.push(Prev);
108 if (start + 2 < labels.length) nav.push(Next);
109 const text = pageCount > 1 ? `${header} (${page + 1}/${pageCount})` : header;
110 const chosen: string = await $.ui.ask(text, [...labels.slice(start, start + 2), ...nav]);
111 if (chosen === Next) page += 1;
112 else if (chosen === Prev) page -= 1;
113 else return [chosen];
114 }
115 } catch {
116 return null;
117 }
118};
119
120const askAllWithNativeDialog = async ($: any, s: Session) => {
121 const answers: { question: string; selected: string[] }[] = [];
122 for (const [k, it] of s.items.entries()) {
123 const tag = s.items.length > 1 ? `(${k + 1}/${s.items.length}) ` : '';
124 const picked = await askOneWithNativeDialog($, it, tag);
125 if (!picked) return cancelledAnswer(s);
126 answers.push({ question: it.question, selected: picked });
127 }
128 return s.isBatch ? { answers } : { selected: answers[0]?.selected ?? [] };
129};
130
131type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
132
133const parseQuestion = (raw: unknown): Parsed<Question> => {
134 const input = (raw ?? {}) as {
135 question?: unknown;
136 summary?: unknown;
137 options?: unknown;
138 multiSelect?: unknown;
139 };
140 if (typeof input.question !== 'string' || input.question.length === 0) {
141 return { ok: false, error: 'question が空です' };
142 }
143 if (!Array.isArray(input.options) || input.options.length < 2) {
144 return { ok: false, error: 'options には2個以上の選択肢が必要です' };
145 }
146 const options: Option[] = [];
147 for (const rawOption of input.options) {
148 const o = rawOption as { label?: unknown; description?: unknown };
149 if (typeof o?.label !== 'string' || o.label.length === 0) {
150 return { ok: false, error: '各 option には空でない label が必要です' };
151 }
152 options.push({
153 label: o.label,
154 description: typeof o.description === 'string' && o.description ? o.description : undefined,
155 });
156 }
157 return {
158 ok: true,
159 value: {
160 question: input.question,
161 summary: typeof input.summary === 'string' && input.summary ? input.summary : undefined,
162 options,
163 multiSelect: input.multiSelect === true,
164 },
165 };
166};
167
168const parseInput = (e: unknown): Parsed<{ questions: Question[]; isBatch: boolean }> => {
169 const input = e as { questions?: unknown; question?: unknown; options?: unknown };
170 if (input.questions !== undefined) {
171 if (input.question !== undefined || input.options !== undefined) {
172 return { ok: false, error: 'question/options と questions は同時に指定できません' };
173 }
174 if (!Array.isArray(input.questions) || input.questions.length === 0) {
175 return { ok: false, error: 'questions には1問以上必要です' };
176 }
177 const questions: Question[] = [];
178 for (const [k, raw] of input.questions.entries()) {
179 const parsed = parseQuestion(raw);
180 if (!parsed.ok) return { ok: false, error: `questions[${k}]: ${parsed.error}` };
181 questions.push(parsed.value);
182 }
183 return { ok: true, value: { questions, isBatch: true } };
184 }
185 const parsed = parseQuestion(e);
186 if (!parsed.ok) return parsed;
187 return { ok: true, value: { questions: [parsed.value], isBatch: false } };
188};
189
190// ペインの高さ: 最も背の高い設問に合わせる(設問が切り替わっても高さは変えない)
191const rowsFor = (s: Session) => {
192 const tallest = Math.max(
193 ...s.items.map(
194 (it) =>
195 6 +
196 it.options.length * (it.options.some((o) => o.description) ? 2 : 1) +
197 (it.summary ? 3 : 0)
198 )
199 );
200 return Math.min(26, tallest + (s.items.length > 1 ? 2 : 0));
201};
202
203export const register: Register = (on, _options) => {
204 on('session.start', async ($, e, next) => {
205 const questionSchema = {
206 type: 'object',
207 properties: {
208 question: { type: 'string', description: '質問文(?で終わる)' },
209 summary: { type: 'string', description: '質問の概要・背景(Markdown、任意)' },
210 options: {
211 type: 'array',
212 minItems: 2,
213 items: {
214 type: 'object',
215 properties: {
216 label: { type: 'string', description: '選択肢のラベル' },
217 description: { type: 'string', description: '選択肢の説明(任意)' },
218 },
219 required: ['label'],
220 },
221 description: '選択肢の一覧(上限なし)',
222 },
223 multiSelect: { type: 'boolean', description: 'true で複数選択(決定ボタンで確定)' },
224 },
225 required: ['question', 'options'],
226 };
227 await $.tool.register({
228 name: TOOL_NAME,
229 description:
230 '質問の概要を表示しつつ、選択肢を全件1画面に出してユーザーに選ばせる。' +
231 '選択肢の数に上限はなく、multiSelect で複数選択もできる。' +
232 '1問だけなら question/options を、複数の設問を順に聞くなら questions を使う(同時指定は不可)。' +
233 '戻り値は JSON 文字列。1問: {"selected": [選んだlabel...]}、' +
234 'questions: {"answers": [{"question": 質問文, "selected": [...]}...]}。' +
235 'キャンセル時は cancelled: true。',
236 inputSchema: {
237 type: 'object',
238 properties: {
239 ...questionSchema.properties,
240 questions: {
241 type: 'array',
242 minItems: 1,
243 items: questionSchema,
244 description:
245 '複数の設問を順に聞く場合の設問一覧(1問だけなら question/options を使う)',
246 },
247 },
248 },
249 });
250 // 前回のモジュールが残したペインを片付ける
251 try {
252 if ((await $.ui.panes()).some((p) => p.id === PANE_ID)) await $.ui.close({ id: PANE_ID });
253 } catch {}
254 return next(e);
255 });
256
257 on('tool.call', { tool: FULL_TOOL_NAME }, async ($, e, next) => {
258 const parsed = parseInput(e);
259 if (!parsed.ok) return { deny: parsed.error };
260 if (session && session.status === 'open') {
261 return { deny: '別の質問を表示中です。回答を待ってから呼び直してください' };
262 }
263
264 const s: Session = {
265 items: parsed.value.questions.map((q) => ({ ...q, selected: new Set<number>() })),
266 index: 0,
267 isBatch: parsed.value.isBatch,
268 status: 'open',
269 sentinel: `/tmp/ask-picker-${Date.now()}-${Math.floor(Math.random() * 1e9)}.done`,
270 };
271 session = s;
272 const onAbort = () => void finish($, s, 'cancelled');
273 next.signal.addEventListener('abort', onAbort);
274
275 try {
276 await $.ui.open({
277 id: PANE_ID,
278 title: '質問',
279 focus: true,
280 closeOnEscape: true,
281 holdToasts: true,
282 rows: rowsFor(s),
283 });
284
285 const pane = (await $.ui.panes()).find((p) => p.id === PANE_ID);
286 if (!pane?.isPlaced) {
287 // 端末幅が足りずペインが描画されない: 待たずにネイティブダイアログへ
288 await $.ui.close({ id: PANE_ID });
289 const fallback = await askAllWithNativeDialog($, s);
290 return {
291 result: JSON.stringify(fallback),
292 context: [`answered (native dialog): ${JSON.stringify(fallback)}`],
293 };
294 }
295 if (!pane.isFocused) $.ui.toast('ペインを選ぶには ctrl+x の後に tab キー');
296
297 await waitForAnswer($, s);
298 if (s.status === 'open') s.status = 'cancelled';
299
300 const answer = answerOf(s);
301 return { result: JSON.stringify(answer), context: [`answered: ${JSON.stringify(answer)}`] };
302 } catch (err) {
303 return {
304 deny: `質問を表示できませんでした: ${err instanceof Error ? err.message : String(err)}`,
305 };
306 } finally {
307 next.signal.removeEventListener('abort', onAbort);
308 if (session === s) session = null;
309 if (s.status === 'open') s.status = 'cancelled';
310 try {
311 await $.ui.close({ id: PANE_ID });
312 } catch {}
313 try {
314 await $.process.run(['rm', '-f', s.sentinel]);
315 } catch {}
316 }
317 });
318
319 // ユーザーがペインを閉じた(Esc・×)ときはキャンセル扱いで待機を解く
320 on('ui.close', async ($, e, next) => {
321 if (e.id === PANE_ID && e.origin.kind === 'person' && session)
322 void finish($, session, 'cancelled');
323 return next(e);
324 });
325
326 on('ui.render', { component: 'Pane' }, ($, e, next) => {
327 const s = session;
328 if (e.requestId !== PANE_ID || !s || s.status !== 'open') return next(e);
329 const it = s.items[s.index];
330 if (!it) return next(e);
331
332 const { Box, Text, Button, Markdown } = $.ui.resolve(e);
333
334 const total = s.items.length;
335 const isLast = s.index === total - 1;
336
337 // 次の設問へ。最後の設問なら回答を確定する
338 const advance = () => {
339 if (s.status !== 'open') return;
340 if (s.index < total - 1) {
341 s.index += 1;
342 $.ui.invalidate('ui.render');
343 } else {
344 void finish($, s, 'done');
345 }
346 };
347
348 const press = (i: number) => {
349 const cur = s.items[s.index];
350 if (!cur || s.status !== 'open') return;
351 if (cur.multiSelect) {
352 if (cur.selected.has(i)) cur.selected.delete(i);
353 else cur.selected.add(i);
354 $.ui.invalidate('ui.render');
355 return;
356 }
357 cur.selected = new Set([i]);
358 advance();
359 };
360
361 // 設問が複数あるときだけ、単一選択にも選択状態の印を付ける(戻ったときに分かるように)
362 const mark = (i: number) => {
363 const isSelected = it.selected.has(i);
364 if (it.multiSelect) return isSelected ? '[x] ' : '[ ] ';
365 return total > 1 ? (isSelected ? '(*) ' : '( ) ') : '';
366 };
367
368 const progress = total > 1 ? `(${s.index + 1}/${total}) ` : '';
369 const heading = it.summary
370 ? `**${progress}${it.question}**\n\n${it.summary}`
371 : `**${progress}${it.question}**`;
372
373 return (
374 <Box flexDirection="column">
375 <Markdown text={heading} />
376 <Box flexDirection="column" marginTop={1}>
377 {it.options.map((o, i) => (
378 <Box flexDirection="column">
379 <Button
380 key={`q${s.index}-opt-${i}`}
381 plain
382 autoFocus={i === 0 ? true : undefined}
383 hotkey={HOTKEYS[i]}
384 label={`${mark(i)}${o.label}`}
385 onPress={() => press(i)}
386 />
387 {o.description ? (
388 <Box paddingLeft={4}>
389 <Text dimColor wrap="wrap">
390 {o.description}
391 </Text>
392 </Box>
393 ) : null}
394 </Box>
395 ))}
396 </Box>
397 <Box marginTop={1} gap={2}>
398 {it.multiSelect ? (
399 <Button
400 key="confirm"
401 plain
402 hotkey={CONFIRM_HOTKEY}
403 label={`${isLast ? '決定' : '次へ'} (${it.selected.size}件)`}
404 onPress={advance}
405 />
406 ) : null}
407 {s.index > 0 ? (
408 <Button
409 key="back"
410 plain
411 hotkey={BACK_HOTKEY}
412 label="戻る"
413 onPress={() => {
414 if (s.status !== 'open' || s.index === 0) return;
415 s.index -= 1;
416 $.ui.invalidate('ui.render');
417 }}
418 />
419 ) : null}
420 <Button
421 key="cancel"
422 plain
423 hotkey={CANCEL_HOTKEY}
424 label="キャンセル"
425 onPress={() => void finish($, s, 'cancelled')}
426 />
427 </Box>
428 </Box>
429 );
430 });
431};
432