SLOPSHOPPER

grill-panel

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

newpanecommandtoaststatusprompt
★ 2v0.1.0MITupdated 2026-10-09MichaelP17/claude-mods/grill-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grill-panel
│ ┃ grill ✕ › fix the failing auth te╭─────────────────────────────────────────╮ │ ┃ No open question round. /grill brings back │ grill-panel │ │ ┃ the latest one. ⏺ Read(src/auth.ts) │ No question round in this conversation. │ │ ⎿ Read 6 lines ╰─────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /grill │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · grill
No open question round. /grill brings back the latest one.
README

grill-panel

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.

WhenWhat you see
Claude's answer ends on a question roundthe panel opens on the first open question; Grill 0/5 · /grill in the status line
Enter in the fieldthe answer is saved, the next open question comes up
Enter on an empty fieldthe question's recommendation is taken (Q2: Recommendation accepted)
Edit recommendationthe recommendation's text goes into the field to change before Enter
Shift+Tab, then Enterback to the previous question
Skip ▶the question stays open and comes back after the others
Every question answeredthe review: a digit opens that answer again, Enter sends
Send… with questions still openasks first, then sends the open ones as Q4: (unanswered)
Discard roundasks first, then closes the round for good; /grill brings it back
Esc, or the pane's close markthe 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.

Format

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.

Where answers are kept

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.

Details and limits

  • The panel opens by itself as a side pane when the terminal runs fullscreen and is at least 144 columns wide (110 once you opened it yourself with /grill); otherwise the status line points to /grill, which opens it at any width. The desktop app always opens it as a side pane.
  • It takes the keyboard when it opens only while the prompt box is empty; otherwise click into the field.
  • The desktop app's text field is a single line.
  • Typing an ordinary prompt while a round is open leaves the round alone, so you can ask Claude about a question before answering it. A new round from Claude replaces the old one.
  • Only Claude's final text of a turn is read, never a subagent's.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The saved answers live in the mod's store and expire after 30 days.

Source 3 files
hooks/register.tsx 407 lines
1import { 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}
407
hooks/rounds.ts 309 lines
1import 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}
309
types/index.d.ts 35 lines
1export 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