A side pane that collects everything Claude needs from you: decisions, things to do, things to know. Answer in place; the transcript stays quiet.

A Claude Code mod. Everything Claude needs from you lands in one side pane: decisions to make, things to do outside the chat, things you should know. You answer there. The transcript stays quiet.
Working with an agent means a lot of scrolling back to find the one line that actually needed you: which option?, did you paste the key?, heads up, the old endpoint goes away. Those questions get buried under tool output and narration.
needs-you gives Claude a pin tool and a standing instruction to use it the moment something needs you, not at the end of a long reply. Each pin becomes a card in a docked pane. You answer on the card and the answer reaches Claude as a normal message. Nothing else changes about how you work.
At the prompt of a terminal session:
/plugin install needs-you --marketplace brandon-stride/needs-you
Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install needs-you --marketplace brandon-stride/needs-you. In the desktop app: + next to the prompt, Plugins, add the marketplace brandon-stride/needs-you, then add the plugin.
The pane docks beside the transcript in fullscreen terminals of 110 columns or more, and sits above the prompt in narrower ones. Pictures (the mascot) need a terminal with the kitty graphics protocol: Ghostty, kitty, WezTerm, iTerm2 3.5. Elsewhere it falls back to half-block pixels.
Each card has a colour for its kind, the question in plain words, the reason, and the ways to answer:
| Kind | Colour | Answer with |
|---|---|---|
| decide | amber | option chips, the recommendation starred, or ✓ go with it |
| do | clay | ✓ done once you have done it |
| know | sky | ✓ got it |
Every card also takes a note (context, a correction) and has 💬 chat, which brings the item into the conversation.
Your answer goes to Claude quietly while a turn runs, or as a prompt when the session is idle. An answered card folds to one line, "sending", then "Claude has it", and leaves once Claude resolves it. Helper agents can pin too; the card says who asked.
Focus mode (on by default) hides tool rows in the transcript and folds Claude's working narration once a turn ends, so what stays on screen is the answer and the pane. Press ctrl+o to see everything, or turn it off below.
The mascot is a pixel Clawd who lives in the minimized pane: a random hat, something in hand, a dance while items wait, a reaction when you press something, and a nap when nothing does.
| Command | Does |
|---|---|
/needs-you | open the pane |
/needs-you add <text> | pin a reminder of your own |
/needs-you focus on / off | focus mode |
/needs-you catch on / off | the reply catcher, which pins questions Claude asked in prose but forgot to pin |
/needs-you clear | drop every item |
─ minimizes the pane, ✕ or Escape closes it until the next pin. While items wait and the pane is off screen, a line under the prompt says so.
hooks/register.tsx holds every hook: the tools Claude calls (pin, resolve, update, list), the system-prompt section that tells it when to pin, turn tracking, the pane, focus mode and the command.hooks/items.ts is pure logic: parsing pins, building the messages, the seen/archive rules, the reply catcher.hooks/clawd.ts draws the mascot on a 30×28 pixel grid and turns it into RGBA for a terminal image or half-block cells for a raster.types/index.d.ts is the state contract the engine checks the module against.Items persist per session in the plugin store; settings persist across sessions.
claude --plugin-dir . # run it from this folder; saves hot-reload
npm run validate # claude plugin validate .
npm test # claude plugin test .
npm run typecheck # needs .claude-plugin/types/, which the engine writes on first load
npm run media # re-render media/clawd.png from the drawing code
MIT
hooks/register.tsx 1116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { RASTER_COLS, RASTER_ROWS, draw, frameAt, frameKey, toRaster, toRgba } from './clawd'
5import type { Reaction } from './clawd'
6
7import type { Item, Kind, Live, Note, PaneSettings } from '../types'
8import {
9 CATCH_SYSTEM,
10 DEFAULT_SETTINGS,
11 TOOL_LIST,
12 TOOL_PIN,
13 TOOL_RESOLVE,
14 TOOL_UPDATE,
15 WAKE_TEXT,
16 archiveSeen,
17 avatarLine,
18 catchPrompt,
19 caughtNote,
20 clip,
21 doneItems,
22 findByN,
23 firstLine,
24 fyiFor,
25 hasCue,
26 isQuiet,
27 listReply,
28 markSeen,
29 messageFor,
30 newItem,
31 normalize,
32 oneLine,
33 openItems,
34 parseCaught,
35 parseCommand,
36 parsePin,
37 pendingKey,
38 personMessageFor,
39 pinReply,
40 wantedRows,
41} from './items'
42import type { Answer, Pin } from './items'
43
44const PANE = 'needs-you'
45
46// Obsidian: the pane sits a hair above the terminal's near-black (#08090a), each
47// item a card a step above that, with one colour per kind on its edge.
48const INK = {
49 pane: '#0d0f12',
50 card: '#14171c',
51 cardOpen: '#1a1e25',
52 mute: '#5c636e',
53 chip: '#262b33',
54 chipText: '#d7dbe2',
55 decide: '#E6B84A',
56 do: '#D97757',
57 know: '#6FA8DC',
58} as const
59// Ask for a wide dock: the engine caps it at 45% of the terminal.
60const COLUMNS = 90
61const FRESH_MS = 12_000
62const TICK_MS = 250
63const REACT_MS = 2_500
64// The picture's box in the pane: 10 columns by 5 rows is about square.
65const PIC_COLS = 10
66const PIC_ROWS = 5
67const PIC_SCALE = 3
68// A second "chat about this" on the same item within this long is ignored.
69const CHAT_MS = 15_000
70// Option labels on chips are cut to this many columns.
71const OPTION_COLS = 30
72// The store keeps other sessions' lists this long, and this many of them.
73const STORE_SHAPE = 'items-2'
74const KEEP_MS = 14 * 24 * 60 * 60 * 1000
75const KEEP_SESSIONS = 30
76// The folded-narration ids kept across turns.
77const INTERIM_CAP = 500
78
79type $T = EngineInterface
80
81// Bump a tag when its value's fields change: old values are then declined.
82const items = atom({ plugin: 'needs-you', key: 'items' } as const, [] as Item[], { shape: 'items-1' })
83const settings = atom({ plugin: 'needs-you', key: 'settings' } as const, { ...DEFAULT_SETTINGS } as PaneSettings, { shape: 'settings-1' })
84const live = atom({ plugin: 'needs-you', key: 'live' } as const, {
85 isTurnRunning: false,
86 turnId: '',
87 pinsThisTurn: 0,
88 lastPinAt: 0,
89 tick: 0,
90} as Live)
91// Assistant blocks of past turns that came before each turn's last tool call:
92// the working narration, folded to one line once its turn is over.
93const interim = atom({ plugin: 'needs-you', key: 'interim' } as const, [] as string[])
94
95// Per-load bookkeeping; a reload starts it over, the state above stays.
96const local = {
97 sessionId: '',
98 paneIsPlaced: false,
99 drafts: {} as Record<string, string>,
100 // Assistant blocks first drawn during the running main turn, in order, and
101 // every block drawn before it began (a re-draw of an old one is not new).
102 assistantIds: [] as string[],
103 knownIds: new Set<string>(),
104 cutIndex: 0,
105 isTurnRunning: false,
106 // Items whose answer is being handled right now (a second press is ignored),
107 // and those whose row is mid-append, so no request counts as carrying it yet.
108 inFlight: new Set<string>(),
109 holding: new Set<string>(),
110 chatAt: {} as Record<string, number>,
111 // The pending answers the last wake-up prompt was for.
112 wokeFor: '',
113 storeWarned: false,
114 // The mascot: pictures while the terminal draws them, half-block cells once
115 // a blit says it cannot; the last frame blitted, so a beat with no change
116 // sends nothing; the reaction to the person's last press, for a moment.
117 pictures: true,
118 frameKey: '',
119 reaction: undefined as { kind: Reaction; until: number } | undefined,
120 // Unanswered items while the pane is off screen: what the hint line shows.
121 offScreen: 0,
122}
123
124const SECTION = {
125 id: 'needs-you:pane',
126 scope: 'session',
127 text: [
128 '# The Needs-you pane',
129 `The person reads a side pane, not your transcript, for anything that needs them. Whenever you need the person to DECIDE something, DO something outside this chat (mint a key, click approve, pay, reply to someone), or KNOW something that blocks or changes the work, call ${TOOL_PIN} at that moment, not at the end of a long reply. One item per call. Plain words a stranger would follow: no code names, paths or jargon unless they must act on them; what and why, one line each. A decision lists 2 to 4 options, your recommendation first.`,
130 `A message containing [needs-you] is the person answering an item from the pane: act on it and call ${TOOL_RESOLVE} with that number once handled. A note on an item is added context: take it into account, and resolve only once the item is settled. One that says no reply is needed needs none. If the question of an open item changes, call ${TOOL_UPDATE}; do not pin it again. Never repeat an open item's question in prose; say "waiting on #3 in the pane" and move on. ${TOOL_LIST} shows what is open. Subagents pin too; the pane shows who asked.`,
131 ].join('\n\n'),
132} as const
133
134export const register: Register = on => {
135 let timer: Timer | undefined
136
137 // Registered first, so it runs for every tool, the pane's own included (the
138 // pane's tool hooks below answer without calling next). A tool call ends the
139 // narration of the turn so far: blocks drawn before it fold once it is over.
140 on('tool.call', async ($, e, next) => {
141 if (e.agentId === undefined) local.cutIndex = local.assistantIds.length
142 return next(e)
143 })
144
145 on('session.start', async ($, e, next) => {
146 local.sessionId = await $.session.id()
147 await restore($, false)
148 $.clock.after(0, () => {
149 void pruneStore($).catch(() => undefined)
150 })
151 await Promise.all([
152 $.tool.register({
153 name: 'pin',
154 description:
155 'Pin something the person must decide, do outside this chat, or know. It shows in their Needs-you pane; they answer there. Plain words, one item per call.',
156 isDeferred: false,
157 inputSchema: {
158 type: 'object',
159 properties: {
160 kind: {
161 type: 'string',
162 enum: ['decide', 'do', 'know'],
163 description: 'decide: they pick. do: they act outside this chat. know: it blocks or changes the work.',
164 },
165 title: { type: 'string', description: 'One plain line, under 100 characters, no jargon.' },
166 detail: { type: 'string', description: 'One or two plain sentences: why, and what happens either way. Optional.' },
167 options: {
168 type: 'array',
169 items: { type: 'string' },
170 description: 'decide only: 2 to 4 short options, your recommendation first.',
171 },
172 },
173 required: ['kind', 'title'],
174 },
175 }),
176 $.tool.register({
177 name: 'resolve',
178 description: "Mark a Needs-you item handled, by its number. Call it once you have acted on the person's answer.",
179 isDeferred: false,
180 inputSchema: { type: 'object', properties: { n: { type: 'number' } }, required: ['n'] },
181 }),
182 $.tool.register({
183 name: 'update',
184 description: 'Change the title, detail or options of an open Needs-you item, by its number. Use it when the question changes instead of pinning it again.',
185 isDeferred: false,
186 inputSchema: {
187 type: 'object',
188 properties: {
189 n: { type: 'number' },
190 title: { type: 'string' },
191 detail: { type: 'string' },
192 options: { type: 'array', items: { type: 'string' } },
193 },
194 required: ['n'],
195 },
196 }),
197 $.tool.register({
198 name: 'list',
199 description: 'List the open Needs-you items and what the person has answered so far.',
200 isDeferred: false,
201 inputSchema: { type: 'object' },
202 }),
203 $.command.register({
204 name: 'needs-you',
205 description: 'Needs-you pane: open it, focus on|off, add <text>, clear, catch on|off',
206 argumentHint: '[focus on|off] [add <text>] [clear] [catch on|off]',
207 immediate: true,
208 }),
209 ])
210 // Open at startup (a new session, or a reload) only when restored items are
211 // waiting; otherwise the first pin or /needs-you opens it.
212 if (openItems(await read($, items)).length > 0) {
213 const opened = await $.ui.open(await openArgs($))
214 local.paneIsPlaced = opened.isPlaced
215 } else {
216 local.paneIsPlaced = (await $.ui.panes()).some(p => p.id === PANE)
217 }
218 timer?.cancel()
219 timer = $.clock.every(TICK_MS, () => {
220 void tick($).catch(() => undefined)
221 })
222 return next(e)
223 })
224
225 // The person's close (the mark, Esc, or the ✕ chip) sticks until the next pin.
226 on('ui.close', async ($, e, next) => {
227 if (e.id === PANE) {
228 local.paneIsPlaced = false
229 await signal($)
230 }
231 return next(e)
232 })
233
234 // A /clear ends the conversation, not the session: the list empties and the
235 // tick keeps running (no session.start follows). The next turn.start picks up
236 // the new session id.
237 on('session.end', async ($, e, next) => {
238 if (e.reason === 'clear') {
239 await update($, items, () => [])
240 await update($, interim, () => [])
241 local.assistantIds = []
242 local.knownIds.clear()
243 local.wokeFor = ''
244 } else {
245 timer?.cancel()
246 timer = undefined
247 }
248 return next(e)
249 })
250
251 // ---- the model's side: pin, resolve, update, list ------------------------
252
253 on('tool.call', { tool: TOOL_PIN }, async ($, e) => {
254 const parsed = parsePin(e as Record<string, unknown>)
255 if (!parsed.ok) return { result: `Not pinned: ${parsed.why}.` }
256 const { item, isPlaced } = await pin($, parsed.pin, { agentId: e.agentId })
257 return { result: pinReply(item, isPlaced) }
258 }).catch(() => ({ result: 'Not pinned: the Needs-you pane failed. Ask the person in one plain line instead.' }))
259
260 on('tool.call', { tool: TOOL_RESOLVE }, async ($, e) => {
261 const n = Number((e as Record<string, unknown>).n)
262 const list = await read($, items)
263 const item = findByN(list, n)
264 if (!item) return { result: `No item #${n}.` }
265 await setItems($, cur => cur.map(i => (i.n === n ? { ...i, status: 'done' as const } : i)))
266 return { result: `#${n} resolved.` }
267 }).catch(() => ({ result: 'The Needs-you pane failed to resolve that item; carry on.' }))
268
269 on('tool.call', { tool: TOOL_UPDATE }, async ($, e) => {
270 const args = e as Record<string, unknown>
271 const n = Number(args.n)
272 const list = await read($, items)
273 const item = findByN(list, n)
274 if (!item || item.status === 'done') return { result: `No open item #${n}.` }
275 const parsed = parsePin({
276 kind: item.kind,
277 title: args.title ?? item.title,
278 detail: args.detail ?? item.detail,
279 options: args.options ?? item.options,
280 })
281 if (!parsed.ok) return { result: `Not changed: ${parsed.why}.` }
282 await setItems($, cur => cur.map(i => (i.n === n ? { ...i, ...parsed.pin } : i)))
283 return { result: `#${n} updated.` }
284 }).catch(() => ({ result: 'The Needs-you pane failed to update that item; carry on.' }))
285
286 on('tool.call', { tool: TOOL_LIST }, async $ => ({ result: listReply(await read($, items)) })).catch(() => ({
287 result: 'The Needs-you pane is not answering.',
288 }))
289
290 on('prompt.compose', async ($, e, next) => {
291 const r = await next(e)
292 return { sections: [...r.sections, SECTION] }
293 })
294
295 // ---- the turn: what is running, what has been read -----------------------
296
297 on('turn.start', async ($, e, next) => {
298 if (e.agentId === undefined) {
299 // A /clear or an in-process resume changes the session without a
300 // session.start: follow it, so the list is that session's.
301 const sid = await $.session.id()
302 if (sid !== local.sessionId) {
303 local.sessionId = sid
304 await restore($, true)
305 }
306 for (const id of local.assistantIds) local.knownIds.add(id)
307 local.assistantIds = []
308 local.cutIndex = 0
309 local.isTurnRunning = true
310 await update($, live, l => ({ ...l, isTurnRunning: true, turnId: e.turnId, pinsThisTurn: 0 }))
311 }
312 return next(e)
313 })
314
315 // Every model request carries what was handed to its loop before it started.
316 on('turn.step', async function* ($, e, next) {
317 const at = await $.clock.now()
318 const list = await read($, items)
319 const seen = markSeen(list, e.turnId, at, e.agentId, local.holding)
320 if (seen.some((i, k) => i !== list[k])) await setItems($, cur => markSeen(cur, e.turnId, at, e.agentId, local.holding))
321 return yield* next(e)
322 })
323
324 on('turn.complete', async ($, e, next) => {
325 const r = await next(e)
326 if (e.agentId !== undefined) {
327 await rescueFromAgent($, e.agentId)
328 return r
329 }
330 const l = await read($, live)
331 local.isTurnRunning = false
332 await update($, live, x => ({ ...x, isTurnRunning: false }))
333 const fold = local.assistantIds.slice(0, local.cutIndex)
334 for (const id of local.assistantIds) local.knownIds.add(id)
335 local.assistantIds = []
336 local.cutIndex = 0
337 if (fold.length) await update($, interim, prev => [...prev, ...fold.filter(id => !prev.includes(id))].slice(-INTERIM_CAP))
338 const list = await read($, items)
339 const kept = archiveSeen(list, e.turnId)
340 if (kept.some((i, k) => i !== list[k])) await setItems($, cur => archiveSeen(cur, e.turnId))
341 const s = await read($, settings)
342 if (s.catch && e.reason === 'answer' && l.pinsThisTurn === 0 && hasCue(e.answer)) {
343 const answer = e.answer
344 const turnId = e.turnId
345 $.clock.after(0, () => {
346 void catchFromReply($, answer, turnId).catch(() => undefined)
347 })
348 }
349 // An answer that reached the conversation during the turn's last request
350 // is read by nothing until the next turn: start one. Only after a turn that
351 // answered: not after the person stopped it, nor after an error.
352 if (e.reason === 'answer') {
353 $.clock.after(0, () => {
354 void wakeIfStranded($).catch(() => undefined)
355 })
356 }
357 return r
358 })
359
360 // ---- the person's side: the slash command -------------------------------
361
362 on('command.run', { command: 'needs-you' }, async ($, e) => {
363 const { verb, rest } = parseCommand(e.args)
364 const s = await read($, settings)
365 if (verb === 'focus') {
366 const focus = rest === 'off' ? false : rest === 'on' ? true : !s.focus
367 await setSettings($, { focus })
368 $.ui.invalidate('ui.render')
369 return {
370 text: focus
371 ? 'Focus on: tool rows and working notes are hidden (ctrl+o still shows all).'
372 : 'Focus off: the full transcript is back.',
373 }
374 }
375 if (verb === 'catch') {
376 const isOn = rest !== 'off'
377 await setSettings($, { catch: isOn })
378 return { text: isOn ? 'Reply catcher on: asks that were not pinned get pinned after each reply.' : 'Reply catcher off.' }
379 }
380 if (verb === 'add' && rest) {
381 const { item } = await pin($, { kind: 'do', title: oneLine(rest).slice(0, 140), detail: '', options: [] }, { from: 'you', isCounted: false })
382 return { text: `Pinned #${item.n}: ${item.title}` }
383 }
384 if (verb === 'clear') {
385 await setItems($, cur => cur.map(i => (i.status === 'open' ? i : { ...i, status: 'done' as const })))
386 return { text: 'Answered items cleared.' }
387 }
388 const opened = await $.ui.open({ ...(await openArgs($)), focus: true })
389 local.paneIsPlaced = opened.isPlaced
390 const open = openItems(await read($, items)).length
391 return {
392 text: opened.isPlaced
393 ? `Needs-you pane open: ${open} item${open === 1 ? '' : 's'} waiting. Focus mode is ${s.focus ? 'on' : 'off'}.`
394 : `The pane could not be drawn here (${opened.reason}).`,
395 }
396 })
397
398 // ---- focus mode: a quiet transcript -------------------------------------
399 // The terminal alone: ctrl+o (isExpanded) shows what is hidden there. Other
400 // surfaces have no such key, so their transcript stays whole.
401
402 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
403 if (e.surface !== 'terminal') return next(e)
404 const s = await read($, settings)
405 if (!s.focus || e.props.isExpanded === true || e.props.isErrored || e.props.isInterrupted) return next(e)
406 const { Box } = $.ui.resolve(e)
407 return <Box display="none" />
408 })
409
410 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
411 if (e.surface !== 'terminal') return next(e)
412 const s = await read($, settings)
413 if (!s.focus || e.props.isExpanded === true || e.props.isErrored) return next(e)
414 const { Box } = $.ui.resolve(e)
415 return <Box display="none" />
416 })
417
418 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
419 if (e.surface !== 'terminal') return next(e)
420 const s = await read($, settings)
421 if (!s.focus || e.props.isExpanded) return next(e)
422 const { Box } = $.ui.resolve(e)
423 return <Box display="none" />
424 })
425
426 // While items wait and the pane is off screen, a few dim characters at the
427 // end of the hint line under the prompt (`? for shortcuts`) say so: no row of
428 // their own, nothing pinned.
429 on('ui.render', { component: 'PromptHint' }, async (_$, e, next) => {
430 if (!local.offScreen) return next(e)
431 const tail = `${e.props.tail ?? ''} ● ${local.offScreen} needs you`
432 return next({ ...e, props: { ...e.props, tail } })
433 })
434
435 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
436 const s = await read($, settings)
437 return s.focus ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e)
438 })
439
440 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
441 // Only a block first drawn while the main turn runs is that turn's; a
442 // re-draw of an older one (scroll, resize, invalidate) is not.
443 const id = e.requestId
444 if (!local.knownIds.has(id) && !local.assistantIds.includes(id)) {
445 if (local.isTurnRunning) local.assistantIds.push(id)
446 else local.knownIds.add(id)
447 }
448 if (e.surface !== 'terminal') return next(e)
449 const s = await read($, settings)
450 const folded = await read($, interim)
451 if (!s.focus || !folded.includes(id)) return next(e)
452 const { Box, Text } = $.ui.resolve(e)
453 const width = Math.max(20, (e.viewport?.columns ?? 80) - 6)
454 return (
455 <Box paddingLeft={2}>
456 <Text dimColor wrap="truncate">
457 · {firstLine(e.props.text, width)}
458 </Text>
459 </Box>
460 )
461 })
462
463 // ---- the pane -----------------------------------------------------------
464
465 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
466 if (!local.paneIsPlaced) {
467 local.paneIsPlaced = true
468 await signal($)
469 }
470 const [list, s, l] = await Promise.all([read($, items), read($, settings), read($, live)])
471 const now = await $.clock.now()
472 const open = openItems(list)
473 const done = doneItems(list)
474 // Only an unanswered item waits on the person; a sent or seen one is Claude's.
475 const waiting = open.filter(i => i.status === 'open').length
476 const width = Math.max(24, e.props.bodyColumns)
477 const face = { open: open.length, isFresh: now - l.lastPinAt < FRESH_MS, tick: Math.floor(now / 900), seed: open.length * 7 + done.length }
478 const frame = frameAt(await scene($))
479 local.frameKey = frameKey(frame)
480 const grid = draw(frame)
481 const tone = open.length ? 'claude' : 'inactive'
482
483 if (e.surface === 'mobile') {
484 const { Box, Text, Button } = $.ui.resolve(e)
485 return (
486 <Box flexDirection="column">
487 <Text color={tone}>
488 {avatarLine(face)} Needs you · {open.length} open
489 </Text>
490 {open.map(i => {
491 // Answered: a non-pressable [~] and where the answer is. Open: a
492 // decision shows its options, the first being the recommendation;
493 // anything else a box to tick.
494 const isOpen = i.status === 'open'
495 const state = i.status === 'sent' ? ' · sending' : i.status === 'seen' ? ' · Claude has it' : ''
496 return (
497 <Box key={`row:${i.id}`} flexDirection="column">
498 <Box flexDirection="row" gap={1}>
499 {!isOpen ? (
500 <Text dimColor>[~]</Text>
501 ) : i.options.length === 0 ? (
502 <Button key={`d:${i.id}`} plain label="[ ]" onPress={() => void answer($, i.id, { kind: 'check' })} />
503 ) : null}
504 <Text dimColor={!isOpen}>
505 {i.n} {i.title}
506 {state}
507 </Text>
508 </Box>
509 {isOpen && i.options.length > 0 && (
510 <Box flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={2}>
511 {i.options.map((o, k) => (
512 <Button
513 key={`o:${i.id}:${k}`}
514 plain
515 label={k === 0 ? `★ ${clip(o, OPTION_COLS)}` : clip(o, OPTION_COLS)}
516 onPress={() => void answer($, i.id, { kind: 'option', text: o })}
517 />
518 ))}
519 </Box>
520 )}
521 </Box>
522 )
523 })}
524 </Box>
525 )
526 }
527
528 const ui = $.ui.resolve(e)
529 const { Box, Text, Button, Input } = ui
530 // The terminal draws Clawd as a picture (kitty graphics), or as half-block
531 // cells once a blit says pictures cannot be drawn; other surfaces get the face.
532 const mascot =
533 'Image' in ui && local.pictures ? (
534 <ui.Image key="clawd" source={toRgba(grid, PIC_SCALE)} columns={PIC_COLS} rows={PIC_ROWS} alt="Claude" />
535 ) : 'Raster' in ui ? (
536 <ui.Raster key="clawd" columns={RASTER_COLS} rows={RASTER_ROWS} cells={toRaster(grid)} />
537 ) : (
538 <Text color={tone}>{avatarLine(face)}</Text>
539 )
540 const bodyRows = e.props.scroll.bodyRows
541 // A chip: a compact button drawn as a filled label, one space of padding,
542 // instead of the engine's wide `[ label ]`. The focus and the pointer invert it.
543 // No hotkeys: the engine prefixes a plain button with `a:` for one, which clutters the chip.
544 const chip = (key: string, text: string, onPress: () => void, o: { hue?: string; dim?: boolean } = {}) => (
545 <Button key={key} plain {...(o.dim ? { dimColor: true } : {})} onPress={onPress}>
546 <Text backgroundColor={o.hue ?? INK.chip} color={o.hue ? INK.pane : INK.chipText} bold={Boolean(o.hue)}>
547 {` ${text} `}
548 </Text>
549 </Button>
550 )
551
552 if (s.view === 'icon') {
553 return (
554 <Box flexDirection="column" alignItems="flex-start" backgroundColor={INK.pane} width={width} minHeight={bodyRows} paddingX={1}>
555 {mascot}
556 <Box flexDirection="row" gap={1}>
557 <Text bold color={open.length ? INK.decide : INK.mute}>
558 {waiting ? `${waiting} waiting` : 'all clear'}
559 </Text>
560 {chip('view', '▸ open', () => void setSettings($, { view: 'list' }))}
561 </Box>
562 </Box>
563 )
564 }
565
566 return (
567 <Box flexDirection="column" backgroundColor={INK.pane} width={width} minHeight={bodyRows} paddingX={1}>
568 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
569 <Box flexDirection="row" gap={1}>
570 <Text bold color={INK.do}>
571 Needs you
572 </Text>
573 {/* Under 40 columns the count alone, so the header keeps one row. */}
574 <Text dimColor>
575 {width < 40 ? (waiting ? String(waiting) : '') : waiting ? `${waiting} waiting on you` : open.length ? 'with Claude' : 'nothing waiting'}
576 </Text>
577 </Box>
578 <Box flexDirection="row" gap={1}>
579 {chip('view', '─', () => void setSettings($, { view: 'icon' }))}
580 {chip('close', '✕', () => void closePane($))}
581 </Box>
582 </Box>
583
584 {open.length === 0 && (
585 <Box flexDirection="column" paddingX={1}>
586 <Text dimColor>Claude pins what it needs from you here.</Text>
587 <Text dimColor>Try: /needs-you add something</Text>
588 </Box>
589 )}
590
591 {open.map(i => {
592 // An open item is a full card: title, why, the actions, a note line.
593 // Once answered it folds to one line that says where the answer is.
594 const hue = kindHue(i.kind)
595 const isOpen = i.status === 'open'
596 const state = i.status === 'sent' ? '… sending' : '✓ Claude has it'
597 const tick = i.kind === 'decide' ? 'go with it' : i.kind === 'do' ? 'done' : 'got it'
598 const fromAgent = i.from && i.from !== 'you' && i.from !== 'Claude' ? i.from : ''
599 if (!isOpen) {
600 return (
601 <Box key={`row:${i.id}`} flexDirection="row" marginBottom={1}>
602 <Box width={1} flexShrink={0} backgroundColor={INK.mute} />
603 <Box flexDirection="row" flexGrow={1} gap={1} paddingX={1} justifyContent="space-between">
604 <Box flexGrow={1} flexShrink={1}>
605 <Text dimColor wrap="truncate-end">
606 {i.n} {i.title}
607 {i.answer ? ` → ${i.answer}` : ''}
608 </Text>
609 </Box>
610 <Box flexShrink={0}>
611 <Text dimColor>{state}</Text>
612 </Box>
613 </Box>
614 </Box>
615 )
616 }
617 return (
618 <Box key={`row:${i.id}`} flexDirection="row" marginBottom={1}>
619 <Box width={1} flexShrink={0} backgroundColor={hue} />
620 <Box flexDirection="column" flexGrow={1} backgroundColor={INK.card} paddingX={1}>
621 <Box flexDirection="row" gap={1}>
622 <Text backgroundColor={hue} color={INK.pane} bold>
623 {` ${i.n} `}
624 </Text>
625 <Box flexGrow={1} width={width - 10}>
626 <Text wrap="wrap" bold>
627 {i.title}
628 {fromAgent ? <Text dimColor>{` from ${fromAgent}`}</Text> : null}
629 </Text>
630 </Box>
631 </Box>
632 {i.detail !== '' && (
633 <Box width={width - 6} paddingLeft={4}>
634 <Text wrap="wrap" dimColor>
635 {i.detail}
636 </Text>
637 </Box>
638 )}
639 <Box flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={4} marginTop={1}>
640 {i.options.length > 0
641 ? i.options.map((o, k) =>
642 chip(
643 `o:${i.id}:${k}`,
644 k === 0 ? `★ ${clip(o, OPTION_COLS)}` : clip(o, OPTION_COLS),
645 () => void answer($, i.id, { kind: 'option', text: o }),
646 k === 0 ? { hue } : {},
647 ),
648 )
649 : chip(`d:${i.id}`, `✓ ${tick}`, () => void answer($, i.id, { kind: 'check' }), { hue })}
650 {chip(`c:${i.id}`, '💬 chat', () => void answer($, i.id, { kind: 'chat' }))}
651 </Box>
652 {i.notes.map((n, k) => (
653 <Box key={`note:${i.id}:${k}`} paddingLeft={4}>
654 <Text dimColor wrap="wrap">
655 {n.status === 'seen' ? '✓' : '…'} {n.text}
656 </Text>
657 </Box>
658 ))}
659 {/* The field needs a width of its own, or a long note runs off the card:
660 pane padding (2), the colour bar (1), card padding (2), the indent (4). */}
661 <Box flexDirection="row" paddingLeft={4} width={width - 2}>
662 <Input
663 key={`n:${i.id}`}
664 placeholder="✎ add a note"
665 submitLabel=" "
666 rows={2}
667 value={local.drafts[i.id] ?? ''}
668 onInput={v => {
669 local.drafts[i.id] = v
670 }}
671 onSubmit={v => void answer($, i.id, { kind: 'note', text: v })}
672 />
673 </Box>
674 </Box>
675 </Box>
676 )
677 })}
678
679 {done.length > 0 && (
680 <Box flexDirection="column" marginTop={1}>
681 {/* A link-like toggle, not a chip: a filled chip keeps the engine's
682 focus inversion after the press and reads as "selected". */}
683 <Button key="done" plain dimColor onPress={() => void setSettings($, { showDone: !s.showDone })}>
684 {s.showDone ? `▴ hide ${done.length} done` : `▾ show ${done.length} done`}
685 </Button>
686 {s.showDone ? (
687 <Box flexDirection="column">
688 {done.slice(-12).map(i => (
689 <Text key={`done:${i.id}`} dimColor strikethrough>
690 {String(i.n)} {i.title}
691 </Text>
692 ))}
693 </Box>
694 ) : null}
695 </Box>
696 )}
697 </Box>
698 )
699 })
700}
701
702// ---- helpers that touch the engine ----------------------------------------
703
704// What the store keeps per session: the list's not-done items under a shape
705// tag, and when it was written. A value of another shape is declined.
706type Stored = { shape: string; at: number; items: Item[] }
707
708function asStored(raw: unknown): Stored | undefined {
709 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
710 const v = raw as Partial<Stored>
711 return v.shape === STORE_SHAPE && Array.isArray(v.items) && typeof v.at === 'number' ? (v as Stored) : undefined
712}
713
714// The session's stored list. On a change of session (`isNewSession`) the list
715// becomes that session's, empty when it has none; at a (re)load a stored list
716// replaces the one in memory only when there is one.
717async function restore($: $T, isNewSession: boolean) {
718 const stored = asStored(await $.store.get(`items:${local.sessionId}`).catch(() => undefined))
719 if (stored && stored.items.length) await update($, items, () => stored.items)
720 else if (isNewSession) await update($, items, () => [])
721 if (isNewSession) {
722 await update($, interim, () => [])
723 local.wokeFor = ''
724 }
725 const s = (await $.store.get('settings').catch(() => undefined)) as Partial<PaneSettings> | undefined
726 if (s && typeof s === 'object') await update($, settings, cur => ({ ...cur, ...s }))
727 await resize($)
728}
729
730// Other sessions' lists: drop those of another shape, empty, or older than
731// KEEP_MS, and all but the newest KEEP_SESSIONS, so the store stays small.
732async function pruneStore($: $T) {
733 const now = await $.clock.now()
734 const mine = `items:${local.sessionId}`
735 const kept: { key: string; at: number }[] = []
736 for (const key of await $.store.keys()) {
737 if (!key.startsWith('items:') || key === mine) continue
738 const v = asStored(await $.store.get(key))
739 if (v && v.items.length && now - v.at < KEEP_MS) kept.push({ key, at: v.at })
740 else await $.store.delete(key)
741 }
742 kept.sort((a, b) => b.at - a.at)
743 for (const { key } of kept.slice(KEEP_SESSIONS)) await $.store.delete(key)
744}
745
746// Every change to the list goes through one atomic update over the latest
747// value: a hook that read the list before a tool call wrote it (a turn step
748// marking "seen" around a resolve) must not put the old list back.
749async function setItems($: $T, fn: (list: readonly Item[]) => Item[]) {
750 const next = await update($, items, fn)
751 // The list in memory is what counts; a failed save (a full store) costs only
752 // the copy a restart would read, so it is said once and the work goes on.
753 try {
754 const value: Stored = { shape: STORE_SHAPE, at: await $.clock.now(), items: next.filter(i => i.status !== 'done') }
755 await $.store.set(`items:${local.sessionId}`, value)
756 } catch (err) {
757 if (!local.storeWarned) {
758 local.storeWarned = true
759 $.ui.toast(`Needs you: could not save the list (${String(err)}); it is kept for this session only.`)
760 }
761 }
762 await resize($)
763 await signal($)
764}
765
766async function setSettings($: $T, patch: Partial<PaneSettings>) {
767 const next = await update($, settings, s => ({ ...s, ...patch }))
768 await $.store.set('settings', next)
769 await resize($)
770}
771
772async function title($: $T): Promise<string> {
773 const open = openItems(await read($, items)).length
774 return open ? `Needs you (${open})` : 'Needs you'
775}
776
777async function rows($: $T): Promise<number> {
778 return wantedRows(await read($, items), await read($, settings))
779}
780
781// The pane follows its content: a re-open with the same id only re-sizes. A
782// pane the person closed stays closed; the next pin brings it back.
783async function resize($: $T) {
784 if (!local.paneIsPlaced) return
785 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
786 if (!isUp) {
787 local.paneIsPlaced = false
788 return
789 }
790 await $.ui.open(await openArgs($))
791}
792
793// Every open of the pane: its title and size follow the content, and Escape
794// closes it while it holds the keyboard (a running turn or typed text keeps
795// Escape for the main session).
796async function openArgs($: $T) {
797 const s = await read($, settings)
798 return {
799 id: PANE,
800 title: await title($),
801 columns: s.view === 'icon' ? 24 : COLUMNS,
802 rows: await rows($),
803 closeOnEscape: true as const,
804 }
805}
806
807// While items wait and the pane is not on screen (closed by hand, or an unasked
808// open held back on a narrow terminal), a pinned line under the prompt says so.
809async function signal($: $T) {
810 const waiting = openItems(await read($, items)).filter(i => i.status === 'open').length
811 const next = waiting > 0 && !local.paneIsPlaced ? waiting : 0
812 if (next !== local.offScreen) {
813 local.offScreen = next
814 $.ui.invalidate('ui.render')
815 }
816}
817
818async function closePane($: $T) {
819 local.paneIsPlaced = false
820 try {
821 await $.ui.close({ id: PANE })
822 } catch (err) {
823 $.ui.toast(`Needs you: could not close (${String(err)})`)
824 }
825 await signal($)
826}
827
828// A new item. `isCounted: false` (the reply catcher, the person's own `add`)
829// leaves the turn's pin count alone, which only the model's pins should move.
830async function pin(
831 $: $T,
832 p: Pin,
833 o: { agentId?: string; from?: string; isCounted?: boolean } = {},
834): Promise<{ item: Item; isPlaced: boolean }> {
835 const now = await $.clock.now()
836 const list = await read($, items)
837 const item = newItem(list, p, o.from ?? (await whoAsked($, o.agentId)), now, o.agentId)
838 await setItems($, cur => [...cur, item])
839 const counted = o.isCounted === false ? 0 : 1
840 await update($, live, l => ({ ...l, pinsThisTurn: l.pinsThisTurn + counted, lastPinAt: now }))
841 const opened = await $.ui.open(await openArgs($))
842 local.paneIsPlaced = opened.isPlaced
843 if (!opened.isPlaced) $.ui.toast(`Needs you #${item.n}: ${item.title} (/needs-you)`, { timeoutMs: 8000 })
844 await signal($)
845 return { item, isPlaced: opened.isPlaced }
846}
847
848async function whoAsked($: $T, agentId?: string): Promise<string> {
849 if (!agentId) return ''
850 try {
851 const agent = (await $.agent.list()).find(a => a.id === agentId)
852 return agent ? `${agent.name ?? agent.type} (${agent.description})` : 'a helper agent'
853 } catch {
854 return 'a helper agent'
855 }
856}
857
858// The person acted on an item. One answer per item at a time: a second press
859// while the first is still being handled (a double press, the same chip drawn
860// on two surfaces) is dropped.
861async function answer($: $T, id: string, given: Answer) {
862 if (local.inFlight.has(id)) return
863 local.inFlight.add(id)
864 try {
865 await handleAnswer($, id, given)
866 } finally {
867 local.inFlight.delete(id)
868 local.holding.delete(id)
869 }
870}
871
872async function handleAnswer($: $T, id: string, given: Answer) {
873 const item = (await read($, items)).find(i => i.id === id)
874 if (!item || item.status === 'done') return
875 let a = normalize(item, given)
876 // A tick or a choice answers the item once; after that it is on its way.
877 if ((a.kind === 'check' || a.kind === 'option') && item.status !== 'open') return
878 if (a.kind === 'note') {
879 const text = oneLine(a.text)
880 if (!text) return
881 a = { kind: 'note', text }
882 }
883 const now = await $.clock.now()
884 if (a.kind === 'chat') {
885 if (item.chatAt !== undefined || now - (local.chatAt[id] ?? -CHAT_MS) < CHAT_MS) return
886 local.chatAt[id] = now
887 }
888 react(a.kind === 'check' ? 'love' : a.kind === 'option' ? 'ok' : a.kind === 'note' ? 'note' : 'chat', now)
889
890 // The route: to the helper agent that asked (a chat goes to the main session,
891 // where the person talks), appended to the running turn (a quiet one to the
892 // conversation, running or not), or a prompt that starts a turn.
893 const quiet = isQuiet(item, a)
894 const agentId = a.kind === 'chat' ? undefined : item.agentId
895 const isRunning = (await read($, live)).isTurnRunning
896 // An appended row may miss a request that starts while it is on its way, so
897 // none counts as carrying it until the append is done. A prompt's turn starts
898 // after this, so its requests do carry it.
899 if (agentId || isRunning || quiet) local.holding.add(id)
900
901 const note = a.kind === 'note' ? { text: a.text, at: now, status: 'sent' as const } : undefined
902 await patchItem($, id, i => markSent(i, a, now, note))
903 if (note) local.drafts[id] = ''
904
905 const r = await deliver($, item, a, { agentId, quiet })
906 if ('failed' in r) {
907 await patchItem($, id, i => unmarkSent(i, item, a, note))
908 if (a.kind === 'note') local.drafts[id] = a.text
909 if (a.kind === 'chat') delete local.chatAt[id]
910 $.ui.toast(`Needs you #${item.n}: not delivered (${r.failed}). It is still open; try again.`)
911 return
912 }
913 const at = r.submitted ? now : await $.clock.now()
914 await patchItem($, id, i => stampSent(i, a, at, r.to, note))
915 local.holding.delete(id)
916 // The turn ended while the row went in: nothing will read it until a turn starts.
917 if (!r.submitted && !r.to && !quiet && !(await read($, live)).isTurnRunning) await wakeIfStranded($)
918}
919
920async function patchItem($: $T, id: string, fn: (i: Item) => Item) {
921 await setItems($, cur => cur.map(i => (i.id === id ? fn(i) : i)))
922}
923
924function sameNote(n: Note, note: Note | undefined): boolean {
925 return note !== undefined && n.at === note.at && n.text === note.text
926}
927
928// On its way: what the pane shows at once.
929function markSent(i: Item, a: Answer, now: number, note: Note | undefined): Item {
930 if (a.kind === 'check' || a.kind === 'option') {
931 const next: Item = { ...i, status: 'sent', sentAt: now, answer: a.kind === 'option' ? a.text : i.answer }
932 delete next.sentTo
933 return next
934 }
935 if (a.kind === 'note' && note) return { ...i, notes: [...i.notes, note] }
936 return { ...i, chatAt: now }
937}
938
939// Not delivered: back to how it was.
940function unmarkSent(i: Item, before: Item, a: Answer, note: Note | undefined): Item {
941 if (a.kind === 'check' || a.kind === 'option') {
942 if (i.status !== 'sent') return i
943 const next: Item = { ...i, status: before.status, answer: before.answer }
944 delete next.sentAt
945 delete next.sentTo
946 return next
947 }
948 if (a.kind === 'note') return { ...i, notes: i.notes.filter(n => !sameNote(n, note)) }
949 const next = { ...i }
950 delete next.chatAt
951 return next
952}
953
954// Handed over at `at`, to agent `to` (absent: the main conversation). What a
955// request has already carried stays as it is.
956function stampSent(i: Item, a: Answer, at: number, to: string | undefined, note: Note | undefined): Item {
957 if (a.kind === 'check' || a.kind === 'option') {
958 if (i.status !== 'sent') return i
959 const next: Item = { ...i, sentAt: at }
960 if (to) next.sentTo = to
961 return next
962 }
963 if (a.kind === 'note') {
964 return {
965 ...i,
966 notes: i.notes.map(n => (n.status === 'sent' && sameNote(n, note) ? { ...n, at, ...(to ? { to } : {}) } : n)),
967 }
968 }
969 return i.chatAt === undefined ? i : { ...i, chatAt: at }
970}
971
972type Delivered = { submitted: boolean; to?: string } | { failed: string }
973
974// Puts the answer in front of the model without interrupting anything. A
975// helper agent's item goes to that agent, the main session getting an FYI; if
976// the agent has finished, the main session takes the answer itself.
977async function deliver($: $T, item: Item, a: Answer, o: { agentId?: string; quiet: boolean }): Promise<Delivered> {
978 const hidden = messageFor(item, a)
979 if (o.agentId) {
980 const denied = await appendRow($, hidden, o.agentId)
981 if (denied === undefined) {
982 await appendRow($, fyiFor(item, a))
983 return { submitted: false, to: o.agentId }
984 }
985 }
986 if (o.quiet || (await read($, live)).isTurnRunning) {
987 const denied = await appendRow($, hidden)
988 return denied === undefined ? { submitted: false } : { failed: denied }
989 }
990 local.holding.delete(item.id)
991 try {
992 const r = await $.prompt.submit({ text: personMessageFor(item, a), asUser: true })
993 return r.drop !== undefined ? { failed: r.drop } : { submitted: true }
994 } catch (err) {
995 return { failed: String(err) }
996 }
997}
998
999// A hidden user row in a conversation (main, or `agentId`'s); the reason when
1000// it was refused or failed, undefined once it is stored.
1001async function appendRow($: $T, text: string, agentId?: string): Promise<string | undefined> {
1002 try {
1003 const r = await $.session.append({
1004 message: { type: 'user', content: [{ type: 'text', text }] },
1005 ...(agentId ? { agentId } : {}),
1006 })
1007 return r.deny
1008 } catch (err) {
1009 return String(err)
1010 }
1011}
1012
1013// A helper agent finished with answers handed to it that none of its requests
1014// carried: the main session takes them, as actionable rows.
1015async function rescueFromAgent($: $T, agentId: string) {
1016 const list = await read($, items)
1017 const texts: string[] = []
1018 for (const i of list) {
1019 if (i.status === 'sent' && i.sentTo === agentId) {
1020 texts.push(messageFor(i, i.answer ? { kind: 'option', text: i.answer } : { kind: 'check' }))
1021 }
1022 for (const n of i.notes) if (n.status === 'sent' && n.to === agentId) texts.push(messageFor(i, { kind: 'note', text: n.text }))
1023 }
1024 if (!texts.length) return
1025 for (const text of texts) await appendRow($, text)
1026 const at = await $.clock.now()
1027 await setItems($, cur =>
1028 cur.map(i => {
1029 let next = i
1030 if (i.status === 'sent' && i.sentTo === agentId) {
1031 next = { ...next, sentAt: at }
1032 delete next.sentTo
1033 }
1034 if (i.notes.some(n => n.status === 'sent' && n.to === agentId)) {
1035 next = {
1036 ...next,
1037 notes: next.notes.map(n => (n.status === 'sent' && n.to === agentId ? { text: n.text, at, status: n.status } : n)),
1038 }
1039 }
1040 return next
1041 }),
1042 )
1043 if (!(await read($, live)).isTurnRunning) await wakeIfStranded($)
1044}
1045
1046// Answers in the main conversation that no request carried, with the session
1047// idle: start a turn so they are read. Once per set of answers, so a wake-up
1048// that raced a request does not repeat.
1049async function wakeIfStranded($: $T) {
1050 if ((await read($, live)).isTurnRunning) return
1051 const key = pendingKey(await read($, items))
1052 if (!key) {
1053 local.wokeFor = ''
1054 return
1055 }
1056 if (key === local.wokeFor) return
1057 local.wokeFor = key
1058 void $.prompt.submit({ text: WAKE_TEXT }).catch(() => undefined)
1059}
1060
1061async function catchFromReply($: $T, answer: string, turnId: string) {
1062 const r = await $.model.complete({
1063 model: 'haiku',
1064 system: CATCH_SYSTEM,
1065 prompt: catchPrompt(answer),
1066 maxTokens: 700,
1067 effort: 'low',
1068 timeoutMs: 30_000,
1069 })
1070 if (!r.isAnswered) return
1071 const pins = parseCaught(r.text)
1072 // A new turn has started: the reply is old news, and that turn pins its own.
1073 if (!pins.length || (await read($, live)).turnId !== turnId) return
1074 const pinned: Item[] = []
1075 for (const p of pins) pinned.push((await pin($, p, { from: 'the last reply', isCounted: false })).item)
1076 // Tell the model, which never saw these pins, so it can resolve them.
1077 await appendRow($, caughtNote(pinned))
1078}
1079
1080async function tick($: $T) {
1081 if (!local.paneIsPlaced) return
1082 // Clawd is drawn in the hidden view only; nothing to animate in the list.
1083 if ((await read($, settings)).view !== 'icon') return
1084 const f = frameAt(await scene($))
1085 const key = frameKey(f)
1086 if (key === local.frameKey) return
1087 local.frameKey = key
1088 const g = draw(f)
1089 const r = local.pictures
1090 ? await $.ui.blit({ requestId: PANE, key: 'clawd', source: toRgba(g, PIC_SCALE), columns: PIC_COLS, rows: PIC_ROWS })
1091 : await $.ui.blit({ requestId: PANE, key: 'clawd', cells: toRaster(g), columns: RASTER_COLS, rows: RASTER_ROWS })
1092 if (r.deny && local.pictures && /alt/i.test(r.deny)) {
1093 local.pictures = false
1094 $.ui.invalidate('ui.render')
1095 }
1096}
1097
1098// What the mascot reacts to right now.
1099async function scene($: $T) {
1100 const [list, l] = await Promise.all([read($, items), read($, live)])
1101 const now = await $.clock.now()
1102 const open = openItems(list)
1103 const done = doneItems(list)
1104 if (local.reaction && local.reaction.until < now) local.reaction = undefined
1105 const sc = { open: open.length, isFresh: now - l.lastPinAt < FRESH_MS, at: now, seed: open.length * 7 + done.length }
1106 return local.reaction ? { ...sc, reaction: local.reaction.kind } : sc
1107}
1108
1109function react(kind: Reaction, now: number) {
1110 local.reaction = { kind, until: now + REACT_MS }
1111}
1112
1113function kindHue(kind: Kind): string {
1114 return kind === 'decide' ? INK.decide : kind === 'do' ? INK.do : INK.know
1115}
1116hooks/clawd.ts 224 lines1// Pixel Clawd: the orange Claude mascot on a 30×28 pixel grid (the same
2// proportions as DockCrab's Clawdy), wearing a random hat, holding a random
3// thing, dancing while something needs the person, reacting when they press.
4// Pure: the grid becomes RGBA bytes for a terminal picture, or half-block
5// cells for a Raster where pictures cannot be drawn. No engine in here.
6
7export const GRID_W = 30
8export const GRID_H = 28
9export const CLAY = '#D97757'
10export const INK = '#1F1E1D'
11const WHITE = '#F4F3EE'
12const GOLD = '#F2C14E'
13const PINK = '#F06A7A'
14const SKY = '#6CB4EE'
15const LEAF = '#5FB36A'
16const WOOD = '#A0643A'
17const STEEL = '#B8BCC6'
18const RED = '#D64550'
19
20export type Mood = 'asleep' | 'surprised' | 'dancing'
21export type Reaction = 'love' | 'ok' | 'note' | 'chat'
22export type Frame = {
23 mood: Mood
24 step: number // 0..3, the dance beat
25 hat: number
26 thing: number
27 emote: string // '', '!', '?', 'z', 'zz', 'heart', 'note'
28 reaction?: Reaction
29}
30
31type Grid = (string | undefined)[] // GRID_W * GRID_H colours
32
33const fill = (g: Grid, x: number, y: number, w: number, h: number, c: string): void => {
34 for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
35 const px = x + dx, py = y + dy
36 if (px >= 0 && px < GRID_W && py >= 0 && py < GRID_H) g[py * GRID_W + px] = c
37 }
38}
39const stamp = (g: Grid, x: number, y: number, rows: string[], map: Record<string, string>): void =>
40 rows.forEach((row, dy) => [...row].forEach((ch, dx) => { const c = map[ch]; if (c) fill(g, x + dx, y + dy, 1, 1, c) }))
41
42// ---- the body --------------------------------------------------------------
43
44function body(g: Grid, f: Frame): void {
45 const bob = f.mood === 'dancing' && f.step % 2 === 1 ? 1 : 0
46 const armUp = f.mood === 'surprised' ? -4 : f.mood === 'dancing' && (f.step === 1 || f.step === 2) ? -3 : 0
47 const y = 10 + bob
48 fill(g, 7, y, 16, 12, CLAY)
49 fill(g, 3, y + 4 + (f.mood === 'surprised' ? -4 : 0), 4, 4, CLAY) // left arm
50 fill(g, 23, y + 4 + armUp, 4, 4, CLAY) // right arm, the one that holds
51 // eyes
52 if (f.mood === 'asleep') {
53 fill(g, 9, y + 3, 2, 1, INK); fill(g, 19, y + 3, 2, 1, INK)
54 } else if (f.mood === 'surprised' || f.reaction === 'love') {
55 fill(g, 9, y + 1, 2, 3, INK); fill(g, 19, y + 1, 2, 3, INK)
56 if (f.reaction === 'love') { fill(g, 9, y + 1, 2, 3, PINK); fill(g, 19, y + 1, 2, 3, PINK) }
57 } else if (f.mood === 'dancing' && f.step === 3 && f.hat % 3 === 0) {
58 fill(g, 9, y + 2, 2, 2, INK); fill(g, 19, y + 3, 2, 1, INK) // wink
59 } else {
60 fill(g, 9, y + 2, 2, 2, INK); fill(g, 19, y + 2, 2, 2, INK)
61 }
62 // legs: two pairs that alternate while dancing
63 const a = f.mood === 'dancing' && f.step % 2 === 0 ? -1 : 0
64 const b = f.mood === 'dancing' && f.step % 2 === 1 ? -1 : 0
65 fill(g, 7, 22, 2, 4 + a, CLAY); fill(g, 17, 22, 2, 4 + a, CLAY)
66 fill(g, 11, 22, 2, 4 + b, CLAY); fill(g, 21, 22, 2, 4 + b, CLAY)
67}
68
69// ---- hats: drawn over the head (y 2..9), x 5..24 ----------------------------
70
71export const HATS: ReadonlyArray<{ name: string; draw: (g: Grid, bob: number) => void }> = [
72 { name: 'top hat', draw: (g, b) => { fill(g, 9, 2 + b, 12, 7, INK); fill(g, 6, 8 + b, 18, 2, INK); fill(g, 9, 6 + b, 12, 1, RED) } },
73 { name: 'crown', draw: (g, b) => stamp(g, 7, 3 + b, ['g....gg....g', 'gg..gggg..gg', 'gggggggggggg', 'gggggggggggg', 'g.rr.gg.rr.g', 'gggggggggggg', 'dddddddddddd'], { g: GOLD, r: RED, d: '#A67C00' }) },
74 { name: 'party cone', draw: (g, b) => stamp(g, 11, 1 + b, ['...yy...', '...pp...', '..pppp..', '..yyyy..', '.pppppp.', '.yyyyyy.', 'pppppppp'], { p: PINK, y: GOLD }) },
75 { name: 'cowboy', draw: (g, b) => { fill(g, 10, 3 + b, 10, 5, WOOD); fill(g, 4, 8 + b, 22, 2, WOOD); fill(g, 10, 6 + b, 10, 1, INK) } },
76 { name: 'chef', draw: (g, b) => { fill(g, 8, 2 + b, 14, 5, WHITE); fill(g, 6, 4 + b, 18, 3, WHITE); fill(g, 9, 7 + b, 12, 3, WHITE) } },
77 { name: 'propeller', draw: (g, b) => { fill(g, 9, 6 + b, 12, 4, SKY); fill(g, 9, 8 + b, 12, 1, RED); fill(g, 14, 4 + b, 2, 2, STEEL); fill(g, 10, 3 + b, 10, 1, GOLD) } },
78 { name: 'halo', draw: (g, b) => { fill(g, 9, 3 + b, 12, 1, GOLD); fill(g, 8, 4 + b, 1, 1, GOLD); fill(g, 21, 4 + b, 1, 1, GOLD); fill(g, 9, 5 + b, 12, 1, GOLD) } },
79 { name: 'wizard', draw: (g, b) => stamp(g, 8, 0 + b, ['......ss......', '.....ssss.....', '.....ssss.....', '....ssyyss....', '....ssssss....', '...ssssssss...', '...ssyyssss...', '..ssssssssss..', '..ssssssssss..', 'ssssssssssssss'], { s: '#5A4FB5', y: GOLD }) },
80 { name: 'bucket', draw: (g, b) => { fill(g, 8, 3 + b, 14, 6, STEEL); fill(g, 7, 9 + b, 16, 1, '#8A8E99'); fill(g, 10, 2 + b, 10, 1, '#8A8E99') } },
81 { name: 'headphones', draw: (g, b) => { fill(g, 8, 6 + b, 14, 1, INK); fill(g, 7, 7 + b, 1, 2, INK); fill(g, 22, 7 + b, 1, 2, INK); fill(g, 5, 10 + b, 3, 5, INK); fill(g, 22, 10 + b, 3, 5, INK); fill(g, 6, 11 + b, 1, 3, SKY); fill(g, 23, 11 + b, 1, 3, SKY) } },
82 { name: 'viking', draw: (g, b) => { fill(g, 8, 6 + b, 14, 4, STEEL); fill(g, 5, 3 + b, 2, 5, WHITE); fill(g, 7, 6 + b, 1, 2, WHITE); fill(g, 23, 3 + b, 2, 5, WHITE); fill(g, 22, 6 + b, 1, 2, WHITE); fill(g, 8, 8 + b, 14, 1, GOLD) } },
83 { name: 'beanie', draw: (g, b) => { fill(g, 9, 4 + b, 12, 6, LEAF); fill(g, 8, 8 + b, 14, 2, '#3E8A48'); fill(g, 14, 2 + b, 2, 2, WHITE) } },
84 { name: 'bow', draw: (g, b) => stamp(g, 14, 5 + b, ['pp..pp', 'pppppp', '.pppp.', 'pppppp', 'pp..pp'], { p: PINK }) },
85 { name: 'none', draw: () => undefined },
86]
87
88// ---- things held in the right claw: x 24..29 -------------------------------
89
90export const THINGS: ReadonlyArray<{ name: string; draw: (g: Grid, y: number, step: number) => void }> = [
91 { name: 'wrench', draw: (g, y) => { fill(g, 27, y - 6, 2, 8, STEEL); fill(g, 26, y - 7, 4, 2, STEEL); fill(g, 27, y - 7, 2, 1, INK) } },
92 { name: 'coffee', draw: (g, y, s) => { fill(g, 25, y - 5, 5, 5, WHITE); fill(g, 26, y - 4, 3, 1, WOOD); fill(g, 29, y - 4, 1, 3, WHITE); if (s % 2) { fill(g, 26, y - 7, 1, 1, '#9A9A9A'); fill(g, 28, y - 8, 1, 1, '#9A9A9A') } } },
93 { name: 'pizza', draw: (g, y) => stamp(g, 24, y - 6, ['yyyyyy', '.rrrr.', '.rprp.', '..rr..', '..pp..', '...r..'], { y: GOLD, r: '#E8A24A', p: RED }) },
94 { name: 'balloon', draw: (g, y, s) => { const d = s % 2; fill(g, 25, y - 10 + d, 4, 5, RED); fill(g, 26, y - 11 + d, 2, 1, RED); fill(g, 26, y - 5 + d, 2, 1, RED); fill(g, 27, y - 4 + d, 1, 4, WHITE) } },
95 { name: 'flag', draw: (g, y, s) => { fill(g, 27, y - 9, 1, 11, WOOD); fill(g, 28, y - 9, s % 2 ? 2 : 3, 4, SKY) } },
96 { name: 'magnifier', draw: (g, y) => { stamp(g, 25, y - 8, ['.kkk.', 'k.b.k', 'kbbbk', 'k.b.k', '.kkk.'], { k: INK, b: '#B9E3FF' }); fill(g, 27, y - 3, 1, 4, WOOD) } },
97 { name: 'hammer', draw: (g, y, s) => { const t = s % 2 ? -2 : 0; fill(g, 27, y - 6 + t, 1, 8, WOOD); fill(g, 25, y - 8 + t, 5, 3, STEEL) } },
98 { name: 'guitar', draw: (g, y) => stamp(g, 24, y - 8, ['....w.', '....w.', '....w.', '...ww.', '..wwww', '.wwwww', '.wwwww', '..www.'], { w: WOOD }) },
99 { name: 'book', draw: (g, y) => { fill(g, 24, y - 4, 6, 5, SKY); fill(g, 25, y - 3, 4, 3, WHITE); fill(g, 27, y - 3, 1, 3, SKY) } },
100 { name: 'lollipop', draw: (g, y) => { stamp(g, 25, y - 9, ['.ppp.', 'pwpwp', 'pppwp', 'pwppp', '.ppp.'], { p: PINK, w: WHITE }); fill(g, 27, y - 4, 1, 5, WHITE) } },
101 { name: 'torch', draw: (g, y, s) => { fill(g, 26, y - 3, 2, 5, WOOD); fill(g, 25, y - 6, 4, 3, GOLD); fill(g, 26, y - 8 + (s % 2), 2, 2, RED) } },
102 { name: 'nothing', draw: () => undefined },
103]
104
105// ---- emotes beside the head (x 23..29, y 0..7) ------------------------------
106
107const EMOTE: Record<string, (g: Grid) => void> = {
108 '!': g => { fill(g, 27, 1, 2, 4, WHITE); fill(g, 27, 6, 2, 1, WHITE) },
109 '?': g => stamp(g, 25, 0, ['.www.', 'w...w', '....w', '...w.', '..w..', '.....', '..w..'], { w: WHITE }),
110 'z': g => stamp(g, 26, 2, ['www', '..w', '.w.', 'www'], { w: '#9A9A9A' }),
111 'zz': g => { stamp(g, 23, 4, ['www', '..w', '.w.', 'www'], { w: '#9A9A9A' }); stamp(g, 27, 0, ['www', '..w', '.w.', 'www'], { w: '#C0C0C0' }) },
112 'heart': g => stamp(g, 24, 1, ['.pp.pp.', 'ppppppp', 'ppppppp', '.ppppp.', '..ppp..', '...p...'], { p: PINK }),
113 'note': g => stamp(g, 25, 0, ['...w', '...w', '...w', '.www', 'wwww', '.ww.'], { w: WHITE }),
114 'ok': g => stamp(g, 24, 1, ['.....g', '....gg', 'g..gg.', 'gggg..', '.gg...'], { g: LEAF }),
115 'dots': g => { fill(g, 24, 4, 1, 1, WHITE); fill(g, 26, 4, 1, 1, WHITE); fill(g, 28, 4, 1, 1, WHITE) },
116 'pencil': g => stamp(g, 25, 1, ['....y', '...yy', '..yy.', '.yy..', 'kk...'], { y: GOLD, k: INK }),
117}
118
119export function draw(f: Frame): Grid {
120 const g: Grid = new Array(GRID_W * GRID_H).fill(undefined)
121 const bob = f.mood === 'dancing' && f.step % 2 === 1 ? 1 : 0
122 body(g, f)
123 HATS[f.hat % HATS.length]?.draw(g, bob)
124 const armY = 10 + bob + 4 + (f.mood === 'surprised' ? -4 : f.mood === 'dancing' && (f.step === 1 || f.step === 2) ? -3 : 0)
125 if (f.reaction !== 'note') THINGS[f.thing % THINGS.length]?.draw(g, armY, f.step)
126 const emote = f.reaction === 'love' ? 'heart' : f.reaction === 'ok' ? 'ok' : f.reaction === 'note' ? 'pencil' : f.reaction === 'chat' ? 'dots' : f.emote
127 if (emote) EMOTE[emote]?.(g)
128 return g
129}
130
131// ---- outputs ----------------------------------------------------------------
132
133const hex = (c: string): [number, number, number] => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]
134
135function b64(bytes: Uint8Array): string {
136 const anyBytes = bytes as unknown as { toBase64?: () => string }
137 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
138 const T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
139 let out = ''
140 for (let i = 0; i < bytes.length; i += 3) {
141 const a = bytes[i] ?? 0, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
142 const n = (a << 16) | (b << 8) | c
143 out += T[(n >> 18) & 63]! + T[(n >> 12) & 63]! + (i + 1 < bytes.length ? T[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? T[n & 63]! : '=')
144 }
145 return out
146}
147
148// The grid as RGBA pixels, each grid pixel `scale` wide and tall, transparent
149// where nothing is drawn; for a terminal `Image`.
150export function toRgba(g: Grid, scale = 4): { rgba: string; width: number; height: number } {
151 const width = GRID_W * scale, height = GRID_H * scale
152 const out = new Uint8Array(width * height * 4)
153 for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
154 const c = g[Math.floor(y / scale) * GRID_W + Math.floor(x / scale)]
155 if (!c) continue
156 const [r, gg, b] = hex(c)
157 const i = (y * width + x) * 4
158 out[i] = r; out[i + 1] = gg; out[i + 2] = b; out[i + 3] = 255
159 }
160 return { rgba: b64(out), width, height }
161}
162
163// The grid as half-block cells (two pixels per cell, `▀` top over `▄` bottom),
164// `step` grid pixels per cell column; for a terminal `Raster`.
165export const RASTER_COLS = GRID_W / 2
166export const RASTER_ROWS = GRID_H / 4
167const DEFAULT_BG = 0x01000000
168const rgb = (c: string | undefined): number => (c ? parseInt(c.slice(1), 16) : DEFAULT_BG)
169export function toRaster(g: Grid): string {
170 const words = new Uint32Array(RASTER_COLS * RASTER_ROWS * 3)
171 let k = 0
172 for (let row = 0; row < RASTER_ROWS; row++) for (let col = 0; col < RASTER_COLS; col++) {
173 const x = col * 2, yt = row * 4, yb = row * 4 + 2
174 const t = g[yt * GRID_W + x] ?? g[(yt + 1) * GRID_W + x] ?? g[yt * GRID_W + x + 1]
175 const b = g[yb * GRID_W + x] ?? g[(yb + 1) * GRID_W + x] ?? g[yb * GRID_W + x + 1]
176 if (t && b) { words[k++] = 0x2580; words[k++] = rgb(t); words[k++] = rgb(b) }
177 else if (t) { words[k++] = 0x2580; words[k++] = rgb(t); words[k++] = DEFAULT_BG }
178 else if (b) { words[k++] = 0x2584; words[k++] = rgb(b); words[k++] = DEFAULT_BG }
179 else { words[k++] = 0x20; words[k++] = DEFAULT_BG; words[k++] = DEFAULT_BG }
180 }
181 return b64(new Uint8Array(words.buffer))
182}
183
184// The same grid as plain text lines (for tests and previews): `#` body, `.` empty.
185export function toAscii(g: Grid): string[] {
186 const lines: string[] = []
187 for (let y = 0; y < GRID_H; y++) {
188 let s = ''
189 for (let x = 0; x < GRID_W; x++) { const c = g[y * GRID_W + x]; s += !c ? '.' : c === CLAY ? '#' : c === INK ? '@' : '+' }
190 lines.push(s)
191 }
192 return lines
193}
194
195// ---- picking the frame -------------------------------------------------------
196
197function hash(n: number): number {
198 let x = (n ^ 0x9e3779b9) >>> 0
199 x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
200 x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
201 return (x ^ (x >>> 16)) >>> 0
202}
203
204export type Scene = { open: number; isFresh: boolean; at: number; seed: number; reaction?: Reaction }
205
206// What Clawd looks like at time `at` (ms): the beat runs at 4 a second while
207// dancing, 1 a second asleep; hat and thing change with the seed and slowly
208// with time, so a long wait gets a wardrobe change.
209export function frameAt(s: Scene): Frame {
210 const mood: Mood = s.open === 0 ? 'asleep' : s.isFresh ? 'surprised' : 'dancing'
211 const beat = Math.floor(s.at / (mood === 'asleep' ? 1000 : 250))
212 const wardrobe = Math.floor(s.at / 45_000)
213 const hat = hash(s.seed * 5 + wardrobe) % HATS.length
214 const thing = hash(s.seed * 11 + 3 + Math.floor(s.at / 30_000)) % THINGS.length
215 const emotes = ['', '', '', 'note', '', '!', '', '?', '', 'heart', '', '']
216 const emote = mood === 'asleep' ? (beat % 2 ? 'zz' : 'z') : mood === 'surprised' ? (beat % 2 ? '!' : '') : emotes[hash(s.seed + Math.floor(s.at / 3000)) % emotes.length]!
217 const f: Frame = { mood, step: beat % 4, hat, thing, emote }
218 if (s.reaction) f.reaction = s.reaction
219 return f
220}
221
222// Two frames draw the same picture: nothing to blit.
223export const frameKey = (f: Frame): string => `${f.mood}:${f.step}:${f.hat}:${f.thing}:${f.emote}:${f.reaction ?? ''}`
224hooks/items.ts 344 lines1// Pure helpers for the Needs-you pane: items, the messages that go back into
2// the session, the reply catcher, and the little avatar. No engine in here, so
3// every function is testable on its own.
4
5import type { Item, Kind, PaneSettings } from '../types'
6
7export const KINDS: readonly Kind[] = ['decide', 'do', 'know']
8
9export const TOOL_PIN = 'mcp__needs-you__pin'
10export const TOOL_RESOLVE = 'mcp__needs-you__resolve'
11export const TOOL_UPDATE = 'mcp__needs-you__update'
12export const TOOL_LIST = 'mcp__needs-you__list'
13
14export const DEFAULT_SETTINGS: PaneSettings = { view: 'list', focus: true, catch: true, showDone: false }
15
16export type Pin = { title: string; detail: string; kind: Kind; options: string[] }
17
18// What the model sent to the pin tool, checked field by field: a plugin tool's
19// input arrives untyped.
20export function parsePin(args: Record<string, unknown>): { ok: true; pin: Pin } | { ok: false; why: string } {
21 const title = oneLine(String(args.title ?? '')).slice(0, 140)
22 if (!title) return { ok: false, why: 'title is required: one plain line saying what you need from the person' }
23 const kind = KINDS.includes(args.kind as Kind) ? (args.kind as Kind) : 'know'
24 const detail = String(args.detail ?? '').trim().slice(0, 600)
25 const options = Array.isArray(args.options)
26 ? args.options.map(o => oneLine(String(o)).slice(0, 60)).filter(Boolean).slice(0, 4)
27 : []
28 if (kind === 'decide' && options.length < 2) {
29 return { ok: false, why: 'a decision needs 2 to 4 options, your recommendation first' }
30 }
31 return { ok: true, pin: { title, detail, kind, options: kind === 'decide' ? options : [] } }
32}
33
34export function newItem(items: readonly Item[], pin: Pin, from: string, now: number, agentId?: string): Item {
35 const n = items.reduce((m, i) => Math.max(m, i.n), 0) + 1
36 const item: Item = {
37 id: `n${n}-${now.toString(36)}`,
38 n,
39 kind: pin.kind,
40 title: pin.title,
41 detail: pin.detail,
42 options: pin.options,
43 from,
44 status: 'open',
45 createdAt: now,
46 answer: '',
47 notes: [],
48 }
49 if (agentId) item.agentId = agentId
50 return item
51}
52
53export function findByN(items: readonly Item[], n: number): Item | undefined {
54 return items.find(i => i.n === n)
55}
56
57export function openItems(items: readonly Item[]): Item[] {
58 return items.filter(i => i.status !== 'done')
59}
60
61export function doneItems(items: readonly Item[]): Item[] {
62 return items.filter(i => i.status === 'done')
63}
64
65// A sent answer, note or chat request becomes seen once a model request has
66// carried it: a request of the loop it went to (`agentId`, absent for main)
67// that started at or after it was handed over. Ids in `holding` are still
68// being handed over, so no request has them for sure yet.
69export function markSeen(
70 items: readonly Item[],
71 turnId: string,
72 stepAt: number,
73 agentId?: string,
74 holding: ReadonlySet<string> = new Set(),
75): Item[] {
76 const carried = (at: number | undefined, to: string | undefined) => to === agentId && (at ?? 0) <= stepAt
77 return items.map(i => {
78 if (holding.has(i.id)) return i
79 let next = i
80 const notes = i.notes.map(n => (n.status === 'sent' && carried(n.at, n.to) ? { ...n, status: 'seen' as const } : n))
81 if (notes.some((n, k) => n !== i.notes[k])) next = { ...next, notes }
82 if (i.chatAt !== undefined && agentId === undefined && i.chatAt <= stepAt) {
83 next = { ...next }
84 delete next.chatAt
85 }
86 if (i.status === 'sent' && carried(i.sentAt, i.sentTo)) next = { ...next, status: 'seen', seenTurn: turnId, seenAge: 0 }
87 return next
88 })
89}
90
91// How many main turns a seen item stays in the open list unresolved.
92export const ARCHIVE_TURNS = 6
93
94// A seen item stays as one dim line ("Claude has it") until the model resolves
95// it. One left unresolved leaves after ARCHIVE_TURNS main turns; a quiet one
96// (an ack, the person's own reminder) after the turn that carried it. Nothing
97// leaves while a note or chat request on it is still on its way.
98export function archiveSeen(items: readonly Item[], endedTurn: string): Item[] {
99 return items.map(i => {
100 if (i.status !== 'seen' || i.seenTurn === endedTurn) return i
101 if (i.chatAt !== undefined || i.notes.some(n => n.status === 'sent')) return i
102 const age = (i.seenAge ?? 0) + 1
103 const limit = isQuietItem(i) ? 1 : ARCHIVE_TURNS
104 return age >= limit ? { ...i, status: 'done', seenAge: age } : { ...i, seenAge: age }
105 })
106}
107
108export type Answer =
109 | { kind: 'check' }
110 | { kind: 'option'; text: string }
111 | { kind: 'note'; text: string }
112 | { kind: 'chat' }
113
114// An item whose tick needs nothing from the model: a 'know' the person read,
115// or a reminder the person pinned for themselves (`/needs-you add`).
116export function isQuietItem(item: Item): boolean {
117 return item.from === 'you' || item.kind === 'know'
118}
119
120// An answer that is appended quietly and never starts a turn.
121export function isQuiet(item: Item, answer: Answer): boolean {
122 if (answer.kind === 'chat') return false
123 if (item.from === 'you') return true
124 return item.kind === 'know' && answer.kind === 'check'
125}
126
127// A tick on a decision is "go with your recommendation": the first option.
128export function normalize(item: Item, answer: Answer): Answer {
129 if (answer.kind === 'check' && item.kind === 'decide' && item.options[0]) return { kind: 'option', text: item.options[0] }
130 return answer
131}
132
133// What the main conversation still has to read: answers handed to it that no
134// request carried and that ask for a reply. A key of them, '' for none.
135export function pendingKey(items: readonly Item[]): string {
136 const keys: string[] = []
137 for (const i of items) {
138 if (i.status === 'done') continue
139 if (i.status === 'sent' && !i.sentTo && !isQuietItem(i)) keys.push(`${i.id}@${i.sentAt ?? 0}`)
140 if (i.from !== 'you') for (const n of i.notes) if (n.status === 'sent' && !n.to) keys.push(`${i.id}:${n.at}`)
141 if (i.chatAt !== undefined) keys.push(`${i.id}~${i.chatAt}`)
142 }
143 return keys.join(',')
144}
145
146// The hidden message the model reads when the person acts in the pane while a
147// turn runs (or, for a quiet one, at the next turn). The person never sees it.
148export function messageFor(item: Item, answer: Answer): string {
149 const head = `[needs-you] #${item.n} "${item.title}"`
150 const tail = `Act on this, then call ${TOOL_RESOLVE} with n=${item.n}. Do not ask about it again in prose.`
151 const quiet = 'No reply needed; it leaves the pane by itself.'
152 if (item.from === 'you' && answer.kind !== 'chat') {
153 const what = answer.kind === 'note' ? `the person added a note to their own reminder: "${answer.text}"` : 'the person ticked off their own reminder'
154 return `${head}: ${what}. ${quiet}`
155 }
156 switch (answer.kind) {
157 case 'check':
158 return item.kind === 'know'
159 ? `${head}: the person has read this. ${quiet}`
160 : `${head}: the person has done this. ${tail}`
161 case 'option': {
162 const rec = item.options[0] === answer.text ? ' (your recommendation)' : ''
163 return `${head}: the person chose "${answer.text}"${rec}. ${tail}`
164 }
165 case 'note':
166 return `${head}: the person added a note: "${answer.text}". Take it into account. If it changes the question, call ${TOOL_UPDATE} with n=${item.n}; call ${TOOL_RESOLVE} only once the item is settled.`
167 case 'chat':
168 return `${head}: the person wants to talk this one through. In your next reply, bring it up in plain words (what it is, why it matters, what you recommend), ask what they want, and wait. Leave the item open until it is settled.`
169 }
170}
171
172// The same answer as the person's own words, for a prompt that starts a turn:
173// the person sees it in the transcript, so it reads as them speaking. What to
174// do with it is in the system prompt section.
175export function personMessageFor(item: Item, answer: Answer): string {
176 const head = `[needs-you] #${item.n} "${item.title}"`
177 switch (answer.kind) {
178 case 'check':
179 return item.kind === 'know' ? `${head}: got it.` : `${head}: done.`
180 case 'option':
181 return item.options[0] === answer.text
182 ? `${head}: go with your recommendation, "${answer.text}".`
183 : `${head}: I choose "${answer.text}".`
184 case 'note':
185 return `${head}: a note from me: "${answer.text}"`
186 case 'chat':
187 return `${head}: can we talk this one through? What is it, why does it matter, and what do you recommend?`
188 }
189}
190
191// The main conversation's copy of an answer that went to a helper agent.
192export function fyiFor(item: Item, answer: Answer): string {
193 const what = answer.kind === 'note' ? `added a note: "${answer.text}"` : answer.kind === 'option' ? `chose "${answer.text}"` : 'ticked it'
194 return `[needs-you] FYI: #${item.n} "${item.title}" was asked by ${item.from || 'a helper agent'}. The person ${what}; it went to that agent. No action needed from you.`
195}
196
197// The prompt that starts a turn when answers reached the conversation after its
198// last request: the rows are in the conversation; nothing has read them.
199export const WAKE_TEXT =
200 '[needs-you] The person answered in the Needs-you pane while you were finishing your last reply. Read the [needs-you] messages just above and act on them.'
201
202// The hidden row after the reply catcher pinned asks from the last reply.
203export function caughtNote(pinned: readonly Item[]): string {
204 const list = pinned.map(i => `#${i.n} "${i.title}"`).join(', ')
205 return `[needs-you] From your last reply, the pane pinned ${list} for the person. They are open items now: call ${TOOL_RESOLVE} on each once handled, and do not ask them again in prose.`
206}
207
208export function pinReply(item: Item, isPlaced = true): string {
209 const base = `Pinned #${item.n} (${item.kind}): "${item.title}".`
210 const close = `Call ${TOOL_RESOLVE} n=${item.n} once it is handled.`
211 return isPlaced
212 ? `${base} The person sees it in the Needs-you pane; in your reply, at most one short line pointing at #${item.n}. ${close}`
213 : `${base} The Needs-you pane is not showing on the person's screen, so also ask it in one plain line in your reply and say they can type /needs-you to open the pane. ${close}`
214}
215
216export function listReply(items: readonly Item[]): string {
217 const open = openItems(items)
218 if (open.length === 0) return 'Nothing is pinned.'
219 return open
220 .map(i => {
221 const state = i.status === 'open' ? 'waiting' : i.status
222 const ans = i.answer ? ` answer: "${i.answer}"` : ''
223 const notes = i.notes.length ? ` notes: ${i.notes.map(n => `"${n.text}"`).join('; ')}` : ''
224 return `#${i.n} [${i.kind}, ${state}] ${i.title}${ans}${notes}`
225 })
226 .join('\n')
227}
228
229// ---- the reply catcher: asks that never got pinned -----------------------
230
231const CUE = /\?\s*$|\?\n|let me know|your call|up to you|do you want|would you like|should i\b|want me to|need you to|you(?:'ll| will) need to|please (?:confirm|check|send|add|create|mint|set)|which (?:one|option|do you)|heads.?up|fyi\b|note that you|before i (?:can|continue)|waiting on you|blocked on/i
232
233export function hasCue(answer: string): boolean {
234 return answer.length > 20 && CUE.test(answer)
235}
236
237export const CATCH_SYSTEM = `You read the last reply of a coding assistant and pull out only the things the PERSON must now decide, do outside the chat, or know because it blocks or changes the work. Ignore things the assistant itself will do next, status updates, summaries, and rhetorical questions. Answer with a JSON array and nothing else. Each element: {"kind":"decide"|"do"|"know","title":"one plain line, no code names or jargon, under 100 characters","detail":"one or two plain sentences, may be empty","options":["only for decide: 2-4 short options, recommendation first"]}. Empty array when nothing needs the person.`
238
239export function catchPrompt(answer: string): string {
240 return `The reply:\n\n${answer.slice(0, 6000)}`
241}
242
243export function parseCaught(text: string): Pin[] {
244 const start = text.indexOf('[')
245 const end = text.lastIndexOf(']')
246 if (start < 0 || end <= start) return []
247 let raw: unknown
248 try {
249 raw = JSON.parse(text.slice(start, end + 1))
250 } catch {
251 return []
252 }
253 if (!Array.isArray(raw)) return []
254 const pins: Pin[] = []
255 for (const row of raw.slice(0, 4)) {
256 if (!row || typeof row !== 'object') continue
257 const parsed = parsePin(row as Record<string, unknown>)
258 if (parsed.ok) pins.push(parsed.pin)
259 }
260 return pins
261}
262
263// ---- text helpers --------------------------------------------------------
264
265export function oneLine(text: string): string {
266 return text.replace(/\s+/g, ' ').trim()
267}
268
269export function firstLine(text: string, max: number): string {
270 const line = text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
271 const plain = line.replace(/^#+\s*/, '').replace(/[*_`]/g, '')
272 return plain.length > max ? `${plain.slice(0, Math.max(1, max - 1))}…` : plain
273}
274
275// A label cut to `max` columns with an ellipsis.
276export function clip(text: string, max: number): string {
277 return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
278}
279
280export function parseCommand(args: string): { verb: string; rest: string } {
281 const trimmed = args.trim()
282 const space = trimmed.indexOf(' ')
283 if (space < 0) return { verb: trimmed.toLowerCase(), rest: '' }
284 return { verb: trimmed.slice(0, space).toLowerCase(), rest: trimmed.slice(space + 1).trim() }
285}
286
287// How tall the pane wants to be while it sits inline above the prompt.
288export function wantedRows(items: readonly Item[], settings: PaneSettings): number {
289 if (settings.view === 'icon') return 3
290 const open = openItems(items)
291 let rows = 2
292 for (const i of open) {
293 // An answered item is one line; an open one is a full card: title, detail,
294 // a line of chips, its notes, the note field, and a gap.
295 if (i.status !== 'open') {
296 rows += 2
297 continue
298 }
299 rows += 4 + Math.ceil(i.title.length / 36) + Math.ceil(i.detail.length / 40) + i.notes.length
300 }
301 if (doneItems(items).length) rows += 1 + (settings.showDone ? doneItems(items).length : 0)
302 return Math.min(40, Math.max(3, rows))
303}
304
305// ---- the text face ---------------------------------------------------------
306// Surfaces that draw no pictures (desktop, mobile) get a one-line face instead
307// of the pixel mascot in clawd.ts.
308
309const EMOTE_SLEEP = ['z', 'zz', 'zzz']
310
311export type AvatarState = { open: number; isFresh: boolean; tick: number; seed: number }
312
313function hash(n: number): number {
314 let x = (n ^ 0x9e3779b9) >>> 0
315 x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
316 x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
317 return (x ^ (x >>> 16)) >>> 0
318}
319
320function pick<T>(list: readonly T[], n: number): T {
321 return list[hash(n) % list.length] as T
322}
323
324// What the mascot is doing this tick.
325export function avatarMood(s: AvatarState): 'asleep' | 'surprised' | 'dancing' {
326 return s.open === 0 ? 'asleep' : s.isFresh ? 'surprised' : 'dancing'
327}
328
329// The face line's fixed width: ASCII only, so every terminal and font agrees
330// (‿ • ⊙ draw one or two columns depending on the font).
331export const AVATAR_LINE_WIDTH = 9
332
333// One line, for the mobile list and tight widths: the face only, padded so the
334// text after it never shifts between frames.
335export function avatarLine(s: AvatarState): string {
336 const mood = avatarMood(s)
337 const face =
338 mood === 'asleep' ? `[-_-] ${pick(EMOTE_SLEEP, Math.floor(s.tick / 2))}` :
339 mood === 'surprised' ? (s.tick % 2 ? '[O_O]!' : '[O_O]') :
340 s.tick % 7 === 0 ? '[^_-]' : '[^_^]'
341 return face.padEnd(AVATAR_LINE_WIDTH, ' ')
342}
343
344types/index.d.ts 67 lines1// What one pinned item is, and what the pane keeps in the session.
2
3export type Kind = 'decide' | 'do' | 'know'
4
5// open: waiting on the person
6// sent: the person answered; the message is in the session, no request has carried it yet
7// seen: a model request carried it; Claude has it but has not resolved it
8// done: the model resolved it, or it sat seen for several turns
9export type Status = 'open' | 'sent' | 'seen' | 'done'
10
11// `at`: when the note was handed over; `to`: the helper agent that got it
12// (absent: the main conversation).
13export type Note = { text: string; at: number; status: 'sent' | 'seen'; to?: string }
14
15export type Item = {
16 id: string
17 n: number
18 kind: Kind
19 title: string
20 detail: string
21 options: string[]
22 from: string
23 agentId?: string
24 status: Status
25 createdAt: number
26 answer: string
27 notes: Note[]
28 // Left from the old expand/collapse pane; no longer read or written.
29 isExpanded?: boolean
30 // When the answer was handed over, and the helper agent that got it.
31 sentAt?: number
32 sentTo?: string
33 // A "chat about this" no model request has carried yet.
34 chatAt?: number
35 seenTurn?: string
36 // Main turns ended since it was seen; it is archived after a few.
37 seenAge?: number
38}
39
40export type View = 'icon' | 'list'
41
42export type PaneSettings = {
43 view: View
44 focus: boolean
45 catch: boolean
46 showDone: boolean
47}
48
49export type Live = {
50 isTurnRunning: boolean
51 turnId: string
52 pinsThisTurn: number
53 lastPinAt: number
54 tick: number
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'needs-you': {
60 items: Shaped<Item[]>
61 settings: Shaped<PaneSettings>
62 live: Live
63 interim: string[]
64 }
65 }
66}
67