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

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

/plugin install your-serve@claude-desk-neighbours
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.
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.

| Setting | Default |
|---|---|
| Toast when a turn that needs you took longer than (seconds) | 60 |
| Also say it out loud (macOS) | off |
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.
hooks/register.tsx 429 lines1import { 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}
429types/index.d.ts 22 lines1/** 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