SLOPSHOPPER

pin-board

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

panebandguardcommandtoastprompt
★ 1v0.1.0MITupdated 2026-10-18daaain/skills/mods/pin-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-board
│ ┃ pins ✕ › fix the failing auth test and add an audit log call │ ┃ No pins. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pins │ ⎿ pin-board: No pins. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pins
No pins.
README

pin-board

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.

pin-board in a fullscreen terminal: two of Claude's unanswered questions in the docked Pins pane, each with its time, kind and ↑ jump, and the one-line band above the prompt

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.

How it decides what to pin

It works in two layers:

  1. Automatic (safety net). When a turn was started by something other than you (a task notification, a scheduled trigger, another session, a channel, another plugin), its final answer is pinned if it:
  2. asks a question: a line ending in ?, outside code fences; or
  3. is at least 280 characters, so it reads as a summary rather than an all-clear.

Short "nothing new, still watching" answers are skipped. Turns you started yourself are never auto-pinned.

  1. Claude-driven (curated). Claude gets a 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.

Parking unanswered questions

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" })
FieldMeaning
textWhat you need to see, in a line or two
kindquestion, decision or fyi (the default)
quoteOptional. A short exact excerpt of Claude's earlier message the pin is about
replacesOptional. 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.

Where pins show

  • Fullscreen: a new pin opens a Pins pane docked beside the transcript, running from the top of the screen to the bottom, with every pin in full, newest first. The band above the prompt shrinks to one line. If you close the pane, it stays closed until you press open side pane in the band or run /pins. The mod opens the pane itself only on a terminal at least 144 columns wide; /pins opens it at any width.
  • Elsewhere: pins show in the band above the prompt, newest first, one line each, cut to fit the width. Pressing a row expands that pin to its full text, rendered as Markdown.

Pins never disappear on their own. See Resuming a session for what happens to them across sessions.

Keyboard and mouse

Pins are numbered newest first, and /pins runs at once, even while Claude is working:

CommandDoes
/pinsLists 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 clearDismisses 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.

Jumping back to the transcript

↑ jump (or /pins go <n>) scrolls the transcript to where the pin came from:

  • a pin with a matching 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.
  • a pin from Claude without a quote, or whose quote didn't match: the 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.
  • an automatic pin: the turn's last text reply, shown from its top.

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.

Resuming a session

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.
  • The reply index isn't saved; it's rebuilt from the whole transcript, whatever its size, by streaming 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.
  • The transcript's format belongs to Claude Code, not the mods API, so the mod skips any line it doesn't recognise rather than failing.
  • Boards of the 50 most recent sessions are kept; older ones are pruned.
  • Installed or enabled partway through a session, the mod finds the session and its transcript at your next prompt.

Where an organization manages Claude Code

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:

  • pins aren't restored on resume, and /clear doesn't empty the board;
  • the reply index only covers replies written since the mod loaded;
  • the question-numbering line isn't added (put it in your CLAUDE.md instead).

Install

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

Develop

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.
Source 3 files
hooks/register.tsx 534 lines
1import { 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}
534
hooks/pins.ts 244 lines
1import 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}
244
types/index.d.ts 30 lines
1export 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