SLOPSHOPPER

grill-answer

A pane for answering the question rounds of the grilling / grill-me skill

newpanecommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grill-answer
│ ┃ grill-answer ✕ › fix the failing auth test and add an audit log call │ ┃ No grilling round pending. │ ⏺ Read(src/auth.ts) │ ⎿ 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-answer │ ⎿ grill-answer: No grilling round pending. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · grill-answer
No grilling round pending.
README

grill-answer

A pane for answering the question rounds of the grilling skill (and its /grill-me shortcut).

When one of Claude's replies holds questions in the skill's format, the mod opens a pane with each question, its recommendation and an answer field. On send, your answers become your next prompt:

**Q1** - <title>: <your answer>

**Q2** - <title>: I accept the recommendation

Recognized format

❓ **Q1** - **<title>**: <body>

➡️ <recommendation>

---

Only the main conversation is read, never a subagent's. A reply without this format opens nothing.

Usage

Key / commandAction
ctrl+x tab or clickgives the pane the keyboard (the cursor goes to the first pending field)
Enter in a fieldsaves the answer and jumps to the next pending field
Tab / arrowswalk between fields and buttons
1–9accepts question N's recommendation (with the focus on a button)
aaccepts the recommendation of every pending question
ssends the answers
Eschands the keyboard back to the prompt
/grill-answerreopens the pane for the last round

A pane that opens on its own only shows from 144 columns; below that, use /grill-answer. Text typed without Enter is sent too. Mobile has no text field: only the accept buttons.

Develop

claude --plugin-dir plugins/grill-answer   # loads it for this session only
claude plugin validate plugins/grill-answer
claude plugin test plugins/grill-answer
tsc -p plugins/grill-answer                # once the mod has loaded at least once
Source 3 files
hooks/register.tsx 209 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GrillRound } from '../types'
5import { composeAnswers, parseRound } from './parse'
6
7const PANE = 'grill-answer'
8const ACCEPT = 'I accept the recommendation'
9
10const round = atom({ plugin: 'grill-answer', key: 'round' } as const, null)
11const answers = atom({ plugin: 'grill-answer', key: 'answers' } as const, {})
12
13// Drafts typed without Enter: kept here so a keystroke does not redraw.
14const drafts = new Map<number, string>()
15
16function openPane($: EngineInterface, questions: number) {
17  return $.ui.open({
18    id: PANE,
19    title: `Grill: ${questions} question(s)`,
20    focus: true,
21    rows: Math.min(40, 6 + questions * 6),
22  })
23}
24
25function setAnswer($: EngineInterface, n: number, text: string) {
26  return update($, answers, all => ({ ...all, [n]: text }))
27}
28
29// After Enter, moves the cursor to the next pending field (or to "send").
30async function focusNext(
31  $: EngineInterface,
32  current: GrillRound,
33  given: Record<string, string>,
34  after: number,
35) {
36  const order = [
37    ...current.questions.filter(q => q.n > after),
38    ...current.questions.filter(q => q.n < after),
39  ]
40  const next = order.find(q => !given[q.n]?.trim() && !drafts.get(q.n)?.trim())
41  const key = next === undefined ? 'send' : `answer-${next.n}`
42  try {
43    const moved = await $.ui.focus({ requestId: PANE, key })
44    if (moved.deny !== undefined) $.ui.log(`grill-answer: focus did not move (${moved.deny})`)
45  } catch (error) {
46    $.ui.log(`grill-answer: focus did not move (${String(error)})`)
47  }
48}
49
50async function send($: EngineInterface, current: GrillRound) {
51  const given = { ...(await read($, answers)) }
52  for (const [n, draft] of drafts) {
53    if (!given[n]?.trim() && draft.trim()) given[n] = draft
54  }
55
56  drafts.clear()
57  await update($, round, () => null)
58  await update($, answers, () => ({}))
59  $.ui.status(undefined)
60  await $.ui.close({ id: PANE })
61  await $.prompt.submit({
62    text: composeAnswers(current.questions, given),
63    asUser: true,
64  })
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    await $.command.register({
70      name: 'grill-answer',
71      description: 'Open the pane to answer the last grilling round',
72    })
73
74    return next(e)
75  })
76
77  on('command.run', { command: 'grill-answer' }, async $ => {
78    const current = await read($, round)
79    if (current === null) {
80      return { text: 'No grilling round pending.' }
81    }
82
83    await openPane($, current.questions.length)
84
85    return { text: 'Grilling pane opened.' }
86  })
87
88  on('turn.complete', async ($, e, next) => {
89    const isMain = e.agentId === undefined && e.reason === 'answer'
90    const questions = isMain ? parseRound(e.answer) : []
91
92    if (questions.length > 0) {
93      drafts.clear()
94      await update($, round, () => ({ id: e.turnId, questions }))
95      await update($, answers, () => ({}))
96      $.ui.status(`grill: ${questions.length} question(s) — /grill-answer`)
97
98      const opened = await openPane($, questions.length)
99      if (!opened.isPlaced) {
100        $.ui.toast('grill: /grill-answer to answer the questions')
101      }
102    }
103
104    return next(e)
105  })
106
107  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108    const elements = $.ui.resolve(e)
109    const { Box, Text, Button, Markdown } = elements
110    // mobile has no Input: there only the recommendation can be accepted.
111    const Input = 'Input' in elements ? elements.Input : undefined
112    const current = await read($, round)
113    const given = await read($, answers)
114
115    if (current === null) {
116      return <Text dimColor>No grilling round pending.</Text>
117    }
118
119    const pending = current.questions.filter(q => !given[q.n]?.trim())
120
121    return (
122      <Box flexDirection="column" gap={1}>
123        {!e.props.isFocused && (
124          <Text color="yellow">
125            Pane has no keyboard: ctrl+x tab (or click) to answer.
126          </Text>
127        )}
128        {current.questions.map(q => {
129          const answer = given[q.n]
130
131          return (
132            <Box key={`q${q.n}`} flexDirection="column">
133              <Text bold>
134                Q{q.n} · {q.title}
135              </Text>
136              {q.body !== '' && (
137                <Markdown dimColor text={q.body.slice(0, 2000)} />
138              )}
139              {q.recommendation !== '' && (
140                <Text color="green">➡ {q.recommendation}</Text>
141              )}
142              <Box flexDirection="row" gap={1}>
143                {Input !== undefined && (
144                  <Box flexGrow={1}>
145                    <Input
146                      key={`answer-${q.n}`}
147                      label="answer: "
148                      placeholder="type, then Enter to save"
149                      submitLabel="save"
150                      autoFocus={q === pending[0] ? true : undefined}
151                      value={answer ?? drafts.get(q.n) ?? ''}
152                      onInput={(value: string) => drafts.set(q.n, value)}
153                      onSubmit={async (value: string) => {
154                        await setAnswer($, q.n, value)
155                        await focusNext($, current, { ...given, [q.n]: value }, q.n)
156                      }}
157                    />
158                  </Box>
159                )}
160                <Button
161                  key={`accept-${q.n}`}
162                  label="accept"
163                  hotkey={q.n < 10 ? String(q.n) : undefined}
164                  dimColor={answer === ACCEPT}
165                  onPress={() => setAnswer($, q.n, ACCEPT)}
166                />
167              </Box>
168              {answer !== undefined && answer.trim() !== '' && (
169                <Text dimColor>✔ {answer}</Text>
170              )}
171            </Box>
172          )
173        })}
174        <Box flexDirection="row" gap={1}>
175          <Button
176            key="accept-all"
177            label={`accept pending (${pending.length})`}
178            hotkey="a"
179            onPress={() =>
180              update($, answers, all => {
181                const next = { ...all }
182                for (const q of current.questions) {
183                  if (!next[q.n]?.trim() && !drafts.get(q.n)?.trim()) {
184                    next[q.n] = ACCEPT
185                  }
186                }
187                return next
188              })
189            }
190          />
191          <Button
192            key="send"
193            label="send answers"
194            variant="primary"
195            hotkey="s"
196            onPress={() => send($, current)}
197          />
198          <Button
199            key="dismiss"
200            label="close"
201            role="dismiss"
202            onPress={() => $.ui.close({ id: PANE })}
203          />
204        </Box>
205      </Box>
206    )
207  })
208}
209
hooks/parse.ts 53 lines
1import type { GrillQuestion } from '../types'
2
3// One round of the grilling skill:
4//   ❓ **Q1** - **<title>**: <body>
5//
6//   ➡️ <recommendation>
7//
8//   ---
9const HEAD = /^\*\*Q(\d+)\*\*\s*[-–—:]\s*\*\*(.+?)\*\*\s*:?\s*/
10const ARROW = /^\s*➡️?\s*/m
11
12export const parseRound = (answer: string): GrillQuestion[] => {
13  const questions: GrillQuestion[] = []
14
15  for (const chunk of answer.split(/^\s*❓️?\s*/m).slice(1)) {
16    const head = HEAD.exec(chunk)
17    if (head === null) continue
18
19    const rest = chunk.slice(head[0].length)
20    const arrow = ARROW.exec(rest)
21    const body = arrow === null ? rest : rest.slice(0, arrow.index)
22    const recommendation =
23      arrow === null ? '' : rest.slice(arrow.index + arrow[0].length)
24
25    questions.push({
26      n: Number(head[1]),
27      title: head[2]!.trim(),
28      body: tidy(body),
29      recommendation: tidy(recommendation),
30    })
31  }
32
33  return questions
34}
35
36// Cuts the `---` separator and whatever follows the last question.
37const tidy = (text: string) => text.split(/^\s*---\s*$/m)[0]!.trim()
38
39export const composeAnswers = (
40  questions: GrillQuestion[],
41  answers: Record<string, string>,
42): string => {
43  const lines = questions.map(q => {
44    const answer = answers[q.n]?.trim()
45
46    return answer
47      ? `**Q${q.n}** - ${q.title}: ${answer}`
48      : `**Q${q.n}** - ${q.title}: (no answer yet)`
49  })
50
51  return lines.join('\n\n')
52}
53
types/index.d.ts 18 lines
1export type GrillQuestion = {
2  n: number
3  title: string
4  body: string
5  recommendation: string
6}
7
8export type GrillRound = { id: string; questions: GrillQuestion[] }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'grill-answer': {
13      round: GrillRound | null
14      answers: Record<string, string>
15    }
16  }
17}
18