A poll tool for Claude: any number of multiple-choice questions on one screen, answered in a pane.

A Claude Code plugin that lets Claude ask you any number of multiple-choice questions at once, in a form beside the conversation. The built-in AskUserQuestion takes at most 4 questions with 4 options each. This tool lifts both limits, so Claude can settle a whole batch of decisions in one go, for example while planning.

mcp__extended-polls__ask tool with a list of questions.It draws in the terminal, the desktop app and VS Code. The mobile app has no text fields yet, so there the "Other" field is hidden.
sleep command (see How the wait works), so Windows is not supported./status), and the VS Code extension needs v2.1.299 or later. Plugins don't load in the desktop app's WSL sessions.From your shell:
claude plugin install extended-polls --marketplace illsv/extended-polls-plugin
Or inside a Claude Code session:
/plugin install extended-polls --marketplace illsv/extended-polls-plugin
On older Claude Code versions that lack --marketplace, add the marketplace first:
claude plugin marketplace add illsv/extended-polls-plugin
claude plugin install extended-polls@extended-polls
Start a new session afterwards. To get a newer release, run claude plugin update extended-polls@extended-polls.
Claude reaches for the poll on its own when it has more questions than AskUserQuestion can hold. You can also ask for it, for example: "Ask me everything you need to know before you start, in one poll."
Answer in the pane, then press Submit. If you hide the pane, the row above the prompt keeps the poll in view, and /poll opens the form again.
The pane opens as a dialog, so the keyboard goes to the form.
j and k, move between options.1 to 9, then 0. The digits are drawn beside that question's options.pane:top and pane:bottom, move the cursor to the first or the last question.1 into the empty prompt, click that row's button, or press ctrl+x tab and Enter. The cursor returns to where it was.The pane docks beside the transcript when the terminal is wide enough: 144 columns for a pane that opens by itself, 110 once you have opened it yourself.
The plugin's own hotkeys are j, k and the digits. A hotkey cannot tell g from G, and it cannot be Space, so those come from Claude Code's keybindings. Add this to ~/.claude/keybindings.json:
{
"bindings": [
{
"context": "Pane",
"bindings": {
"g g": "pane:top",
"shift+g": "pane:bottom",
"space": "abovePrompt:press"
}
}
]
}
Then gg and G jump to the first and the last question, and Space picks the option under the cursor. In the own-answer field Space still types a space.
| Field | Default | Meaning |
|---|---|---|
maxQuestions | none | Optional cap on questions in one poll. |
language | en | Language of the pane, as a code below. |
The pane speaks 22 languages: en English, uk Ukrainian, ar Arabic, bn Bengali, cs Czech, de German, es Spanish, fr French, hi Hindi, id Indonesian, it Italian, ja Japanese, ko Korean, nl Dutch, pl Polish, pt Portuguese, ro Romanian, sv Swedish, th Thai, tr Turkish, vi Vietnamese and zh Chinese (Simplified). Any other value falls back to English. The language covers the pane's own texts only: Claude writes the questions and options in whatever language it chooses.
Change them in /config, or in settings.json:
{
"pluginConfigs": {
"extended-polls@extended-polls": { "options": { "maxQuestions": 40 } }
}
}
sleep 20. While a poll waits for you, the plugin keeps the system's sleep command running, with the fixed arguments sleep 20, and starts it again every 20 seconds. That is the only program it runs, and only to wait; How the wait works says why. sleep reads and writes nothing.mcp__extended-polls__ask tool and answers calls to that tool itself: the answer is what you picked in the form. It doesn't look at, change or answer calls to any other tool./poll command, the pane, the row above the prompt and a compact transcript row for the tool call.A plugin hook has 10 seconds of its own time, but its clock stops while one of its calls to Claude Code is in flight. A poll can wait for minutes, so the tool call always has a sleep 20 running beside it through Claude Code's process.run call, and waits for whichever comes first: your reply from the form, an interrupt, or the end of that sleep, after which it starts the next one. Submit or Cancel ends the wait at once; a sleep still running then finishes on its own within its 20 seconds.
git clone https://github.com/illsv/extended-polls-plugin.git
cd extended-polls-plugin
claude --plugin-dir .
claude plugin validate --strict .
claude plugin test .
shared/poll.ts holds the schema, validation and answer text. It has no host imports, so an MCP server for other clients can reuse it.hooks/register.tsx registers the tool, waits for the reply and draws the pane.hooks/strings.ts picks the pane's texts; hooks/locales/ holds one file per language.tests/ runs under claude plugin test ..CONTRIBUTING.md covers the setup, the checks to run and how to add a language. Changes between versions are in CHANGELOG.md.
The mods API this plugin is built on is young and may change between Claude Code releases.
hooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ActivePoll, PollAnswer, PollInput } from '../types'
5import {
6 answeredCount,
7 emptyAnswers,
8 formatAnswers,
9 inputSchema,
10 isAnswered,
11 parseLimit,
12 setOther,
13 toggleOption,
14 toolDescription,
15 validate,
16} from '../shared/poll'
17import type { PollOutcome } from '../shared/poll'
18import { stringsFor } from './strings'
19
20const PANE = 'extended-polls'
21const TOOL = 'mcp__extended-polls__ask'
22const COMMAND = 'poll'
23
24const active = atom({ plugin: 'extended-polls', key: 'active' } as const, null)
25
26type Reply = { outcome: PollOutcome; answers: PollAnswer[] }
27
28// The form hands the answers to the waiting tool call through this resolver:
29// a module's hooks share its variables, while every read of `$.state` inside
30// one dispatch sees the moment it began, so the tool call could not see edits
31// made while it waits.
32let deliver: ((reply: Reply) => void) | null = null
33
34async function finish($: EngineInterface, outcome: PollOutcome): Promise<void> {
35 const poll = await read($, active)
36 if (!poll) return
37 deliver?.({ outcome, answers: poll.answers })
38}
39
40// The tool call has to wait for the person, far longer than a hook's own 10 s
41// budget. A hook's clock stops while one of its `$` calls is in flight, so the
42// wait always has a `sleep` running beside it, with fixed arguments: it reads
43// and writes nothing. The reply or an interrupt ends the wait at once; a sleep
44// still running then just finishes on its own within its 20 seconds.
45async function waitForReply($: EngineInterface, reply: Promise<Reply>, abort: AbortSignal): Promise<Reply> {
46 abort.throwIfAborted()
47 const aborted = new Promise<never>((_, reject) => {
48 abort.addEventListener('abort', () => reject(abort.reason), { once: true })
49 })
50 for (;;) {
51 const slept = $.process.run(['sleep', '20'])
52 const got = await Promise.race([reply, aborted, slept.then(() => undefined)])
53 if (got) return got
54 }
55}
56
57// A dialog pane: Tab and the arrows walk its buttons and hotkeys reach it.
58// Escape closes it; the ui.close hook keeps the poll waiting.
59function openPane($: EngineInterface, title: string) {
60 return $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true, holdToasts: true })
61}
62
63/** Every element the ring can stop on, in drawing order. */
64function focusOrder(poll: ActivePoll): string[] {
65 const keys: string[] = []
66 poll.input.questions.forEach((q, i) => {
67 q.options.forEach((_, j) => keys.push(`q${i}o${j}`))
68 if (q.allowOther !== false) keys.push(poll.editing === i ? `q${i}input` : `q${i}other`)
69 })
70 keys.push('submit', 'cancel')
71 return keys
72}
73
74/** The question a focus key belongs to, or undefined for the buttons. */
75function questionOf(key: string | null): number | undefined {
76 const m = key ? /^q(\d+)/.exec(key) : null
77 return m ? Number(m[1]) : undefined
78}
79
80/**
81 * Scrolls question i whole into the pane's window, then makes sure the
82 * element taking the ring shows too (a question taller than the window).
83 */
84async function reveal($: EngineInterface, i: number, key: string): Promise<void> {
85 await quietly(() => $.ui.scroll({ to: { key: `q${i}` }, in: PANE, block: 'nearest' }))
86 await quietly(() => $.ui.scroll({ to: { key }, in: PANE, block: 'nearest' }))
87}
88
89/** The hotkey of the n-th row of the question in focus: 1-9, then 0. */
90function digit(n: number): string | undefined {
91 return n < 9 ? String(n + 1) : n === 9 ? '0' : undefined
92}
93
94async function quietly(fn: () => unknown): Promise<void> {
95 try {
96 await fn()
97 } catch {
98 // ignored: the pane or status may already be gone
99 }
100}
101
102export const register: Register = (on, options) => {
103 const limit = parseLimit(options.maxQuestions)
104 const t = stringsFor(options.language)
105
106 on('session.start', async ($, e, next) => {
107 await $.tool.register({
108 name: 'ask',
109 description: toolDescription(limit),
110 inputSchema: inputSchema(limit),
111 isDeferred: false,
112 })
113 await $.command.register({ name: COMMAND, description: t.commandDescription })
114 // A poll left over from before a reload has no waiter any more.
115 await update($, active, () => null)
116 return next(e)
117 })
118
119 on('command.run', { command: COMMAND }, async $ => {
120 const poll = await read($, active)
121 if (!poll) return { text: t.commandNone }
122 await update($, active, p => (p ? { ...p, hidden: false } : p))
123 await openPane($, poll.input.title ?? t.paneTitle)
124 return { text: t.commandOpened }
125 })
126
127 on('tool.call', { tool: TOOL }, async ($, e, next) => {
128 const raw = e as unknown as Record<string, unknown>
129 const input = { title: raw.title, intro: raw.intro, questions: raw.questions } as PollInput
130 const problem = validate(input, limit)
131 if (problem) return { deny: problem }
132 if (await read($, active)) return { deny: t.busy }
133
134 const reply = new Promise<Reply>(resolve => (deliver = resolve))
135 try {
136 const poll: ActivePoll = {
137 id: e.tool_use_id ?? String(await $.clock.now()),
138 input,
139 answers: emptyAnswers(input),
140 editing: null,
141 focusKey: 'q0o0',
142 hidden: false,
143 }
144 await update($, active, () => poll)
145 const opened = await openPane($, input.title ?? t.paneTitle)
146 $.ui.status(t.waiting)
147 if (!opened.isPlaced) $.ui.toast(t.notPlaced, { timeoutMs: 10_000 })
148 void $.ui.notify(t.notify(input.questions.length)).catch(() => undefined)
149 const { outcome, answers } = await waitForReply($, reply, next.signal)
150 return { result: formatAnswers(input, answers, outcome) }
151 } catch (error) {
152 // An interrupt ends the wait; the call is over either way.
153 if (next.signal.aborted) return { deny: 'The user interrupted the poll.' }
154 throw error
155 } finally {
156 deliver = null
157 // Cleanup must never cost the answers already read above.
158 await quietly(() => $.ui.status(undefined))
159 await quietly(() => update($, active, () => null))
160 await quietly(() => $.ui.close({ id: PANE }))
161 }
162 })
163
164 // The transcript row of a call: one line instead of the raw JSON input.
165 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
166 const { Box, Text } = $.ui.resolve(e)
167 const raw = (e.props.input ?? {}) as { title?: unknown; questions?: unknown }
168 const title = typeof raw.title === 'string' ? raw.title : undefined
169 const n = Array.isArray(raw.questions) ? raw.questions.length : 0
170 // A plugin-served call never reads as isRunning: it runs until output lands.
171 const failed = e.props.isErrored || e.props.isInterrupted
172 const waiting = !failed && (e.props.isRunning || e.props.output === undefined)
173 const state = waiting ? t.toolWaiting : failed ? t.toolFailed : t.toolDone
174 return (
175 <Box flexDirection="row" gap={1}>
176 <Text color={waiting ? 'suggestion' : failed ? 'error' : 'success'}>●</Text>
177 <Text bold>{t.toolRow(title, n)}</Text>
178 <Text dimColor>· {state}</Text>
179 </Box>
180 )
181 })
182
183 // Escape or the close mark only hides the form: the poll keeps waiting and
184 // the band above the prompt brings it back. Cancel is the form's own button.
185 on('ui.close', async ($, e, next) => {
186 if (e.id === PANE && e.origin.kind === 'person') {
187 await update($, active, p => (p ? { ...p, hidden: true } : p))
188 }
189 return next(e)
190 })
191
192 // While a poll waits: one row above the prompt to bring the form back. Its
193 // button answers a bare 1 typed into an empty prompt, a click, or ctrl+x tab.
194 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
195 const poll = await read($, active)
196 if (!poll || e.props.hasSurvey) return next(e)
197 const { Box, Text, Button } = $.ui.resolve(e)
198 const title = poll.input.title ?? t.paneTitle
199 const done = answeredCount(poll.answers)
200 const reopen = async () => {
201 await update($, active, p => (p && p.id === poll.id ? { ...p, hidden: false } : p))
202 await quietly(() => openPane($, title))
203 }
204 return (
205 <Box flexDirection="row" gap={1}>
206 <Text color="suggestion">●</Text>
207 <Text>{t.bandText(title, done, poll.input.questions.length)}</Text>
208 <Button key="reopen" plain hotkey="1" label={poll.hidden ? t.bandOpen : t.bandFocus} onPress={reopen} />
209 </Box>
210 )
211 })
212
213 // Remember where the ring is: the digit hotkeys follow the question in focus.
214 // Entering another question scrolls all of its options into view.
215 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
216 const key = e.element ?? null
217 const poll = await read($, active)
218 if (!poll || key === null || poll.focusKey === key) return next(e)
219 await update($, active, p => (p && p.id === poll.id ? { ...p, focusKey: key } : p))
220 const moved = await next(e)
221 const to = questionOf(key)
222 if (to !== undefined && to !== questionOf(poll.focusKey)) await reveal($, to, key)
223 return moved
224 })
225
226 // The pane's jump keys (Home and End, or gg and G bound to pane:top and
227 // pane:bottom) also carry the cursor: to the first question, or the last.
228 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
229 const moved = await next(e)
230 const isJump = e.origin.kind === 'person' && e.contentRows > 0 && Math.abs(e.by) >= e.contentRows
231 const poll = isJump ? await read($, active) : null
232 if (!poll) return moved
233 const i = e.by < 0 ? 0 : poll.input.questions.length - 1
234 const key = `q${i}o0`
235 await update($, active, p => (p && p.id === poll.id ? { ...p, focusKey: key } : p))
236 await quietly(() => $.ui.focus({ requestId: PANE, key }))
237 return moved
238 })
239
240 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
241 const table = $.ui.resolve(e)
242 const { Box, Text, Button } = table
243 // The mobile app draws no text field yet.
244 const Input = 'Input' in table && e.surface !== 'mobile' ? table.Input : undefined
245 const poll = await read($, active)
246 if (!poll) return <Text dimColor>{t.noPoll}</Text>
247
248 const { input, answers } = poll
249 const total = input.questions.length
250 const done = answeredCount(answers)
251 const width = Math.max(20, e.props.bodyColumns)
252 const current = questionOf(poll.focusKey) ?? 0
253 const hotkeys = e.surface !== 'mobile'
254
255 const change = (fn: (p: ActivePoll) => ActivePoll) =>
256 update($, active, p => (p && p.id === poll.id ? fn(p) : p))
257
258 const edit = (i: number, fn: (p: ActivePoll) => ActivePoll['answers'][number]) =>
259 change(p => {
260 const next = [...p.answers]
261 next[i] = fn(p)
262 return { ...p, answers: next }
263 })
264
265 // The ring's new place is noted at once, so the digits move with it even
266 // before the surface reports the move back through ui.focus.
267 const focus = async (key: string) => {
268 const from = questionOf((await read($, active))?.focusKey ?? null)
269 await change(p => ({ ...p, focusKey: key }))
270 await quietly(() => $.ui.focus({ requestId: PANE, key }))
271 const to = questionOf(key)
272 if (to !== undefined && to !== from) await reveal($, to, key)
273 }
274 const firstOf = (i: number) => (i < total ? `q${i}o0` : 'submit')
275
276 // Moves the ring by one stop, for the j and k hotkeys.
277 const step = async (by: 1 | -1) => {
278 const now = await read($, active)
279 if (!now) return
280 const keys = focusOrder(now)
281 const at = now.focusKey ? keys.indexOf(now.focusKey) : -1
282 const to = keys[Math.min(keys.length - 1, Math.max(0, at + by))]
283 if (to) await focus(to)
284 }
285
286 const pick = async (i: number, label: string) => {
287 const q = input.questions[i]
288 if (!q) return
289 const before = (await read($, active))?.answers[i] ?? { selected: [], other: '' }
290 const after = toggleOption(q, before, label)
291 await edit(i, () => after)
292 // A single choice made: on to the next question. Several stay put.
293 if (!q.multiSelect && after.selected.length > 0) await focus(firstOf(i + 1))
294 }
295
296 const openOther = async (i: number) => {
297 await change(p => ({ ...p, editing: i }))
298 await focus(`q${i}input`)
299 }
300
301 const submit = () => finish($, 'submitted')
302 const cancel = () => finish($, 'cancelled')
303
304 return (
305 <Box flexDirection="column" width={width}>
306 {input.title ? <Text bold>{input.title}</Text> : null}
307 {input.intro ? <Text dimColor>{input.intro}</Text> : null}
308 {e.surface === 'terminal' ? <Text dimColor>{t.keysHint}</Text> : null}
309 <Box flexDirection="row" gap={2}>
310 <Text color={done === total ? 'success' : 'suggestion'}>{t.progress(done, total)}</Text>
311 {total > 5 ? <Button key="submit-top" label={t.submit} onPress={submit} /> : null}
312 </Box>
313 <Text> </Text>
314 {input.questions.map((q, i) => {
315 const a = answers[i] ?? { selected: [], other: '' }
316 const OtherInput = q.allowOther !== false ? Input : undefined
317 const isCurrent = hotkeys && i === current
318 const hotkey = (n: number) => (isCurrent ? digit(n) : undefined)
319 // Rows without a hotkey keep the column the "1: " prefix takes.
320 const pad = (n: number) => (hotkey(n) ? '' : hotkeys ? ' ' : '')
321 const otherRow = q.options.length
322 return (
323 <Box key={`q${i}`} flexDirection="column" marginBottom={1}>
324 <Text wrap="wrap">
325 <Text color={isAnswered(a) ? 'success' : 'warning'}>{isAnswered(a) ? '✓' : '•'} </Text>
326 <Text bold>
327 {i + 1}. {q.question}
328 </Text>
329 {q.header ? <Text dimColor> [{q.header}]</Text> : null}
330 {q.multiSelect ? <Text dimColor> ({t.several})</Text> : null}
331 </Text>
332 {q.options.map((o, j) => {
333 const picked = a.selected.includes(o.label)
334 const mark = q.multiSelect ? (picked ? '☑' : '☐') : picked ? '◉' : '○'
335 return (
336 <Box key={`q${i}r${j}`} paddingLeft={2}>
337 <Button
338 key={`q${i}o${j}`}
339 plain
340 hotkey={hotkey(j)}
341 autoFocus={poll.focusKey === `q${i}o${j}` ? true : undefined}
342 onPress={() => pick(i, o.label)}
343 >
344 {`${pad(j)}${mark} `}
345 <Text bold={picked}>{o.label}</Text>
346 {o.label === q.recommended ? <Text color="success"> ({t.recommended})</Text> : ''}
347 {o.description ? <Text dimColor> · {o.description}</Text> : ''}
348 </Button>
349 </Box>
350 )
351 })}
352 {OtherInput && poll.editing === i ? (
353 <Box paddingLeft={hotkeys ? 5 : 2}>
354 <OtherInput
355 key={`q${i}input`}
356 label={t.otherLabel}
357 placeholder={t.otherPlaceholder}
358 submitLabel={t.otherSubmit}
359 value={a.other}
360 autoFocus
361 onInput={text => edit(i, p => setOther(q, p.answers[i] ?? a, text))}
362 onSubmit={async text => {
363 await edit(i, p => setOther(q, p.answers[i] ?? a, text))
364 await change(p => ({ ...p, editing: null }))
365 // Tab out of a field leaves the pane in the terminal, so
366 // Enter carries the keyboard on to the next question.
367 await focus(firstOf(i + 1))
368 }}
369 />
370 </Box>
371 ) : null}
372 {OtherInput && poll.editing !== i ? (
373 <Box paddingLeft={2}>
374 <Button
375 key={`q${i}other`}
376 plain
377 hotkey={hotkey(otherRow)}
378 autoFocus={poll.focusKey === `q${i}other` ? true : undefined}
379 onPress={() => openOther(i)}
380 >
381 {`${pad(otherRow)}✎ `}
382 {a.other.trim() !== '' ? (
383 <Text bold>
384 {t.otherLabel}: {a.other}
385 </Text>
386 ) : (
387 <Text dimColor>{t.otherLabel}…</Text>
388 )}
389 </Button>
390 </Box>
391 ) : null}
392 </Box>
393 )
394 })}
395 {done < total ? <Text dimColor>{t.unanswered(total - done)}</Text> : null}
396 <Box flexDirection="row" gap={2}>
397 <Button
398 key="submit"
399 variant="primary"
400 label={t.submit}
401 autoFocus={poll.focusKey === 'submit' ? true : undefined}
402 onPress={submit}
403 />
404 <Button key="cancel" label={t.cancel} onPress={cancel} />
405 {hotkeys ? <Button key="down" plain hotkey="j" label={t.down} onPress={() => step(1)} /> : null}
406 {hotkeys ? <Button key="up" plain hotkey="k" label={t.up} onPress={() => step(-1)} /> : null}
407 </Box>
408 </Box>
409 )
410 })
411}
412shared/poll.ts 149 lines1// The poll's core: schema, validation, answer bookkeeping and the text the
2// model reads back. Plain TypeScript with no host imports, so a future MCP
3// server (ChatGPT, claude.ai) can reuse it unchanged.
4
5import type { PollAnswer, PollInput, PollQuestion } from '../types'
6
7export const MIN_OPTIONS = 2
8export const MAX_OPTIONS = 10
9
10/** The configured cap on questions per poll, or undefined for no cap (unset, empty or below 1). */
11export function parseLimit(value: unknown): number | undefined {
12 if (value === undefined || value === null || value === '') return undefined
13 const n = typeof value === 'number' ? value : Number(value)
14 return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined
15}
16
17export function toolDescription(maxQuestions?: number): string {
18 const count = maxQuestions === undefined ? 'any number of' : `up to ${maxQuestions}`
19 return [
20 `Ask the user ${count} multiple-choice questions at once, all on one screen.`,
21 'Use it instead of AskUserQuestion when you need more than 4 questions or more than 4 options per question,',
22 'for example to settle many decisions while planning. The call blocks until the user submits the form,',
23 'then returns every answer. Each question gets an automatic free-text "Other" field unless allowOther is false.',
24 'Unanswered questions come back as "(no answer)". Group related questions and keep labels short.',
25 'Mark a recommendation only with the recommended field; never write "(Recommended)" into a label.',
26 ].join(' ')
27}
28
29export function inputSchema(maxQuestions?: number): Record<string, unknown> {
30 return {
31 type: 'object',
32 additionalProperties: false,
33 required: ['questions'],
34 properties: {
35 title: { type: 'string', description: 'Short title of the poll, shown on top of the form.' },
36 intro: { type: 'string', description: 'Optional context shown under the title.' },
37 questions: {
38 type: 'array',
39 minItems: 1,
40 ...(maxQuestions === undefined ? {} : { maxItems: maxQuestions }),
41 description: maxQuestions === undefined ? 'One or more questions.' : `1 to ${maxQuestions} questions.`,
42 items: {
43 type: 'object',
44 additionalProperties: false,
45 required: ['question', 'options'],
46 properties: {
47 question: { type: 'string', description: 'The full question, ending with a question mark.' },
48 header: { type: 'string', description: 'Very short topic label (max ~12 chars), e.g. "Auth".' },
49 options: {
50 type: 'array',
51 minItems: MIN_OPTIONS,
52 maxItems: MAX_OPTIONS,
53 items: {
54 type: 'object',
55 additionalProperties: false,
56 required: ['label'],
57 properties: {
58 label: { type: 'string', description: 'Concise choice text, 1-5 words. Unique within the question.' },
59 description: { type: 'string', description: 'What this choice means or implies.' },
60 },
61 },
62 },
63 multiSelect: { type: 'boolean', description: 'Allow picking several options. Default false.' },
64 allowOther: { type: 'boolean', description: 'Show a free-text "Other" field. Default true.' },
65 recommended: { type: 'string', description: 'Label of the option you recommend, if any.' },
66 },
67 },
68 },
69 },
70 }
71}
72
73/** Returns an error message for an input the form cannot draw, else undefined. */
74export function validate(input: unknown, maxQuestions?: number): string | undefined {
75 if (typeof input !== 'object' || input === null) return 'The input must be an object with a questions array.'
76 const { questions } = input as { questions?: unknown }
77 if (!Array.isArray(questions) || questions.length === 0) return 'questions must be a non-empty array.'
78 if (maxQuestions !== undefined && questions.length > maxQuestions) {
79 return `Too many questions: ${questions.length}. The limit is ${maxQuestions} per poll; split them into several polls.`
80 }
81 for (const [i, raw] of questions.entries()) {
82 const q = raw as Partial<PollQuestion>
83 const at = `Question ${i + 1}`
84 if (typeof q.question !== 'string' || q.question.trim() === '') return `${at}: question text is missing.`
85 if (!Array.isArray(q.options)) return `${at}: options must be an array.`
86 if (q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS) {
87 return `${at}: needs ${MIN_OPTIONS}-${MAX_OPTIONS} options, got ${q.options.length}.`
88 }
89 const labels = q.options.map(o => (o as { label?: unknown })?.label)
90 if (labels.some(l => typeof l !== 'string' || l.trim() === '')) return `${at}: every option needs a label.`
91 if (new Set(labels).size !== labels.length) return `${at}: option labels must be unique.`
92 if (q.recommended !== undefined && !labels.includes(q.recommended)) {
93 return `${at}: recommended "${q.recommended}" is not one of its option labels.`
94 }
95 }
96 return undefined
97}
98
99export function emptyAnswers(input: PollInput): PollAnswer[] {
100 return input.questions.map(() => ({ selected: [], other: '' }))
101}
102
103export function isAnswered(a: PollAnswer | undefined): boolean {
104 return a !== undefined && (a.selected.length > 0 || a.other.trim() !== '')
105}
106
107export function answeredCount(answers: readonly PollAnswer[]): number {
108 return answers.filter(isAnswered).length
109}
110
111/** Picks or unpicks an option. Single-select keeps one pick and drops "Other". */
112export function toggleOption(q: PollQuestion, a: PollAnswer, label: string): PollAnswer {
113 if (q.multiSelect) {
114 const selected = a.selected.includes(label)
115 ? a.selected.filter(l => l !== label)
116 : q.options.map(o => o.label).filter(l => l === label || a.selected.includes(l))
117 return { ...a, selected }
118 }
119 return a.selected[0] === label ? { selected: [], other: a.other } : { selected: [label], other: '' }
120}
121
122/** Sets the free text. Single-select drops the picked option once text is typed. */
123export function setOther(q: PollQuestion, a: PollAnswer, text: string): PollAnswer {
124 if (!q.multiSelect && text.trim() !== '') return { selected: [], other: text }
125 return { ...a, other: text }
126}
127
128export type PollOutcome = 'submitted' | 'cancelled'
129
130/** The text the model reads as the tool's result. */
131export function formatAnswers(input: PollInput, answers: readonly PollAnswer[], outcome: PollOutcome): string {
132 const total = input.questions.length
133 const done = answeredCount(answers)
134 const name = input.title ? ` "${input.title}"` : ''
135 const head =
136 outcome === 'submitted'
137 ? `The user submitted the poll${name}: ${done} of ${total} answered.`
138 : `The user closed the poll${name} without submitting it: ${done} of ${total} answered so far. Treat these as unconfirmed.`
139 const lines = input.questions.map((q, i) => {
140 const a = answers[i] ?? { selected: [], other: '' }
141 const parts = [...a.selected]
142 if (a.other.trim() !== '') parts.push(`Other: "${a.other.trim()}"`)
143 const answer = parts.length > 0 ? parts.join(', ') : '(no answer)'
144 const tag = q.header ? `[${q.header}] ` : ''
145 return `${i + 1}. ${tag}${q.question}\n → ${answer}`
146 })
147 return [head, '', ...lines].join('\n')
148}
149hooks/strings.ts 61 lines1import { en } from './locales/en'
2import { uk } from './locales/uk'
3import { ar } from './locales/ar'
4import { bn } from './locales/bn'
5import { cs } from './locales/cs'
6import { de } from './locales/de'
7import { es } from './locales/es'
8import { fr } from './locales/fr'
9import { hi } from './locales/hi'
10import { id } from './locales/id'
11import { it } from './locales/it'
12import { ja } from './locales/ja'
13import { ko } from './locales/ko'
14import { nl } from './locales/nl'
15import { pl } from './locales/pl'
16import { pt } from './locales/pt'
17import { ro } from './locales/ro'
18import { sv } from './locales/sv'
19import { th } from './locales/th'
20import { tr } from './locales/tr'
21import { vi } from './locales/vi'
22import { zh } from './locales/zh'
23
24export type Strings = {
25 paneTitle: string
26 noPoll: string
27 progress: (done: number, total: number) => string
28 unanswered: (n: number) => string
29 several: string
30 recommended: string
31 otherLabel: string
32 otherPlaceholder: string
33 otherSubmit: string
34 submit: string
35 cancel: string
36 waiting: string
37 notPlaced: string
38 notify: (total: number) => string
39 commandDescription: string
40 commandOpened: string
41 commandNone: string
42 busy: string
43 keysHint: string
44 bandText: (title: string, done: number, total: number) => string
45 bandOpen: string
46 bandFocus: string
47 down: string
48 up: string
49 toolRow: (title: string | undefined, n: number) => string
50 toolWaiting: string
51 toolDone: string
52 toolFailed: string
53}
54
55// Keyed by the codes `language` accepts in plugin.json.
56export const languages: Record<string, Strings> = { en, uk, ar, bn, cs, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ro, sv, th, tr, vi, zh }
57
58export function stringsFor(language: unknown): Strings {
59 return typeof language === 'string' && Object.hasOwn(languages, language) ? languages[language]! : en
60}
61hooks/locales/en.ts 33 lines1import type { Strings } from '../strings'
2
3export const en: Strings = {
4 paneTitle: 'Poll',
5 noPoll: 'There is no active poll.',
6 progress: (done, total) => `Answered: ${done} of ${total}`,
7 unanswered: n => `Unanswered: ${n}. They will be sent as skipped.`,
8 several: 'several',
9 recommended: 'recommended',
10 otherLabel: 'Your own answer',
11 otherPlaceholder: 'type an answer',
12 otherSubmit: 'next',
13 submit: 'Submit',
14 cancel: 'Cancel',
15 waiting: 'A poll is waiting for answers',
16 notPlaced: 'A poll is waiting. Widen the terminal to see the form.',
17 notify: total => `Claude is waiting for answers: ${total} question${total === 1 ? '' : 's'}`,
18 commandDescription: 'Open the active poll form',
19 commandOpened: 'Poll form opened.',
20 commandNone: 'There is no active poll.',
21 busy: 'Another poll is already open. Wait for it to be answered.',
22 keysHint: '↑↓ or j/k: move · 1-9: pick in the current question · Enter: pick · Esc: hide, 1 in an empty prompt: back',
23 bandText: (title, done, total) => `"${title}" is waiting for answers: ${done} of ${total}`,
24 bandOpen: 'open the form',
25 bandFocus: 'go to the form',
26 down: 'down',
27 up: 'up',
28 toolRow: (title, n) => `Poll${title ? ` "${title}"` : ''} · ${n} question${n === 1 ? '' : 's'}`,
29 toolWaiting: 'waiting for answers in the form',
30 toolDone: 'answers received',
31 toolFailed: 'did not run',
32}
33hooks/locales/uk.ts 41 lines1import type { Strings } from '../strings'
2
3// 1 питання, 2-4 питання, 5+ питань (11-14 питань).
4function questionsUk(n: number): string {
5 const tens = n % 100
6 const last = n % 10
7 const word = tens >= 11 && tens <= 14 ? 'питань' : last >= 1 && last <= 4 ? 'питання' : 'питань'
8 return `${n} ${word}`
9}
10
11export const uk: Strings = {
12 paneTitle: 'Опитування',
13 noPoll: 'Зараз немає активного опитування.',
14 progress: (done, total) => `Відповіді: ${done} з ${total}`,
15 unanswered: n => `Без відповіді: ${n}. Вони підуть як пропущені.`,
16 several: 'кілька',
17 recommended: 'рекомендовано',
18 otherLabel: 'Свій варіант',
19 otherPlaceholder: 'напишіть відповідь',
20 otherSubmit: 'далі',
21 submit: 'Надіслати',
22 cancel: 'Скасувати',
23 waiting: 'Опитування чекає на відповіді',
24 notPlaced: 'Опитування чекає. Розширте термінал, щоб побачити форму.',
25 notify: total => `Claude чекає на відповіді: ${questionsUk(total)}`,
26 commandDescription: 'Відкрити форму активного опитування',
27 commandOpened: 'Форму опитування відкрито.',
28 commandNone: 'Активного опитування немає.',
29 busy: 'Інше опитування вже відкрите. Дочекайтеся відповіді на нього.',
30 keysHint: '↑↓ або j/k: рух · 1-9: вибір у поточному питанні · Enter: вибір · Esc: сховати, 1 у порожньому полі вводу: повернути',
31 bandText: (title, done, total) => `«${title}» чекає на відповіді: ${done} з ${total}`,
32 bandOpen: 'відкрити форму',
33 bandFocus: 'перейти до форми',
34 down: 'вниз',
35 up: 'вгору',
36 toolRow: (title, n) => `Опитування${title ? ` «${title}»` : ''} · ${questionsUk(n)}`,
37 toolWaiting: 'чекає на відповіді у формі',
38 toolDone: 'отримано відповіді',
39 toolFailed: 'не відбулося',
40}
41hooks/locales/ar.ts 42 lines1import type { Strings } from '../strings'
2
3// سؤال واحد, سؤالان, 3-10 أسئلة, 11-99 سؤالًا, otherwise سؤال (by the last two digits).
4function questions(n: number): string {
5 if (n === 1) return 'سؤال واحد'
6 if (n === 2) return 'سؤالان'
7 const tens = n % 100
8 const word = tens >= 3 && tens <= 10 ? 'أسئلة' : tens >= 11 ? 'سؤالًا' : 'سؤال'
9 return `${n} ${word}`
10}
11
12export const ar: Strings = {
13 paneTitle: 'استطلاع',
14 noPoll: 'لا يوجد استطلاع نشط.',
15 progress: (done, total) => `الإجابات: ${done} من ${total}`,
16 unanswered: n => `بلا إجابة: ${n}. ستُرسل على أنها متخطاة.`,
17 several: 'متعدد',
18 recommended: 'موصى به',
19 otherLabel: 'إجابتك الخاصة',
20 otherPlaceholder: 'اكتب إجابة',
21 otherSubmit: 'التالي',
22 submit: 'إرسال',
23 cancel: 'إلغاء',
24 waiting: 'استطلاع بانتظار الإجابات',
25 notPlaced: 'استطلاع بانتظار الإجابات. وسّع الطرفية لرؤية النموذج.',
26 notify: total => `Claude بانتظار الإجابات: ${questions(total)}`,
27 commandDescription: 'فتح نموذج الاستطلاع النشط',
28 commandOpened: 'تم فتح نموذج الاستطلاع.',
29 commandNone: 'لا يوجد استطلاع نشط.',
30 busy: 'هناك استطلاع آخر مفتوح بالفعل. انتظر حتى تتم الإجابة عنه.',
31 keysHint: '↑↓ أو j/k: تنقّل · 1-9: اختيار في السؤال الحالي · Enter: اختيار · Esc: إخفاء، 1 في سطر الإدخال الفارغ: إعادة',
32 bandText: (title, done, total) => `«${title}» بانتظار الإجابات: ${done} من ${total}`,
33 bandOpen: 'فتح النموذج',
34 bandFocus: 'الانتقال إلى النموذج',
35 down: 'لأسفل',
36 up: 'لأعلى',
37 toolRow: (title, n) => `استطلاع${title ? ` «${title}»` : ''} · ${questions(n)}`,
38 toolWaiting: 'بانتظار الإجابات في النموذج',
39 toolDone: 'تم استلام الإجابات',
40 toolFailed: 'لم يتم',
41}
42hooks/locales/bn.ts 33 lines1import type { Strings } from '../strings'
2
3export const bn: Strings = {
4 paneTitle: 'জরিপ',
5 noPoll: 'কোনো সক্রিয় জরিপ নেই।',
6 progress: (done, total) => `উত্তর দেওয়া হয়েছে: ${total}টির মধ্যে ${done}টি`,
7 unanswered: n => `উত্তরহীন: ${n}। এগুলো বাদ দেওয়া হিসেবে পাঠানো হবে।`,
8 several: 'একাধিক',
9 recommended: 'প্রস্তাবিত',
10 otherLabel: 'নিজের উত্তর',
11 otherPlaceholder: 'উত্তর লিখুন',
12 otherSubmit: 'পরবর্তী',
13 submit: 'পাঠান',
14 cancel: 'বাতিল',
15 waiting: 'একটি জরিপ উত্তরের অপেক্ষায়',
16 notPlaced: 'একটি জরিপ অপেক্ষা করছে। ফর্মটি দেখতে টার্মিনাল চওড়া করুন।',
17 notify: total => `Claude উত্তরের অপেক্ষায়: ${total}টি প্রশ্ন`,
18 commandDescription: 'সক্রিয় জরিপের ফর্ম খুলুন',
19 commandOpened: 'জরিপের ফর্ম খোলা হয়েছে।',
20 commandNone: 'কোনো সক্রিয় জরিপ নেই।',
21 busy: 'আরেকটি জরিপ ইতিমধ্যে খোলা আছে। সেটির উত্তরের জন্য অপেক্ষা করুন।',
22 keysHint: '↑↓ বা j/k: সরান · 1-9: বর্তমান প্রশ্নে বাছাই · Enter: বাছাই · Esc: লুকান, খালি প্রম্পটে 1: ফিরিয়ে আনুন',
23 bandText: (title, done, total) => `"${title}" উত্তরের অপেক্ষায়: ${total}টির মধ্যে ${done}টি`,
24 bandOpen: 'ফর্ম খুলুন',
25 bandFocus: 'ফর্মে যান',
26 down: 'নিচে',
27 up: 'উপরে',
28 toolRow: (title, n) => `জরিপ${title ? ` "${title}"` : ''} · ${n}টি প্রশ্ন`,
29 toolWaiting: 'ফর্মে উত্তরের অপেক্ষায়',
30 toolDone: 'উত্তর পাওয়া গেছে',
31 toolFailed: 'সম্পন্ন হয়নি',
32}
33hooks/locales/cs.ts 39 lines1import type { Strings } from '../strings'
2
3// 1 otázka, 2-4 otázky, otherwise otázek.
4function questions(n: number): string {
5 const word = n === 1 ? 'otázka' : n >= 2 && n <= 4 ? 'otázky' : 'otázek'
6 return `${n} ${word}`
7}
8
9export const cs: Strings = {
10 paneTitle: 'Anketa',
11 noPoll: 'Žádná anketa není aktivní.',
12 progress: (done, total) => `Zodpovězeno: ${done} z ${total}`,
13 unanswered: n => `Bez odpovědi: ${n}. Odešlou se jako přeskočené.`,
14 several: 'více',
15 recommended: 'doporučeno',
16 otherLabel: 'Vlastní odpověď',
17 otherPlaceholder: 'napište odpověď',
18 otherSubmit: 'dál',
19 submit: 'Odeslat',
20 cancel: 'Zrušit',
21 waiting: 'Anketa čeká na odpovědi',
22 notPlaced: 'Anketa čeká. Rozšiřte terminál, abyste viděli formulář.',
23 notify: total => `Claude čeká na odpovědi: ${questions(total)}`,
24 commandDescription: 'Otevřít formulář aktivní ankety',
25 commandOpened: 'Formulář ankety je otevřený.',
26 commandNone: 'Žádná anketa není aktivní.',
27 busy: 'Jiná anketa je už otevřená. Počkejte, až bude zodpovězena.',
28 keysHint: '↑↓ nebo j/k: pohyb · 1-9: výběr v aktuální otázce · Enter: výběr · Esc: skrýt, 1 v prázdném poli: zpět',
29 bandText: (title, done, total) => `„${title}“ čeká na odpovědi: ${done} z ${total}`,
30 bandOpen: 'otevřít formulář',
31 bandFocus: 'přejít na formulář',
32 down: 'dolů',
33 up: 'nahoru',
34 toolRow: (title, n) => `Anketa${title ? ` „${title}“` : ''} · ${questions(n)}`,
35 toolWaiting: 'čeká na odpovědi ve formuláři',
36 toolDone: 'odpovědi přijaty',
37 toolFailed: 'neproběhla',
38}
39hooks/locales/de.ts 35 lines1import type { Strings } from '../strings'
2
3const questions = (n: number) => `${n} ${n === 1 ? 'Frage' : 'Fragen'}`
4
5export const de: Strings = {
6 paneTitle: 'Umfrage',
7 noPoll: 'Es gibt keine aktive Umfrage.',
8 progress: (done, total) => `Beantwortet: ${done} von ${total}`,
9 unanswered: n => `Unbeantwortet: ${n}. Sie werden als übersprungen gesendet.`,
10 several: 'mehrere',
11 recommended: 'empfohlen',
12 otherLabel: 'Eigene Antwort',
13 otherPlaceholder: 'Antwort eingeben',
14 otherSubmit: 'weiter',
15 submit: 'Senden',
16 cancel: 'Abbrechen',
17 waiting: 'Eine Umfrage wartet auf Antworten',
18 notPlaced: 'Eine Umfrage wartet. Verbreitern Sie das Terminal, um das Formular zu sehen.',
19 notify: total => `Claude wartet auf Antworten: ${questions(total)}`,
20 commandDescription: 'Formular der aktiven Umfrage öffnen',
21 commandOpened: 'Umfrageformular geöffnet.',
22 commandNone: 'Es gibt keine aktive Umfrage.',
23 busy: 'Eine andere Umfrage ist bereits offen. Warten Sie, bis sie beantwortet ist.',
24 keysHint: '↑↓ oder j/k: bewegen · 1-9: Auswahl in der aktuellen Frage · Enter: auswählen · Esc: ausblenden, 1 in leerer Eingabe: zurück',
25 bandText: (title, done, total) => `„${title}“ wartet auf Antworten: ${done} von ${total}`,
26 bandOpen: 'Formular öffnen',
27 bandFocus: 'zum Formular',
28 down: 'runter',
29 up: 'hoch',
30 toolRow: (title, n) => `Umfrage${title ? ` „${title}“` : ''} · ${questions(n)}`,
31 toolWaiting: 'wartet auf Antworten im Formular',
32 toolDone: 'Antworten erhalten',
33 toolFailed: 'nicht durchgeführt',
34}
35hooks/locales/es.ts 35 lines1import type { Strings } from '../strings'
2
3const questions = (n: number) => `${n} ${n === 1 ? 'pregunta' : 'preguntas'}`
4
5export const es: Strings = {
6 paneTitle: 'Encuesta',
7 noPoll: 'No hay ninguna encuesta activa.',
8 progress: (done, total) => `Respondidas: ${done} de ${total}`,
9 unanswered: n => `Sin responder: ${n}. Se enviarán como omitidas.`,
10 several: 'varias',
11 recommended: 'recomendada',
12 otherLabel: 'Tu propia respuesta',
13 otherPlaceholder: 'escribe una respuesta',
14 otherSubmit: 'siguiente',
15 submit: 'Enviar',
16 cancel: 'Cancelar',
17 waiting: 'Una encuesta espera respuestas',
18 notPlaced: 'Una encuesta espera. Amplía la terminal para ver el formulario.',
19 notify: total => `Claude espera respuestas: ${questions(total)}`,
20 commandDescription: 'Abrir el formulario de la encuesta activa',
21 commandOpened: 'Formulario de la encuesta abierto.',
22 commandNone: 'No hay ninguna encuesta activa.',
23 busy: 'Ya hay otra encuesta abierta. Espera a que se responda.',
24 keysHint: '↑↓ o j/k: mover · 1-9: elegir en la pregunta actual · Enter: elegir · Esc: ocultar, 1 en el prompt vacío: volver',
25 bandText: (title, done, total) => `«${title}» espera respuestas: ${done} de ${total}`,
26 bandOpen: 'abrir el formulario',
27 bandFocus: 'ir al formulario',
28 down: 'abajo',
29 up: 'arriba',
30 toolRow: (title, n) => `Encuesta${title ? ` «${title}»` : ''} · ${questions(n)}`,
31 toolWaiting: 'esperando respuestas en el formulario',
32 toolDone: 'respuestas recibidas',
33 toolFailed: 'no se realizó',
34}
35hooks/locales/fr.ts 36 lines1import type { Strings } from '../strings'
2
3// French keeps the singular for 0 and 1.
4const questions = (n: number) => `${n} ${n < 2 ? 'question' : 'questions'}`
5
6export const fr: Strings = {
7 paneTitle: 'Sondage',
8 noPoll: 'Aucun sondage actif.',
9 progress: (done, total) => `Réponses : ${done} sur ${total}`,
10 unanswered: n => `Sans réponse : ${n}. Elles seront envoyées comme ignorées.`,
11 several: 'plusieurs',
12 recommended: 'recommandé',
13 otherLabel: 'Votre propre réponse',
14 otherPlaceholder: 'saisissez une réponse',
15 otherSubmit: 'suivant',
16 submit: 'Envoyer',
17 cancel: 'Annuler',
18 waiting: 'Un sondage attend des réponses',
19 notPlaced: 'Un sondage attend. Agrandissez le terminal pour voir le formulaire.',
20 notify: total => `Claude attend des réponses : ${questions(total)}`,
21 commandDescription: 'Ouvrir le formulaire du sondage actif',
22 commandOpened: 'Formulaire du sondage ouvert.',
23 commandNone: 'Aucun sondage actif.',
24 busy: 'Un autre sondage est déjà ouvert. Attendez qu’il reçoive une réponse.',
25 keysHint: '↑↓ ou j/k : déplacer · 1-9 : choisir dans la question en cours · Entrée : choisir · Échap : masquer, 1 dans l’invite vide : revenir',
26 bandText: (title, done, total) => `« ${title} » attend des réponses : ${done} sur ${total}`,
27 bandOpen: 'ouvrir le formulaire',
28 bandFocus: 'aller au formulaire',
29 down: 'bas',
30 up: 'haut',
31 toolRow: (title, n) => `Sondage${title ? ` « ${title} »` : ''} · ${questions(n)}`,
32 toolWaiting: 'en attente de réponses dans le formulaire',
33 toolDone: 'réponses reçues',
34 toolFailed: 'n’a pas eu lieu',
35}
36hooks/locales/hi.ts 33 lines1import type { Strings } from '../strings'
2
3export const hi: Strings = {
4 paneTitle: 'सर्वेक्षण',
5 noPoll: 'कोई सक्रिय सर्वेक्षण नहीं है।',
6 progress: (done, total) => `उत्तर दिए: ${total} में से ${done}`,
7 unanswered: n => `बिना उत्तर: ${n}। इन्हें छोड़े गए के रूप में भेजा जाएगा।`,
8 several: 'एक से अधिक',
9 recommended: 'अनुशंसित',
10 otherLabel: 'अपना उत्तर',
11 otherPlaceholder: 'उत्तर लिखें',
12 otherSubmit: 'आगे',
13 submit: 'भेजें',
14 cancel: 'रद्द करें',
15 waiting: 'सर्वेक्षण उत्तरों की प्रतीक्षा में है',
16 notPlaced: 'सर्वेक्षण प्रतीक्षा में है। फ़ॉर्म देखने के लिए टर्मिनल चौड़ा करें।',
17 notify: total => `Claude उत्तरों की प्रतीक्षा में है: ${total} प्रश्न`,
18 commandDescription: 'सक्रिय सर्वेक्षण का फ़ॉर्म खोलें',
19 commandOpened: 'सर्वेक्षण का फ़ॉर्म खुल गया।',
20 commandNone: 'कोई सक्रिय सर्वेक्षण नहीं है।',
21 busy: 'एक और सर्वेक्षण पहले से खुला है। उसके उत्तर की प्रतीक्षा करें।',
22 keysHint: '↑↓ या j/k: चलें · 1-9: मौजूदा प्रश्न में चुनें · Enter: चुनें · Esc: छिपाएँ, खाली प्रॉम्प्ट में 1: वापस लाएँ',
23 bandText: (title, done, total) => `"${title}" उत्तरों की प्रतीक्षा में है: ${total} में से ${done}`,
24 bandOpen: 'फ़ॉर्म खोलें',
25 bandFocus: 'फ़ॉर्म पर जाएँ',
26 down: 'नीचे',
27 up: 'ऊपर',
28 toolRow: (title, n) => `सर्वेक्षण${title ? ` "${title}"` : ''} · ${n} प्रश्न`,
29 toolWaiting: 'फ़ॉर्म में उत्तरों की प्रतीक्षा',
30 toolDone: 'उत्तर मिल गए',
31 toolFailed: 'नहीं हो सका',
32}
33