SLOPSHOPPER

better-ask-picker

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.

newpaneguardtoasttoolprocess
A shopper browsing a rack in a slop shop
README

better-ask-picker

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).

Install

/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).

Usage

The model calls mcp__better-ask-picker__pick:

InputTypeNotes
questionstringRequired. Should end with ?
summarystringOptional. Background shown above the options (Markdown)
options{ label, description? }[]Required, 2 or more, no upper limit
multiSelectbooleantrue 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.

Keys

  • 1-9, then a-z (b, n, y excluded) toggle (or pick, in single-select) the matching option directly. y confirms / goes next, b goes back, n cancels.
  • Tab / arrows / Enter also work when the pane has focus. Esc closes the pane and cancels.
  • Space cannot be used for toggling: the plugin API offers no raw key events (only digit/letter hotkeys and Enter).

Fallback

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.

Requirements and limits

  • macOS / Linux. The wait uses sh, sleep, touch and rm via the plugin API's process runner; Windows is not supported.
  • Interactive sessions only. In claude -p (headless) there is nobody to ask and the call fails.
  • One call at a time; a second call while one is open is denied (e.g. two parallel tool calls). Use questions to ask several questions in one call.
  • The UI text ("決定", "キャンセル") and the tool description are in Japanese.
  • In the native-dialog fallback, a label containing a comma can confuse multi-select parsing.

How it works (for contributors)

  • hooks/hooks.tsx registers the tool at session.start, serves it in tool.call, and draws the pane in ui.render (component: "Pane").
  • A hook's budget (10 s) counts its own code but not $ 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[].
  • Check changes with claude plugin validate ., then try them in a new session (claude --debug-file <path> shows pane and hook activity).

日本語

質問の概要と全選択肢を専用ペインに1画面で表示し、単一選択・複数選択ができる Claude Code プラグインです。ネイティブの AskUserQuestion(選択肢4個まで)の代わりに使えます。

  • 数字キー(1-9)→文字キー(a-z)で直接選択、y で決定、n でキャンセル、Esc でも閉じます
  • 端末幅が144列未満のときはネイティブダイアログにフォールバックします
  • Space でのトグルは、プラグインAPIの制約でできません

License

MIT

Source 1 files
hooks/hooks.tsx 432 lines
1import 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