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…

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.
claude plugin install pinboard@vise-tools
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"./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.A new pin replaces an existing one only when all of these hold:
title, or /pin <title>), not derived from the content;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.
| Command | Does | ||
|---|---|---|---|
/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 \ | links | Pin only the last answer's tables \ | links |
| `/pin rm <n\ | last>, /unpin <n>` | Remove pin n (the pane's number) or the latest | |
/pin undo | Undo the last remove or clear | ||
/pin show <n> | Print pin n into the conversation, where Claude reads it too | ||
/pin clear | Remove all, after confirming | ||
/pin export | Write every pin to ~/.claude/pins/pins-<date>.md | ||
| `/pin auto on\ | off` | Open the pane on session start and when new candidates appear | |
/pins | Open 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.
/pin can't read a mouse selection.--plugin-dir working copy don't see each other.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.
MIT. Original work © 2026 Young-in; see LICENSE.
hooks/register.tsx 1065 lines1// 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}
1065hooks/extract.ts 119 lines1// 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}
119hooks/format.ts 84 lines1// 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}
84types/index.d.ts 50 lines1export 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