SLOPSHOPPER

grill

While Claude works on your task, a sharp partner asks the questions that change the result. Answers reach Claude mid-turn. Brainstorm and side chat modes too.

newpanebandcommandtoastprompt
★ 1v0.2.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/grill
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grill
│ ┃ grill-answer ✕ › fix the failing auth test and add an audit log call │ ┃ There is no open question. │ ⏺ 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 │ ⎿ grill: Grill mode: grill. Use /grill grill, brainstorm, off, ask │ │ ▌ Grill is reading your request while Claude starts… ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▌ Grill is reading your request while Claude starts… ⟨Claude Code's own drawing⟩
Pane · grill-answer
There is no open question.
Pane · grill-chat
Side chat Claude does not see this until you share it. Claude is on: fix the failing auth test and add an audit log call Think out loud about the task, a design, or anything else. Your partner answers here. Say something and press Enter [ Share with Claude ]
README

Grill

While Claude works on your task, a sharp partner asks you the questions that would change the result. Your answers reach Claude in the middle of its turn.

Grill preview

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install grill@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How it works

When you send a task (a prompt of eight words or more), Grill reads it with your project's file list and writes three to five questions about requirements, edge cases, and trade-offs. They appear one at a time above the prompt:

▌ GRILL 1/4  Should an empty cart be deleted?
  Decides whether empty carts exist in the database.
1: Delete it  2: Keep it  3: Type my own  4: Skip  type the number, Enter

If Claude is still working, your answer goes straight into its turn, and Claude adjusts. If the turn already ended, Grill keeps your answers and offers to send them all as one prompt.

How to use

  • /grill grill: questions on each task (the default).
  • /grill brainstorm: "what if" ideas on each task. Mark an idea "Worth doing" to send it to Claude.
  • /grill off: nothing starts by itself.
  • /grill ask or /grill ideas: run a round on your last task now.
  • /grill chat: a side chat with the helper model about anything. Claude does not see it until you press "Share with Claude", which puts short notes from the chat into your prompt for you to edit and send.

How to interact

  1. Type the number of an answer and press Enter. This always works, also while Claude streams text.
  2. When the prompt is empty and Claude is not streaming text, the number alone works too.
  3. "Type my own" opens a small box. Type your answer and press Enter. Esc closes the box.
  4. "Skip" moves to the next question.

While a question is open, a prompt that is only a number answers it instead of going to Claude. Any other prompt goes to Claude as usual.

In the chat pane, type in the box and press Enter to send. Tab moves to the "Share with Claude" button. Esc closes the chat, and /grill chat opens it again with the same messages.

Settings

Change these in /plugin:

  • startMode (grill): the mode for new sessions until you pick one with /grill.
  • helperModel (claude-sonnet-5-5): the model that writes questions and chats with you.
  • helperEffort (medium): how hard the helper model thinks (low, medium, or high).

Each round and each chat reply is one helper-model call.

See the marketplace README for configuration, privacy, updates, and removal.

Source 9 files
hooks/register.ts 136 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { CHAT_PANE, installChat } from './chat'
4import { helperFrom } from './grill/helper'
5import { CHAT_SYSTEM, chatPrompt, parseQuestions, ROUND_SYSTEM, roundPrompt, sharePrompt } from './grill/prompts'
6import { addChatMessage, configureGrill, grillView, setMode, setQuestions, startRound, takeJob } from './grill/state'
7import type { GrillMode } from './grill/types'
8import { installQuestions } from './questions'
9import { noop } from './shared/noop'
10
11const JOB_POLL_MS = 400
12const MODE_KEY = 'mode'
13const MODES: readonly GrillMode[] = ['grill', 'brainstorm', 'off']
14
15let isBusy = false
16let projectRoot = ''
17
18function modeFrom(value: unknown): GrillMode | undefined {
19  return MODES.find(mode => mode === value)
20}
21
22async function complete($: EngineInterface, options: { system: string; prompt: string; maxTokens: number }): Promise<string | undefined> {
23  const result = await $.model
24    .complete({
25      model: grillView().helper.helperModel,
26      effort: grillView().helper.helperEffort,
27      system: options.system,
28      prompt: options.prompt,
29      maxTokens: options.maxTokens,
30      timeoutMs: 40000,
31    })
32    .catch(() => undefined)
33  return result?.isAnswered ? result.text.trim() : undefined
34}
35
36async function runRound($: EngineInterface, options: { roundId: number }): Promise<void> {
37  const round = grillView().round
38  if (!round || round.id !== options.roundId) {
39    return
40  }
41  const listing = await $.process
42    .run(['git', 'ls-files'], { cwd: projectRoot, timeoutMs: 3000 })
43    .catch(() => undefined)
44  const files = (listing?.stdout ?? '').split('\n').slice(0, 200).join('\n')
45  const reply = await complete($, { system: ROUND_SYSTEM, prompt: roundPrompt({ mode: round.mode, task: round.prompt, files }), maxTokens: 1200 })
46  setQuestions({ roundId: round.id, questions: reply === undefined ? null : parseQuestions(reply) })
47  $.ui.invalidate('ui.render')
48}
49
50async function runChat($: EngineInterface): Promise<void> {
51  const { chat, lastTask } = grillView()
52  const reply = await complete($, { system: CHAT_SYSTEM, prompt: chatPrompt({ task: lastTask, chat }), maxTokens: 500 })
53  addChatMessage({ role: 'partner', text: reply ?? 'I could not answer just now. Say that again in a moment.' })
54  $.ui.invalidate('ui.render')
55}
56
57async function runShare($: EngineInterface): Promise<void> {
58  const { chat } = grillView()
59  if (chat.length === 0) {
60    $.ui.toast('The side chat is empty, so there is nothing to share')
61    return
62  }
63  const notes = await complete($, { system: CHAT_SYSTEM, prompt: sharePrompt(chat), maxTokens: 400 })
64  if (notes === undefined) {
65    $.ui.toast('The notes could not be written. Try Share again.')
66    return
67  }
68  await $.prompt.fill({ text: `Notes from my side chat:\n${notes}\n`, mode: 'insert' })
69  $.ui.toast('The notes are in your prompt. Edit them and press Enter to send.')
70}
71
72async function runJob($: EngineInterface): Promise<void> {
73  if (isBusy) {
74    return
75  }
76  const job = takeJob()
77  if (!job) {
78    return
79  }
80  isBusy = true
81  if (job.kind === 'round') {
82    await runRound($, { roundId: job.roundId }).catch(noop)
83  } else if (job.kind === 'chat') {
84    await runChat($).catch(noop)
85  } else {
86    await runShare($).catch(noop)
87  }
88  isBusy = false
89}
90
91export const register: Register = (on, options) => {
92  configureGrill(helperFrom(options))
93  installQuestions(on)
94  installChat(on)
95
96  on('session.start', async ($, e, next) => {
97    projectRoot = e.cwd
98    await $.command.register({
99      name: 'grill',
100      description: 'Grill: questions while Claude works. grill, brainstorm, or off sets the mode; ask or ideas runs a round now; chat opens a side chat.',
101      argumentHint: '[grill|brainstorm|off|ask|ideas|chat]',
102      immediate: true,
103    })
104    setMode(modeFrom(await $.store.get(MODE_KEY)) ?? modeFrom(options.startMode) ?? 'grill')
105    $.clock.every(JOB_POLL_MS, () => {
106      runJob($).catch(noop)
107    })
108    return next(e)
109  })
110
111  on('command.run', { command: 'grill' }, async ($, e) => {
112    const action = e.args.trim().toLowerCase()
113    const mode = modeFrom(action)
114    if (mode) {
115      setMode(mode)
116      await $.store.set(MODE_KEY, mode)
117      $.ui.invalidate('ui.render')
118      return { text: mode === 'off' ? 'Grill is off. /grill ask still runs a round on demand.' : `Grill mode: ${mode}. It starts on your next task prompt.` }
119    }
120    if (action === 'chat') {
121      await $.ui.open({ id: CHAT_PANE, title: 'Side chat', focus: true, closeOnEscape: true })
122      return { text: 'Side chat opened. Claude does not see it until you press Share with Claude.' }
123    }
124    if (action === 'ask' || action === 'ideas') {
125      const task = grillView().lastTask
126      if (task === '') {
127        return { text: 'There is no task yet. Send Claude a task first, then run /grill ask.' }
128      }
129      startRound({ prompt: task, mode: action === 'ask' ? 'grill' : 'brainstorm' })
130      $.ui.invalidate('ui.render')
131      return { text: 'Questions about your last task are on the way.' }
132    }
133    return { text: `Grill mode: ${grillView().mode}. Use /grill grill, brainstorm, off, ask, ideas, or chat.` }
134  })
135}
136
hooks/chat.tsx 65 lines
1import type { On } from 'claude-code'
2
3import { addChatMessage, grillView, queueShare } from './grill/state'
4
5export const CHAT_PANE = 'grill-chat'
6const PARTNER = '#facc15'
7const YOU = '#7dd3fc'
8
9export function installChat(on: On): void {
10  on('ui.render', { component: 'Pane', requestId: CHAT_PANE }, async ($, e) => {
11    if (e.surface === 'mobile') {
12      const { Text } = $.ui.resolve(e)
13      return <Text dimColor>The side chat needs the terminal or the desktop app.</Text>
14    }
15    const { Box, Text, Input, Button } = $.ui.resolve(e)
16    const { chat, isChatThinking, lastTask } = grillView()
17    const room = Math.max(4, (e.viewport?.rows ?? 30) - 10)
18    return (
19      <Box flexDirection="column">
20        <Text>
21          <Text bold>Side chat</Text>
22          <Text dimColor>  Claude does not see this until you share it.</Text>
23        </Text>
24        {lastTask !== '' && <Text dimColor italic>{`Claude is on: ${lastTask.slice(0, 120)}`}</Text>}
25        <Box flexDirection="column" marginY={1}>
26          {chat.length === 0 && (
27            <Text dimColor>Think out loud about the task, a design, or anything else. Your partner answers here.</Text>
28          )}
29          {chat.slice(-room).map(message => (
30            <Text>
31              <Text bold color={message.role === 'you' ? YOU : PARTNER}>{message.role === 'you' ? 'you      ' : 'partner  '}</Text>
32              {message.text}
33            </Text>
34          ))}
35          {isChatThinking && <Text color={PARTNER}>partner  thinking…</Text>}
36        </Box>
37        <Input
38          key="message"
39          autoFocus
40          placeholder="Say something and press Enter"
41          onSubmit={value => {
42            if (value.trim() === '') {
43              return
44            }
45            addChatMessage({ role: 'you', text: value.trim() })
46            $.ui.invalidate('ui.render')
47          }}
48        />
49        <Box flexDirection="row" columnGap={2} marginTop={1}>
50          <Button
51            key="share"
52            hotkey="s"
53            label="Share with Claude"
54            variant="primary"
55            onPress={() => {
56              queueShare()
57              $.ui.toast('Writing notes from this chat into your prompt')
58            }}
59          />
60        </Box>
61      </Box>
62    )
63  })
64}
65
hooks/grill/helper.ts 16 lines
1import type { ModelEffort, PluginOptions } from 'claude-code'
2
3export const DEFAULT_HELPER_MODEL = 'claude-sonnet-5-5'
4
5const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high']
6
7export type Helper = { helperModel: string; helperEffort: ModelEffort }
8
9export function helperFrom(options: PluginOptions): Helper {
10  const model = options.helperModel
11  return {
12    helperModel: typeof model === 'string' && model.trim() !== '' ? model.trim() : DEFAULT_HELPER_MODEL,
13    helperEffort: EFFORTS.find(effort => effort === options.helperEffort) ?? 'medium',
14  }
15}
16
hooks/grill/prompts.ts 106 lines
1import type { Answer, ChatMessage, Question, RoundMode } from './types'
2
3export const ROUND_SYSTEM =
4  'You are a sharp senior engineer pairing with a developer. An AI agent has just started on their request. You ask the developer the few questions whose answers would change the result the most. Reply with one JSON object and nothing else.'
5
6export const CHAT_SYSTEM =
7  'You are a sharp, friendly thinking partner for a developer while their AI coding agent works. Keep replies under 120 words, plain and specific. Push back when an idea is weak, and end with a question when one would help.'
8
9export function roundPrompt(options: { mode: RoundMode; task: string; files: string }): string {
10  const context = [`The developer asked the agent: ${options.task.slice(0, 3000)}`, '', 'Files in the project (partial):', options.files || '(unknown)', '']
11  if (options.mode === 'brainstorm') {
12    return [
13      ...context,
14      'Suggest 3 to 5 ideas the developer may not have considered for this task: better approaches, risks, or extras worth doing now.',
15      'Each idea starts with "What if". Return JSON: {"questions": [{"text": "What if ...?", "why": "one sentence on the payoff", "options": []}]}',
16    ].join('\n')
17  }
18  return [
19    ...context,
20    'Ask 3 to 5 questions about requirements, edge cases, scope, and trade-offs that the request leaves open.',
21    'Never ask what the code can answer. Each question must be specific to this request, never generic.',
22    'Give 2 or 3 likely answers per question, at most 6 words each, that do not overlap.',
23    'Return JSON: {"questions": [{"text": "the question", "why": "one sentence on what the answer changes", "options": ["answer", "answer"]}]}',
24  ].join('\n')
25}
26
27function isRecord(value: unknown): value is Record<string, unknown> {
28  return typeof value === 'object' && value !== null && !Array.isArray(value)
29}
30
31function clean(options: { value: unknown; limit: number }): string {
32  return typeof options.value === 'string' ? options.value.replace(/\s+/g, ' ').trim().slice(0, options.limit) : ''
33}
34
35export function parseQuestions(reply: string): Question[] | null {
36  const start = reply.indexOf('{')
37  const end = reply.lastIndexOf('}')
38  if (start < 0 || end <= start) {
39    return null
40  }
41  let parsed: unknown
42  try {
43    parsed = JSON.parse(reply.slice(start, end + 1))
44  } catch {
45    return null
46  }
47  if (!isRecord(parsed) || !Array.isArray(parsed.questions)) {
48    return null
49  }
50  return parsed.questions
51    .filter(isRecord)
52    .map(question => ({
53      text: clean({ value: question.text, limit: 220 }),
54      why: clean({ value: question.why, limit: 200 }),
55      options: Array.isArray(question.options)
56        ? question.options.map(option => clean({ value: option, limit: 48 })).filter(Boolean).slice(0, 3)
57        : [],
58    }))
59    .filter(question => question.text !== '')
60    .slice(0, 5)
61}
62
63export function answerNote(answer: Answer): string {
64  if (answer.mode === 'brainstorm') {
65    return [
66      '[Grill] While you work, the user marked this idea as worth doing for the current task:',
67      answer.question,
68      'Fold it in if it fits the request. If it does not fit, say why in your final answer.',
69    ].join('\n')
70  }
71  return [
72    '[Grill] While you work, the user answered a clarifying question about the current task.',
73    `Q: ${answer.question}`,
74    `A: ${answer.answer}`,
75    'If this changes your plan, adjust your work now. Do not reply to this note on its own.',
76  ].join('\n')
77}
78
79export function answersMessage(answers: readonly Answer[]): string {
80  const lines = answers.map(answer =>
81    answer.mode === 'brainstorm' ? `- Idea to include: ${answer.question}` : `- ${answer.question} ${answer.answer}`,
82  )
83  return ['Here are my answers to the grill questions. Please apply them to the work you just did:', ...lines].join('\n')
84}
85
86export function chatPrompt(options: { task: string; chat: readonly ChatMessage[] }): string {
87  const transcript = options.chat.map(message => `${message.role === 'you' ? 'Developer' : 'You'}: ${message.text}`).join('\n')
88  return [
89    options.task ? `The developer's current task for their agent: ${options.task.slice(0, 1500)}` : '',
90    'Conversation so far:',
91    transcript,
92    'Reply as "You" with your next message only.',
93  ]
94    .filter(Boolean)
95    .join('\n')
96}
97
98export function sharePrompt(chat: readonly ChatMessage[]): string {
99  return [
100    'Summarize this side conversation into notes the developer can hand to their AI coding agent.',
101    'Write 2 to 5 short bullet points with the decisions and ideas worth acting on. No preamble.',
102    '',
103    chat.map(message => `${message.role === 'you' ? 'Developer' : 'Partner'}: ${message.text}`).join('\n'),
104  ].join('\n')
105}
106
hooks/grill/state.ts 150 lines
1import { DEFAULT_HELPER_MODEL } from './helper'
2import type { Helper } from './helper'
3import type { Answer, ChatMessage, GrillMode, Job, Question, Round, RoundMode } from './types'
4
5const MIN_WORDS = 8
6const MAX_CHAT = 40
7
8type Grill = {
9  mode: GrillMode
10  round: Round | undefined
11  isWorking: boolean
12  jobs: Job[]
13  chat: ChatMessage[]
14  isChatThinking: boolean
15  lastTask: string
16  helper: Helper
17  nextRoundId: number
18}
19
20const grill: Grill = {
21  mode: 'grill',
22  round: undefined,
23  isWorking: false,
24  jobs: [],
25  chat: [],
26  isChatThinking: false,
27  lastTask: '',
28  helper: { helperModel: DEFAULT_HELPER_MODEL, helperEffort: 'medium' },
29  nextRoundId: 1,
30}
31
32export function grillView(): Readonly<Grill> {
33  return grill
34}
35
36export function configureGrill(helper: Helper): void {
37  grill.helper = helper
38}
39
40export function setMode(mode: GrillMode): void {
41  grill.mode = mode
42}
43
44export function setWorking(isWorking: boolean): void {
45  grill.isWorking = isWorking
46}
47
48export function isTaskPrompt(text: string): boolean {
49  const trimmed = text.trim()
50  return !trimmed.startsWith('/') && trimmed.split(/\s+/).filter(Boolean).length >= MIN_WORDS
51}
52
53export function rememberTask(text: string): void {
54  grill.lastTask = text
55}
56
57export function startRound(options: { prompt: string; mode: RoundMode }): Round {
58  const round: Round = {
59    id: grill.nextRoundId,
60    mode: options.mode,
61    prompt: options.prompt,
62    status: 'thinking',
63    questions: [],
64    index: 0,
65    answers: [],
66  }
67  grill.nextRoundId += 1
68  grill.round = round
69  grill.lastTask = options.prompt
70  grill.jobs = [...grill.jobs.filter(job => job.kind !== 'round'), { kind: 'round', roundId: round.id }]
71  return round
72}
73
74export function setQuestions(options: { roundId: number; questions: Question[] | null }): void {
75  const round = grill.round
76  if (!round || round.id !== options.roundId) {
77    return
78  }
79  round.questions = options.questions ?? []
80  round.status = options.questions && options.questions.length > 0 ? 'ready' : 'failed'
81}
82
83export function currentQuestion(): Question | undefined {
84  const round = grill.round
85  return round && round.status === 'ready' ? round.questions[round.index] : undefined
86}
87
88function advance(round: Round): void {
89  round.index += 1
90  if (round.index >= round.questions.length) {
91    round.status = 'done'
92  }
93}
94
95export function answerCurrent(options: { answer: string; isDelivered: boolean }): Answer | undefined {
96  const round = grill.round
97  const question = currentQuestion()
98  if (!round || !question) {
99    return undefined
100  }
101  const answer: Answer = { question: question.text, answer: options.answer, mode: round.mode, isDelivered: options.isDelivered }
102  round.answers.push(answer)
103  advance(round)
104  return answer
105}
106
107export function skipCurrent(): void {
108  if (grill.round && currentQuestion()) {
109    advance(grill.round)
110  }
111}
112
113export function dismissRound(): void {
114  grill.round = undefined
115}
116
117export function undeliveredAnswers(): Answer[] {
118  return grill.round ? grill.round.answers.filter(answer => !answer.isDelivered) : []
119}
120
121export function markUndelivered(answer: Answer): void {
122  answer.isDelivered = false
123}
124
125export function markAllDelivered(): void {
126  grill.round?.answers.forEach(answer => {
127    answer.isDelivered = true
128  })
129}
130
131export function addChatMessage(message: ChatMessage): void {
132  grill.chat = [...grill.chat, message].slice(-MAX_CHAT)
133  if (message.role === 'you') {
134    grill.isChatThinking = true
135    grill.jobs = [...grill.jobs, { kind: 'chat' }]
136  } else {
137    grill.isChatThinking = false
138  }
139}
140
141export function queueShare(): void {
142  grill.jobs = [...grill.jobs, { kind: 'share' }]
143}
144
145export function takeJob(): Job | undefined {
146  const [job, ...rest] = grill.jobs
147  grill.jobs = rest
148  return job
149}
150
hooks/grill/types.ts 24 lines
1export type GrillMode = 'grill' | 'brainstorm' | 'off'
2
3export type RoundMode = 'grill' | 'brainstorm'
4
5export type Question = { text: string; why: string; options: string[] }
6
7export type Answer = { question: string; answer: string; mode: RoundMode; isDelivered: boolean }
8
9export type RoundStatus = 'thinking' | 'ready' | 'failed' | 'done'
10
11export type Round = {
12  id: number
13  mode: RoundMode
14  prompt: string
15  status: RoundStatus
16  questions: Question[]
17  index: number
18  answers: Answer[]
19}
20
21export type ChatMessage = { role: 'you' | 'partner'; text: string }
22
23export type Job = { kind: 'round'; roundId: number } | { kind: 'chat' } | { kind: 'share' }
24
hooks/questions.tsx 198 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { choiceForDigit, choicesFor } from './grill/choices'
4import type { Choice, LabeledChoice } from './grill/choices'
5import { answerNote, answersMessage } from './grill/prompts'
6import {
7  answerCurrent,
8  currentQuestion,
9  dismissRound,
10  grillView,
11  isTaskPrompt,
12  markAllDelivered,
13  markUndelivered,
14  rememberTask,
15  setWorking,
16  skipCurrent,
17  startRound,
18  undeliveredAnswers,
19} from './grill/state'
20
21export const ANSWER_PANE = 'grill-answer'
22const FLAME = '#ff7a45'
23const IDEA = '#facc15'
24
25function currentChoices(): LabeledChoice[] {
26  const { round, isWorking } = grillView()
27  return choicesFor({ round, question: currentQuestion(), lateCount: undeliveredAnswers().length, isWorking })
28}
29
30function keyFor(entry: LabeledChoice): string {
31  return entry.choice.kind === 'answer' ? `option-${entry.digit}` : entry.choice.kind
32}
33
34function takeAnswersToSend(): string | undefined {
35  const answers = undeliveredAnswers()
36  markAllDelivered()
37  dismissRound()
38  return answers.length > 0 ? answersMessage(answers) : undefined
39}
40
41async function deliver($: EngineInterface, options: { answer: string }): Promise<void> {
42  const answer = answerCurrent({ answer: options.answer, isDelivered: grillView().isWorking })
43  if (answer?.isDelivered) {
44    const appended = await $.session
45      .append({ message: { type: 'user', content: [{ type: 'text', text: answerNote(answer) }] } })
46      .catch(() => undefined)
47    if (appended === undefined || appended.deny !== undefined) {
48      markUndelivered(answer)
49      $.ui.toast('Claude could not take this mid-turn. Send it when the turn ends.')
50    } else {
51      $.ui.toast(answer.mode === 'brainstorm' ? 'Idea sent to Claude' : 'Answer sent to Claude')
52    }
53  }
54  $.ui.invalidate('ui.render')
55}
56
57async function perform($: EngineInterface, options: { choice: Choice }): Promise<void> {
58  const { choice } = options
59  if (choice.kind === 'answer') {
60    await deliver($, { answer: choice.text })
61    return
62  }
63  if (choice.kind === 'own') {
64    await $.ui.open({ id: ANSWER_PANE, title: 'Your answer', focus: true, closeOnEscape: true, rows: 5 })
65    return
66  }
67  if (choice.kind === 'send') {
68    const message = takeAnswersToSend()
69    $.ui.invalidate('ui.render')
70    if (message !== undefined) {
71      await $.prompt.submit({ text: message })
72    }
73    return
74  }
75  if (choice.kind === 'skip') {
76    skipCurrent()
77  } else {
78    dismissRound()
79  }
80  $.ui.invalidate('ui.render')
81}
82
83export function installQuestions(on: On): void {
84  on('prompt.submit', async ($, e, next) => {
85    const picked = e.origin.kind === 'composer' ? choiceForDigit({ choices: currentChoices(), text: e.text }) : undefined
86    if (picked?.choice.kind === 'send') {
87      const message = takeAnswersToSend()
88      $.ui.invalidate('ui.render')
89      return message === undefined ? { drop: `Grill: ${picked.label}` } : next({ ...e, text: message })
90    }
91    if (picked) {
92      await perform($, { choice: picked.choice })
93      return { drop: `Grill: ${picked.label}` }
94    }
95    const mode = grillView().mode
96    const isNewTask = e.origin.kind === 'composer' && e.turnId === undefined && isTaskPrompt(e.text)
97    if (isNewTask) {
98      rememberTask(e.text)
99    }
100    if (isNewTask && (mode === 'grill' || mode === 'brainstorm')) {
101      startRound({ prompt: e.text, mode })
102      $.ui.invalidate('ui.render')
103    }
104    return next(e)
105  })
106
107  on('turn.start', ($, e, next) => {
108    setWorking(true)
109    return next(e)
110  })
111
112  on('turn.complete', ($, e, next) => {
113    if (e.agentId === undefined) {
114      setWorking(false)
115      $.ui.invalidate('ui.render')
116    }
117    return next(e)
118  })
119
120  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121    const below = await next(e)
122    const { round } = grillView()
123    if (!round || e.props.hasSurvey) {
124      return below
125    }
126    const { Box, Text, Button } = $.ui.resolve(e)
127    const question = currentQuestion()
128    const isIdea = round.mode === 'brainstorm'
129    const color = isIdea ? IDEA : FLAME
130    const choices = currentChoices()
131    const buttons = choices.map(entry => (
132      <Button key={keyFor(entry)} plain hotkey={entry.digit} label={entry.label} onPress={() => perform($, { choice: entry.choice })} />
133    ))
134    let header
135    if (round.status === 'thinking') {
136      header = <Text color={color}>{`▌ ${isIdea ? 'Brainstorm' : 'Grill'} is reading your request while Claude starts…`}</Text>
137    } else if (question) {
138      header = (
139        <Box flexDirection="column">
140          <Text>
141            <Text color={color} bold>{`▌ ${isIdea ? 'IDEA' : 'GRILL'} `}</Text>
142            <Text dimColor>{`${round.index + 1}/${round.questions.length}  `}</Text>
143            <Text bold>{question.text}</Text>
144          </Text>
145          {question.why !== '' && <Text dimColor italic>{`  ${question.why}`}</Text>}
146        </Box>
147      )
148    } else if (choices.length > 0) {
149      header = <Text color={color}>{`▌ Claude finished before reading ${undeliveredAnswers().length} of your answers.`}</Text>
150    } else {
151      return below
152    }
153    return (
154      <Box flexDirection="column">
155        {header}
156        {choices.length > 0 && (
157          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
158            {buttons}
159            <Text dimColor>type the number, Enter</Text>
160          </Box>
161        )}
162        {below}
163      </Box>
164    )
165  })
166
167  on('ui.render', { component: 'Pane', requestId: ANSWER_PANE }, async ($, e) => {
168    if (e.surface === 'mobile') {
169      const { Text } = $.ui.resolve(e)
170      return <Text dimColor>Answer from the terminal or the desktop app.</Text>
171    }
172    const { Box, Text, Input } = $.ui.resolve(e)
173    const question = currentQuestion()
174    if (!question) {
175      return <Text dimColor>There is no open question.</Text>
176    }
177    return (
178      <Box flexDirection="column">
179        <Text>
180          <Text color={FLAME} bold>GRILL </Text>
181          <Text bold>{question.text}</Text>
182        </Text>
183        <Input
184          key="answer"
185          autoFocus
186          placeholder="Type your answer and press Enter"
187          onSubmit={async value => {
188            if (value.trim() !== '') {
189              await deliver($, { answer: value.trim() })
190            }
191            await $.ui.close({ id: ANSWER_PANE })
192          }}
193        />
194      </Box>
195    )
196  })
197}
198
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/grill/choices.ts 52 lines
1import type { Question, Round } from './types'
2
3export type Choice =
4  | { kind: 'answer'; text: string }
5  | { kind: 'own' }
6  | { kind: 'skip' }
7  | { kind: 'send' }
8  | { kind: 'dismiss' }
9
10export type LabeledChoice = { digit: string; label: string; choice: Choice }
11
12export function choicesFor(options: {
13  round: Round | undefined
14  question: Question | undefined
15  lateCount: number
16  isWorking: boolean
17}): LabeledChoice[] {
18  const { round, question } = options
19  if (!round) {
20    return []
21  }
22  if (question) {
23    const isIdea = round.mode === 'brainstorm'
24    const answers: Choice[] = isIdea
25      ? [{ kind: 'answer', text: 'Worth doing' }, { kind: 'skip' }]
26      : question.options.map(text => ({ kind: 'answer', text }))
27    const labels = isIdea ? ['Worth doing', 'Not now'] : question.options
28    const extras: { label: string; choice: Choice }[] = isIdea
29      ? [{ label: 'Skip', choice: { kind: 'skip' } }]
30      : [
31          { label: 'Type my own', choice: { kind: 'own' } },
32          { label: 'Skip', choice: { kind: 'skip' } },
33        ]
34    return [
35      ...answers.map((choice, index) => ({ label: labels[index] ?? '', choice })),
36      ...extras,
37    ].map((entry, index) => ({ digit: String(index + 1), ...entry }))
38  }
39  if (round.status === 'done' && options.lateCount > 0 && !options.isWorking) {
40    return [
41      { digit: '1', label: 'Send them now', choice: { kind: 'send' } },
42      { digit: '2', label: 'Dismiss', choice: { kind: 'dismiss' } },
43    ]
44  }
45  return []
46}
47
48export function choiceForDigit(options: { choices: readonly LabeledChoice[]; text: string }): LabeledChoice | undefined {
49  const typed = options.text.trim()
50  return /^[1-9]$/.test(typed) ? options.choices.find(entry => entry.digit === typed) : undefined
51}
52