Answers Claude's numbered question rounds one by one in a panel, keeps confirmed answers across restarts, and sends them back as Q1: … lines

Answers Claude's question rounds one question at a time. When Claude ends a turn with numbered questions — the ❓ Q1 - Title: … / ➡️ recommendation format of the grilling skill — a panel opens beside the conversation with a text field for the first open question. Enter saves the answer and moves on; once every question has an answer, a review lets you change any of them or send them all to Claude as Q1: … lines.
| When | What you see |
|---|---|
| Claude's answer ends on a question round | the panel opens on the first open question; Grill 0/5 · /grill in the status line |
| Enter in the field | the answer is saved, the next open question comes up |
| Enter on an empty field | the question's recommendation is taken (Q2: Recommendation accepted) |
| Edit recommendation | the recommendation's text goes into the field to change before Enter |
| Shift+Tab, then Enter | back to the previous question |
| Skip ▶ | the question stays open and comes back after the others |
| Every question answered | the review: a digit opens that answer again, Enter sends |
| Send… with questions still open | asks first, then sends the open ones as Q4: (unanswered) |
| Discard round | asks first, then closes the round for good; /grill brings it back |
| Esc, or the pane's close mark | the panel closes, the answers stay; /grill reopens it |
The answers reach Claude as one message of yours. Until you send, nothing you type in the panel reaches Claude.
No setup needed. Works in the terminal, the desktop app and VS Code; the mobile app draws no text field and says so.
The panel reads the format the grilling skill asks for, and tolerates the drift a model falls into: Q2 (updated), a missing ❓, – or : as separators, a question without a recommendation. A bold Q3 in ordinary prose is not a round: one question needs the ❓ or a ➡️ recommendation.
Whenever the grilling skill loads, the mod appends a short paragraph to the prompt the model reads, pinning that format and explaining what Recommendation accepted and (unanswered) mean. The skill's own file is left alone, so updating the skill from upstream loses nothing.
Each answer confirmed with Enter is saved at once in the mod's store under ~/.claude, per project and question round. Starting Claude Code again with --continue or --resume, or /resume in a running session, brings the round back with its answers when the conversation still ends on it. A round you sent or discarded is not offered again; entries untouched for 30 days are dropped at the next start.
/grill); otherwise the status line points to /grill, which opens it at any width. The desktop app always opens it as a side pane.Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The saved answers live in the mod's store and expire after 30 days.
hooks/register.tsx 407 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Question, Round, StoredRound } from '../types'
5import {
6 FORMAT_CONTRACT,
7 answerCurrent,
8 answerOf,
9 editAt,
10 formatAnswers,
11 goBack,
12 isStoredRound,
13 lastRoundIn,
14 latestRoundIn,
15 newRound,
16 openCount,
17 parseQuestions,
18 prefillRecommendation,
19 previewOf,
20 resume,
21 roundKeyOf,
22 skipCurrent,
23 statusOf,
24} from './rounds'
25
26const PANE = 'grill'
27const TITLE = 'Grill'
28const STORE_PREFIX = 'round:'
29const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
30
31const round = atom({ plugin: 'grill-panel', key: 'round' } as const, null)
32
33async function loadStored($: EngineInterface, key: string): Promise<StoredRound | undefined> {
34 const value = await $.store.get(key)
35
36 return isStoredRound(value) ? value : undefined
37}
38
39async function save($: EngineInterface, value: Round, status: StoredRound['status']): Promise<void> {
40 const stored: StoredRound = { status, answers: value.answers, updatedAt: await $.clock.now() }
41 await $.store.set(value.key, stored)
42}
43
44async function prune($: EngineInterface): Promise<void> {
45 const now = await $.clock.now()
46 const keys = await $.store.keys()
47 for (const key of keys) {
48 if (!key.startsWith(STORE_PREFIX)) {
49 continue
50 }
51 const stored = await loadStored($, key)
52 if (stored === undefined || now - stored.updatedAt > RETENTION_MS) {
53 await $.store.delete(key)
54 }
55 }
56}
57
58function showStatus($: EngineInterface, value: Round | null): void {
59 $.ui.status(value === null ? undefined : statusOf(value))
60}
61
62async function openPane($: EngineInterface): Promise<void> {
63 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
64}
65
66// Moving the ring is a convenience: where it cannot move (a site without the
67// keys, the test kit, which has no ring) the press that asked still counts.
68async function focus($: EngineInterface, key: string): Promise<void> {
69 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
70}
71
72// A round already sent or discarded stays closed unless the person asks for it
73// again with /grill (`isRevived`).
74async function begin($: EngineInterface, questions: readonly Question[], isRevived: boolean): Promise<boolean> {
75 const root = await $.session.root()
76 const key = `${STORE_PREFIX}${roundKeyOf(root, questions)}`
77 const current = await read($, round)
78 if (current?.key === key) {
79 return true
80 }
81 const stored = await loadStored($, key)
82 if (stored?.status === 'done' && !isRevived) {
83 return false
84 }
85 const value = newRound(key, questions, stored?.answers ?? [])
86 await update($, round, () => value)
87 showStatus($, value)
88 if (stored?.status === 'done') {
89 await save($, value, 'open')
90 }
91
92 return true
93}
94
95async function restore($: EngineInterface): Promise<void> {
96 const current = await read($, round)
97 if (current !== null) {
98 return
99 }
100 const messages = await $.session.messages()
101 const questions = lastRoundIn(messages)
102 if (questions.length > 0 && (await begin($, questions, false))) {
103 await openPane($)
104 }
105}
106
107async function reset($: EngineInterface): Promise<void> {
108 await update($, round, () => null)
109 showStatus($, null)
110 await $.ui.close({ id: PANE })
111}
112
113async function finish($: EngineInterface, value: Round): Promise<void> {
114 await save($, value, 'done')
115 await reset($)
116}
117
118async function change($: EngineInterface, apply: (value: Round) => Round): Promise<Round | null> {
119 const value = await update($, round, current => (current === null ? null : apply(current)))
120 showStatus($, value)
121
122 return value
123}
124
125async function submit($: EngineInterface, text: string): Promise<void> {
126 const before = await read($, round)
127 const question = before?.questions[before.current]
128 if (before?.mode !== 'answer' || question === undefined) {
129 return
130 }
131 if (answerOf(text, question) === null) {
132 $.ui.toast('This question has no recommendation to take; type an answer.')
133 return
134 }
135 const value = await change($, current => answerCurrent(current, text))
136 if (value === null) {
137 return
138 }
139 await save($, value, 'open')
140 if (value.mode === 'review') {
141 await focus($, 'send')
142 }
143}
144
145async function skip($: EngineInterface): Promise<void> {
146 const value = await change($, skipCurrent)
147 await focus($, value?.mode === 'review' ? 'send' : 'answer')
148}
149
150async function back($: EngineInterface): Promise<void> {
151 await change($, goBack)
152 await focus($, 'answer')
153}
154
155async function edit($: EngineInterface, index: number): Promise<void> {
156 await change($, current => editAt(current, index))
157 await focus($, 'answer')
158}
159
160async function useRecommendation($: EngineInterface): Promise<void> {
161 await change($, prefillRecommendation)
162 await focus($, 'answer')
163}
164
165async function requestSend($: EngineInterface): Promise<void> {
166 const value = await read($, round)
167 if (value === null) {
168 return
169 }
170 if (openCount(value) === 0) {
171 await send($)
172 return
173 }
174 await change($, current => ({ ...current, mode: 'confirm-send', prefill: null }))
175 await focus($, 'confirm-send')
176}
177
178async function send($: EngineInterface): Promise<void> {
179 const value = await read($, round)
180 if (value === null || value.mode === 'sending') {
181 return
182 }
183 await change($, current => ({ ...current, mode: 'sending' }))
184 try {
185 await $.prompt.submit({ text: formatAnswers(value), asUser: true })
186 } catch (error) {
187 await change($, resume)
188 $.ui.toast(`grill-panel could not send the answers; they are kept. ${String(error)}`)
189 return
190 }
191 await finish($, value)
192}
193
194async function requestDiscard($: EngineInterface): Promise<void> {
195 await change($, current => ({ ...current, mode: 'confirm-discard', prefill: null }))
196 await focus($, 'cancel')
197}
198
199async function discard($: EngineInterface): Promise<void> {
200 const value = await read($, round)
201 if (value !== null) {
202 await finish($, value)
203 }
204}
205
206async function cancel($: EngineInterface): Promise<void> {
207 const value = await change($, resume)
208 await focus($, value?.mode === 'review' ? 'send' : 'answer')
209}
210
211function markOf(value: Round, index: number): string {
212 if (value.answers[index] !== null) {
213 return '✓'
214 }
215 if (index === value.current && value.mode === 'answer') {
216 return '›'
217 }
218
219 return value.skipped[index] === true ? '↷' : '·'
220}
221
222function headingOf(question: Question): string {
223 return question.title.length > 0 ? `Q${question.number} · ${question.title}` : `Q${question.number}`
224}
225
226export const register: Register = on => {
227 on('session.start', async ($, e, next) => {
228 await $.command.register({
229 name: 'grill',
230 description: 'Open the panel for the open question round, or bring back the latest one',
231 immediate: true,
232 })
233 await prune($)
234 await restore($)
235
236 return next(e)
237 })
238
239 // An in-process /resume or /clear starts no new session.start; the round on
240 // screen belongs to the conversation that was left.
241 on('classic.SessionStart', async ($, e, next) => {
242 const result = await next(e)
243 if (e.source === 'clear' || e.source === 'resume') {
244 await reset($)
245 }
246 if (e.source === 'resume') {
247 await restore($)
248 }
249
250 return result
251 })
252
253 on('turn.complete', async ($, e, next) => {
254 const result = await next(e)
255 if (e.agentId !== undefined || e.reason !== 'answer') {
256 return result
257 }
258 const questions = parseQuestions(e.answer)
259 if (questions.length > 0 && (await begin($, questions, false))) {
260 await openPane($)
261 }
262
263 return result
264 })
265
266 on('skill.prompt', { skill: 'grilling' }, async ($, e, next) => {
267 const result = await next(e)
268
269 return { text: `${result.text}\n\n${FORMAT_CONTRACT}` }
270 })
271
272 on('command.run', { command: 'grill' }, async $ => {
273 const current = await read($, round)
274 if (current === null) {
275 const messages = await $.session.messages()
276 const questions = latestRoundIn(messages)
277 if (questions.length === 0) {
278 $.ui.toast('No question round in this conversation.')
279 return {}
280 }
281 await begin($, questions, true)
282 }
283 await openPane($)
284
285 return {}
286 })
287
288 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
289 const value = await read($, round)
290 if (e.surface === 'mobile') {
291 const { Text } = $.ui.resolve(e)
292
293 return <Text dimColor>This app draws no text field; answer this round in the terminal or the desktop app.</Text>
294 }
295 const { Box, Button, Input, Markdown, Text } = $.ui.resolve(e)
296 if (value === null) {
297 return <Text dimColor>No open question round. /grill brings back the latest one.</Text>
298 }
299 const total = value.questions.length
300 const open = openCount(value)
301
302 if (value.mode === 'sending') {
303 return <Text dimColor>Sending… Claude gets the answers as soon as it is free.</Text>
304 }
305
306 if (value.mode === 'confirm-send') {
307 return (
308 <Box flexDirection="column">
309 <Text>{`${open} of ${total} questions are unanswered and go out as "(unanswered)".`}</Text>
310 <Box flexDirection="row" columnGap={2} marginTop={1}>
311 <Button key="confirm-send" variant="primary" autoFocus label="Send anyway" onPress={() => send($)} />
312 <Button key="cancel" label="Keep answering" onPress={() => cancel($)} />
313 </Box>
314 </Box>
315 )
316 }
317
318 if (value.mode === 'confirm-discard') {
319 return (
320 <Box flexDirection="column">
321 <Text>Discard this round? The panel stops offering it; /grill brings it back with its answers.</Text>
322 <Box flexDirection="row" columnGap={2} marginTop={1}>
323 <Button key="cancel" autoFocus label="Keep round" onPress={() => cancel($)} />
324 <Button key="confirm-discard" label="Discard" onPress={() => discard($)} />
325 </Box>
326 </Box>
327 )
328 }
329
330 if (value.mode === 'review') {
331 return (
332 <Box flexDirection="column">
333 <Text bold>{open === 0 ? 'All questions answered' : `${open} of ${total} unanswered`}</Text>
334 {value.questions.map((question, index) => (
335 <Button
336 key={`edit-${index}`}
337 plain
338 {...(index < 9 ? { hotkey: String(index + 1) } : {})}
339 onPress={() => edit($, index)}
340 >
341 {`${headingOf(question)}: `}
342 <Text dimColor>{previewOf(value.answers[index] ?? null)}</Text>
343 </Button>
344 ))}
345 <Box flexDirection="row" columnGap={2} marginTop={1}>
346 <Button key="send" variant="primary" autoFocus label="Send to Claude" onPress={() => requestSend($)} />
347 <Button key="discard" dimColor label="Discard round" onPress={() => requestDiscard($)} />
348 </Box>
349 <Text dimColor>A digit edits that answer · Enter sends</Text>
350 </Box>
351 )
352 }
353
354 const question = value.questions[value.current]
355 if (question === undefined) {
356 return <Text dimColor>No open question round. /grill brings back the latest one.</Text>
357 }
358 const answer = value.answers[value.current] ?? null
359 const previous = value.questions[value.current - 1]
360 const placeholder =
361 question.recommendation === null
362 ? 'Your answer'
363 : answer?.kind === 'recommendation'
364 ? 'Enter keeps the recommendation'
365 : 'Your answer · Enter on an empty field takes the recommendation'
366
367 return (
368 <Box flexDirection="column">
369 <Box flexDirection="column" marginBottom={1}>
370 {value.questions.map((item, index) => (
371 <Text bold={index === value.current} dimColor={index !== value.current} wrap="truncate-end">
372 {`${markOf(value, index)} ${headingOf(item)}`}
373 </Text>
374 ))}
375 </Box>
376 <Text bold>{headingOf(question)}</Text>
377 {question.body.length > 0 && <Markdown text={question.body} />}
378 {question.recommendation !== null && <Markdown dimColor text={`➡️ ${question.recommendation}`} />}
379 <Box flexDirection="column" marginTop={1}>
380 {previous !== undefined && (
381 <Button key="back" plain dimColor label={`◀ ${headingOf(previous)}`} onPress={() => back($)} />
382 )}
383 <Input
384 key="answer"
385 value={value.prefill ?? (answer?.kind === 'text' ? answer.text : '')}
386 placeholder={placeholder}
387 submitLabel="save"
388 autoFocus
389 onSubmit={text => {
390 void submit($, text)
391 }}
392 />
393 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
394 <Button key="skip" label="Skip ▶" onPress={() => skip($)} />
395 {question.recommendation !== null && (
396 <Button key="edit-recommendation" label="Edit recommendation" onPress={() => useRecommendation($)} />
397 )}
398 <Button key="send" label="Send…" onPress={() => requestSend($)} />
399 <Button key="discard" dimColor label="Discard round" onPress={() => requestDiscard($)} />
400 </Box>
401 </Box>
402 <Text dimColor>{`${total - open}/${total} answered · Enter saves · Shift+Tab goes back · Esc closes, /grill reopens`}</Text>
403 </Box>
404 )
405 })
406}
407hooks/rounds.ts 309 lines1import type { Answer, Question, Round, StoredRound } from '../types'
2
3// Appended to the grilling skill's prompt rather than written into the skill,
4// whose file is replaced whenever it is updated from upstream.
5export const FORMAT_CONTRACT = `## Answer panel
6
7The grill-panel mod reads each question round from your message, lets the user answer it in a panel, and sends the answers back as \`Q<n>: <answer>\` lines. It only finds questions written exactly like this:
8
9- Every question starts on a line of its own with \`❓ **Q<n>** - **<title>**: <body>\`; no number repeats within one message.
10- The recommendation follows on a line of its own starting with \`➡️\` and ends at the next blank line.
11- Text after the last recommendation, separated by a blank line, belongs to no question.
12
13An answer of \`Recommendation accepted\` means the user took your recommendation as written. \`(unanswered)\` means the user left that question open: ask it again in a later round if it still matters.`
14
15const HEADER = /^\s*(❓\uFE0F?\s*)?(\*\*)?Q(\d{1,3})(?!\d)(.*)$/u
16const RECOMMENDATION = /^\s*➡\uFE0F?\s*/u
17const SEPARATOR = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/
18const FENCE = /^\s*(```|~~~)/
19
20type Draft = {
21 number: number
22 title: string
23 hasMark: boolean
24 body: string[]
25 recommendation: string[] | null
26 isClosed: boolean
27}
28
29export function parseQuestions(text: string): Question[] {
30 const drafts: Draft[] = []
31 let isInFence = false
32 for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
33 const isCode = isInFence || FENCE.test(line)
34 if (FENCE.test(line)) {
35 isInFence = !isInFence
36 }
37 const header = isCode ? undefined : headerOf(line)
38 if (header !== undefined) {
39 drafts.push(header)
40 continue
41 }
42 const draft = drafts.at(-1)
43 if (draft === undefined || draft.isClosed) {
44 continue
45 }
46 if (draft.recommendation !== null) {
47 if (!isCode && (line.trim().length === 0 || SEPARATOR.test(line))) {
48 draft.isClosed = true
49 } else {
50 draft.recommendation.push(line)
51 }
52 continue
53 }
54 if (!isCode && RECOMMENDATION.test(line)) {
55 draft.recommendation = [line.replace(RECOMMENDATION, '')]
56 continue
57 }
58 if (!isCode && SEPARATOR.test(line)) {
59 draft.isClosed = true
60 continue
61 }
62 draft.body.push(line)
63 }
64 // A bold "**Q3**" alone also turns up in ordinary prose; only the question
65 // mark or a recommendation makes it a round.
66 if (!drafts.some(i => i.hasMark || i.recommendation !== null)) {
67 return []
68 }
69
70 return drafts.map(i => {
71 const recommendation = i.recommendation === null ? '' : i.recommendation.join('\n').trim()
72
73 return {
74 number: i.number,
75 title: i.title,
76 body: i.body.join('\n').trim(),
77 recommendation: recommendation.length === 0 ? null : recommendation,
78 }
79 })
80}
81
82// Accepts "❓ **Q1** - **Title**: body" and the variants a model drifts into:
83// "**Q2 (updated)** - **Title**", "**Q3 – Title**", "❓ Q4 - Title: body".
84function headerOf(line: string): Draft | undefined {
85 const match = HEADER.exec(line)
86 if (match === null) {
87 return undefined
88 }
89 const [, mark, bold, digits = '', rest = ''] = match
90 if (mark === undefined && bold === undefined) {
91 return undefined
92 }
93 let title = ''
94 let remainder = rest
95 if (bold !== undefined) {
96 const end = remainder.indexOf('**')
97 if (end !== -1) {
98 const inner = stripSeparators(remainder.slice(0, end))
99 if (!inner.startsWith('(')) {
100 title = inner
101 }
102 remainder = remainder.slice(end + 2)
103 }
104 }
105 remainder = stripSeparators(remainder)
106 if (remainder.startsWith('**')) {
107 const end = remainder.indexOf('**', 2)
108 if (end !== -1) {
109 title = remainder.slice(2, end)
110 remainder = stripSeparators(remainder.slice(end + 2))
111 }
112 } else if (bold === undefined) {
113 const colon = remainder.indexOf(':')
114 if (colon > 0 && colon <= 80) {
115 title = remainder.slice(0, colon)
116 remainder = remainder.slice(colon + 1).trim()
117 }
118 }
119
120 return {
121 number: Number(digits),
122 title: title.trim().replace(/:$/, '').trim(),
123 hasMark: mark !== undefined,
124 body: remainder.length > 0 ? [remainder] : [],
125 recommendation: null,
126 isClosed: false,
127 }
128}
129
130function stripSeparators(text: string): string {
131 return text.replace(/^[\s:\-–—]+/u, '')
132}
133
134type TranscriptMessage = { role: 'user' | 'assistant'; text: string }
135
136// The round a resumed conversation ends on: only Claude's last words count, so a
137// round the user already answered by hand is not offered again.
138export function lastRoundIn(messages: readonly TranscriptMessage[]): Question[] {
139 for (let index = messages.length - 1; index >= 0; index--) {
140 const message = messages[index]
141 if (message?.role === 'assistant' && message.text.trim().length > 0) {
142 return parseQuestions(message.text)
143 }
144 }
145
146 return []
147}
148
149export function latestRoundIn(messages: readonly TranscriptMessage[]): Question[] {
150 for (let index = messages.length - 1; index >= 0; index--) {
151 const message = messages[index]
152 if (message?.role !== 'assistant') {
153 continue
154 }
155 const questions = parseQuestions(message.text)
156 if (questions.length > 0) {
157 return questions
158 }
159 }
160
161 return []
162}
163
164// The project root is part of the key, so the same wording asked in two
165// projects keeps two sets of answers.
166export function roundKeyOf(root: string, questions: readonly Question[]): string {
167 return hashOf(JSON.stringify([root, questions.map(i => [i.number, i.title, i.body, i.recommendation])]))
168}
169
170// cyrb53: no crypto in the hooks environment, and the key only has to keep
171// rounds apart, not resist anyone.
172export function hashOf(text: string): string {
173 let first = 0xdeadbeef
174 let second = 0x41c6ce57
175 for (let index = 0; index < text.length; index++) {
176 const code = text.charCodeAt(index)
177 first = Math.imul(first ^ code, 2654435761)
178 second = Math.imul(second ^ code, 1597334677)
179 }
180 first = Math.imul(first ^ (first >>> 16), 2246822507) ^ Math.imul(second ^ (second >>> 13), 3266489909)
181 second = Math.imul(second ^ (second >>> 16), 2246822507) ^ Math.imul(first ^ (first >>> 13), 3266489909)
182
183 return (4294967296 * (2097151 & second) + (first >>> 0)).toString(36)
184}
185
186export function newRound(key: string, questions: readonly Question[], answers: readonly (Answer | null)[]): Round {
187 return resume({
188 key,
189 questions: [...questions],
190 answers: questions.map((_, index) => answers[index] ?? null),
191 skipped: questions.map(() => false),
192 current: 0,
193 mode: 'answer',
194 prefill: null,
195 })
196}
197
198// Back to answering where it makes sense: the question in view while it is
199// still open, else the first open one, else the review.
200export function resume(round: Round): Round {
201 const index = round.answers[round.current] === null ? round.current : round.answers.indexOf(null)
202
203 return index === -1 ? toReview(round) : { ...round, current: index, mode: 'answer', prefill: null }
204}
205
206export function answerOf(text: string, question: Question): Answer | null {
207 const normalized = text.replace(/\r\n?/g, '\n').trim()
208 if (normalized.length > 0) {
209 return { kind: 'text', text: normalized }
210 }
211
212 return question.recommendation === null ? null : { kind: 'recommendation' }
213}
214
215export function answerCurrent(round: Round, text: string): Round {
216 const question = round.questions[round.current]
217 const answer = question === undefined ? null : answerOf(text, question)
218 if (round.mode !== 'answer' || answer === null) {
219 return round
220 }
221
222 return moveOn({
223 ...round,
224 answers: round.answers.map((value, index) => (index === round.current ? answer : value)),
225 skipped: round.skipped.map((value, index) => (index === round.current ? false : value)),
226 })
227}
228
229export function skipCurrent(round: Round): Round {
230 return moveOn({
231 ...round,
232 skipped: round.skipped.map((value, index) => (index === round.current ? round.answers[index] === null : value)),
233 })
234}
235
236export function goBack(round: Round): Round {
237 return round.current === 0 ? round : { ...round, current: round.current - 1, mode: 'answer', prefill: null }
238}
239
240export function editAt(round: Round, index: number): Round {
241 return index < 0 || index >= round.questions.length ? round : { ...round, current: index, mode: 'answer', prefill: null }
242}
243
244export function prefillRecommendation(round: Round): Round {
245 const recommendation = round.questions[round.current]?.recommendation ?? null
246
247 return recommendation === null ? round : { ...round, prefill: recommendation }
248}
249
250// After an answer or a skip the next open question after the current one,
251// wrapping around, so skipped questions come back once the rest are done.
252function moveOn(round: Round): Round {
253 const count = round.questions.length
254 for (let step = 1; step < count; step++) {
255 const index = (round.current + step) % count
256 if (round.answers[index] === null) {
257 return { ...round, current: index, mode: 'answer', prefill: null }
258 }
259 }
260
261 return toReview(round)
262}
263
264function toReview(round: Round): Round {
265 return { ...round, mode: 'review', prefill: null }
266}
267
268export function openCount(round: Round): number {
269 return round.answers.filter(i => i === null).length
270}
271
272export function statusOf(round: Round): string {
273 return `Grill ${round.questions.length - openCount(round)}/${round.questions.length} · /grill`
274}
275
276export function formatAnswers(round: Pick<Round, 'questions' | 'answers'>): string {
277 return round.questions.map((question, index) => `Q${question.number}: ${answerText(round.answers[index] ?? null)}`).join('\n\n')
278}
279
280function answerText(answer: Answer | null): string {
281 if (answer === null) {
282 return '(unanswered)'
283 }
284
285 return answer.kind === 'recommendation' ? 'Recommendation accepted' : answer.text
286}
287
288export function previewOf(answer: Answer | null, length = 60): string {
289 if (answer === null) {
290 return 'unanswered'
291 }
292 if (answer.kind === 'recommendation') {
293 return 'recommendation accepted'
294 }
295 const [first = ''] = answer.text.split('\n')
296 const cut = first.length > length ? `${first.slice(0, length - 1)}…` : first
297
298 return answer.text.includes('\n') && cut === first ? `${cut} …` : cut
299}
300
301export function isStoredRound(value: unknown): value is StoredRound {
302 if (typeof value !== 'object' || value === null) {
303 return false
304 }
305 const candidate = value as Partial<StoredRound>
306
307 return (candidate.status === 'open' || candidate.status === 'done') && Array.isArray(candidate.answers) && typeof candidate.updatedAt === 'number'
308}
309types/index.d.ts 35 lines1export type Question = {
2 number: number
3 title: string
4 body: string
5 recommendation: string | null
6}
7
8export type Answer = { kind: 'text'; text: string } | { kind: 'recommendation' }
9
10export type Mode = 'answer' | 'review' | 'confirm-send' | 'confirm-discard' | 'sending'
11
12export type Round = {
13 key: string
14 questions: Question[]
15 answers: (Answer | null)[]
16 skipped: boolean[]
17 current: number
18 mode: Mode
19 prefill: string | null
20}
21
22export type StoredRound = {
23 status: 'open' | 'done'
24 answers: (Answer | null)[]
25 updatedAt: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'grill-panel': {
31 round: Round | null
32 }
33 }
34}
35