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

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
❓ **Q1** - **<title>**: <body>
➡️ <recommendation>
---
Only the main conversation is read, never a subagent's. A reply without this format opens nothing.
| Key / command | Action |
|---|---|
ctrl+x tab or click | gives the pane the keyboard (the cursor goes to the first pending field) |
Enter in a field | saves the answer and jumps to the next pending field |
Tab / arrows | walk between fields and buttons |
1–9 | accepts question N's recommendation (with the focus on a button) |
a | accepts the recommendation of every pending question |
s | sends the answers |
Esc | hands the keyboard back to the prompt |
/grill-answer | reopens 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.
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 oncehooks/register.tsx 209 lines1import { 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}
209hooks/parse.ts 53 lines1import 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}
53types/index.d.ts 18 lines1export 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