Highlights the Needs you section of a reply, with the hopping Claude from the Stream Deck beside it, and turns its decisions into buttons.

Claude Code plugins I've built for my own work and decided to share. Each one installs on its own.
Add the marketplace once:
claude plugin marketplace add robfresh2o/fresh2o-plugins
Then install the plugins you want:
claude plugin install needs-you@fresh2o-plugins
https://github.com/user-attachments/assets/dc29b0c6-68a3-48ad-90cb-7b5c6df6fb8b
When Claude needs something from you, such as a decision, an approval or a command only you can run, it opens its reply with a Needs you line. This plugin finds that line and draws the section under it in a box, with a small Claude creature hopping beside it. The creature keeps hopping until you send your next prompt, so you can see at a glance that a reply is waiting on you.
A decision written as a question, with its choices as a numbered list under it, gets one button per choice. Pressing a button sends the question and your choice as your reply. If the section holds several decisions, your picks collect and send together. Add (recommended) after a choice to make it the primary button. When a choice is longer than 40 characters, the choices stay listed as text and the buttons show their numbers instead, so no button runs past the box. A note written after the choices, after a blank line and indented no deeper than the choices, shows under the buttons.
The plugin only draws what Claude writes. It makes no model calls. For it to do anything, Claude has to write the section, so add something like this to your ~/.claude/CLAUDE.md:
If anything needs me (a decision, an approval, something only I can do), open the reply
with `**Needs you**` on a line of its own, before anything else. Under it, one bullet per
thing you need. A decision is at most one sentence of context, then the question, ending in
`?`. Under it go its choices as a numbered list, each a short label, with `(recommended)`
after the one you recommend. An action says what to do and includes everything needed to do
it, such as the exact command or the steps. Put the reasoning, and what each choice means,
after the section. Leave the line out when nothing needs me.
needs-you uses Claude Code's plugin hooks to draw inside the session. It was built on Claude Code 2.1.288 and tested in the desktop app's Code tab. It has not been tested in the terminal.
A plugin runs with the same access to your files and processes as Claude Code. Read the code of any plugin before you install it, including these.
MIT
hooks/register.tsx 441 lines1/**
2 * Highlights the part of a reply that needs Rob.
3 *
4 * Rob's global CLAUDE.md has Claude open any reply that needs him with a line reading
5 * `**Needs you**`. This finds that line, draws the section under it in a box, and puts the
6 * Claude creature from the Stream Deck's Status key beside it, hopping to the same ratified
7 * timeline. Only the latest such reply hops, and only until Rob sends a prompt of his own;
8 * after that the creature stands still and the box stays, so the transcript does not fill with
9 * motion. A turn Rob did not start (a background task finishing, say) does not settle an ask he
10 * has not answered, though a new section in its reply takes over the hop.
11 *
12 * While the ask is open, a decision draws a button per choice: a top-level bullet with a sentence
13 * ending in "?", with a numbered list of two or more one-line choices under it, and optionally a
14 * note under those. `(recommended)` marks the primary button. When a choice is too long for a
15 * button, the choices stay listed as text and the buttons carry their numbers. Pressing one sends
16 * the question and the choice as Rob's reply; with several decisions, the picks collect and send
17 * together.
18 *
19 * There is no model call anywhere in this. The marker is plain text, and `isMarker` is the rule
20 * the Claude Deck plugin uses to decide when the Status key hops (`isMarkerLine` in
21 * `plugin/src/stop-intent.ts` in robfresh2o/Stream-Deck), copied line for line, and so is
22 * `inlineAsk` (`inlineAsk` there), so the box and the key agree. Change one and change the other.
23 */
24
25import type { Register } from 'claude-code'
26
27const active = { plugin: 'needs-you', key: 'active' } as const
28const picks = { plugin: 'needs-you', key: 'picks' } as const
29
30/** The Status key's body colour, `BODY` in the deck's `plugin/tools/export_faces.py`. */
31const BODY = '#d77757'
32
33/** Markdown's own limit per element. A reply longer than this is left to the engine. */
34const MARKDOWN_LIMIT = 10000
35
36/**
37 * Whether a line is the marker: once trimmed, with a heading's one to six `#` and the space after
38 * them, the `**` or `__` around it, and one colon (inside the bold or after it) taken off, it is
39 * "needs you" in any case. A sentence that merely contains the words is not. A quoted or
40 * inline-code mention never is either, because neither the `>` nor the backticks come off.
41 */
42function isMarker(line: string): boolean {
43 let text = line.trim().replace(/^#{1,6}\s+/, '').trim()
44 let droppedColon = false
45 if (text.endsWith(':')) {
46 text = text.slice(0, -1).trim()
47 droppedColon = true
48 }
49 for (const wrap of ['**', '__']) {
50 if (text.length > 2 * wrap.length && text.startsWith(wrap) && text.endsWith(wrap)) {
51 text = text.slice(wrap.length, -wrap.length).trim()
52 break
53 }
54 }
55 if (!droppedColon && text.endsWith(':')) {
56 text = text.slice(0, -1).trim()
57 }
58 return text.toLowerCase() === 'needs you'
59}
60
61/**
62 * The ask written on the marker's own line, when the marker opens the line in bold with a colon:
63 * `**Needs you**: approve the wording` or `**Needs you:** approve the wording`. Null otherwise, so
64 * a sentence that only mentions the words ("This **needs you** to approve") never counts.
65 */
66function inlineAsk(line: string): string | null {
67 const found = /^(?:#{1,6}\s+)?(\*\*|__)\s*needs you\s*(?::\s*\1|\1\s*:)\s*([^\s:].*)$/i.exec(line.trim())
68 return found?.[2] ?? null
69}
70
71type Split = { before: string; section: string; after: string }
72
73const LIST_OR_INDENT = /^(\s*([-*+]|\d+[.)])\s|\s{2,}\S|\s*>|\s*(`{3,}|~{3,}))/
74const BREAK = /^(#{1,6}\s|(-{3,}|\*{3,}|_{3,})\s*$)/
75
76/**
77 * The reply cut into what comes before the marker, the section it opens, and the rest.
78 *
79 * The section is the first paragraph after the marker, then every following paragraph that is
80 * a list item, indented, quoted or a fenced block, which is how the asks are written: a command
81 * an action needs, written after its lead-in, stays in the box. A heading, a rule written
82 * without spaces (`---`, `***`, `___`) or an ordinary paragraph ends it. A fenced block is taken
83 * whole, blank lines and all, so a command block after the marker stays in one piece. Lines inside fenced code never count as the marker.
84 * When the ask starts on the marker's own line (`inlineAsk`), that text opens the section.
85 */
86function split(text: string): Split | null {
87 const lines = text.split(/\r?\n/)
88 const line = (i: number): string => lines[i] ?? ''
89 const fenceOf = (line: string) => /^\s*(`{3,}|~{3,})/.exec(line)?.[1]?.[0]
90 let fence: string | undefined
91 let at = -1
92 for (let i = 0; i < lines.length; i++) {
93 const opens = fenceOf(line(i))
94 if (fence === undefined) {
95 if (opens !== undefined) fence = opens
96 else if (isMarker(line(i)) || inlineAsk(line(i)) !== null) {
97 at = i
98 break
99 }
100 } else if (opens === fence) {
101 fence = undefined
102 }
103 }
104 if (at < 0) return null
105 const ask = inlineAsk(line(at))
106 if (ask !== null) lines[at] = ask
107 const start = ask === null ? at + 1 : at
108
109 let end = start
110 let isFirst = true
111 while (end < lines.length) {
112 let next = end
113 while (next < lines.length && line(next).trim() === '') next++
114 if (next >= lines.length || BREAK.test(line(next))) break
115 if (!isFirst && !LIST_OR_INDENT.test(line(next))) break
116 // Take the paragraph, and any list item that runs on without a blank line. A fence opened in
117 // it runs to its close, or to the end of the reply if it never closes.
118 end = next
119 let open: string | undefined
120 while (end < lines.length && (open !== undefined || line(end).trim() !== '')) {
121 if (open === undefined && end > next && BREAK.test(line(end))) break
122 const marks = fenceOf(line(end))
123 if (open === undefined) open = marks
124 else if (marks === open) open = undefined
125 end++
126 }
127 isFirst = false
128 }
129
130 return {
131 before: lines.slice(0, at).join('\n').trim(),
132 section: lines.slice(start, end).join('\n').trim(),
133 after: lines.slice(end).join('\n').trim(),
134 }
135}
136
137/**
138 * One thing the section asks of Rob: the bullet's text, and its choices when it is a decision,
139 * with any note written under them.
140 */
141type Item = { text: string; question: string; choices: Choice[]; note: string }
142type Choice = { number: string; label: string; answer: string; isRecommended: boolean }
143
144const TOP_BULLET = /^[-*+]\s+/
145const CHOICE = /^\s+(\d+)[.)]\s+(.*)$/
146const RECOMMENDED = /\s*(\*\*|__|\*|_)?\(recommended\)\1?\s*/i
147
148/**
149 * A "?" that ends a sentence: followed, past any closing bold, code, bracket or quote, by a space
150 * or the end. The "?" of a URL query runs on into the query, so it never counts.
151 */
152const ENDS_QUESTION = /\?[*_`)"']*(\s|$)/
153
154/**
155 * The longest choice drawn on its own button. A decision with a longer choice lists its choices
156 * as text and numbers its buttons, so no button runs past the box.
157 */
158const LONGEST_LABEL = 40
159
160/** Whether the decision's buttons carry numbers, its choices listed as text above them. */
161function isNumbered(item: Item): boolean {
162 return item.choices.some(choice => choice.label.length > LONGEST_LABEL)
163}
164
165/**
166 * The section as its bullets, when it is written as one: each top-level bullet is an item, and a
167 * bullet with a numbered list under it is a decision whose choices are that list. Rob's
168 * CLAUDE.md writes decisions that way, with `(recommended)` after the recommended choice. A
169 * section not written as bullets is null, and draws as plain Markdown with no buttons.
170 */
171function itemsOf(section: string): Item[] | null {
172 const lines = section.split('\n')
173 if (!TOP_BULLET.test(lines[0] ?? '')) return null
174 const items: string[][] = []
175 for (const line of lines) {
176 if (TOP_BULLET.test(line)) items.push([line])
177 else items[items.length - 1]?.push(line)
178 }
179 return items.map(block => {
180 const text = block.join('\n').trim()
181 const plain: Item = { text, question: '', choices: [], note: '' }
182 const firstChoice = block.findIndex(line => CHOICE.test(line))
183 if (firstChoice === -1) return plain
184 // Choices are the numbered lines at the first choice's indent, with only blank lines between
185 // them. A choice with lines of its own under it is more than a button can carry, so the bullet
186 // draws as text instead. After the last choice and a blank line, lines no deeper than the
187 // choices are a note, drawn under the buttons. Without the blank line Markdown reads them as
188 // more of the last choice, so they count as the choice's own lines.
189 const indentOf = (line: string) => (/^\s*/.exec(line)?.[0] ?? '').length
190 const indent = indentOf(block[firstChoice] ?? '')
191 const isChoice = (line: string) => CHOICE.test(line) && indentOf(line) === indent
192 let lastChoice = firstChoice
193 block.forEach((line, i) => {
194 if (isChoice(line)) lastChoice = i
195 })
196 const list = block.slice(firstChoice, lastChoice + 1)
197 if (list.some(line => line.trim() !== '' && !isChoice(line))) return plain
198 const tail = block.slice(lastChoice + 1)
199 const noteAt = tail.findIndex(line => line.trim() !== '')
200 if (noteAt === 0) return plain
201 const note = noteAt === -1 ? [] : tail.slice(noteAt)
202 if (note.some(line => line.trim() !== '' && indentOf(line) > indent)) return plain
203 const question = block.slice(0, firstChoice).join(' ').replace(TOP_BULLET, '').replace(/\s+/g, ' ').trim()
204 // A decision asks a direct question, as Rob's CLAUDE.md has it: a sentence of its text ends
205 // in "?", though context may follow it. An action with numbered steps is the same shape
206 // without the question, and its steps are not answers. A lead-in ending in ":" introduces
207 // steps, whatever it asked on the way. A "?" in a URL does not count.
208 const isQuestion = ENDS_QUESTION.test(question) && !question.replace(/[*_`\s]+$/, '').endsWith(':')
209 if (!isQuestion) return plain
210 const choices = list.filter(isChoice).map(line => {
211 // The reply carries the choice as written, so code and links reach Claude intact. A button
212 // draws plain text, so its face loses the backticks around code, the bold and a link's
213 // address, never what is inside the code.
214 const found = CHOICE.exec(line)
215 const answer = (found?.[2] ?? '').replace(RECOMMENDED, ' ').replace(/\s+/g, ' ').trim()
216 return {
217 number: found?.[1] ?? '',
218 label: answer.replace(/`([^`]*)`|\*\*(.+?)\*\*|\[([^\]]+)\]\([^)]*\)/g, (_, code, bold, link) => code ?? bold ?? link),
219 answer,
220 isRecommended: RECOMMENDED.test(line),
221 }
222 })
223 if (choices.length < 2) return plain
224 return { text, question, choices, note: note.map(line => line.slice(Math.min(indent, indentOf(line)))).join('\n').trim() }
225 })
226}
227
228/** What pressing sends as Rob's reply: each decision's question, then the choice he made. */
229function answerFor(answers: Array<{ question: string; answer: string }>): string {
230 return answers.map(({ question, answer }) => `${question}\n${answer}`).join('\n\n')
231}
232
233/**
234 * The creature, transcribed cell for cell from `cells()` in the deck's `plugin/tools/export_faces.py`.
235 * The eyes are the holes at (2, 2) and (8, 2). Row 7 is the lower half of the legs, which the
236 * hop tucks away.
237 */
238function sprite(): { body: string; feet: string } {
239 const cells: Array<[number, number]> = []
240 for (const r of [0, 1]) for (let c = 1; c <= 9; c++) cells.push([c, r])
241 for (let c = 0; c <= 10; c++) if (c !== 2 && c !== 8) cells.push([c, 2])
242 for (let c = 0; c <= 10; c++) cells.push([c, 3])
243 for (const r of [4, 5]) for (let c = 1; c <= 9; c++) cells.push([c, r])
244 for (const c of [1, 3, 7, 9]) cells.push([c, 6])
245 const rect = ([c, r]: [number, number]) => `<rect x="${c}" y="${r}" width="1" height="1"/>`
246 return {
247 body: cells.map(rect).join(''),
248 feet: [1, 3, 7, 9].map(c => rect([c, 7])).join(''),
249 }
250}
251
252/**
253 * The hop, as ratified on the deck (`HOP` in `plugin/src/hop.ts`): nine frames over 1680 ms.
254 * The offsets are the deck's 216 px render divided by its 15 px cell, so they are in cells
255 * here. Frames 3 to 5 have the legs tucked.
256 */
257const HOP_TIMES = '0;0.41667;0.45833;0.5119;0.57738;0.63095;0.67262;0.72619;0.7619'
258const HOP_OFFSETS = '0 0;0 -0.5333;0 -1.4667;0 -2;0 -1.4667;0 -0.5333;0 0;0 0.1333;0 0'
259
260// Drawn as an image, not in the interactive frame. `SvgProps.isInteractive` says SMIL needs the
261// frame, but in the desktop app (engine 2.1.286, 3 October 2026) an image plays it and stays
262// transparent, while the frame paints an opaque white page behind the SVG whatever it declares.
263function creature(isHopping: boolean): string {
264 const { body, feet } = sprite()
265 const hop = isHopping
266 ? `<animateTransform attributeName="transform" type="translate" calcMode="discrete" dur="1.68s" repeatCount="indefinite" keyTimes="${HOP_TIMES}" values="${HOP_OFFSETS}"/>`
267 : ''
268 const tuck = isHopping
269 ? '<animate attributeName="opacity" calcMode="discrete" dur="1.68s" repeatCount="indefinite" keyTimes="0;0.45833;0.63095" values="1;0;1"/>'
270 : ''
271 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -2.5 12 11" width="48" height="44" shape-rendering="crispEdges"><g fill="${BODY}">${hop}${body}<g>${tuck}${feet}</g></g></svg>`
272}
273
274/** The prompt origins that are not Rob answering. */
275const NOT_ROB = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message'])
276
277export const register: Register = on => {
278 // A turn that ends on a reply with a Needs you section makes that section the one that hops.
279 // The final text is what the deck reads too (`last_assistant_message` at Stop). Only the main
280 // conversation counts: a subagent finishing is not a reply to Rob, and Stop, which the deck
281 // reads, fires only for the main loop.
282 //
283 // The section's text is the key, since nothing here ties a turn to the message it drew, so an
284 // earlier reply with exactly the same section hops alongside the latest one.
285 on('turn.complete', async ($, e, next) => {
286 const result = await next(e)
287 if (e.agentId !== undefined) return result
288 // A reply without a section leaves the hop alone: Rob's own prompt already settled it before
289 // a turn he started, and a turn he did not start does not answer the ask.
290 const found = split(result.text)
291 if (found !== null) {
292 await $.state.set(active, found.section)
293 await $.state.set(picks, null)
294 }
295 return result
296 })
297
298 // Rob answering settles it. A prompt he did not send (a background task finishing, a
299 // scheduled trigger, another session's message) is not an answer and leaves it hopping,
300 // through the turn it starts as well.
301 on('prompt.submit', async ($, e, next) => {
302 if (!NOT_ROB.has(e.origin.kind)) {
303 await $.state.set(active, null)
304 await $.state.set(picks, null)
305 }
306 return next(e)
307 })
308
309 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
310 const found = split(e.props.text)
311 if (found === null) return next(e)
312 if ([found.before, found.section, found.after].some(part => part.length > MARKDOWN_LIMIT)) {
313 return next(e)
314 }
315
316 const { value = null } = await $.state.get(active)
317 const isHopping = value === found.section
318 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
319
320 // The terminal has no Svg; its table hands one out anyway, as an empty fragment, so the
321 // surface decides, not whether the element exists.
322 let figure
323 if (e.surface === 'terminal') {
324 figure = <Text color={BODY} bold>{isHopping ? '▲' : '■'}</Text>
325 } else {
326 const { Svg } = $.ui.resolve(e)
327 figure = <Svg source={creature(isHopping)} alt={isHopping ? 'Claude hopping' : 'Claude'} width={48} height={44} />
328 }
329
330 // Pressing answers for Rob. The plugin's own prompt.submit hook does not see a prompt the
331 // plugin submits, so the box settles here, before the reply goes.
332 // Only one press answers: it closes the ask with `ifVersion`, so a second press before the
333 // redraw finds it closed and sends nothing.
334 const answer = async (text: string) => {
335 const open = await $.state.get(active)
336 if (open.value !== found.section) return
337 const closed = await $.state.set(active, null, { ifVersion: open.version })
338 if (!closed.isSet) return
339 await $.state.set(picks, null)
340 await $.prompt.submit({ text, asUser: true })
341 }
342
343 // Buttons only while the ask is open: once Rob has answered, an old box pressing out a reply
344 // would answer a question nobody is asking any more.
345 const items = isHopping ? itemsOf(found.section) : null
346 const decisions = (items ?? []).filter(item => item.choices.length > 0)
347 let body
348 if (items === null || decisions.length === 0) {
349 body = found.section ? <Markdown text={found.section} /> : null
350 } else {
351 const { value: held = null } = await $.state.get(picks)
352 const chosen = held !== null && held.section === found.section ? held.chosen : {}
353 const isSingle = decisions.length === 1
354 // A pick reads the picks as they stand, not as this drawing saw them, and tries again if
355 // another press wrote in between, so two quick presses on different decisions both land.
356 const pick = async (d: number, c: number) => {
357 for (let tries = 0; tries < 5; tries++) {
358 const now = await $.state.get(picks)
359 const kept = now.value != null && now.value.section === found.section ? now.value.chosen : {}
360 const chosen = { ...kept, [String(d)]: c }
361 const wrote = await $.state.set(picks, { section: found.section, chosen }, { ifVersion: now.version })
362 if (wrote.isSet) return
363 }
364 }
365 body = (
366 <Box flexDirection="column">
367 {items.map((item, i) => {
368 if (item.choices.length === 0) return <Markdown key={`item-${i}`} text={item.text} />
369 const d = decisions.indexOf(item)
370 // Long choices stay readable as a numbered list, and their buttons carry the numbers.
371 const numbered = isNumbered(item)
372 const list = numbered
373 ? item.choices.map(choice => `\n ${choice.number}. ${choice.answer}${choice.isRecommended ? ' (recommended)' : ''}`).join('')
374 : ''
375 return (
376 <Box key={`item-${i}`} flexDirection="column">
377 <Markdown text={`- ${item.question}${list}`} />
378 <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
379 {item.choices.map((choice, c) => {
380 const isPicked = chosen[String(d)] === c
381 const face = numbered ? choice.number : choice.label
382 return (
383 <Button
384 key={`choice-${d}-${c}`}
385 label={isPicked ? `✓ ${face}` : face}
386 variant={choice.isRecommended ? 'primary' : 'secondary'}
387 onPress={() => {
388 if (isSingle) {
389 void answer(answerFor([{ question: item.question, answer: choice.answer }]))
390 } else {
391 void pick(d, c)
392 }
393 }}
394 />
395 )
396 })}
397 </Box>
398 {item.note ? (
399 <Box paddingLeft={2}>
400 <Markdown text={item.note} />
401 </Box>
402 ) : null}
403 </Box>
404 )
405 })}
406 {!isSingle && decisions.every((_, d) => chosen[String(d)] !== undefined) ? (
407 <Box paddingLeft={2} marginTop={1}>
408 <Button
409 key="send"
410 label="Send answers"
411 variant="primary"
412 onPress={async () => {
413 // The picks as they stand, not as this drawing saw them.
414 const now = await $.state.get(picks)
415 const fresh = now.value != null && now.value.section === found.section ? now.value.chosen : chosen
416 const answers = decisions.map((item, d) => ({ question: item.question, answer: item.choices[fresh[String(d)] ?? 0]?.answer ?? '' }))
417 await answer(answerFor(answers))
418 }}
419 />
420 </Box>
421 ) : null}
422 </Box>
423 )
424 }
425
426 return (
427 <Box flexDirection="column">
428 {found.before ? <Markdown text={found.before} /> : null}
429 <Box flexDirection="row" borderStyle="round" borderColor={BODY} borderDimColor={!isHopping} paddingX={1} columnGap={2} marginY={1}>
430 <Box flexShrink={0} alignItems="flex-start">{figure}</Box>
431 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
432 <Text color={BODY} bold>Needs you</Text>
433 {body}
434 </Box>
435 </Box>
436 {found.after ? <Markdown text={found.after} /> : null}
437 </Box>
438 )
439 })
440}
441types/index.d.ts 15 lines1/** The Needs you section of the latest reply that has one, until Rob next sends a prompt. */
2export type Active = string | null
3
4/**
5 * The choices Rob has pressed in the hopping box while it holds more than one decision, by the
6 * decision's place in the section. Kept with the section they belong to, so a new ask starts clean.
7 */
8export type Picks = { section: string; chosen: Record<string, number> } | null
9
10declare module 'claude-code' {
11 interface PluginState {
12 'needs-you': { active: Active; picks: Picks }
13 }
14}
15