SLOPSHOPPER

your-serve

🎾 Tells you when Claude needs you: one line with the question it ended on, and its answers as buttons.

newpanebandspinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · your-serve
│ ┃ 🎾 Your Serve ✕ › fix the failing auth test and add an audit log call │ ┃ 🎾 Your Serve · nothing waiting on you │ ┃ ⏺ Read(src/auth.ts) │ ┃ No questions yet. The ball's with Claude. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ toast after 60s · voice off · /config to ⎿ Added 2 lines, removed 1 line │ ┃ change · esc closes ⏺ 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 │ │ › /your-serve │ ⎿ your-serve: 🎾 Your Serve open. Esc closes. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 🎾 ready

Draws

Pane · 🎾 Your Serve
🎾 Your Serve · nothing waiting on you No questions yet. The ball's with Claude. toast after 60s · voice off · /config to change · esc closes
Prompt hint
⟨Claude Code's own drawing⟩ 🎾 ready
README

🎾 Your Serve

Lets you know when Claude is waiting on you, so you don't miss the question at the bottom of a long answer.

Claude ends a long answer with a question; Your Serve puts it above the prompt with its answers, and 1 sends the reply straight to Claude

/plugin install your-serve@claude-desk-neighbours

How it works

When a turn ends with a question, a choice between options, or a "should I also…", it shows up on the top line of the band:

🎾 Your serve: Keep the old cache or drop it?          1: Keep  2: Drop  3: Something else

Press an answer and a natural-sounding reply ("Drop the old cache.") goes straight to Claude. You can type its digit into an empty prompt or click it. If you've already started typing, the reply goes on the end of your draft instead, for you to finish and send. If there aren't any clear options, you just get the question. Either way it stays put until you send a prompt. Answers that don't ask anything produce nothing, and subagent turns are ignored.

If Claude asks several things at once, you get them one at a time, however many there are:

🎾 Your serve (2/3): What should the new timeout be?          1: 30s  2: 60s  other  skip

Each answer takes you to the next question. After the last one, they all go to Claude as a single reply:

1. Keep the old cache or drop it? Drop the old cache.
2. What should the new timeout be? Make it 30 seconds.
3. Add a retry? Yes, add a retry.

If none of the answers fit, click other and type your own. Enter answers the question with what you typed and moves on, the same as pressing an answer. If you skip a question, or press Enter on an empty other, it's left unanswered, and the replies go into your prompt instead of being sent, so you can type that answer before sending. You have to click other and skip (or reach them with Tab) because the digits after 3 belong to the other Desk Neighbours. The mobile app can't show a text field, so there you only get skip.

Nothing gets cut off. If the question and its answers don't fit on one line, the answers wrap onto the next one. Only three answers get buttons. For anything else, use other, or with a single question just type it in the prompt.

Your Serve reads the text of Claude's answer, so it won't show questions Claude asks through its own question picker. You've already got the picker in front of you anyway.

If the turn took longer than a minute, you'll also get a 🎾 Claude needs a decision toast in case you'd wandered off.

The pane

Click 🎾 ready in the status row, or run /your-serve, to see the question waiting on you with its answers, plus the questions Claude asked earlier in the session.

The Your Serve pane: "Should I run pnpm add debug and make the swap?" waiting, with an earlier question listed below

Settings

SettingDefault
Toast when a turn that needs you took longer than (seconds)60
Also say it out loud (macOS)off

In the background

Haiku only sees answers that end with a question mark, a list of options or something phrased as a question. It picks out every question and up to three answers for each one.

Source 2 files
hooks/register.tsx 429 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Serve, ServeAsked, ServeOption, ServeQuestion } from '../types'
5
6const SERVE = { plugin: 'your-serve', key: 'serve' } as const
7const HISTORY = { plugin: 'your-serve', key: 'history' } as const
8const serve = atom(SERVE, null)
9const history = atom(HISTORY, [])
10
11const PANE = 'your-serve'
12const TITLE = '🎾 Your Serve'
13
14// Band hotkeys are digits, which press from an empty prompt with no ctrl+x tab first. The Desk
15// Neighbours split them so none clash: Your Serve 1–3, Grudge 4–5, Previously On 0. The pane uses the same digits, so an answer has one key everywhere.
16const HOTKEYS = ['1', '2', '3'] as const
17const TAIL = 400
18// How much of the reply's end Haiku reads: room for a long list of questions with their context.
19const READ = 4000
20
21// Cheap gate on the answer's ending: a question mark, an options list, or asking phrasing.
22const ASKING = /\b(should i|shall i|would you like|do you want|want me to|which (one|option|approach|of these|would)|or should|let me know (if|whether|which))\b/i
23const OPTIONS_LIST = /^\s*(?:[-*]\s*)?(?:\*\*)?(?:\(?[1-3a-c][.)]|option [1-3a-c]\b)/im
24
25const EXTRACT_SYSTEM = [
26  "You read the end of a coding assistant's reply and decide whether it is waiting on the user for",
27  'an answer or a decision before it can go on. Reply with JSON only:',
28  '{"needsInput": boolean, "questions": [{"question": string, "options": [{"label": string, "reply": string}]}]}.',
29  `"questions": each separate thing the user must answer, in the order the reply asks them;`,
30  'alternatives for the same decision are one question with options, never separate questions.',
31  '"question": one thing the user must answer, at most 80 characters, ending in "?", naming what',
32  'is being decided ("Keep the old cache or drop it?"), never only option numbers ("1, 2 or 3?").',
33  '"options": at most 3 distinct answers the reply offers; "label" at most 14 characters naming the',
34  'choice itself ("Keep", "Cap at 30s"), never only its number ("Option 1");',
35  '"reply" how the user would say it, first person, one short sentence ("Keep the old cache.").',
36  'Leave "options" empty when there are no clear choices. "needsInput" is false for statements,',
37  'summaries, rhetorical questions and offers that need no answer to finish the work.',
38].join(' ')
39
40export function looksLikeAsk(answer: string): boolean {
41  const tail = answer.slice(-TAIL)
42  return tail.includes('?') || ASKING.test(tail) || OPTIONS_LIST.test(tail)
43}
44
45function parseJson(text: string): Record<string, unknown> | null {
46  const start = text.indexOf('{')
47  const end = text.lastIndexOf('}')
48  if (start < 0 || end <= start) return null
49  try {
50    const value: unknown = JSON.parse(text.slice(start, end + 1))
51    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
52  } catch {
53    return null
54  }
55}
56
57function clip(text: string, max: number): string {
58  const flat = text.replace(/\s+/g, ' ').trim()
59  return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
60}
61
62export function toServe(verdict: Record<string, unknown> | null): Serve | null {
63  if (verdict === null || verdict.needsInput !== true || !Array.isArray(verdict.questions)) return null
64  const questions: ServeQuestion[] = []
65  for (const one of verdict.questions) {
66    const asked = toQuestion(one)
67    if (asked !== null) questions.push(asked)
68  }
69  return questions.length === 0 ? null : { questions, answers: [], isTyping: false }
70}
71
72function toQuestion(item: unknown): ServeQuestion | null {
73  if (typeof item !== 'object' || item === null) return null
74  const verdict = item as Record<string, unknown>
75  if (typeof verdict.question !== 'string') return null
76  const question = clip(verdict.question, 200)
77  if (question === '') return null
78  const raw = Array.isArray(verdict.options) ? verdict.options : []
79  const options: ServeOption[] = []
80  for (const one of raw) {
81    if (typeof one !== 'object' || one === null) continue
82    const { label, reply } = one as Record<string, unknown>
83    if (typeof label !== 'string' || typeof reply !== 'string' || label.trim() === '' || reply.trim() === '') continue
84    options.push({ label: clip(label, 40), reply: reply.replace(/\s+/g, ' ').trim() })
85    if (options.length === HOTKEYS.length) break
86  }
87  return { question, options }
88}
89
90// Bumped on every prompt, so an answer read after the person already replied is dropped.
91let generation = 0
92
93async function readServe($: EngineInterface, answer: string, durationMs: number, asked: number, toastAfterMs: number, shouldSpeak: boolean): Promise<void> {
94  const reply = await $.model.complete({
95    model: 'haiku',
96    effort: 'low',
97    maxTokens: 2000,
98    timeoutMs: 15000,
99    system: EXTRACT_SYSTEM,
100    prompt: `End of the reply:\n"""\n${answer.slice(-READ)}\n"""`,
101  })
102  if (!reply.isAnswered || asked !== generation) return
103  const next = toServe(parseJson(reply.text))
104  if (next === null) return
105  await $.state.set(SERVE, next)
106  const at = await $.clock.now()
107  const entries: ServeAsked[] = next.questions.map(({ question }) => ({ question, at }))
108  // However many this turn asked, the pane still lists the six asked before them.
109  await update($, history, list => [...entries, ...list].slice(0, entries.length + 6))
110  if (durationMs > toastAfterMs) {
111    $.ui.toast('🎾 Claude needs a decision')
112    if (shouldSpeak) await $.audio.speak('Claude needs a decision').catch(() => undefined)
113  }
114}
115
116/** Puts `text` in the prompt box: fills an empty one, appends to a draft. */
117async function propose($: EngineInterface, text: string): Promise<void> {
118  const box = await $.prompt.read()
119  if (box.text.trim() === '') {
120    await $.prompt.fill({ text, mode: 'replace' })
121    return
122  }
123  const gap = text.includes('\n') ? '\n\n' : /\s$/.test(box.text) ? '' : ' '
124  await $.prompt.fill({ text: `${gap}${text}`, mode: 'append' })
125}
126
127/**
128 * Sends `text` as the person's reply, so there's no enter to press after. It goes in the prompt box instead when
129 * that holds a draft, theirs to finish, or `isWhole` is false: a skipped question is left in it to type.
130 */
131async function send($: EngineInterface, text: string, isWhole: boolean): Promise<void> {
132  if (!isWhole || (await $.prompt.read()).text.trim() !== '') {
133    await propose($, text)
134    return
135  }
136  // This plugin's own `prompt.submit` hook never sees a prompt it sends, so the band clears here.
137  generation += 1
138  await clear($)
139  await $.prompt.submit({ text, asUser: true }).catch(() => propose($, text))
140}
141
142/** The question waiting on an answer, or null once every question has one. */
143export function currentStep(shown: Serve): { step: number; asked: ServeQuestion } | null {
144  const step = shown.answers.length
145  const asked = shown.questions[step]
146  return asked === undefined ? null : { step, asked }
147}
148
149/** Every question with its answer, one per line; a skipped one is left for the person to type after. */
150export function formatAnswers(shown: Serve): string {
151  return shown.questions.map((asked, index) => `${index + 1}. ${asked.question} ${shown.answers[index] ?? ''}`.trimEnd()).join('\n')
152}
153
154/**
155 * An answer pressed or typed in the band or the pane. One question sends its reply straight away. Several are
156 * asked one at a time, and the last answer sends them all as one reply. `reply` is null for a skipped question.
157 * From the pane, the pane gets out of the way before the reply goes.
158 */
159async function choose($: EngineInterface, reply: string | null, isFromPane: boolean): Promise<void> {
160  const shown = await read($, serve)
161  if (shown === null || currentStep(shown) === null) return
162  if (shown.questions.length === 1) {
163    if (isFromPane) await $.ui.close({ id: PANE })
164    await send($, reply ?? '', reply !== null)
165    return
166  }
167  const next: Serve = { ...shown, answers: [...shown.answers, reply], isTyping: false }
168  await $.state.set(SERVE, next)
169  if (currentStep(next) !== null) return
170  if (isFromPane) await $.ui.close({ id: PANE })
171  await send($, formatAnswers(next), next.answers.every(a => a !== null))
172}
173
174/** A typed answer: an empty one is a skip, like the `skip` button. */
175async function chooseTyped($: EngineInterface, value: string, isFromPane: boolean): Promise<void> {
176  const reply = value.replace(/\s+/g, ' ').trim()
177  await choose($, reply === '' ? null : reply, isFromPane)
178}
179
180/**
181 * `other` pressed: the current question's buttons give way to a field. The field takes the `other` button's key,
182 * so the focus ring that was on the button is on the field.
183 */
184async function startTyping($: EngineInterface): Promise<void> {
185  const shown = await read($, serve)
186  if (shown === null || currentStep(shown) === null) return
187  await $.state.set(SERVE, { ...shown, isTyping: true })
188}
189
190async function clear($: EngineInterface): Promise<void> {
191  if ((await read($, serve)) !== null) await $.state.set(SERVE, null)
192}
193
194async function reset($: EngineInterface): Promise<void> {
195  await clear($)
196  await $.state.set(HISTORY, [])
197}
198
199// The hint line under the prompt is shared. Each Desk Neighbours mod adds one clickable item to
200// a row keyed `desk-hint` after the engine's own hint; items are kept in order by their keys
201// (`desk-1-…` to `desk-5-…`), so the row reads the same alone or together, in any load order.
202const DESK = 'desk-hint'
203
204// The engine draws the band's collapse mark `[-]` over the right end of its first row without
205// narrowing `bodyColumns`, so band rows stop this many cells short of the edge.
206const MARKER = 4
207
208function keyOf(node: RenderNode | undefined): string {
209  if (typeof node !== 'object' || node === null || !('props' in node)) return ''
210  const key = (node.props as Record<string, unknown> | undefined)?.key
211  return typeof key === 'string' ? key : ''
212}
213
214export function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
215  if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
216  const [line, ...items] = below.children ?? []
217  const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
218  return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
219}
220
221/** Opens this mod's pane, or closes it when it is the one showing. */
222async function togglePane($: EngineInterface, id: string, title: string): Promise<void> {
223  const pane = (await $.ui.panes()).find(p => p.id === id)
224  if (pane?.isShown === true) {
225    await $.ui.close({ id })
226    return
227  }
228  await $.ui.open({ id, title, focus: true, closeOnEscape: true })
229}
230
231/** "10:42" in local time. */
232function clockTime(ms: number): string {
233  const d = new Date(ms)
234  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
235}
236
237export const register: Register = (on, options) => {
238  const toastAfterMs = Number(options.toastAfterSeconds ?? 60) * 1000
239  const shouldSpeak = options.speak === true
240
241  on('session.start', async ($, e, next) => {
242    await $.command.register({ name: 'your-serve', description: '🎾 What Claude is waiting on you for, and what it asked earlier' })
243    return next(e)
244  })
245
246  on('command.run', { command: 'your-serve' }, async $ => {
247    await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
248    return { text: '🎾 Your Serve open. Esc closes.' }
249  })
250
251  on('turn.complete', async ($, e, next) => {
252    const done = await next(e)
253    const isMainAnswer = e.agentId === undefined && e.reason === 'answer'
254    if (isMainAnswer && looksLikeAsk(e.answer)) {
255      void readServe($, e.answer, e.durationMs, generation, toastAfterMs, shouldSpeak).catch(() => undefined)
256    }
257    return done
258  })
259
260  on('prompt.submit', ($, e, next) => {
261    generation += 1
262    void clear($).catch(() => undefined)
263    return next(e)
264  })
265
266  on('session.end', async ($, e, next) => {
267    if (e.reason === 'clear' || e.reason === 'resume') {
268      generation += 1
269      await reset($).catch(() => undefined)
270    }
271    return next(e)
272  })
273
274  // Always the top line of the band: drawn above whatever the plugins beneath drew.
275  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
276    if (e.props.hasSurvey) return next(e)
277    const shown = await read($, serve)
278    if (shown === null) return next(e)
279    const below = await next(e)
280    const elements = $.ui.resolve(e)
281    const { Box, Text, Button } = elements
282    // The mobile app draws no field, so there `other` isn't offered and a question is skipped instead.
283    const Input = 'Input' in elements ? elements.Input : null
284    const current = currentStep(shown)
285    const count = shown.questions.length
286    const isMany = count > 1
287    const line =
288      current === null
289        ? `🎾 Your serve: all ${count} answers are in your prompt`
290        : `🎾 Your serve${isMany ? ` (${current.step + 1}/${count})` : ''}: ${current.asked.question}`
291    return (
292      <Box flexDirection="column">
293        {/* Wraps rather than truncates: answers you can't read are no use. */}
294        <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns - MARKER}>
295          <Box flexShrink={1}>
296            <Text>{line}</Text>
297          </Box>
298          {current !== null && (current.asked.options.length > 0 || isMany) && (
299            <Box flexShrink={0} flexWrap="wrap" gap={1}>
300              {shown.isTyping && Input !== null ? (
301                <Input key="serve-other" placeholder="your answer" submitLabel="answer" autoFocus onSubmit={value => void chooseTyped($, value, false)} />
302              ) : (
303                current.asked.options.map((option, index) => (
304                  <Button
305                    key={`serve-${index + 1}`}
306                    hotkey={HOTKEYS[index]}
307                    plain
308                    label={option.label}
309                    onPress={() => void choose($, option.reply, false)}
310                  />
311                ))
312              )}
313              {/* No digit left to spare for these two: they're clicked, or reached with the focus ring. */}
314              {isMany && !shown.isTyping && Input !== null && (
315                <Button key="serve-other" plain dimColor label="other" onPress={() => void startTyping($)} />
316              )}
317              {isMany && <Button key="serve-skip" plain dimColor label="skip" onPress={() => void choose($, null, false)} />}
318            </Box>
319          )}
320        </Box>
321        {below}
322      </Box>
323    )
324  })
325
326  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
327    const isWaiting = (await read($, serve)) !== null
328    const label = isWaiting ? '🎾 your serve' : e.props.isWorking ? '🎾 in play' : '🎾 ready'
329    const below = await next(e)
330    const { Box, Button } = $.ui.resolve(e)
331    const mine = <Button key="desk-2-your-serve" plain dimColor label={label} onPress={() => void togglePane($, PANE, TITLE)} />
332    return joinDesk(below, mine, children => (
333      <Box key={DESK} flexDirection="row" columnGap={2}>
334        {children}
335      </Box>
336    ))
337  })
338
339  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
340    const elements = $.ui.resolve(e)
341    const { Box, Text, Button } = elements
342    const Input = 'Input' in elements ? elements.Input : null
343    const shown = await read($, serve)
344    const waiting = new Set(shown?.questions.map(asked => asked.question))
345    const earlier = (await read($, history)).filter(asked => !waiting.has(asked.question))
346    const current = shown === null ? null : currentStep(shown)
347    const isMany = shown !== null && shown.questions.length > 1
348    const isTyping = shown?.isTyping === true && Input !== null
349    const toast = `toast after ${Math.round(toastAfterMs / 1000)}s · voice ${shouldSpeak ? 'on' : 'off'}`
350    return (
351      <Box flexDirection="column">
352        <Box flexDirection="row" marginBottom={1}>
353          <Text bold>🎾 Your Serve</Text>
354          <Text dimColor> · {shown === null ? 'nothing waiting on you' : current === null ? 'answers in your prompt' : 'waiting on you'}</Text>
355        </Box>
356        {shown !== null &&
357          isMany &&
358          shown.answers.map((reply, index) => (
359            <Text dimColor>
360              {index + 1}. {shown.questions[index]?.question} {reply ?? '(skipped, type it in the prompt)'}
361            </Text>
362          ))}
363        {current !== null && (
364          <Text>
365            {isMany ? `${current.step + 1}. ` : ''}
366            {current.asked.question}
367          </Text>
368        )}
369        {current !== null && (current.asked.options.length > 0 || isMany) && (
370          <Box flexDirection="row" gap={1} marginTop={1}>
371            {isTyping && Input !== null ? (
372              <Input key="pane-serve-other" placeholder="your answer" submitLabel="answer" autoFocus onSubmit={value => void chooseTyped($, value, true)} />
373            ) : (
374              current.asked.options.map((option, index) => (
375                <Button
376                  key={`pane-serve-${index + 1}`}
377                  hotkey={HOTKEYS[index]}
378                  plain
379                  label={option.label}
380                  autoFocus={index === 0 ? true : undefined}
381                  onPress={() => void choose($, option.reply, true)}
382                />
383              ))
384            )}
385            {isMany && !isTyping && Input !== null && (
386              <Button
387                key="pane-serve-other"
388                plain
389                dimColor
390                label="other"
391                autoFocus={current.asked.options.length === 0 ? true : undefined}
392                onPress={() => void startTyping($)}
393              />
394            )}
395            {isMany && (
396              <Button
397                key="pane-serve-skip"
398                plain
399                dimColor
400                label="skip"
401                onPress={() => void choose($, null, true)}
402              />
403            )}
404          </Box>
405        )}
406        {earlier.length > 0 && (
407          <Box flexDirection="column" marginTop={shown === null ? 0 : 1}>
408            <Text dimColor>Earlier this session</Text>
409            {earlier.map(asked => (
410              <Box flexDirection="row" width={e.props.bodyColumns}>
411                <Box width={7} flexShrink={0}>
412                  <Text dimColor>{clockTime(asked.at)}</Text>
413                </Box>
414                <Box flexShrink={1}>
415                  <Text>{asked.question}</Text>
416                </Box>
417              </Box>
418            ))}
419          </Box>
420        )}
421        {shown === null && earlier.length === 0 && <Text dimColor>No questions yet. The ball's with Claude.</Text>}
422        <Box marginTop={1}>
423          <Text dimColor>{toast} · /config to change · esc closes</Text>
424        </Box>
425      </Box>
426    )
427  })
428}
429
types/index.d.ts 22 lines
1/** One answer the band offers: the button's label and what it puts in the prompt box. */
2export type ServeOption = { label: string; reply: string }
3
4/** One thing the person must answer, with the answers the turn offered for it. */
5export type ServeQuestion = { question: string; options: ServeOption[] }
6
7/**
8 * What a finished turn left for the person: its questions in order, and the replies given so far. The band
9 * asks `questions[answers.length]`; a `null` answer was skipped, to be typed in the prompt. `isTyping` is set
10 * while the person types their own answer to that question under `other`.
11 */
12export type Serve = { questions: ServeQuestion[]; answers: (string | null)[]; isTyping: boolean }
13
14/** A question a turn ended on, for the pane's list of earlier ones. */
15export type ServeAsked = { question: string; at: number }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'your-serve': { serve: Serve | null; history: ServeAsked[] }
20  }
21}
22