SLOPSHOPPER

pinboard

Pin tables, artifact links and answers into a side pane; re-pinning under the same title replaces the pin, so changing figures stay one current version (/pin…

newpaneguardcommandtoasttool
v0.4.0MITupdated 2026-10-14afinzel/vise-tools/plugins/pinboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pinboard
│ ┃ pins ✕ › fix the failing auth test and add an audit log call │ ┃ Pins 0 f: next filter │ ┃ All 0 This session 0 This project 0 Table ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No pins yet ⏺ Bash(bun test) │ ┃ /pin pin the last answer's ⎿ 3 pass, 1 fail │ ┃ tables and links │ ┃ "pin this table" ask Claude to pin it ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ a s d g h pin a candidate below │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ↩ u: undo I updated src/auth.ts to reject ex › /pin │ ┃ ⎿ pinboard: 📌 Pinned: ¶ I updated src/auth.ts to reject expired cl │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── │ ┃ ctrl+x tab to use the pane · /pin help │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pins
Pins 0 f: next filter All 0 This session 0 This project 0 Tables 0 Artifacts 0 ──────────────────────────────────────────────────────── No pins yet /pin pin the last answer's tables and links "pin this table" ask Claude to pin it a s d g h pin a candidate below ↩ u: undo I updated src/auth.ts to reject expired… ──────────────────────────────────────────────────────── ctrl+x tab to use the pane · /pin help
README

pinboard

A Claude Code mod that keeps tables, artifact links and answers in a Pins side pane, so the figures you are working through stay in view instead of scrolling away.

Forked from Young-in/pinboard. The difference: a pin has a name, and pinning under that name again replaces it. When figures change mid-investigation Claude re-pins the table under the same title, so the pane holds the current version and never a trail of stale ones.

Installation

claude plugin install pinboard@vise-tools

How pins get there

  • Claude pins them. The pin tool asks Claude to pin figures you are working through over several turns without being asked, and to re-pin under the same title when they change. You can also just say "pin this table".
  • You pin them. /pin saves the mouse selection, or else the last answer's tables and artifact links. /pin <title> does the same under a title, replacing a pin of that title.
  • Candidates. Tables and artifact links in answers show at the foot of the pane; one key pins one.

Replacement rules

A new pin replaces an existing one only when all of these hold:

  • its title was given (the tool's title, or /pin <title>), not derived from the content;
  • the existing pin's title was given too, and matches ignoring case and spacing;
  • both are in the same project, and from the same agent (or both from the main conversation).

The replaced pin keeps its id and moves to the top with the new content and time. Identical content is never pinned twice. Pins live in the mod's store across sessions and projects; the pane's This project and This session tabs narrow them.

Commands

CommandDoes
/pin [title]Pin the selection, else the last answer's tables and links (else the whole answer)
/pin all [title]Pin the whole last answer
/pin tables \linksPin only the last answer's tables \links
`/pin rm <n\last>, /unpin <n>`Remove pin n (the pane's number) or the latest
/pin undoUndo the last remove or clear
/pin show <n>Print pin n into the conversation, where Claude reads it too
/pin clearRemove all, after confirming
/pin exportWrite every pin to ~/.claude/pins/pins-<date>.md
`/pin auto on\off`Open the pane on session start and when new candidates appear
/pinsOpen or close the pane

In the pane (focus it with ctrl+x tab): 1–9 select, a s d g h pin a candidate, c copy, p insert into the prompt, x remove, u undo, f next filter.

Claude's tools: pin (markdown, title, kind), unpin (id, index or title), list.

Limits

  • The pane docks right of the transcript only in the fullscreen layout (110+ columns); elsewhere it opens above the prompt, and /pin can't read a mouse selection.
  • Each installed copy has its own store: pins from the marketplace install and from a --plugin-dir working copy don't see each other.
  • The store has no compare-and-set, so two sessions saving within the same few milliseconds can drop one write.

Developing

claude plugin validate plugins/pinboard
claude plugin test plugins/pinboard
claude --plugin-dir plugins/pinboard   # a session with this working copy loaded

Code that uses $ stays in hooks/register.tsx, because the engine follows $ only through functions in the hooks module itself; extract.ts and format.ts hold the pure functions.

License

MIT. Original work © 2026 Young-in; see LICENSE.

Source 4 files
hooks/register.tsx 1065 lines
1// pinboard: pin tables, artifact links and answers from the conversation into a side pane.
2//
3// One module on purpose: the engine follows `$` only into functions declared in the file that
4// registers the hooks, never across an import, so everything that touches `$` lives here, in
5// sections. The markdown extraction is pure and lives in extract.ts.
6//
7// Entry points: /pin, /pins, /unpin; the model's pin/unpin/list tools; every reply scanned for
8// tables and artifact links (candidates); the Pane the person opens.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderInputOf, RenderSurface } from 'claude-code'
11
12import type { PinboardCandidate, PinboardFilter, PinboardKind, PinboardPin } from '../types'
13import {
14  bodyKey,
15  classify,
16  cleanTitle,
17  extractAll,
18  extractLinks,
19  extractTables,
20  firstLineTitle,
21  isKind,
22  projectName,
23} from './extract'
24import { fitWidth, KIND_LABEL, localStamp, shortAge, textWidth } from './format'
25
26// ────────────────────────────────────────────────────────────────────────────
27// State, store and shared operations
28// ────────────────────────────────────────────────────────────────────────────
29
30type Dollar = EngineInterface
31/** Which agent an item came from: a subagent's or teammate's id and the name it is shown by. */
32type AgentTag = { agentId?: string; agentLabel?: string }
33/**
34 * `isNamed`: the title was chosen, by the person or by Claude, rather than derived from the content. A
35 * named item replaces the pin of the same name, so a figure that keeps changing stays one current pin.
36 */
37type PinInput = { kind: PinboardKind; title: string; body: string; sourceUuid?: string; isNamed?: boolean } & AgentTag
38type SavedPin = { pin: PinboardPin; isReplacement: boolean }
39
40const PANE = 'pins'
41const PANE_TITLE = 'Pins'
42const STORE_PINS = 'pins'
43const STORE_BACKUP = 'pins.backup'
44const STORE_AUTO_OPEN = 'autoOpen'
45const MAX_PINS = 200
46const MAX_BODY = 20_000
47const MAX_CANDIDATES = 8
48/** How often a session re-reads the shared store to show what other sessions pinned or removed. */
49const REFRESH_MS = 3_000
50
51const STORE_FILTER = 'filter'
52
53const FILTERS: PinboardFilter[] = ['all', 'session', 'project', 'table', 'artifact', 'link']
54const FILTER_LABEL: Record<PinboardFilter, string> = {
55  all: 'All',
56  session: 'This session',
57  table: 'Tables',
58  artifact: 'Artifacts',
59  link: 'Links',
60  project: 'This project',
61}
62const GLYPH: Record<PinboardKind, string> = { table: '▤', artifact: '◈', link: '⇗', text: '¶' }
63
64const pins = atom({ plugin: 'pinboard', key: 'pins' } as const, [])
65const selectedId = atom({ plugin: 'pinboard', key: 'selectedId' } as const, null)
66const filter = atom({ plugin: 'pinboard', key: 'filter' } as const, 'all')
67const candidates = atom({ plugin: 'pinboard', key: 'candidates' } as const, [])
68const lastRemoved = atom({ plugin: 'pinboard', key: 'lastRemoved' } as const, null)
69const confirmDeleteId = atom({ plugin: 'pinboard', key: 'confirmDeleteId' } as const, null)
70const autoOpenSuppressed = atom({ plugin: 'pinboard', key: 'autoOpenSuppressed' } as const, false)
71
72function newId(seed: number): string {
73  return `${seed.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
74}
75
76/**
77 * The agent whose transcript the person last had in view beside the pane, as the pane's last drawing saw it;
78 * undefined for the main conversation. Commands read it, since only a drawing is told which view is on screen.
79 * A module variable on purpose: a hint about the screen, fine to lose on a reload.
80 */
81let viewedAgentId: string | undefined
82
83/** Where the pane is looked at from: the session's folder and id, and the agent in view if any. */
84type Scope = { cwd: string; sessionId: string; agentId?: string }
85
86async function currentScope($: Dollar, agentId = viewedAgentId): Promise<Scope> {
87  const [cwd, sessionId] = await Promise.all([$.session.cwd(), $.session.id()])
88  return agentId ? { cwd, sessionId, agentId } : { cwd, sessionId }
89}
90
91function isFilter(value: unknown): value is PinboardFilter {
92  return typeof value === 'string' && (FILTERS as string[]).includes(value)
93}
94
95/** The pins of the view: one agent's while its transcript is in view, else every pin. */
96function inView(list: PinboardPin[], scope: Scope): PinboardPin[] {
97  return scope.agentId ? list.filter(p => p.agentId === scope.agentId) : list
98}
99
100function visible(list: PinboardPin[], f: PinboardFilter, scope: Scope): PinboardPin[] {
101  const base = inView(list, scope)
102  if (f === 'all') return base
103  if (f === 'session') return base.filter(p => p.sessionId === scope.sessionId)
104  if (f === 'project') return base.filter(p => p.cwd === scope.cwd)
105  return base.filter(p => p.kind === f)
106}
107
108/** The name an agent is shown by: its SendMessage name, its task description, or its type. */
109async function agentLabelOf($: Dollar, agentId: string): Promise<string> {
110  try {
111    const agent = (await $.agent.list()).find(a => a.id === agentId)
112    const label = agent?.name || agent?.description || agent?.type
113    if (label) return label
114  } catch {
115    // the roster is a nicety; fall back to the id
116  }
117  return `agent ${agentId.slice(0, 6)}`
118}
119
120async function agentTag($: Dollar, agentId: string | undefined): Promise<AgentTag> {
121  return agentId ? { agentId, agentLabel: await agentLabelOf($, agentId) } : {}
122}
123
124type PinGroup = { sessionId: string; isCurrent: boolean; label: string; pins: PinboardPin[] }
125
126/** Pins grouped by the session that saved them: this session first, then the others, most recent first. */
127function groupBySession(list: PinboardPin[], scope: Scope): PinGroup[] {
128  const bySession = new Map<string, PinboardPin[]>()
129  for (const p of [...list].sort((a, b) => b.createdAt - a.createdAt)) {
130    const group = bySession.get(p.sessionId)
131    if (group) group.push(p)
132    else bySession.set(p.sessionId, [p])
133  }
134  const groups = [...bySession].map(([sessionId, group]) => {
135    const isCurrent = sessionId === scope.sessionId
136    const oldest = group[group.length - 1]!
137    const label = isCurrent
138      ? 'This session'
139      : `Other session · ${localStamp(oldest.createdAt).slice(5)} · ${projectName(oldest.cwd)}`
140    return { sessionId, isCurrent, label, pins: group }
141  })
142  return groups.sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent) || b.pins[0]!.createdAt - a.pins[0]!.createdAt)
143}
144
145/** The pins a filter shows, in the order the pane numbers them. */
146function displayOrder(list: PinboardPin[], f: PinboardFilter, scope: Scope): PinboardPin[] {
147  return groupBySession(visible(list, f, scope), scope).flatMap(g => g.pins)
148}
149
150function describe(p: PinboardPin): string {
151  return `${GLYPH[p.kind]} ${p.title}`
152}
153
154// Every session of one installed copy shares one list in `$.store`. The store is the truth: each change
155// reads it right before writing, and `refreshPins` mirrors it into session state, where the pane reads it.
156
157/** The pins as the store holds them now. */
158async function loadPins($: Dollar): Promise<PinboardPin[]> {
159  const stored = await $.store.get(STORE_PINS)
160  return Array.isArray(stored) ? (stored as PinboardPin[]) : []
161}
162
163function signature(list: readonly PinboardPin[]): string {
164  return list.map(p => p.id).join(',')
165}
166
167/** Mirrors the store into session state when another session changed it, so the pane redraws. */
168async function refreshPins($: Dollar): Promise<PinboardPin[]> {
169  const fresh = await loadPins($)
170  const mirrored = await read($, pins)
171  if (signature(fresh) !== signature(mirrored)) await update($, pins, () => fresh)
172  return fresh
173}
174
175/**
176 * Changes the shared list: reads the store fresh, applies `change`, writes the result back and mirrors it.
177 * Reading right before writing keeps what other sessions saved meanwhile. `change` returning its input
178 * unchanged writes nothing.
179 */
180async function mutatePins($: Dollar, change: (list: PinboardPin[]) => PinboardPin[]): Promise<PinboardPin[]> {
181  const list = await loadPins($)
182  const next = change(list)
183  if (next !== list) await $.store.set(STORE_PINS, next.slice(0, MAX_PINS))
184  await update($, pins, () => next.slice(0, MAX_PINS))
185  return next
186}
187
188/**
189 * Adds the items not already pinned (same body, whitespace aside), newest first; returns what was saved.
190 * A named item takes the place of the pin with the same name in this project, keeping its id.
191 */
192async function addPins($: Dollar, items: PinInput[]): Promise<SavedPin[]> {
193  const [now, sessionId, cwd] = await Promise.all([$.clock.now(), $.session.id(), $.session.cwd()])
194  const saved: SavedPin[] = []
195  await mutatePins($, current => {
196    let kept = current
197    const known = new Set(current.map(p => bodyKey(p.body)))
198    for (const it of items) {
199      const body = it.body.length > MAX_BODY ? `${it.body.slice(0, MAX_BODY)}\n\n…(truncated)` : it.body
200      const key = bodyKey(body)
201      if (!key || known.has(key)) continue
202      known.add(key)
203      const replaced = it.isNamed ? kept.find(p => isSameNamedPin(p, it, cwd)) : undefined
204      if (replaced) kept = kept.filter(p => p.id !== replaced.id)
205      const pin: PinboardPin = {
206        id: replaced?.id ?? newId(now + saved.length),
207        createdAt: now,
208        sessionId,
209        cwd,
210        kind: it.kind,
211        title: it.title || firstLineTitle(body),
212        body,
213      }
214      if (it.sourceUuid) pin.sourceUuid = it.sourceUuid
215      if (it.isNamed) pin.isNamed = true
216      if (it.agentId) pin.agentId = it.agentId
217      if (it.agentLabel) pin.agentLabel = it.agentLabel
218      saved.push({ pin, isReplacement: Boolean(replaced) })
219    }
220    return saved.length > 0 ? [...saved.map(s => s.pin), ...kept] : current
221  })
222  if (saved.length === 0) return []
223  const savedKeys = new Set(saved.map(s => bodyKey(s.pin.body)))
224  await update($, selectedId, () => saved[0]?.pin.id ?? null)
225  await update($, confirmDeleteId, () => null)
226  await update($, candidates, list => list.filter(c => !savedKeys.has(bodyKey(c.body))))
227  return saved
228}
229
230/** A named pin with the same name, case and spacing aside, in the same project and from the same agent. */
231function isSameNamedPin(p: PinboardPin, it: PinInput, cwd: string): boolean {
232  return p.isNamed === true && p.cwd === cwd && p.agentId === it.agentId && nameKey(p.title) === nameKey(it.title)
233}
234
235function nameKey(title: string): string {
236  return title.trim().replace(/\s+/g, ' ').toLowerCase()
237}
238
239/** Removes one pin and keeps it as `lastRemoved` for undo. */
240async function removePin($: Dollar, id: string): Promise<PinboardPin | undefined> {
241  let target: PinboardPin | undefined
242  await mutatePins($, current => {
243    target = current.find(p => p.id === id)
244    return target ? current.filter(p => p.id !== id) : current
245  })
246  if (!target) return undefined
247  const removed = target
248  await update($, lastRemoved, () => removed)
249  await update($, confirmDeleteId, () => null)
250  await update($, selectedId, cur => (cur === id ? null : cur))
251  return removed
252}
253
254/** Restores the last removed pin, or the backup `/pin clear` left. */
255async function undo($: Dollar): Promise<string> {
256  const removed = await read($, lastRemoved)
257  if (removed) {
258    await mutatePins($, current =>
259      current.some(p => p.id === removed.id)
260        ? current
261        : [removed, ...current].sort((a, b) => b.createdAt - a.createdAt),
262    )
263    await update($, lastRemoved, () => null)
264    await update($, selectedId, () => removed.id)
265    return `↩ Restored: ${describe(removed)}`
266  }
267  const backup = await $.store.get(STORE_BACKUP)
268  if (Array.isArray(backup) && backup.length > 0) {
269    let restored = 0
270    await mutatePins($, current => {
271      const ids = new Set(current.map(p => p.id))
272      const back = (backup as PinboardPin[]).filter(p => !ids.has(p.id))
273      restored = back.length
274      return [...current, ...back].sort((a, b) => b.createdAt - a.createdAt)
275    })
276    await $.store.delete(STORE_BACKUP)
277    return `↩ Restored ${restored} pins`
278  }
279  return 'Nothing to undo.'
280}
281
282async function isPaneOpen($: Dollar): Promise<boolean> {
283  return (await $.ui.panes()).some(p => p.id === PANE)
284}
285
286/**
287 * Opens the pane. `asked` means the person did something (a command, a press): it seats at any width and
288 * lifts the suppression their close set. Unasked opens respect `/pin auto off` and a close this session.
289 */
290async function openPane($: Dollar, options: { asked: boolean; focus?: boolean }): Promise<boolean> {
291  if (options.asked) {
292    await update($, autoOpenSuppressed, () => false)
293  } else {
294    const auto = (await $.store.get(STORE_AUTO_OPEN)) !== false
295    if (!auto || (await read($, autoOpenSuppressed))) return false
296  }
297  const opened = await $.ui.open(
298    options.focus ? { id: PANE, title: PANE_TITLE, focus: true } : { id: PANE, title: PANE_TITLE },
299  )
300  return opened.isPlaced
301}
302
303/** A pin by its number in the pane (filtered, newest first), `last`, its id, or a title substring. */
304async function resolvePin($: Dollar, token: string): Promise<PinboardPin | undefined> {
305  const list = await refreshPins($)
306  const t = token.trim()
307  if (!t || t === 'last') return list[0]
308  const n = Number(t)
309  if (Number.isInteger(n) && n >= 1) {
310    const shown = displayOrder(list, await read($, filter), await currentScope($))
311    if (n <= shown.length) return shown[n - 1]
312  }
313  const lower = t.toLowerCase()
314  return list.find(p => p.id === t) ?? list.find(p => p.title.toLowerCase().includes(lower))
315}
316
317/** The newest assistant message with text in the main conversation, or in one agent's; undefined before any. */
318async function lastAnswer($: Dollar, agentId?: string): Promise<string | undefined> {
319  const messages = agentId ? await $.session.messages({ agentId }) : await $.session.messages()
320  if (!Array.isArray(messages)) return undefined
321  for (let i = messages.length - 1; i >= 0; i--) {
322    const m = messages[i]
323    if (m && m.role === 'assistant' && m.text.trim()) return m.text
324  }
325  return undefined
326}
327
328// ────────────────────────────────────────────────────────────────────────────
329// Slash commands: /pin, /unpin, /pins
330// ────────────────────────────────────────────────────────────────────────────
331
332const HELP = [
333  "/pin                save the mouse selection, else the last answer's tables and artifact links (else the whole answer)",
334  '/pin [title]        as above, under a title; an existing pin with that title is replaced',
335  '/pin all [title]    save the whole last answer',
336  "/pin tables|links   save only the last answer's tables | links",
337  '/pin rm <n|last>    remove pin n (pane number) or the latest  (same as /unpin <n>)',
338  '/pin undo           undo the last remove (or clear)',
339  '/pin show <n>       print pin n into the conversation (the model reads it too)',
340  '/pin clear          remove all (after confirming)',
341  '/pin export         export to ~/.claude/pins/pins-<date>.md',
342  '/pin auto on|off    open the pane on session start and when new candidates appear',
343  '/pins               open/close the pane',
344  'Sessions: "All" groups pins by session · the "This session" tab shows only this session\'s pins',
345  'Agents: while viewing an agent (pane open), the pane and /pin work on that agent',
346  'Pane keys (focus with ctrl+x tab): 1-9 select · a/s/d/g/h save candidate · c copy · p insert into prompt · x remove · u undo · f filter',
347].join('\n')
348
349function report(saved: SavedPin[], tag: AgentTag = {}): string {
350  const from = tag.agentLabel ? ` (agent ${tag.agentLabel})` : ''
351  if (saved.length === 0) return 'Already pinned.'
352  if (saved.length === 1) return `📌 ${savedVerb(saved[0]!)}${from}: ${describe(saved[0]!.pin)}`
353  return `📌 Pinned ${saved.length}${from}:\n${saved.map(s => `  ${describe(s.pin)}${s.isReplacement ? ' (updated)' : ''}`).join('\n')}`
354}
355
356function savedVerb(s: SavedPin): string {
357  return s.isReplacement ? 'Updated' : 'Pinned'
358}
359
360/**
361 * Pins from the last answer of the transcript in view: the main conversation's, or the agent's whose
362 * transcript the pane last showed beside it.
363 */
364async function pinFromAnswer($: Dollar, mode: 'auto' | 'all' | 'links' | 'tables', title: string): Promise<string> {
365  const tag = await agentTag($, viewedAgentId)
366  const text = await lastAnswer($, tag.agentId)
367  if (!text) return tag.agentId ? 'No answer from this agent to pin.' : 'No answer to pin.'
368  let items: PinInput[]
369  if (mode === 'all') items = [{ kind: classify(text), title: title || firstLineTitle(text), body: text.trim() }]
370  else if (mode === 'links') items = extractLinks(text, false)
371  else if (mode === 'tables') items = extractTables(text)
372  else items = extractAll(text)
373  if (items.length === 0) {
374    if (mode === 'links') return 'The last answer has no links.'
375    if (mode === 'tables') return 'The last answer has no tables.'
376    items = [{ kind: 'text', title: title || firstLineTitle(text), body: text.trim() }]
377  } else if (title && mode !== 'all') {
378    const n = items.length
379    items = items.map((it, i) => ({ ...it, title: n > 1 ? `${title} (${i + 1}/${n})` : title }))
380  }
381  const added = await addPins(
382    $,
383    items.map(it => ({ ...it, ...tag, isNamed: Boolean(title) })),
384  )
385  await openPane($, { asked: true })
386  return report(added, tag)
387}
388
389/** `/pin [title]`: the mouse selection when there is one, else the last answer's tables and artifact links. */
390async function pinDefault($: Dollar, title: string): Promise<string> {
391  let selected: { text: string; requestId?: string } | undefined
392  try {
393    selected = await $.ui.selection()
394  } catch {
395    selected = undefined
396  }
397  const text = selected?.text.trim() ?? ''
398  if (text) {
399    const tag = await agentTag($, viewedAgentId)
400    const item: PinInput = {
401      kind: classify(text),
402      title: title || firstLineTitle(text),
403      body: text,
404      isNamed: Boolean(title),
405      ...tag,
406    }
407    if (selected?.requestId) item.sourceUuid = selected.requestId
408    const added = await addPins($, [item])
409    await openPane($, { asked: true })
410    return report(added, tag)
411  }
412  return pinFromAnswer($, 'auto', title)
413}
414
415async function runRemove($: Dollar, token: string): Promise<string> {
416  const target = await resolvePin($, token)
417  if (!target) return `No such pin: ${token.trim() || '(none)'}`
418  await removePin($, target.id)
419  return `🗑 Removed: ${describe(target)}  (/pin undo to restore)`
420}
421
422async function runShow($: Dollar, token: string): Promise<string> {
423  const target = await resolvePin($, token)
424  if (!target) return `No such pin: ${token.trim() || '(none)'}`
425  await update($, selectedId, () => target.id)
426  await openPane($, { asked: true })
427  return `${describe(target)}\n\n${target.body}`
428}
429
430async function runClear($: Dollar, rest: string): Promise<string> {
431  const list = await refreshPins($)
432  if (list.length === 0) return 'No pins.'
433  let ok = rest.trim() === '--yes'
434  if (!ok) {
435    try {
436      ok = (await $.ui.ask(`Remove all ${list.length} pins?`, ['Remove', 'Cancel'])) === 'Remove'
437    } catch {
438      return 'Cancelled (non-interactive: /pin clear --yes)'
439    }
440  }
441  if (!ok) return 'Cancelled'
442  let cleared: PinboardPin[] = []
443  await mutatePins($, current => {
444    cleared = current
445    return []
446  })
447  await $.store.set(STORE_BACKUP, cleared)
448  await update($, selectedId, () => null)
449  await update($, lastRemoved, () => null)
450  return `🗑 Removed ${cleared.length} pins (/pin undo to restore)`
451}
452
453async function runExport($: Dollar): Promise<string> {
454  const list = await refreshPins($)
455  if (list.length === 0) return 'No pins to export.'
456  const home = (await $.env.get('HOME')) ?? '.'
457  const date = localStamp(await $.clock.now()).slice(0, 10)
458  const path = `${home}/.claude/pins/pins-${date}.md`
459  const sections = list.map(p => {
460    const meta = `${KIND_LABEL[p.kind]} · ${projectName(p.cwd)} · ${localStamp(p.createdAt)}`
461    return `## ${GLYPH[p.kind]} ${p.title}\n\n_${meta}_\n\n${p.body}\n`
462  })
463  try {
464    await $.fs.write(path, [`# Pins (${list.length}) · ${date}`, '', ...sections].join('\n'))
465  } catch (err) {
466    return `Export failed: ${String(err)}`
467  }
468  return `📄 Exported: ${path}`
469}
470
471async function runAuto($: Dollar, rest: string): Promise<string> {
472  const v = rest.trim().toLowerCase()
473  if (v !== 'on' && v !== 'off') {
474    const cur = await $.store.get(STORE_AUTO_OPEN)
475    return `Auto-open: ${cur === false ? 'off' : 'on'}  (/pin auto on|off)`
476  }
477  await $.store.set(STORE_AUTO_OPEN, v === 'on')
478  return `Auto-open: ${v}`
479}
480
481/** `/pin ...`: the first word picks a subcommand; anything else is a title for the default save. */
482async function runPin($: Dollar, rawArgs: string): Promise<string> {
483  const args = rawArgs.trim()
484  const [head = '', ...tail] = args.split(/\s+/)
485  const rest = tail.join(' ').trim()
486  switch (head.toLowerCase()) {
487    case 'help':
488    case '?':
489      return HELP
490    case 'rm':
491    case 'remove':
492    case 'del':
493    case 'delete':
494      return runRemove($, rest)
495    case 'undo':
496      return undo($)
497    case 'show':
498    case 'cat':
499      return runShow($, rest)
500    case 'clear':
501      return runClear($, rest)
502    case 'export':
503      return runExport($)
504    case 'auto':
505      return runAuto($, rest)
506    case 'all':
507      return pinFromAnswer($, 'all', rest)
508    case 'links':
509    case 'link':
510      return pinFromAnswer($, 'links', rest)
511    case 'tables':
512    case 'table':
513      return pinFromAnswer($, 'tables', rest)
514    default:
515      return pinDefault($, args)
516  }
517}
518
519/** `/pins`: closes the pane when open, else opens it with the keyboard. */
520async function togglePane($: Dollar): Promise<string> {
521  if (await isPaneOpen($)) {
522    await $.ui.close({ id: PANE })
523    return 'Pins pane closed'
524  }
525  const placed = await openPane($, { asked: true, focus: true })
526  return placed
527    ? 'Pins pane opened (ctrl+x tab: focus pane · Esc: back to prompt · /pin help: help)'
528    : 'Pins pane opened but not shown yet (widen the terminal to see it)'
529}
530
531// ────────────────────────────────────────────────────────────────────────────
532// The Pins pane
533// ────────────────────────────────────────────────────────────────────────────
534
535const MAX_ROWS = 30
536const CANDIDATE_HOTKEYS = ['a', 's', 'd', 'g', 'h']
537// Theme keys, so the colors follow the person's light or dark theme.
538const ACCENT = 'claude'
539const KIND_COLOR: Record<PinboardKind, string> = {
540  table: 'suggestion',
541  artifact: 'merged',
542  link: 'ide',
543  text: 'subtle',
544}
545
546async function renderPane($: Dollar, e: RenderInputOf<'Pane', RenderSurface>) {
547  const { Box, Text, Button, Markdown, Link } = $.ui.resolve(e)
548  // Only a drawing is told which transcript is on screen; remember it for /pin and /unpin.
549  const viewAgent = e.props.view.agentId
550  viewedAgentId = viewAgent
551  const [list, f, sel, allCands, removed, confirm, now, scope, viewLabel] = await Promise.all([
552    read($, pins),
553    read($, filter),
554    read($, selectedId),
555    read($, candidates),
556    read($, lastRemoved),
557    read($, confirmDeleteId),
558    $.clock.now(),
559    currentScope($, viewAgent),
560    viewAgent ? agentLabelOf($, viewAgent) : Promise.resolve(undefined),
561  ])
562  const base = inView(list, scope)
563  const groups = groupBySession(visible(list, f, scope), scope)
564  const shown = groups.flatMap(g => g.pins)
565  const selected = shown.find(p => p.id === sel) ?? null
566  // The main view offers the main conversation's candidates; an agent's view offers that agent's.
567  const cands = allCands.filter(c => c.agentId === viewAgent)
568  const width = Math.max(20, e.props.bodyColumns)
569
570  const select = (id: string) => {
571    void update($, confirmDeleteId, () => null)
572    void update($, selectedId, cur => (cur === id ? null : id))
573  }
574  const setFilter = (next: PinboardFilter) => {
575    void update($, selectedId, () => null)
576    void update($, filter, () => next)
577    void $.store.set(STORE_FILTER, next)
578  }
579  const cycleFilter = () => setFilter(FILTERS[(FILTERS.indexOf(f) + 1) % FILTERS.length] ?? 'all')
580  const counts: Record<PinboardFilter, number> = {
581    all: base.length,
582    session: base.filter(p => p.sessionId === scope.sessionId).length,
583    project: base.filter(p => p.cwd === scope.cwd).length,
584    table: base.filter(p => p.kind === 'table').length,
585    artifact: base.filter(p => p.kind === 'artifact').length,
586    link: base.filter(p => p.kind === 'link').length,
587  }
588  const rule = '─'.repeat(width)
589
590  const pinRow = (p: PinboardPin, i: number) => {
591    const isSelected = p.id === sel
592    const hotkey = i < 9 ? String(i + 1) : undefined
593    const age = shortAge(now, p.createdAt)
594    // In the main view a pin an agent saved names the agent beside its age.
595    const badge = !viewAgent && p.agentLabel ? fitWidth(`@${p.agentLabel}`, 14) : ''
596    // marker 2 + "1: " 3 + gap 2 + glyph and age on the right (+ the badge)
597    const room = width - 2 - 3 - 2 - (2 + textWidth(age)) - (badge ? textWidth(badge) + 1 : 0)
598    const title = fitWidth(hotkey ? p.title : `${i + 1}. ${p.title}`, room)
599    return (
600      <Box key={`row:${p.id}`} justifyContent="space-between">
601        <Box>
602          <Text color={ACCENT}>{isSelected ? '▍ ' : '  '}</Text>
603          <Button
604            key={`sel:${p.id}`}
605            {...(hotkey ? { hotkey } : {})}
606            plain
607            dimColor={!isSelected}
608            hover={{ dimColor: false }}
609            label={title}
610            onPress={() => select(p.id)}
611          />
612        </Box>
613        <Text>
614          {badge ? <Text dimColor>{badge} </Text> : null}
615          <Text color={KIND_COLOR[p.kind]}>{GLYPH[p.kind]}</Text> <Text dimColor>{age}</Text>
616        </Text>
617      </Box>
618    )
619  }
620
621  // Rows in display order, with a header per session when more than one session's pins are shown.
622  const rows: ReturnType<typeof pinRow>[] = []
623  let index = 0
624  for (const g of groups) {
625    if (index >= MAX_ROWS) break
626    if (groups.length > 1) {
627      rows.push(
628        <Box key={`group:${g.sessionId}`} marginTop={index === 0 ? 0 : 1}>
629          {g.isCurrent ? (
630            <Text color={ACCENT} bold>
631              {fitWidth(g.label, width)}
632            </Text>
633          ) : (
634            <Text dimColor>{fitWidth(g.label, width)}</Text>
635          )}
636        </Box>,
637      )
638    }
639    for (const p of g.pins) {
640      if (index >= MAX_ROWS) break
641      rows.push(pinRow(p, index))
642      index++
643    }
644  }
645
646  return (
647    <Box flexDirection="column">
648      <Box justifyContent="space-between">
649        <Text bold>
650          Pins <Text dimColor>{base.length}</Text>
651        </Text>
652        <Button key="filter" hotkey="f" plain dimColor label="next filter" onPress={cycleFilter} />
653      </Box>
654      {viewLabel ? (
655        <Text wrap="truncate-end">
656          <Text color={ACCENT} bold>
657            ↳ {viewLabel}
658          </Text>
659          <Text dimColor>: showing only this agent's pins and candidates</Text>
660        </Text>
661      ) : null}
662      <Box flexWrap="wrap" columnGap={2}>
663        {FILTERS.map(name =>
664          name === f ? (
665            <Text color="claude" bold underline>
666              {FILTER_LABEL[name]} {counts[name]}
667            </Text>
668          ) : (
669            <Button
670              key={`tab:${name}`}
671              plain
672              dimColor
673              label={`${FILTER_LABEL[name]} ${counts[name]}`}
674              onPress={() => setFilter(name)}
675            />
676          ),
677        )}
678      </Box>
679      <Text dimColor>{rule}</Text>
680
681      {base.length === 0 && (
682        <Box flexDirection="column" paddingX={2} marginY={1}>
683          <Text bold>{viewAgent ? 'No pins from this agent yet' : 'No pins yet'}</Text>
684          {EMPTY_HINTS.map(([how, what]) => (
685            <Text>
686              <Text color={ACCENT}>{padCells(how, 16)}</Text>
687              <Text dimColor>{what}</Text>
688            </Text>
689          ))}
690        </Box>
691      )}
692      {base.length > 0 && shown.length === 0 && (
693        <Box paddingX={2} marginY={1}>
694          <Text dimColor>No pins match this filter · f for the next filter</Text>
695        </Box>
696      )}
697
698      {rows}
699      {shown.length > MAX_ROWS && (
700        <Text dimColor>
701          {'  '}… {shown.length - MAX_ROWS} more · f to filter
702        </Text>
703      )}
704
705      {selected && (
706        <Box
707          flexDirection="column"
708          marginTop={1}
709          paddingX={1}
710          borderStyle="round"
711          borderColor={KIND_COLOR[selected.kind]}
712        >
713          <Text bold>
714            <Text color={KIND_COLOR[selected.kind]}>{GLYPH[selected.kind]}</Text> {fitWidth(selected.title, width - 6)}
715          </Text>
716          <Text dimColor wrap="truncate-end">
717            {KIND_LABEL[selected.kind]} · {projectName(selected.cwd)} · {localStamp(selected.createdAt)}
718            {selected.agentLabel ? ` · agent ${selected.agentLabel}` : ''}
719          </Text>
720          <Box marginY={1} flexDirection="column">
721            {selected.kind === 'link' || selected.kind === 'artifact' ? (
722              <Link href={selected.body} label={fitWidth(selected.body, width - 4)} />
723            ) : (
724              <Markdown text={selected.body} />
725            )}
726          </Box>
727          {confirm === selected.id ? (
728            <Box columnGap={2} flexWrap="wrap">
729              <Text color="error" bold>
730                Remove this pin?
731              </Text>
732              <Button
733                key="delete-yes"
734                hotkey="x"
735                plain
736                label="remove"
737                onPress={() => {
738                  void removePin($, selected.id)
739                }}
740              />
741              <Button
742                key="delete-no"
743                hotkey="o"
744                plain
745                label="cancel"
746                onPress={() => void update($, confirmDeleteId, () => null)}
747              />
748            </Box>
749          ) : (
750            <Box columnGap={2} flexWrap="wrap">
751              <Button
752                key="copy"
753                hotkey="c"
754                plain
755                label="copy"
756                onPress={press => {
757                  void $.ui
758                    .copy({ text: selected.body, surface: press.surface })
759                    .then(r => $.ui.toast(r.isCopied ? 'Copied' : `Copy failed: ${r.reason}`))
760                }}
761              />
762              <Button
763                key="prompt"
764                hotkey="p"
765                plain
766                label="insert into prompt"
767                onPress={() => {
768                  void $.prompt.fill({ text: `${selected.body}\n`, mode: 'insert' })
769                }}
770              />
771              <Button
772                key="delete"
773                hotkey="x"
774                plain
775                dimColor
776                label="remove"
777                onPress={() => void update($, confirmDeleteId, () => selected.id)}
778              />
779            </Box>
780          )}
781        </Box>
782      )}
783
784      {removed && (
785        <Box marginTop={1}>
786          <Text color="warning">↩ </Text>
787          <Button
788            key="undo"
789            hotkey="u"
790            plain
791            label="undo"
792            onPress={() => {
793              void undo($)
794            }}
795          />
796          <Text dimColor> {fitWidth(removed.title, width - 16)}</Text>
797        </Box>
798      )}
799
800      {cands.length > 0 && (
801        <Box flexDirection="column" marginTop={1}>
802          <Text dimColor>
803            {labelledRule(viewAgent ? 'Candidates from this agent · press to pin' : 'Candidates from this session · press to pin', width)}
804          </Text>
805          {cands.map((c, i) => {
806            const hotkey = CANDIDATE_HOTKEYS[i]
807            const age = shortAge(now, c.seenAt)
808            const room = width - 2 - 3 - 2 - (2 + textWidth(age))
809            return (
810              <Box key={`cand:${c.id}`} justifyContent="space-between">
811                <Box>
812                  <Text color={ACCENT}>+ </Text>
813                  <Button
814                    key={`save:${c.id}`}
815                    {...(hotkey ? { hotkey } : {})}
816                    plain
817                    dimColor
818                    hover={{ dimColor: false }}
819                    label={fitWidth(c.title, room)}
820                    onPress={() => {
821                      const tag: AgentTag = c.agentId ? { agentId: c.agentId, agentLabel: c.agentLabel ?? '' } : {}
822                      void addPins($, [
823                        { kind: c.kind, title: c.title, body: c.body, sourceUuid: c.sourceUuid, ...tag },
824                      ])
825                    }}
826                  />
827                </Box>
828                <Text>
829                  <Text color={KIND_COLOR[c.kind]}>{GLYPH[c.kind]}</Text> <Text dimColor>{age}</Text>
830                </Text>
831              </Box>
832            )
833          })}
834        </Box>
835      )}
836
837      <Box flexDirection="column" marginTop={1}>
838        <Text dimColor>{rule}</Text>
839        <Text dimColor wrap="truncate-end">
840          {e.props.isFocused ? 'Esc back to prompt · /pin help' : 'ctrl+x tab to use the pane · /pin help'}
841        </Text>
842      </Box>
843    </Box>
844  )
845}
846
847const EMPTY_HINTS: [string, string][] = [
848  ['/pin', "pin the last answer's tables and links"],
849  ['"pin this table"', 'ask Claude to pin it'],
850  ['a s d g h', 'pin a candidate below'],
851]
852
853/** `text` followed by spaces up to `cells` cells (at least one). */
854function padCells(text: string, cells: number): string {
855  return text + ' '.repeat(Math.max(1, cells - textWidth(text)))
856}
857
858/** A rule with a label at its start: `─ label ─────`, `width` cells long. */
859function labelledRule(label: string, width: number): string {
860  const head = `─ ${label} `
861  return head + '─'.repeat(Math.max(0, width - textWidth(head)))
862}
863
864// ────────────────────────────────────────────────────────────────────────────
865// Hooks
866// ────────────────────────────────────────────────────────────────────────────
867
868/** Keeps at most `cap` candidates for each agent (and for the main conversation), newest first. */
869function capPerAgent(list: PinboardCandidate[], cap: number): PinboardCandidate[] {
870  const seen = new Map<string, number>()
871  return list.filter(c => {
872    const key = c.agentId ?? ''
873    const n = (seen.get(key) ?? 0) + 1
874    seen.set(key, n)
875    return n <= cap
876  })
877}
878
879/** A plugin tool answers with a string (or an array of content blocks); an object is refused. */
880function toolText(text: string) {
881  return { result: text }
882}
883
884export const register: Register = on => {
885  on('session.start', async ($, e, next) => {
886    try {
887      await Promise.all([
888        $.command.register({
889          name: 'pin',
890          description: "Pin the last answer's tables and artifact links (or the mouse selection) to the Pins pane",
891          argumentHint:
892            '[title] | all | tables | links | rm <n> | undo | show <n> | clear | export | auto on|off | help',
893        }),
894        $.command.register({ name: 'pins', description: 'Open/close the Pins pane' }),
895        $.command.register({ name: 'unpin', description: 'Remove a pin (same as /pin rm)', argumentHint: '<n|last|title>' }),
896        $.tool.register({
897          name: 'pin',
898          description:
899            "Save an item from this conversation into the user's Pins side pane so they can reopen it any time. " +
900            "Use when the user asks to pin, save, keep or bookmark a table, link, artifact or answer, and also, unasked, " +
901            'for a table or set of figures the user is working through over several turns, so they need not ask for it again. ' +
902            'Pass the exact markdown of the item: a whole markdown table, a URL, or the text itself. ' +
903            'Pinning again under the same title replaces that pin: when figures you pinned change, re-pin under the same ' +
904            'title rather than a new one, so the pane holds only the current version.',
905          inputSchema: {
906            type: 'object',
907            properties: {
908              markdown: {
909                type: 'string',
910                description: 'The item to save, verbatim markdown (a full table, a URL, or text).',
911              },
912              title: {
913                type: 'string',
914                description:
915                  'Short title (<= 60 chars), stable across updates. A pin with this title in this project is replaced. Derived from the content when omitted, and then nothing is replaced.',
916              },
917              kind: {
918                type: 'string',
919                enum: ['table', 'artifact', 'link', 'text'],
920                description: 'Derived from the content when omitted.',
921              },
922            },
923            required: ['markdown'],
924          },
925        }),
926        $.tool.register({
927          name: 'unpin',
928          description:
929            "Remove a pin from the user's Pins pane. Identify it by id, by its 1-based index as `list` shows, or by a title substring. Call `list` first when unsure.",
930          inputSchema: {
931            type: 'object',
932            properties: {
933              id: { type: 'string' },
934              index: { type: 'integer', minimum: 1 },
935              title: { type: 'string', description: 'A substring of the pin title.' },
936            },
937          },
938        }),
939        $.tool.register({
940          name: 'list',
941          description: "List the user's saved pins: index, kind, title, project and id.",
942        }),
943      ])
944    } catch (err) {
945      $.ui.log(`pinboard: registration failed: ${String(err)}`, { to: 'debug' })
946    }
947
948    // The store is the truth; session state mirrors it so the pane redraws on every change. The timer
949    // picks up what other sessions pin or remove while this one runs.
950    const list = await refreshPins($)
951    const savedFilter = await $.store.get(STORE_FILTER)
952    if (isFilter(savedFilter)) await update($, filter, () => savedFilter)
953    $.clock.every(REFRESH_MS, () => {
954      void refreshPins($).catch(() => undefined)
955    })
956    if (e.isInteractive && list.length > 0) await openPane($, { asked: false })
957
958    return next(e)
959  })
960
961  on('command.run', { command: 'pin' }, async ($, e) => ({ text: await runPin($, e.args) }))
962  on('command.run', { command: 'unpin' }, async ($, e) => ({ text: await runRemove($, e.args) }))
963  on('command.run', { command: 'pins' }, async $ => ({ text: await togglePane($) }))
964
965  on('tool.call', { tool: 'mcp__pinboard__pin' }, async ($, e) => {
966    const input = e as unknown as Record<string, unknown>
967    const markdown = typeof input.markdown === 'string' ? input.markdown.trim() : ''
968    if (!markdown) return toolText('error: markdown is empty')
969    const title = typeof input.title === 'string' ? cleanTitle(input.title) : ''
970    const kind = isKind(input.kind) ? input.kind : classify(markdown)
971    // A subagent or teammate calling the tool runs in its own loop: the pin remembers which.
972    const tag = await agentTag($, typeof input.agentId === 'string' ? input.agentId : undefined)
973    const added = await addPins($, [{ kind, title, body: markdown, isNamed: Boolean(title), ...tag }])
974    await openPane($, { asked: true })
975    const first = added[0]
976    return toolText(
977      first
978        ? `${savedVerb(first).toLowerCase()}: ${describe(first.pin)} (id ${first.pin.id})`
979        : 'already pinned (identical content exists)',
980    )
981  }).catch(() => ({ deny: 'pinboard: pin failed' }))
982
983  on('tool.call', { tool: 'mcp__pinboard__unpin' }, async ($, e) => {
984    const input = e as unknown as Record<string, unknown>
985    const token =
986      typeof input.id === 'string'
987        ? input.id
988        : typeof input.index === 'number'
989          ? String(input.index)
990          : typeof input.title === 'string'
991            ? input.title
992            : ''
993    if (!token) return toolText('error: give id, index or title')
994    const target = await resolvePin($, token)
995    if (!target) return toolText(`error: no pin matches "${token}"`)
996    await removePin($, target.id)
997    return toolText(`unpinned: ${describe(target)} (the user can restore it with /pin undo)`)
998  }).catch(() => ({ deny: 'pinboard: unpin failed' }))
999
1000  on('tool.call', { tool: 'mcp__pinboard__list' }, async $ => {
1001    const list = await refreshPins($)
1002    if (list.length === 0) return toolText('no pins')
1003    return toolText(
1004      list
1005        .map((p, i) => {
1006          const agent = p.agentLabel ? ` · agent ${p.agentLabel}` : ''
1007          return `${i + 1}. [${p.kind}] ${p.title} · ${projectName(p.cwd)}${agent} · id ${p.id}`
1008        })
1009        .join('\n'),
1010    )
1011  }).catch(() => ({ deny: 'pinboard: list failed' }))
1012
1013  // Every block of a reply, the main conversation's and each agent's: its tables and artifact links become
1014  // one-press candidates, tagged with the agent. The pane offers each view its own.
1015  on('session.append', { door: 'response' }, async ($, e, next) => {
1016    const stored = await next(e)
1017    try {
1018      if (e.message.role === 'assistant') {
1019        const texts: string[] = []
1020        for (const block of e.message.content) {
1021          if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
1022        }
1023        const found = texts.flatMap(t => extractAll(t))
1024        if (found.length > 0) {
1025          const now = await $.clock.now()
1026          const pinned = new Set((await refreshPins($)).map(p => bodyKey(p.body)))
1027          const fresh = found.filter(f => !pinned.has(bodyKey(f.body)))
1028          if (fresh.length > 0) {
1029            const tag = await agentTag($, e.agentId)
1030            await update($, candidates, list => {
1031              const have = new Set(list.map(c => bodyKey(c.body)))
1032              const incoming: PinboardCandidate[] = fresh
1033                .filter(f => !have.has(bodyKey(f.body)))
1034                .map((f, i) => ({
1035                  id: newId(now + i),
1036                  kind: f.kind,
1037                  title: f.title,
1038                  body: f.body,
1039                  sourceUuid: e.uuid,
1040                  seenAt: now,
1041                  ...tag,
1042                }))
1043              return capPerAgent([...incoming, ...list], MAX_CANDIDATES)
1044            })
1045            // Only the main conversation opens the pane unasked: agents can be many and busy.
1046            // Awaited: work a hook leaves running is dropped when its dispatch ends.
1047            if (e.agentId === undefined && !(await isPaneOpen($))) await openPane($, { asked: false })
1048          }
1049        }
1050      }
1051    } catch {
1052      // candidates are best effort; the row is already stored
1053    }
1054    return stored
1055  })
1056
1057  // The person closed the pane: no unasked reopen until they ask for it again.
1058  on('ui.close', { id: PANE }, async ($, e, next) => {
1059    if (e.origin.kind === 'person') await update($, autoOpenSuppressed, () => true)
1060    return next(e)
1061  })
1062
1063  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
1064}
1065
hooks/extract.ts 119 lines
1// Pure helpers: find markdown tables and links in a reply, name them, classify text.
2import type { PinboardKind } from '../types'
3
4export type Extracted = { kind: PinboardKind; title: string; body: string }
5
6export const MAX_TITLE = 60
7
8const SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/
9const URL = /https?:\/\/[^\s<>()[\]'"`]+/g
10const MD_LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
11const ARTIFACT = /claude\.ai\/(?:code\/)?artifact\//
12
13export function cleanTitle(line: string): string {
14  return line
15    .replace(/^[#>\-*\d.)\s]+/, '')
16    .replace(/[*_`]/g, '')
17    .replace(/[::]\s*$/, '')
18    .trim()
19    .slice(0, MAX_TITLE)
20}
21
22function isTableLine(line: string): boolean {
23  const t = line.trim()
24  return t.length > 0 && t.includes('|')
25}
26
27export function headerCells(line: string): string[] {
28  return line
29    .trim()
30    .replace(/^\|/, '')
31    .replace(/\|$/, '')
32    .split('|')
33    .map(c => c.trim())
34    .filter(Boolean)
35}
36
37export function extractTables(text: string): Extracted[] {
38  const lines = text.split('\n')
39  const out: Extracted[] = []
40  let i = 0
41  while (i < lines.length - 1) {
42    const header = lines[i] ?? ''
43    const sep = lines[i + 1] ?? ''
44    if (isTableLine(header) && SEPARATOR.test(sep)) {
45      let end = i + 2
46      while (end < lines.length && isTableLine(lines[end] ?? '')) end++
47      const body = lines.slice(i, end).join('\n').trim()
48      let title = ''
49      for (let j = i - 1; j >= 0; j--) {
50        const above = (lines[j] ?? '').trim()
51        if (!above) continue
52        if (isTableLine(above) || SEPARATOR.test(above)) break
53        // A heading, a short label or a line ending in a colon names the table; a long sentence does not.
54        if (above.length <= 48 || /^#/.test(above) || /[::]$/.test(above)) title = cleanTitle(above)
55        break
56      }
57      if (!title) title = headerCells(header).join(' | ').slice(0, MAX_TITLE)
58      out.push({ kind: 'table', title: title || 'Table', body })
59      i = end
60    } else {
61      i++
62    }
63  }
64  return out
65}
66
67export function extractLinks(text: string, onlyArtifacts: boolean): Extracted[] {
68  const labels = new Map<string, string>()
69  for (const m of text.matchAll(MD_LINK)) labels.set(m[2] ?? '', m[1] ?? '')
70  const out: Extracted[] = []
71  const seen = new Set<string>()
72  for (const m of text.matchAll(URL)) {
73    const url = (m[0] ?? '').replace(/[.,;:!?*]+$/, '')
74    if (!url || seen.has(url)) continue
75    seen.add(url)
76    const isArtifact = ARTIFACT.test(url)
77    if (onlyArtifacts && !isArtifact) continue
78    const label = cleanTitle(labels.get(url) ?? '')
79    const fallback = isArtifact
80      ? `artifact ${url.split('/').filter(Boolean).pop() ?? ''}`
81      : url.replace(/^https?:\/\//, '')
82    out.push({ kind: isArtifact ? 'artifact' : 'link', title: (label || fallback).slice(0, MAX_TITLE), body: url })
83  }
84  return out
85}
86
87/** Tables and artifact links: what /pin and the candidate list pick up by default. */
88export function extractAll(text: string): Extracted[] {
89  return [...extractTables(text), ...extractLinks(text, true)]
90}
91
92export function firstLineTitle(text: string, fallback = 'Note'): string {
93  for (const line of text.split('\n')) {
94    const t = cleanTitle(line)
95    if (t) return t
96  }
97  return fallback
98}
99
100export function classify(text: string): PinboardKind {
101  const t = text.trim()
102  if (/^https?:\/\/\S+$/.test(t)) return ARTIFACT.test(t) ? 'artifact' : 'link'
103  if (extractTables(t).length > 0) return 'table'
104  return 'text'
105}
106
107export function isKind(value: unknown): value is PinboardKind {
108  return value === 'table' || value === 'artifact' || value === 'link' || value === 'text'
109}
110
111export function projectName(cwd: string): string {
112  return cwd.split('/').filter(Boolean).pop() ?? cwd
113}
114
115/** Whitespace-insensitive identity of a body, for de-duplication. */
116export function bodyKey(body: string): string {
117  return body.replace(/\s+/g, ' ').trim()
118}
119
hooks/format.ts 84 lines
1// Pure display helpers for the pane: terminal cell widths, truncation, short times.
2import type { PinboardKind } from '../types'
3
4/** Cells a code point takes in a terminal: 2 for wide East Asian characters and emoji, 0 for combining marks. */
5export function cellWidth(codePoint: number): number {
6  if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0
7  if (
8    (codePoint >= 0x0300 && codePoint <= 0x036f) ||
9    (codePoint >= 0x200b && codePoint <= 0x200f) ||
10    (codePoint >= 0xfe00 && codePoint <= 0xfe0f)
11  )
12    return 0
13  if (
14    (codePoint >= 0x1100 && codePoint <= 0x115f) ||
15    (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
16    (codePoint >= 0x3041 && codePoint <= 0x33ff) ||
17    (codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
18    (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
19    (codePoint >= 0xa960 && codePoint <= 0xa97f) ||
20    (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
21    (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
22    (codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
23    (codePoint >= 0xff00 && codePoint <= 0xff60) ||
24    (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
25    (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
26    (codePoint >= 0x20000 && codePoint <= 0x3fffd)
27  )
28    return 2
29  return 1
30}
31
32/** Cells a string takes in a terminal. */
33export function textWidth(text: string): number {
34  let w = 0
35  for (const ch of text) w += cellWidth(ch.codePointAt(0) ?? 0)
36  return w
37}
38
39/** Cuts `text` to at most `cells` cells, ending in `…` when it was cut. */
40export function fitWidth(text: string, cells: number): string {
41  if (cells <= 0) return ''
42  if (textWidth(text) <= cells) return text
43  let out = ''
44  let w = 0
45  for (const ch of text) {
46    const cw = cellWidth(ch.codePointAt(0) ?? 0)
47    if (w + cw > cells - 1) break
48    out += ch
49    w += cw
50  }
51  return `${out.trimEnd()}…`
52}
53
54/** A compact age for a list column: now, 5m, 3h, 2d, or the date as MM-DD. */
55export function shortAge(now: number, then: number): string {
56  const s = Math.max(0, Math.round((now - then) / 1000))
57  if (s < 60) return 'now'
58  const m = Math.floor(s / 60)
59  if (m < 60) return `${m}m`
60  const h = Math.floor(m / 60)
61  if (h < 24) return `${h}h`
62  const d = Math.floor(h / 24)
63  if (d < 7) return `${d}d`
64  const date = new Date(then)
65  return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
66}
67
68/** Local date and time as YYYY-MM-DD HH:MM. */
69export function localStamp(then: number): string {
70  const d = new Date(then)
71  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
72}
73
74function pad2(n: number): string {
75  return n < 10 ? `0${n}` : String(n)
76}
77
78export const KIND_LABEL: Record<PinboardKind, string> = {
79  table: 'table',
80  artifact: 'artifact',
81  link: 'link',
82  text: 'note',
83}
84
types/index.d.ts 50 lines
1export type PinboardKind = 'table' | 'artifact' | 'link' | 'text'
2
3/** One saved item. Kept in `$.store` across sessions. */
4export type PinboardPin = {
5  id: string
6  createdAt: number
7  sessionId: string
8  cwd: string
9  kind: PinboardKind
10  title: string
11  body: string
12  sourceUuid?: string
13  /** The title was chosen rather than derived from the content; only such a pin is replaced by name. */
14  isNamed?: boolean
15  /** The subagent or teammate it came from; absent for the main conversation. */
16  agentId?: string
17  /** That agent's name as shown when it was pinned, kept since the agent is gone in later sessions. */
18  agentLabel?: string
19}
20
21/** A table or artifact link seen in a reply this session, one press away from being a pin. */
22export type PinboardCandidate = {
23  id: string
24  kind: PinboardKind
25  title: string
26  body: string
27  sourceUuid: string
28  seenAt: number
29  /** The subagent or teammate whose reply it was in; absent for the main conversation. */
30  agentId?: string
31  agentLabel?: string
32}
33
34export type PinboardFilter = 'all' | 'session' | 'project' | 'table' | 'artifact' | 'link'
35
36declare module 'claude-code' {
37  interface PluginState {
38    pinboard: {
39      pins: PinboardPin[]
40      selectedId: string | null
41      filter: PinboardFilter
42      candidates: PinboardCandidate[]
43      lastRemoved: PinboardPin | null
44      confirmDeleteId: string | null
45      /** True once the person closed the pane this session: no unasked reopen until they open it again. */
46      autoOpenSuppressed: boolean
47    }
48  }
49}
50