Pins questions and summaries from background-woken turns above the prompt so they don't scroll away.

A Claude Code mod that keeps what matters from background-woken turns pinned above the prompt.
When Claude is watching a PR, running a Monitor, waiting on subagents or looping with /loop / ScheduleWakeup, each wake-up ends with a summary or a question. The transcript scrolls past it and it's easy to miss. pin-board catches those and pins them (in a side pane in fullscreen, above the prompt otherwise) until you dismiss them.

On the normal screen, pins show in a band above the prompt instead:
📌 Pinned (2) clear all
▸1 10:05 📝 CI is green on all three branches; two reviewers approved… ✕
▸2 09:41 ❓ PR #42 got a review: rename the helper or keep it? ✕
Each pin's kind shows as an emoji: ❓ question, 🔀 decision, 💡 fyi, 📝 summary.
It works in two layers:
?, outside code fences; orShort "nothing new, still watching" answers are skipped. Turns you started yourself are never auto-pinned.
pin tool (mcp__pin-board__pin), approved without a permission prompt unless you deny it. Its description is one sentence, so it costs little on every request. The guidance on when to pin during wake-ups is attached only to prompts that come from background events, which is where it matters. When Claude pins during such a turn, the automatic pin for that turn is skipped.When Claude asks several things and you answer only some, or reply about something else, Claude can pin what's still open, instead of reminding you every turn:
pin({ kind: "question",
text: "Still open: Q2 (rename the helper?) and Q4 (keep the v1 endpoint?)",
quote: "Should we keep the v1 endpoint" })
| Field | Meaning |
|---|---|
text | What you need to see, in a line or two |
kind | question, decision or fyi (the default) |
quote | Optional. A short exact excerpt of Claude's earlier message the pin is about |
replaces | Optional. The id of Claude's earlier pin to update, or, with empty text, remove |
So that you can answer by number and Claude can see which questions are left, the mod adds one line to Claude's system prompt: when it asks several questions in one message, number them, and pin any left unanswered. The line is cached with the rest of the system prompt, so it costs almost nothing per turn. To turn it off, set Number Claude's questions (numberQuestions) to off in the config menu.
Each of Claude's pins gets a stable id (p1, p2, …). The tool's reply tells Claude the id and how to update or remove that pin, so when you answer Q2, Claude can change the pin to "Still open: Q4", or remove it once nothing is left. Updating a pin you already dismissed creates a new one.
/pins. The mod opens the pane itself only on a terminal at least 144 columns wide; /pins opens it at any width.Pins never disappear on their own. See Resuming a session for what happens to them across sessions.
Pins are numbered newest first, and /pins runs at once, even while Claude is working:
| Command | Does |
|---|---|
/pins | Lists the pins, and reopens the side pane in fullscreen |
/pins <n> | Shows pin n in full |
/pins go <n> | Scrolls the transcript to where pin n came from |
/pins done <n>… | Dismisses pins by number, e.g. /pins done 1 3 |
/pins clear | Dismisses every pin |
The buttons are also reachable from the keyboard: press ctrl+x tab to put focus on the band or pane, then Tab between buttons, Enter to press one, and Esc to go back to the prompt.
With the mouse, every button (a pin's whole row, ↑ jump, ✕, clear all, open side pane) is clickable in the fullscreen terminal, the desktop app and VS Code; the wheel scrolls the band and pane. On the terminal's normal screen Claude Code doesn't receive clicks, so use the keys or /pins there.
↑ jump (or /pins go <n>) scrolls the transcript to where the pin came from:
quote: the earlier reply it quotes, shown from its top. The match ignores case, spacing and markdown emphasis, and checks the last 200 replies, newest first.pin call itself, placed at the bottom of the screen, so the questions and your answer that led to it show above it. Claude is told when its quote didn't match.The reply index is rebuilt from the session's transcript when you resume it, and after a hot reload of the mod.
It only works in fullscreen, where Claude Code scrolls the transcript itself; on the normal screen your terminal owns the scrollback. Claude Code only moves the transcript when you ask, by a press or a typed command. If it can't move there (for example, the row is folded into a collapsed tool group), a toast says why.
The board is saved in the mod's store under the session's id after every change, so claude --resume (or /resume) brings back that session's pins, and Claude's pin ids carry on from where they were.
/clear and --fork-session start with an empty board. The board you left stays saved under the old session, ready for its resume.grep for Claude's reply lines (the replies are a small part of a transcript, under 15% here). Where grep isn't available, as on Windows, a transcript up to 4 MiB is read whole instead; beyond that, quotes only match replies written since, and jumps fall back to the pin call.There, a mod you install yourself doesn't receive Claude Code's session events or system-prompt composition; only mods the organization lists in managed settings do. Pinning, the band, the pane, jumps and /pins all still work, but:
/clear doesn't empty the board;At the prompt of a terminal session:
/plugin install pin-board --marketplace daaain/skills
To try it from a clone without installing:
claude --plugin-dir ./mods/pin-board
claude plugin validate mods/pin-board # what it hooks and calls
claude plugin test mods/pin-board # unit + integration tests
hooks/pins.ts holds the pure logic: origin classification, the question and summary heuristics, previews, quote matching, transcript parsing, board pruning, and /pins argument parsing.hooks/register.tsx holds the hooks: turn tracking, the reply index, saving and restoring the board per session, the pin tool (quote, replaces) and its wake-only guidance, /pins, the AbovePrompt band and the Pins pane.types/index.d.ts is the $.state contract.hooks/register.tsx 534 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pin, PinAnchor, PinKind } from '../types'
5import {
6 autoPinFor,
7 boardsToPrune,
8 cellWidth,
9 findQuoted,
10 fitTo,
11 isBackgroundOrigin,
12 parsePinsCommand,
13 preview,
14 repliesFromTranscript,
15 replyWindow,
16 textOf,
17} from './pins'
18
19const PIN_TOOL = 'mcp__pin-board__pin'
20const PANE = 'pins'
21const MAX_PINS = 50
22// Recent replies kept for matching a pin's quote; older questions fall back to the pin's own call.
23const MAX_REPLIES = 200
24// Boards of this many recent sessions stay in the store, for resuming them.
25const MAX_BOARDS = 50
26const BOARD_PREFIX = 'board:'
27// $.fs.read takes a file up to 4 MiB.
28const MAX_WHOLE_READ = 4 * 1024 * 1024
29
30const pins = atom({ plugin: 'pin-board', key: 'pins' } as const, [])
31const expanded = atom({ plugin: 'pin-board', key: 'expanded' } as const, null)
32// The last `p<n>` id handed to Claude, in state so a hot reload never hands one out twice.
33const seq = atom({ plugin: 'pin-board', key: 'seq' } as const, 0)
34// Which session the board belongs to, and its transcript; in state so a hot reload keeps both.
35const session = atom({ plugin: 'pin-board', key: 'session' } as const, null)
36
37// Each wide by default, so no terminal draws one a cell short.
38const KIND_EMOJI: Record<PinKind, string> = {
39 question: '❓',
40 decision: '🔀',
41 fyi: '💡',
42 summary: '📝',
43}
44const EMOJI_CELLS = 2
45// What a band row's buttons take on its right: a gap, `↑ jump` and a gap where it shows, and `✕`.
46const JUMP_CELLS = 7
47const DISMISS_CELLS = 2
48
49// Sent on every request, so it stays one sentence; the wake guidance rides on background prompts only,
50// and how to update a pin is in each call's result.
51const TOOL_DESCRIPTION =
52 "Pin a one- or two-line note above the user's prompt so it isn't lost when the transcript scrolls, such as questions of yours the user hasn't answered yet."
53
54const WAKE_CONTEXT = [
55 'pin-board: this turn was started by a background event, not by the user typing.',
56 `If something here needs the user's input or attention, call ${PIN_TOOL} with the question`,
57 'or finding itself in one or two lines; it stays visible above their prompt. Skip routine',
58 "all-clear updates. If you don't pin, a question or long summary in your final answer is pinned for you.",
59].join(' ')
60
61// A standing instruction, so it lives in the system prompt (cached) rather than in every turn.
62const NUMBERING_HINT =
63 'pin-board: when you ask the user several questions in one message, number them (1., 2., ...) so they can answer by number; pin any they leave unanswered.'
64
65const USAGE = [
66 'Usage:',
67 ' /pins list pins, newest first (and reopen the side pane in fullscreen)',
68 ' /pins <n> show pin n in full',
69 ' /pins go <n> scroll the transcript to where pin n came from (fullscreen)',
70 ' /pins done <n>… dismiss pins by number',
71 ' /pins clear dismiss every pin',
72].join('\n')
73
74const clockTime = (at: number) => {
75 const d = new Date(at)
76 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
77}
78
79const isPinKind = (value: unknown): value is PinKind =>
80 value === 'question' || value === 'decision' || value === 'fyi'
81
82// Pins are stored oldest first; everything the person sees, and every number, is newest first.
83const newestFirst = (list: readonly Pin[]) => [...list].reverse()
84
85// Session bookkeeping outside $.state: a hot reload loses it, costing at most one auto-pin
86// or anchor, and the fullscreen flag is learnt again on the next draw.
87type Live = {
88 pendingWake: boolean
89 currentTurn: string | undefined
90 lastTextRow: string | undefined
91 replies: { uuid: string; text: string }[]
92 isFullscreen: boolean
93 isPaneClosedByPerson: boolean
94 wakeTurns: Set<string>
95 pinnedByClaude: Set<string>
96}
97
98const fresh = (): Live => ({
99 pendingWake: false,
100 currentTurn: undefined,
101 lastTextRow: undefined,
102 replies: [],
103 isFullscreen: false,
104 isPaneClosedByPerson: false,
105 wakeTurns: new Set(),
106 pinnedByClaude: new Set(),
107})
108
109let live = fresh()
110
111const openPane = async ($: EngineInterface, isAsked: boolean) => {
112 if (isAsked) live.isPaneClosedByPerson = false
113 if (!live.isFullscreen || live.isPaneClosedByPerson) return
114
115 await $.ui.open({ id: PANE, title: 'Pins' })
116 $.ui.invalidate('ui.render')
117}
118
119type Board = { pins: Pin[]; seq: number; savedAt: number }
120
121const isBoard = (value: unknown): value is Board =>
122 typeof value === 'object' &&
123 value !== null &&
124 Array.isArray((value as Board).pins) &&
125 typeof (value as Board).seq === 'number' &&
126 typeof (value as Board).savedAt === 'number'
127
128// Writes the board under its session, so `claude --resume` brings it back.
129const saveBoard = async ($: EngineInterface) => {
130 const id = (await read($, session))?.id ?? (await $.session.id())
131 const board: Board = { pins: await read($, pins), seq: await read($, seq), savedAt: await $.clock.now() }
132 await $.store.set(`${BOARD_PREFIX}${id}`, board)
133}
134
135// Every change to the pins goes through here, so the store never lags behind.
136const changePins = async ($: EngineInterface, change: (list: Pin[]) => Pin[]) => {
137 const changed = await update($, pins, change)
138 await saveBoard($)
139 return changed
140}
141
142// Rebuilds the reply index that quotes match against, from the whole transcript: grep streams
143// just the reply lines, whatever the file's size. Without grep, a file small enough is read whole.
144const restoreReplies = async ($: EngineInterface, transcriptPath: string) => {
145 try {
146 const window = replyWindow(MAX_REPLIES)
147 for await (const piece of $.process.spawn({ argv: ['grep', '-F', '"type":"assistant"', transcriptPath] })) {
148 if (piece.stream === 'stdout') window.push(piece.text)
149 }
150 live.replies = window.end()
151 if (live.replies.length > 0) return
152 } catch {
153 // No grep here: try reading the file whole.
154 }
155
156 try {
157 if ((await $.fs.stat(transcriptPath)).size <= MAX_WHOLE_READ) {
158 live.replies = repliesFromTranscript(await $.fs.read(transcriptPath), false).slice(-MAX_REPLIES)
159 }
160 } catch {
161 // No transcript yet, or one the mod can't read: quotes fall back to the pin call.
162 }
163}
164
165const pruneBoards = async ($: EngineInterface) => {
166 const keys = (await $.store.keys()).filter(key => key.startsWith(BOARD_PREFIX))
167 if (keys.length <= MAX_BOARDS) return
168
169 const boards = await Promise.all(
170 keys.map(async key => {
171 const board = await $.store.get(key)
172 return { key, savedAt: isBoard(board) ? board.savedAt : 0 }
173 }),
174 )
175 for (const key of boardsToPrune(boards, MAX_BOARDS)) await $.store.delete(key)
176}
177
178// Moves the board to the session now running: a resume brings its pins back, a new session or
179// a /clear starts empty, and the board left behind stays stored under its own id.
180const joinSession = async ($: EngineInterface, id: string, transcriptPath: string) => {
181 if ((await read($, session))?.id === id) return
182
183 const stored = await $.store.get(`${BOARD_PREFIX}${id}`)
184 const board = isBoard(stored) ? stored : undefined
185 await update($, session, () => ({ id, transcriptPath }))
186 await update($, pins, () => board?.pins ?? [])
187 await update($, seq, () => board?.seq ?? 0)
188 await update($, expanded, () => null)
189 live = { ...fresh(), isFullscreen: live.isFullscreen }
190
191 await restoreReplies($, transcriptPath)
192 if (board !== undefined && board.pins.length > 0) await openPane($, false)
193 else if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
194 await pruneBoards($)
195}
196
197const addPin = async ($: EngineInterface, fields: Omit<Pin, 'id' | 'at'>) => {
198 const at = await $.clock.now()
199 const pin: Pin = { ...fields, id: `${at.toString(36)}-${Math.random().toString(36).slice(2, 6)}`, at }
200 await changePins($, list => [...list, pin].slice(-MAX_PINS))
201 await openPane($, false)
202}
203
204const removePins = async ($: EngineInterface, ids: ReadonlySet<string>) => {
205 const left = await changePins($, list => list.filter(pin => !ids.has(pin.id)))
206 if (left.length === 0 && (await $.ui.panes()).some(pane => pane.id === PANE)) {
207 await $.ui.close({ id: PANE })
208 }
209}
210
211const clearPins = async ($: EngineInterface) => removePins($, new Set((await read($, pins)).map(pin => pin.id)))
212
213// A quoted earlier reply, read from its top; else the pin call itself at the screen's foot,
214// so the questions and the person's answer that led to it show above.
215const anchorFor = (quote: string | undefined, toolUseId: string): PinAnchor => {
216 const row = quote === undefined ? undefined : findQuoted(live.replies, quote)
217 return row === undefined ? { row: toolUseId, block: 'end' } : { row, block: 'start' }
218}
219
220// Scrolls the transcript to where the pin came from; answers why not, or undefined once it moved.
221// Only works in answer to the person's own press or command, and only where the transcript scrolls.
222const jumpTo = async ($: EngineInterface, pin: Pin): Promise<string | undefined> => {
223 if (pin.anchor === undefined) return 'That pin has no place in the transcript to jump to.'
224
225 try {
226 const moved = await $.ui.scroll({ to: { requestId: pin.anchor.row }, block: pin.anchor.block })
227 return moved.deny === undefined ? undefined : `Couldn't scroll there: ${moved.deny}`
228 } catch (error) {
229 return `Couldn't scroll there: ${error instanceof Error ? error.message : String(error)}`
230 }
231}
232
233type RenderSite = Parameters<EngineInterface['ui']['resolve']>[0]
234
235// One pin's row: number, time, kind and, in the band, a preview cut to the `columns` left, all of
236// it one button that expands the pin; then its own buttons. `isFull` shows the whole text below.
237const pinRow = ($: EngineInterface, e: RenderSite, pin: Pin, n: number, open: string | null, isFull: boolean, columns: number) => {
238 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
239 const isOpen = isFull || open === pin.id
240 const hasJump = live.isFullscreen && pin.anchor !== undefined
241 const toggle = () => update($, expanded, current => (current === pin.id ? null : pin.id))
242 const dismiss = () => removePins($, new Set([pin.id]))
243 const jump = async () => {
244 const why = await jumpTo($, pin)
245 if (why !== undefined) $.ui.toast(`pin-board: ${why}`)
246 }
247
248 const head = `${isFull ? '' : isOpen ? '▾' : '▸'}${n} ${clockTime(pin.at)}`
249 const room = columns - cellWidth(head) - 1 - EMOJI_CELLS - 1 - (hasJump ? JUMP_CELLS : 0) - DISMISS_CELLS
250 const lead = isFull ? (
251 <Box flexShrink={0}>
252 <Text dimColor>{head}</Text>
253 <Text> {KIND_EMOJI[pin.kind]}</Text>
254 </Box>
255 ) : (
256 <Button key={`toggle:${pin.id}`} plain onPress={toggle}>
257 <Text dimColor>{head}</Text>
258 {` ${KIND_EMOJI[pin.kind]} ${fitTo(preview(pin.text), room)}`}
259 </Button>
260 )
261
262 return (
263 <Box key={`pin:${pin.id}`} flexDirection="column">
264 <Box flexDirection="row" justifyContent="space-between" gap={1}>
265 {lead}
266 <Box flexDirection="row" flexShrink={0} gap={1}>
267 {hasJump && <Button key={`jump:${pin.id}`} plain label="↑ jump" onPress={jump} />}
268 <Button key={`dismiss:${pin.id}`} plain label="✕" onPress={dismiss} />
269 </Box>
270 </Box>
271 {isOpen && (
272 <Box paddingLeft={2} marginBottom={isFull ? 1 : 0}>
273 <Markdown text={pin.text} />
274 </Box>
275 )}
276 </Box>
277 )
278}
279
280export const register: Register = (on, options) => {
281 live = fresh()
282
283 if (options.numberQuestions !== false) {
284 on('prompt.compose', async ($, e, next) => {
285 const composed = await next(e)
286 return { ...composed, sections: [...composed.sections, { id: 'pin-board', text: NUMBERING_HINT, scope: 'session' }] }
287 })
288 }
289
290 on('session.start', async ($, e, next) => {
291 await $.tool.register({
292 name: 'pin',
293 description: TOOL_DESCRIPTION,
294 isDeferred: false,
295 inputSchema: {
296 type: 'object',
297 properties: {
298 text: {
299 type: 'string',
300 description: 'What the user needs to see, in one or two lines. Empty with `replaces` removes that pin.',
301 },
302 kind: { type: 'string', enum: ['question', 'decision', 'fyi'], description: 'Defaults to fyi.' },
303 quote: {
304 type: 'string',
305 description: "A short exact excerpt of your earlier message this is about, so the pin's jump goes there.",
306 },
307 replaces: { type: 'string', description: 'The id (`p1`) of your earlier pin to update or, with empty text, remove.' },
308 },
309 required: ['text'],
310 },
311 })
312 await $.command.register({
313 name: 'pins',
314 description: 'List, show, jump to or dismiss pinned notes',
315 argumentHint: '[<n> | go <n> | done <n>… | clear]',
316 })
317
318 // After a hot reload the board is still in $.state, but the reply index went with the module.
319 const current = await read($, session)
320 if (current !== null) await restoreReplies($, current.transcriptPath)
321
322 return next(e)
323 })
324
325 // Startup, resume, /clear and fork each start a session: the board follows its id.
326 on('classic.SessionStart', async ($, e, next) => {
327 const done = await next(e)
328 await joinSession($, e.session_id, e.transcript_path)
329 return done
330 })
331
332 // Safety nets for a session the mod saw no start of (installed or enabled mid-session, or
333 // switched mid-process): the person's next prompt, early enough for Claude's pins that turn,
334 // and the end of any turn.
335 on('classic.UserPromptSubmit', async ($, e, next) => {
336 const done = await next(e)
337 await joinSession($, e.session_id, e.transcript_path)
338 return done
339 })
340
341 on('classic.Stop', async ($, e, next) => {
342 const done = await next(e)
343 await joinSession($, e.session_id, e.transcript_path)
344 return done
345 })
346
347 // The turn starts inside next(e), so the wake is marked before it.
348 on('prompt.submit', async ($, e, next) => {
349 const isWake = e.origin !== undefined && isBackgroundOrigin(e.origin)
350 const passed = isWake ? { ...e, context: [...(e.context ?? []), WAKE_CONTEXT] } : e
351
352 if (e.turnId !== undefined) {
353 if (isWake) live.wakeTurns.add(e.turnId)
354 return next(passed)
355 }
356
357 live.pendingWake = isWake
358 const entered = await next(passed)
359 if (entered.drop !== undefined) live.pendingWake = false
360
361 return entered
362 })
363
364 on('turn.start', ($, e, next) => {
365 if (e.agentId === undefined) {
366 live.currentTurn = e.turnId
367 live.lastTextRow = undefined
368 if (live.pendingWake) live.wakeTurns.add(e.turnId)
369 live.pendingWake = false
370 }
371
372 return next(e)
373 })
374
375 // Indexes the main loop's text replies: the rows a pin's quote and an automatic pin jump back to.
376 on('session.append', async ($, e, next) => {
377 const stored = await next(e)
378 const isReply = e.door === 'response' && e.agentId === undefined && e.message.role === 'assistant'
379 const text = isReply ? textOf(e.message.content) : ''
380
381 if (stored.uuid !== undefined && text.trim() !== '') {
382 live.lastTextRow = stored.uuid
383 live.replies = [...live.replies, { uuid: stored.uuid, text }].slice(-MAX_REPLIES)
384 }
385
386 return stored
387 })
388
389 on('tool.call', { tool: PIN_TOOL }, async ($, e) => {
390 const input = e as unknown as { text?: unknown; kind?: unknown; quote?: unknown; replaces?: unknown }
391 const text = typeof input.text === 'string' ? input.text.trim() : ''
392 const kind = isPinKind(input.kind) ? input.kind : undefined
393 const quote = typeof input.quote === 'string' && input.quote.trim() !== '' ? input.quote : undefined
394 const replaces = typeof input.replaces === 'string' && input.replaces.trim() !== '' ? input.replaces.trim() : undefined
395 const existing = replaces === undefined ? undefined : (await read($, pins)).find(pin => pin.claudeId === replaces)
396
397 if (text === '') {
398 if (replaces === undefined) return { deny: 'pin needs a non-empty `text`, or `replaces` to remove a pin.' }
399 if (existing !== undefined) await removePins($, new Set([existing.id]))
400 return { result: existing === undefined ? `${replaces} was already gone.` : `Removed ${replaces}.` }
401 }
402
403 const anchor = anchorFor(quote, e.tool_use_id)
404 const isQuoteMissed = quote !== undefined && anchor.block === 'end'
405 const missed = isQuoteMissed ? " The quote didn't match any of your recent replies, so its jump goes to this call instead." : ''
406 if (e.agentId === undefined && live.currentTurn !== undefined) live.pinnedByClaude.add(live.currentTurn)
407
408 if (existing !== undefined) {
409 // An update stays where the questions were asked unless it quotes somewhere new.
410 const kept = quote === undefined || isQuoteMissed ? (existing.anchor ?? anchor) : anchor
411 await changePins($, list =>
412 list.map(pin => (pin.id === existing.id ? { ...pin, text, kind: kind ?? pin.kind, anchor: kept } : pin)),
413 )
414 return { result: `Updated ${replaces}.${missed}` }
415 }
416
417 const claudeId = `p${await update($, seq, n => n + 1)}`
418 await addPin($, { kind: kind ?? 'fyi', text, source: 'claude', claudeId, anchor })
419 const gone = replaces === undefined ? '' : `${replaces} was already dismissed, so this is a new pin. `
420
421 return {
422 result: `${gone}Pinned as ${claudeId}. To update it later, call pin with replaces: "${claudeId}" and the new text; with empty text to remove it.${missed}`,
423 }
424 })
425
426 // A permission prompt mid-wake would be the very interruption this mod exists to avoid.
427 // Pinning only writes session state, so approve it, but keep a deny the person set.
428 on('tool.check', { tool: PIN_TOOL }, async ($, e, next) => {
429 const verdict = await next(e)
430 return verdict.decision === 'deny' ? verdict : { decision: 'allow', reason: 'pin-board: pinning only writes session state' }
431 })
432
433 on('turn.complete', async ($, e, next) => {
434 const done = await next(e)
435
436 if (e.agentId === undefined) {
437 const isWake = live.wakeTurns.delete(e.turnId)
438 const isPinned = live.pinnedByClaude.delete(e.turnId)
439 if (live.currentTurn === e.turnId) live.currentTurn = undefined
440
441 const auto = isWake && !isPinned && e.reason === 'answer' ? autoPinFor(e.answer) : null
442 const anchor: PinAnchor | undefined = live.lastTextRow === undefined ? undefined : { row: live.lastTextRow, block: 'start' }
443 if (auto !== null) await addPin($, { ...auto, source: 'auto', anchor })
444 }
445
446 return done
447 })
448
449 on('ui.close', { id: PANE }, async ($, e, next) => {
450 const closed = await next(e)
451 if (e.origin.kind === 'person') live.isPaneClosedByPerson = true
452 $.ui.invalidate('ui.render')
453 return closed
454 })
455
456 on('command.run', { command: 'pins' }, async ($, e) => {
457 const parsed = parsePinsCommand(e.args)
458 const list = newestFirst(await read($, pins))
459 const at = (n: number) => list[n - 1]
460 const missing = (n: number) => `No pin ${n}. There ${list.length === 1 ? 'is 1' : `are ${list.length}`}.`
461
462 switch (parsed.action) {
463 case 'usage':
464 return { text: USAGE }
465 case 'clear':
466 await clearPins($)
467 return { text: 'Cleared all pins.' }
468 case 'show': {
469 const pin = at(parsed.n)
470 return { text: pin === undefined ? missing(parsed.n) : `${pin.kind} · ${clockTime(pin.at)}\n\n${pin.text}` }
471 }
472 case 'go': {
473 const pin = at(parsed.n)
474 return { text: pin === undefined ? missing(parsed.n) : ((await jumpTo($, pin)) ?? 'Scrolled to the pin.') }
475 }
476 case 'done': {
477 const bad = parsed.ns.find(n => at(n) === undefined)
478 if (bad !== undefined) return { text: missing(bad) }
479
480 await removePins($, new Set(parsed.ns.map(n => at(n)!.id)))
481 return { text: `Dismissed ${parsed.ns.length === 1 ? 'pin' : 'pins'} ${parsed.ns.join(', ')}.` }
482 }
483 case 'list':
484 if (list.length === 0) return { text: 'No pins.' }
485 await openPane($, true)
486 return { text: list.map((pin, i) => `${i + 1}. [${clockTime(pin.at)}] ${pin.kind}: ${pin.text}`).join('\n\n') }
487 }
488 })
489
490 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
491 live.isFullscreen = e.viewport?.isFullscreen === true
492 const list = newestFirst(await read($, pins))
493
494 if (e.props.hasSurvey || list.length === 0) {
495 return next(e)
496 }
497
498 const { Box, Button, Text } = $.ui.resolve(e)
499 const isDocked = live.isFullscreen && (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
500
501 if (isDocked) {
502 return <Text dimColor>📌 {list.length} pinned in the side pane · /pins for commands</Text>
503 }
504
505 const open = await read($, expanded)
506
507 return (
508 <Box flexDirection="column">
509 <Box flexDirection="row" gap={1}>
510 <Text weight="bold">📌 Pinned ({list.length})</Text>
511 <Button key="clear-all" plain label="clear all" onPress={() => clearPins($)} />
512 {live.isFullscreen && <Button key="open-pane" plain label="open side pane" onPress={() => openPane($, true)} />}
513 </Box>
514 {list.map((pin, i) => pinRow($, e, pin, i + 1, open, false, e.props.bodyColumns))}
515 </Box>
516 )
517 })
518
519 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
520 const { Box, Button, Text } = $.ui.resolve(e)
521 const list = newestFirst(await read($, pins))
522
523 return (
524 <Box flexDirection="column">
525 <Box flexDirection="row" gap={1}>
526 <Text weight="bold">{list.length === 0 ? 'No pins.' : `${list.length} pinned, newest first`}</Text>
527 {list.length > 0 && <Button key="clear-all" plain label="clear all" onPress={() => clearPins($)} />}
528 </Box>
529 {list.map((pin, i) => pinRow($, e, pin, i + 1, null, true, e.props.bodyColumns))}
530 </Box>
531 )
532 })
533}
534hooks/pins.ts 244 lines1import type { PromptOrigin } from 'claude-code'
2
3import type { PinKind } from '../types'
4
5// Origins that mean the person themselves started the turn, on whichever client.
6const PERSON_ORIGINS: ReadonlySet<PromptOrigin['kind']> = new Set([
7 'composer',
8 'bridge',
9 'sdk',
10 'auto-continuation',
11])
12
13// An answer at least this long, with no question, still reads as a summary worth keeping.
14export const MIN_SUMMARY_CHARS = 280
15
16export const isBackgroundOrigin = (origin: PromptOrigin): boolean => !PERSON_ORIGINS.has(origin.kind)
17
18const TRAILING_MARKUP = /[\s*_`)\]"'”’]+$/
19
20export const asksQuestion = (text: string): boolean => {
21 let isInFence = false
22
23 for (const line of text.split('\n')) {
24 if (line.trimStart().startsWith('```')) {
25 isInFence = !isInFence
26 continue
27 }
28 if (!isInFence && line.replace(TRAILING_MARKUP, '').endsWith('?')) {
29 return true
30 }
31 }
32
33 return false
34}
35
36export const autoPinFor = (answer: string): { kind: PinKind; text: string } | null => {
37 const text = answer.trim()
38
39 if (asksQuestion(text)) {
40 return { kind: 'question', text }
41 }
42 if (text.length >= MIN_SUMMARY_CHARS) {
43 return { kind: 'summary', text }
44 }
45
46 return null
47}
48
49const LEADING_MARKUP = /^\s*(#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s*)*/
50
51export const preview = (text: string): string =>
52 text
53 .split('\n')
54 .map(line => line.replace(LEADING_MARKUP, '').trim())
55 .find(line => line.length > 0) ?? ''
56
57export type PinsCommand =
58 | { action: 'list' }
59 | { action: 'clear' }
60 | { action: 'show'; n: number }
61 | { action: 'go'; n: number }
62 | { action: 'done'; ns: number[] }
63 | { action: 'usage' }
64
65const NUMBER = /^\d+$/
66
67export const parsePinsCommand = (args: string): PinsCommand => {
68 const [verb, ...rest] = args.trim().split(/[\s,]+/).filter(word => word !== '')
69
70 if (verb === undefined) return { action: 'list' }
71 if (rest.length === 0 && verb === 'clear') return { action: 'clear' }
72 if (rest.length === 0 && NUMBER.test(verb)) return { action: 'show', n: Number(verb) }
73 if (verb === 'go' && rest.length === 1 && NUMBER.test(rest[0]!)) return { action: 'go', n: Number(rest[0]) }
74 if (verb === 'done' && rest.length > 0 && rest.every(word => NUMBER.test(word))) {
75 return { action: 'done', ns: rest.map(Number) }
76 }
77
78 return { action: 'usage' }
79}
80
81// The visible text of a Messages API `content`: a string, or a list of blocks whose text blocks are joined.
82export const textOf = (content: unknown): string => {
83 if (typeof content === 'string') return content
84 if (!Array.isArray(content)) return ''
85
86 return content
87 .filter(block => typeof block === 'object' && block !== null && block.type === 'text')
88 .map(block => String(block.text))
89 .join('\n')
90}
91
92// A quote shorter than this, once normalised, would match too many replies to trust.
93export const MIN_QUOTE_CHARS = 8
94
95const normalise = (text: string) =>
96 text
97 .toLowerCase()
98 .replace(/[*_`~]/g, '')
99 .replace(/\s+/g, ' ')
100 .trim()
101
102// The newest reply whose text holds the quote, ignoring case, spacing and markdown emphasis.
103export const findQuoted = (rows: readonly { uuid: string; text: string }[], quote: string): string | undefined => {
104 const needle = normalise(quote)
105 if (needle.length < MIN_QUOTE_CHARS) return undefined
106
107 for (let i = rows.length - 1; i >= 0; i -= 1) {
108 if (normalise(rows[i]!.text).includes(needle)) return rows[i]!.uuid
109 }
110
111 return undefined
112}
113
114type Reply = { uuid: string; text: string }
115
116// The main conversation's text replies in a transcript's JSON lines, oldest first. The format
117// is Claude Code's own, not the mods API, so anything unexpected is skipped, never thrown.
118// `isTail` drops the first line, which a read of the file's end cuts part-way.
119export const repliesFromTranscript = (transcript: string, isTail: boolean): Reply[] => {
120 const lines = transcript.split('\n')
121 const replies: Reply[] = []
122
123 for (const line of isTail ? lines.slice(1) : lines) {
124 let row: unknown
125 try {
126 row = JSON.parse(line)
127 } catch {
128 continue
129 }
130 if (typeof row !== 'object' || row === null) continue
131
132 const { type, uuid, isSidechain, message } = row as Record<string, unknown>
133 if (type !== 'assistant' || isSidechain === true || typeof uuid !== 'string') continue
134
135 const text = textOf((message as { content?: unknown } | undefined)?.content)
136 if (text.trim() !== '') replies.push({ uuid, text })
137 }
138
139 return replies
140}
141
142// The store keys of the oldest boards beyond the newest `keep`.
143export const boardsToPrune = (boards: readonly { key: string; savedAt: number }[], keep: number): string[] =>
144 [...boards]
145 .sort((a, b) => b.savedAt - a.savedAt)
146 .slice(keep)
147 .map(board => board.key)
148
149// Collects replies from a transcript streamed in pieces of any size, keeping the newest `max`;
150// a line split across two pieces is read once it is whole.
151export const replyWindow = (max: number) => {
152 let partial = ''
153 let replies: Reply[] = []
154
155 const take = (lines: string) => {
156 replies = [...replies, ...repliesFromTranscript(lines, false)].slice(-max)
157 }
158
159 return {
160 push(text: string) {
161 const joined = partial + text
162 const cut = joined.lastIndexOf('\n')
163 if (cut === -1) {
164 partial = joined
165 return
166 }
167 take(joined.slice(0, cut))
168 partial = joined.slice(cut + 1)
169 },
170 end(): Reply[] {
171 take(partial)
172 partial = ''
173 return replies
174 },
175 }
176}
177
178// Code points a terminal draws two cells wide (East Asian wide, emoji), and none wide (combining
179// marks, zero-width joiners, variation selectors); close enough to keep a row on one line.
180const WIDE: readonly [number, number][] = [
181 [0x1100, 0x115f],
182 [0x231a, 0x231b],
183 [0x2614, 0x2615],
184 [0x2648, 0x2653],
185 [0x26a1, 0x26a1],
186 [0x26aa, 0x26ab],
187 [0x26bd, 0x26be],
188 [0x26c4, 0x26c5],
189 [0x26d4, 0x26d4],
190 [0x26ea, 0x26ea],
191 [0x26f2, 0x26f5],
192 [0x26fa, 0x26fd],
193 [0x2705, 0x2705],
194 [0x270a, 0x270b],
195 [0x2728, 0x2728],
196 [0x274c, 0x274c],
197 [0x274e, 0x274e],
198 [0x2753, 0x2755],
199 [0x2757, 0x2757],
200 [0x2795, 0x2797],
201 [0x27b0, 0x27b0],
202 [0x27bf, 0x27bf],
203 [0x2b1b, 0x2b1c],
204 [0x2b50, 0x2b50],
205 [0x2b55, 0x2b55],
206 [0x2e80, 0x303e],
207 [0x3041, 0xa4cf],
208 [0xac00, 0xd7a3],
209 [0xf900, 0xfaff],
210 [0xfe30, 0xfe4f],
211 [0xff00, 0xff60],
212 [0xffe0, 0xffe6],
213 [0x1f300, 0x1f64f],
214 [0x1f680, 0x1f6ff],
215 [0x1f900, 0x1f9ff],
216 [0x20000, 0x3fffd],
217]
218const ZERO_WIDTH = /[̀-ͯ-⃐-︀-️]/
219
220const charWidth = (char: string): number => {
221 if (ZERO_WIDTH.test(char)) return 0
222 const code = char.codePointAt(0)!
223 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
224}
225
226export const cellWidth = (text: string): number => Array.from(text).reduce((sum, char) => sum + charWidth(char), 0)
227
228// The text cut to `width` cells, an ellipsis ending what was cut.
229export const fitTo = (text: string, width: number): string => {
230 if (cellWidth(text) <= width) return text
231 if (width <= 0) return ''
232
233 let kept = ''
234 let used = 0
235 for (const char of text) {
236 const next = charWidth(char)
237 if (used + next > width - 1) break
238 kept += char
239 used += next
240 }
241
242 return `${kept}…`
243}
244types/index.d.ts 30 lines1export type PinKind = 'question' | 'decision' | 'fyi' | 'summary'
2
3export type Pin = {
4 id: string
5 kind: PinKind
6 text: string
7 // 'claude' when the model pinned it with the tool, 'auto' when a background turn's answer was caught.
8 source: 'claude' | 'auto'
9 // Epoch milliseconds from $.clock.now().
10 at: number
11 // The id Claude updates or removes it by (`p1`, `p2`, ...); only on pins Claude made.
12 claudeId?: string
13 anchor?: PinAnchor
14}
15
16// Where a jump scrolls the transcript: a row by its id, and which edge of the screen it lands on.
17// `start` for a reply the pin quotes (read from its top); `end` for the pin call itself, so
18// what led up to it shows above.
19export type PinAnchor = { row: string; block: 'start' | 'end' }
20
21// The session the board belongs to: its id keys the board in $.store, its transcript rebuilds
22// the reply index that quotes match against.
23export type PinSession = { id: string; transcriptPath: string }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'pin-board': { pins: Pin[]; expanded: string | null; seq: number; session: PinSession | null }
28 }
29}
30