SLOPSHOPPER

pinboard

Pin tables, artifact links and answers from the conversation into a side pane you can reopen any time (/pin, /pins, /unpin).

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-08Young-in/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: 필터: 전체 │ ┃ 저장된 핀이 없습니다. /pin 으로 마지막 ⏺ Read(src/auth.ts) │ ┃ 답변의 표·링크를 저장하거나, 아래 후보에서 ⎿ Read 6 lines │ ┃ 고르세요. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ u: 되돌리기: ¶ I updated src/auth.ts to reje ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ctrl+x tab: 패널 포커스 · /pin help: 도움말 │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pin │ ⎿ pinboard: 📌 저장: ¶ I updated src/auth.ts to reject expired claims │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pins
Pins 0 f: 필터: 전체 저장된 핀이 없습니다. /pin 으로 마지막 답변의 표·링크를 저장하거나, 아래 후보에서 고르세요. u: 되돌리기: ¶ I updated src/auth.ts to reject expi ctrl+x tab: 패널 포커스 · /pin help: 도움말
README

pinboard

Claude Code mod: 대화 중에 나온 표, 아티팩트 링크, 답변을 핀으로 저장해 두고 옆 패널(Pins)에서 언제든 다시 보는 플러그인입니다. Pin tables, artifact links and answers from a Claude Code conversation into a side pane you can reopen any time.

긴 대화에서 "아까 그 표"를 찾으려고 위로 한참 스크롤하는 일을 없애려고 만들었습니다. 핀은 세션이 끝나도 남습니다.

설치

GitHub 저장소를 마켓플레이스로 등록해 설치합니다. Claude Code 프롬프트에서:

/plugin install pinboard --marketplace Young-in/pinboard

Add marketplace?에 y, 범위는 user를 고르면 됩니다. 개발 중이거나 로컬 폴더에서 바로 쓰려면:

claude --plugin-dir /path/to/pinboard

터미널 플래그를 줄 수 없는 환경(데스크톱 앱 등)에서는 ~/.claude/settings.json의 env에 CLAUDE_CODE_PLUGIN_DIRS=/path/to/pinboard를 넣습니다.

사용법

저장하기

입력동작
/pin마우스로 선택한 텍스트가 있으면 그것을 저장. 없으면 마지막 답변의 표와 아티팩트 링크를 저장. 둘 다 없으면 답변 전체 저장
/pin 제목위와 같되 제목을 지정
/pin all [제목]마지막 답변 전체
/pin tables / /pin links마지막 답변의 표만 / 링크만 (일반 https 링크 포함)
패널의 "최근 후보"답변이 올 때마다 표와 claude.ai/artifact 링크를 감지해 띄움. 한 번 누르면 저장
모델에게 말로"이 표 저장해줘", "핀해줘" 하면 모델이 pin 도구를 호출

보기·쓰기

  • /pins: 패널 열기/닫기. fullscreen 터미널(110열 이상)에서는 대화 오른쪽에 도킹되고, 그 외에는 프롬프트 위 인라인 블록으로 열립니다.
  • ctrl+x tab으로 패널에 포커스를 주면 단축키가 동작합니다. 1~9 핀 선택, a/s/d/g/h 후보 저장, c 복사, p 프롬프트에 넣기, x 삭제(두 번 눌러 확인), u 되돌리기, f 필터(전체/표/아티팩트/링크/이 프로젝트), Esc 프롬프트로.
  • 선택한 핀은 패널 안에 마크다운으로 다시 그려집니다. 링크 핀은 클릭할 수 있는 하이퍼링크입니다.
  • /pin show 3: 3번 핀의 내용을 대화에 출력합니다. 모델도 읽으므로 "이걸로 다시 해줘"가 됩니다.

지우기·관리

입력동작
/pin rm 3, /unpin 3, /pin rm last패널 번호 또는 가장 최근 핀 삭제
/pin undo마지막 삭제 되돌리기 (/pin clear도 되돌릴 수 있음)
/pin clear전체 삭제 (확인 후)
/pin export~/.claude/pins/pins-<날짜>.md로 내보내기
`/pin auto on\off`세션 시작·새 후보 감지 시 패널 자동 열기 (기본 on). 패널을 직접 닫으면 그 세션에서는 다시 자동으로 열지 않음
/pin help도움말

모델용 도구는 pin, unpin, list 세 개입니다. "핀 목록 보여줘", "둘째 핀 지워줘" 같은 요청을 모델이 처리합니다.

동작 방식

Claude Code 함수 훅 플러그인입니다. hooks/register.tsx 하나가 다음 이벤트를 받습니다.

  • session.start: 커맨드와 도구를 등록하고, 저장소($.store)의 핀을 세션 상태로 불러옵니다.
  • command.run: /pin, /unpin, /pins.
  • tool.call: mcp__pinboard__pin|unpin|list.
  • session.append (door response): 답변의 각 텍스트 블록에서 표와 아티팩트 링크를 뽑아 후보로 둡니다.
  • ui.render (Pane): 패널을 그립니다. 상태는 $.state atom에 두어 바뀔 때마다 패널이 다시 그려집니다.
  • ui.close: 사용자가 패널을 닫으면 그 세션에서는 자동으로 다시 열지 않습니다.

핀은 $.store의 pins 키에 JSON으로 보관됩니다. 최대 200개, 본문은 20 KB에서 잘립니다. 같은 내용(공백 차이 무시)은 두 번 저장되지 않습니다.

표 추출은 hooks/extract.ts의 순수 함수입니다. 헤더 줄 다음에 |---| 구분 줄이 오는 마크다운 표를 찾고, 바로 위의 짧은 줄이나 헤딩, 콜론으로 끝나는 줄을 제목으로 씁니다.

제약

  • 오른쪽 도킹과 마우스 선택 읽기는 fullscreen 터미널 레이아웃에서만 됩니다. 일반 화면에서는 패널이 프롬프트 위에 인라인으로 열리고, /pin은 마지막 답변 추출로 동작합니다.
  • 패널의 버튼과 단축키는 패널이 키보드를 잡고 있을 때(ctrl+x tab 또는 클릭) 눌립니다.
  • 플러그인 API는 early access입니다. Claude Code 2.1.294에서 작성·검증했고, 이후 빌드에서 바뀔 수 있습니다.

개발

claude plugin validate .   # 매니페스트와 훅 모듈을 엔진 기준으로 검사
claude plugin test .       # hooks/*.test.ts(x) 를 엔진 위에서 실행

Claude Code가 이 폴더를 로드하면 .claude-plugin/types/에 API 타입 선언과 tsconfig.json을 생성합니다(둘 다 .gitignore 대상). 그 뒤 tsc -p .로 타입 검사가 됩니다(TypeScript 5.5 이상, Node 20 이상).

엔진은 $(엔진 인터페이스)를 훅 모듈과 같은 파일에 선언된 함수로만 따라가고 import 경계를 넘기지 못합니다. 그래서 $를 쓰는 코드는 register.tsx 한 파일에 섹션으로 나뉘어 있고, 순수 함수만 extract.ts로 분리돼 있습니다.

.claude-plugin/plugin.json      매니페스트
.claude-plugin/marketplace.json 이 저장소를 마켓플레이스로 쓰기 위한 파일
hooks/hooks.json                훅 모듈 지정
hooks/register.tsx              상태·저장소, 커맨드, 패널, 훅 등록
hooks/extract.ts                표·링크 추출, 제목 생성 (순수 함수)
hooks/*.test.ts(x)              테스트
types/index.d.ts                세션 상태 계약 (PluginState)

라이선스

MIT

Source 3 files
hooks/register.tsx 726 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  relativeTime,
24} from './extract'
25
26// ────────────────────────────────────────────────────────────────────────────
27// State, store and shared operations
28// ────────────────────────────────────────────────────────────────────────────
29
30type Dollar = EngineInterface
31type PinInput = { kind: PinboardKind; title: string; body: string; sourceUuid?: string }
32
33const PANE = 'pins'
34const PANE_TITLE = 'Pins'
35const STORE_PINS = 'pins'
36const STORE_BACKUP = 'pins.backup'
37const STORE_AUTO_OPEN = 'autoOpen'
38const MAX_PINS = 200
39const MAX_BODY = 20_000
40const MAX_CANDIDATES = 8
41
42const FILTERS: PinboardFilter[] = ['all', 'table', 'artifact', 'link', 'project']
43const FILTER_LABEL: Record<PinboardFilter, string> = {
44  all: '전체',
45  table: '표',
46  artifact: '아티팩트',
47  link: '링크',
48  project: '이 프로젝트',
49}
50const GLYPH: Record<PinboardKind, string> = { table: '▤', artifact: '◈', link: '⇗', text: '¶' }
51
52const pins = atom({ plugin: 'pinboard', key: 'pins' } as const, [])
53const selectedId = atom({ plugin: 'pinboard', key: 'selectedId' } as const, null)
54const filter = atom({ plugin: 'pinboard', key: 'filter' } as const, 'all')
55const candidates = atom({ plugin: 'pinboard', key: 'candidates' } as const, [])
56const lastRemoved = atom({ plugin: 'pinboard', key: 'lastRemoved' } as const, null)
57const confirmDeleteId = atom({ plugin: 'pinboard', key: 'confirmDeleteId' } as const, null)
58const autoOpenSuppressed = atom({ plugin: 'pinboard', key: 'autoOpenSuppressed' } as const, false)
59
60function newId(seed: number): string {
61  return `${seed.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
62}
63
64function visible(list: PinboardPin[], f: PinboardFilter, cwd: string): PinboardPin[] {
65  if (f === 'all') return list
66  if (f === 'project') return list.filter(p => p.cwd === cwd)
67  return list.filter(p => p.kind === f)
68}
69
70function describe(p: PinboardPin): string {
71  return `${GLYPH[p.kind]} ${p.title}`
72}
73
74/** Writes the list to session state (redraws the pane) and to the store (survives the session). */
75async function persist($: Dollar, list: PinboardPin[]): Promise<void> {
76  await update($, pins, () => list)
77  await $.store.set(STORE_PINS, list)
78}
79
80/** Adds the items not already pinned (same body, whitespace aside), newest first; returns what was added. */
81async function addPins($: Dollar, items: PinInput[]): Promise<PinboardPin[]> {
82  const [now, sessionId, cwd, current] = await Promise.all([
83    $.clock.now(),
84    $.session.id(),
85    $.session.cwd(),
86    read($, pins),
87  ])
88  const known = new Set(current.map(p => bodyKey(p.body)))
89  const added: PinboardPin[] = []
90  for (const it of items) {
91    const body = it.body.length > MAX_BODY ? `${it.body.slice(0, MAX_BODY)}\n\n…(잘림)` : it.body
92    const key = bodyKey(body)
93    if (!key || known.has(key)) continue
94    known.add(key)
95    const pin: PinboardPin = {
96      id: newId(now + added.length),
97      createdAt: now,
98      sessionId,
99      cwd,
100      kind: it.kind,
101      title: it.title || firstLineTitle(body),
102      body,
103    }
104    if (it.sourceUuid) pin.sourceUuid = it.sourceUuid
105    added.push(pin)
106  }
107  if (added.length === 0) return []
108  await persist($, [...added, ...current].slice(0, MAX_PINS))
109  await update($, selectedId, () => added[0]?.id ?? null)
110  await update($, confirmDeleteId, () => null)
111  await update($, candidates, list => list.filter(c => !known.has(bodyKey(c.body))))
112  return added
113}
114
115/** Removes one pin and keeps it as `lastRemoved` for undo. */
116async function removePin($: Dollar, id: string): Promise<PinboardPin | undefined> {
117  const current = await read($, pins)
118  const target = current.find(p => p.id === id)
119  if (!target) return undefined
120  await persist(
121    $,
122    current.filter(p => p.id !== id),
123  )
124  await update($, lastRemoved, () => target)
125  await update($, confirmDeleteId, () => null)
126  await update($, selectedId, cur => (cur === id ? null : cur))
127  return target
128}
129
130/** Restores the last removed pin, or the backup `/pin clear` left. */
131async function undo($: Dollar): Promise<string> {
132  const removed = await read($, lastRemoved)
133  if (removed) {
134    const current = await read($, pins)
135    if (!current.some(p => p.id === removed.id)) await persist($, [removed, ...current].slice(0, MAX_PINS))
136    await update($, lastRemoved, () => null)
137    await update($, selectedId, () => removed.id)
138    return `↩ 되돌림: ${describe(removed)}`
139  }
140  const backup = await $.store.get(STORE_BACKUP)
141  if (Array.isArray(backup) && backup.length > 0) {
142    const current = await read($, pins)
143    const ids = new Set(current.map(p => p.id))
144    const restored = (backup as PinboardPin[]).filter(p => !ids.has(p.id))
145    const merged = [...current, ...restored].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_PINS)
146    await persist($, merged)
147    await $.store.delete(STORE_BACKUP)
148    return `↩ 되돌림: 핀 ${restored.length}개 복구`
149  }
150  return '되돌릴 핀이 없습니다.'
151}
152
153async function isPaneOpen($: Dollar): Promise<boolean> {
154  return (await $.ui.panes()).some(p => p.id === PANE)
155}
156
157/**
158 * Opens the pane. `asked` means the person did something (a command, a press): it seats at any width and
159 * lifts the suppression their close set. Unasked opens respect `/pin auto off` and a close this session.
160 */
161async function openPane($: Dollar, options: { asked: boolean; focus?: boolean }): Promise<boolean> {
162  if (options.asked) {
163    await update($, autoOpenSuppressed, () => false)
164  } else {
165    const auto = (await $.store.get(STORE_AUTO_OPEN)) !== false
166    if (!auto || (await read($, autoOpenSuppressed))) return false
167  }
168  const opened = await $.ui.open(
169    options.focus ? { id: PANE, title: PANE_TITLE, focus: true } : { id: PANE, title: PANE_TITLE },
170  )
171  return opened.isPlaced
172}
173
174/** A pin by its number in the pane (filtered, newest first), `last`, its id, or a title substring. */
175async function resolvePin($: Dollar, token: string): Promise<PinboardPin | undefined> {
176  const list = await read($, pins)
177  const t = token.trim()
178  if (!t || t === 'last') return list[0]
179  const n = Number(t)
180  if (Number.isInteger(n) && n >= 1) {
181    const shown = visible(list, await read($, filter), await $.session.cwd())
182    if (n <= shown.length) return shown[n - 1]
183  }
184  const lower = t.toLowerCase()
185  return list.find(p => p.id === t) ?? list.find(p => p.title.toLowerCase().includes(lower))
186}
187
188/** The newest assistant message with text, or undefined before the first reply. */
189async function lastAnswer($: Dollar): Promise<string | undefined> {
190  const messages = await $.session.messages()
191  for (let i = messages.length - 1; i >= 0; i--) {
192    const m = messages[i]
193    if (m && m.role === 'assistant' && m.text.trim()) return m.text
194  }
195  return undefined
196}
197
198// ────────────────────────────────────────────────────────────────────────────
199// Slash commands: /pin, /unpin, /pins
200// ────────────────────────────────────────────────────────────────────────────
201
202const HELP = [
203  '/pin                마우스 선택이 있으면 그것을, 없으면 마지막 답변의 표·아티팩트 링크를 저장 (없으면 답변 전체)',
204  '/pin [제목]         위와 같되 제목을 지정',
205  '/pin all [제목]     마지막 답변 전체 저장',
206  '/pin tables|links   마지막 답변의 표만 | 링크만 저장',
207  '/pin rm <n|last>    n번(패널 번호) 또는 최근 핀 삭제  (/unpin <n> 도 같음)',
208  '/pin undo           마지막 삭제(또는 clear) 되돌리기',
209  '/pin show <n>       n번 핀 내용을 대화에 출력 (모델도 읽음)',
210  '/pin clear          모두 삭제 (확인 후)',
211  '/pin export         ~/.claude/pins/pins-<날짜>.md 로 내보내기',
212  '/pin auto on|off    세션 시작·새 후보 감지 시 패널 자동 열기',
213  '/pins               패널 열기/닫기',
214  '패널 단축키(ctrl+x tab 으로 포커스): 1~9 선택 · a/s/d/g/h 후보 저장 · c 복사 · p 프롬프트에 넣기 · x 삭제 · u 되돌리기 · f 필터',
215].join('\n')
216
217function report(added: Awaited<ReturnType<typeof addPins>>): string {
218  if (added.length === 0) return '이미 저장된 내용입니다.'
219  if (added.length === 1) return `📌 저장: ${describe(added[0]!)}`
220  return `📌 ${added.length}개 저장:\n${added.map(p => `  ${describe(p)}`).join('\n')}`
221}
222
223async function pinFromAnswer($: Dollar, mode: 'auto' | 'all' | 'links' | 'tables', title: string): Promise<string> {
224  const text = await lastAnswer($)
225  if (!text) return '저장할 답변이 없습니다.'
226  let items: PinInput[]
227  if (mode === 'all') items = [{ kind: classify(text), title: title || firstLineTitle(text), body: text.trim() }]
228  else if (mode === 'links') items = extractLinks(text, false)
229  else if (mode === 'tables') items = extractTables(text)
230  else items = extractAll(text)
231  if (items.length === 0) {
232    if (mode === 'links') return '마지막 답변에 링크가 없습니다.'
233    if (mode === 'tables') return '마지막 답변에 표가 없습니다.'
234    items = [{ kind: 'text', title: title || firstLineTitle(text), body: text.trim() }]
235  } else if (title && mode !== 'all') {
236    const n = items.length
237    items = items.map((it, i) => ({ ...it, title: n > 1 ? `${title} (${i + 1}/${n})` : title }))
238  }
239  const added = await addPins($, items)
240  await openPane($, { asked: true })
241  return report(added)
242}
243
244/** `/pin [title]`: the mouse selection when there is one, else the last answer's tables and artifact links. */
245async function pinDefault($: Dollar, title: string): Promise<string> {
246  let selected: { text: string; requestId?: string } | undefined
247  try {
248    selected = await $.ui.selection()
249  } catch {
250    selected = undefined
251  }
252  const text = selected?.text.trim() ?? ''
253  if (text) {
254    const item: PinInput = { kind: classify(text), title: title || firstLineTitle(text), body: text }
255    if (selected?.requestId) item.sourceUuid = selected.requestId
256    const added = await addPins($, [item])
257    await openPane($, { asked: true })
258    return report(added)
259  }
260  return pinFromAnswer($, 'auto', title)
261}
262
263async function runRemove($: Dollar, token: string): Promise<string> {
264  const target = await resolvePin($, token)
265  if (!target) return `핀을 찾지 못했습니다: ${token.trim() || '(없음)'}`
266  await removePin($, target.id)
267  return `🗑 삭제: ${describe(target)}  (/pin undo 로 복구)`
268}
269
270async function runShow($: Dollar, token: string): Promise<string> {
271  const target = await resolvePin($, token)
272  if (!target) return `핀을 찾지 못했습니다: ${token.trim() || '(없음)'}`
273  await update($, selectedId, () => target.id)
274  await openPane($, { asked: true })
275  return `${describe(target)}\n\n${target.body}`
276}
277
278async function runClear($: Dollar, rest: string): Promise<string> {
279  const list = await read($, pins)
280  if (list.length === 0) return '저장된 핀이 없습니다.'
281  let ok = rest.trim() === '--yes'
282  if (!ok) {
283    try {
284      ok = (await $.ui.ask(`핀 ${list.length}개를 모두 삭제할까요?`, ['삭제', '취소'])) === '삭제'
285    } catch {
286      return '취소됨 (대화형이 아니면 /pin clear --yes)'
287    }
288  }
289  if (!ok) return '취소됨'
290  await $.store.set(STORE_BACKUP, list)
291  await persist($, [])
292  await update($, selectedId, () => null)
293  await update($, lastRemoved, () => null)
294  return `🗑 핀 ${list.length}개 삭제 (/pin undo 로 복구)`
295}
296
297async function runExport($: Dollar): Promise<string> {
298  const list = await read($, pins)
299  if (list.length === 0) return '내보낼 핀이 없습니다.'
300  const home = (await $.env.get('HOME')) ?? '.'
301  const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
302  const path = `${home}/.claude/pins/pins-${date}.md`
303  const sections = list.map(p => {
304    const when = new Date(p.createdAt).toISOString().slice(0, 16).replace('T', ' ')
305    return `## ${GLYPH[p.kind]} ${p.title}\n\n_${p.kind} · ${projectName(p.cwd)} · ${when}_\n\n${p.body}\n`
306  })
307  try {
308    await $.fs.write(path, [`# Pins (${list.length}) · ${date}`, '', ...sections].join('\n'))
309  } catch (err) {
310    return `내보내기 실패: ${String(err)}`
311  }
312  return `📄 내보냄: ${path}`
313}
314
315async function runAuto($: Dollar, rest: string): Promise<string> {
316  const v = rest.trim().toLowerCase()
317  if (v !== 'on' && v !== 'off') {
318    const cur = await $.store.get(STORE_AUTO_OPEN)
319    return `자동 열기: ${cur === false ? 'off' : 'on'}  (/pin auto on|off)`
320  }
321  await $.store.set(STORE_AUTO_OPEN, v === 'on')
322  return `자동 열기: ${v}`
323}
324
325/** `/pin ...`: the first word picks a subcommand; anything else is a title for the default save. */
326async function runPin($: Dollar, rawArgs: string): Promise<string> {
327  const args = rawArgs.trim()
328  const [head = '', ...tail] = args.split(/\s+/)
329  const rest = tail.join(' ').trim()
330  switch (head.toLowerCase()) {
331    case 'help':
332    case '?':
333      return HELP
334    case 'rm':
335    case 'remove':
336    case 'del':
337    case 'delete':
338      return runRemove($, rest)
339    case 'undo':
340      return undo($)
341    case 'show':
342    case 'cat':
343      return runShow($, rest)
344    case 'clear':
345      return runClear($, rest)
346    case 'export':
347      return runExport($)
348    case 'auto':
349      return runAuto($, rest)
350    case 'all':
351      return pinFromAnswer($, 'all', rest)
352    case 'links':
353    case 'link':
354      return pinFromAnswer($, 'links', rest)
355    case 'tables':
356    case 'table':
357      return pinFromAnswer($, 'tables', rest)
358    default:
359      return pinDefault($, args)
360  }
361}
362
363/** `/pins`: closes the pane when open, else opens it with the keyboard. */
364async function togglePane($: Dollar): Promise<string> {
365  if (await isPaneOpen($)) {
366    await $.ui.close({ id: PANE })
367    return 'Pins 패널 닫음'
368  }
369  const placed = await openPane($, { asked: true, focus: true })
370  return placed
371    ? 'Pins 패널 열림 (ctrl+x tab: 패널 포커스 · Esc: 프롬프트로 · /pin help: 도움말)'
372    : 'Pins 패널은 열렸지만 아직 표시되지 않음 (터미널을 넓히면 보입니다)'
373}
374
375// ────────────────────────────────────────────────────────────────────────────
376// The Pins pane
377// ────────────────────────────────────────────────────────────────────────────
378
379const MAX_ROWS = 30
380const CANDIDATE_HOTKEYS = ['a', 's', 'd', 'g', 'h']
381
382async function renderPane($: Dollar, e: RenderInputOf<'Pane', RenderSurface>) {
383  const { Box, Text, Button, Markdown, Link } = $.ui.resolve(e)
384  const [list, f, sel, cands, removed, confirm, now, cwd] = await Promise.all([
385    read($, pins),
386    read($, filter),
387    read($, selectedId),
388    read($, candidates),
389    read($, lastRemoved),
390    read($, confirmDeleteId),
391    $.clock.now(),
392    $.session.cwd(),
393  ])
394  const shown = visible(list, f, cwd)
395  const selected = shown.find(p => p.id === sel) ?? null
396  const width = Math.max(20, e.props.bodyColumns)
397  const labelWidth = Math.max(10, width - 12)
398
399  const select = (id: string) => {
400    void update($, confirmDeleteId, () => null)
401    void update($, selectedId, cur => (cur === id ? null : id))
402  }
403  const cycleFilter = () => {
404    void update($, selectedId, () => null)
405    void update($, filter, cur => FILTERS[(FILTERS.indexOf(cur ?? 'all') + 1) % FILTERS.length] ?? 'all')
406  }
407
408  return (
409    <Box flexDirection="column">
410      <Box justifyContent="space-between">
411        <Text bold>
412          Pins {shown.length}
413          {f === 'all' ? '' : `/${list.length}`}
414        </Text>
415        <Button key="filter" hotkey="f" plain dimColor label={`필터: ${FILTER_LABEL[f]}`} onPress={cycleFilter} />
416      </Box>
417
418      {shown.length === 0 && (
419        <Text dimColor wrap="wrap">
420          {list.length === 0
421            ? '저장된 핀이 없습니다. /pin 으로 마지막 답변의 표·링크를 저장하거나, 아래 후보에서 고르세요.'
422            : '이 필터에 맞는 핀이 없습니다. f 로 필터를 바꾸세요.'}
423        </Text>
424      )}
425
426      {shown.slice(0, MAX_ROWS).map((p, i) => {
427        const prefix = i < 9 ? '' : `${i + 1}. `
428        const label = `${prefix}${GLYPH[p.kind]} ${p.title}`.slice(0, labelWidth)
429        return (
430          <Box key={`row:${p.id}`} gap={1}>
431            <Button
432              key={`sel:${p.id}`}
433              {...(i < 9 ? { hotkey: String(i + 1) } : {})}
434              plain
435              dimColor={p.id !== sel}
436              label={label}
437              onPress={() => select(p.id)}
438            />
439            <Text dimColor>{relativeTime(now, p.createdAt)}</Text>
440          </Box>
441        )
442      })}
443      {shown.length > MAX_ROWS && <Text dimColor>… {shown.length - MAX_ROWS}개 더 (f 로 필터)</Text>}
444
445      {selected && (
446        <Box flexDirection="column" marginTop={1} paddingX={1} borderStyle="round" borderDimColor>
447          <Text bold wrap="truncate-end">
448            {GLYPH[selected.kind]} {selected.title}
449          </Text>
450          <Text dimColor wrap="truncate-end">
451            {selected.kind} · {projectName(selected.cwd)} ·{' '}
452            {new Date(selected.createdAt).toISOString().slice(0, 16).replace('T', ' ')}
453          </Text>
454          {selected.kind === 'link' || selected.kind === 'artifact' ? (
455            <Link href={selected.body} />
456          ) : (
457            <Markdown text={selected.body} />
458          )}
459          <Box gap={1} marginTop={1} flexWrap="wrap">
460            <Button
461              key="copy"
462              hotkey="c"
463              label="복사"
464              onPress={press => {
465                void $.ui
466                  .copy({ text: selected.body, surface: press.surface })
467                  .then(r => $.ui.toast(r.isCopied ? '복사됨' : `복사 실패: ${r.reason}`))
468              }}
469            />
470            <Button
471              key="prompt"
472              hotkey="p"
473              label="프롬프트에 넣기"
474              onPress={() => {
475                void $.prompt.fill({ text: `${selected.body}\n`, mode: 'insert' })
476              }}
477            />
478            {confirm === selected.id ? (
479              <Box gap={1}>
480                <Button
481                  key="delete-yes"
482                  hotkey="x"
483                  variant="primary"
484                  label="정말 삭제"
485                  onPress={() => {
486                    void removePin($, selected.id)
487                  }}
488                />
489                <Button
490                  key="delete-no"
491                  hotkey="o"
492                  label="취소"
493                  onPress={() => void update($, confirmDeleteId, () => null)}
494                />
495              </Box>
496            ) : (
497              <Button
498                key="delete"
499                hotkey="x"
500                label="삭제"
501                onPress={() => void update($, confirmDeleteId, () => selected.id)}
502              />
503            )}
504          </Box>
505        </Box>
506      )}
507
508      {removed && (
509        <Box marginTop={1}>
510          <Button
511            key="undo"
512            hotkey="u"
513            plain
514            dimColor
515            label={`되돌리기: ${describe(removed)}`.slice(0, labelWidth)}
516            onPress={() => {
517              void undo($)
518            }}
519          />
520        </Box>
521      )}
522
523      {cands.length > 0 && (
524        <Box flexDirection="column" marginTop={1}>
525          <Text bold dimColor>
526            최근 후보 (이번 세션, 누르면 저장)
527          </Text>
528          {cands.map((c, i) => {
529            const hotkey = CANDIDATE_HOTKEYS[i]
530            return (
531              <Box key={`cand:${c.id}`} gap={1}>
532                <Button
533                  key={`save:${c.id}`}
534                  {...(hotkey ? { hotkey } : {})}
535                  plain
536                  label={`+ ${GLYPH[c.kind]} ${c.title}`.slice(0, labelWidth)}
537                  onPress={() => {
538                    void addPins($, [{ kind: c.kind, title: c.title, body: c.body, sourceUuid: c.sourceUuid }])
539                  }}
540                />
541                <Text dimColor>{relativeTime(now, c.seenAt)}</Text>
542              </Box>
543            )
544          })}
545        </Box>
546      )}
547
548      <Box marginTop={1}>
549        <Text dimColor wrap="wrap">
550          {e.props.isFocused
551            ? '1-9 선택 · a/s/d/g/h 후보 저장 · c 복사 · p 프롬프트 · x 삭제 · u 되돌리기 · f 필터 · Esc 프롬프트로'
552            : 'ctrl+x tab: 패널 포커스 · /pin help: 도움말'}
553        </Text>
554      </Box>
555    </Box>
556  )
557}
558
559// ────────────────────────────────────────────────────────────────────────────
560// Hooks
561// ────────────────────────────────────────────────────────────────────────────
562
563/** A plugin tool answers with a string (or an array of content blocks); an object is refused. */
564function toolText(text: string) {
565  return { result: text }
566}
567
568export const register: Register = on => {
569  on('session.start', async ($, e, next) => {
570    try {
571      await Promise.all([
572        $.command.register({
573          name: 'pin',
574          description: '마지막 답변의 표·아티팩트 링크(또는 마우스 선택)를 Pins 패널에 저장',
575          argumentHint:
576            '[제목] | all | tables | links | rm <n> | undo | show <n> | clear | export | auto on|off | help',
577        }),
578        $.command.register({ name: 'pins', description: 'Pins 패널 열기/닫기' }),
579        $.command.register({ name: 'unpin', description: '핀 삭제 (/pin rm 과 같음)', argumentHint: '<n|last|제목>' }),
580        $.tool.register({
581          name: 'pin',
582          description:
583            "Save an item from this conversation into the user's Pins side pane so they can reopen it any time. " +
584            "Use when the user asks to pin, save, keep or bookmark a table, link, artifact or answer (e.g. '이 표 저장해줘', '핀해줘'). " +
585            'Pass the exact markdown of the item: a whole markdown table, a URL, or the text itself.',
586          inputSchema: {
587            type: 'object',
588            properties: {
589              markdown: {
590                type: 'string',
591                description: 'The item to save, verbatim markdown (a full table, a URL, or text).',
592              },
593              title: {
594                type: 'string',
595                description: 'Short title (<= 60 chars). Derived from the content when omitted.',
596              },
597              kind: {
598                type: 'string',
599                enum: ['table', 'artifact', 'link', 'text'],
600                description: 'Derived from the content when omitted.',
601              },
602            },
603            required: ['markdown'],
604          },
605        }),
606        $.tool.register({
607          name: 'unpin',
608          description:
609            "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.",
610          inputSchema: {
611            type: 'object',
612            properties: {
613              id: { type: 'string' },
614              index: { type: 'integer', minimum: 1 },
615              title: { type: 'string', description: 'A substring of the pin title.' },
616            },
617          },
618        }),
619        $.tool.register({
620          name: 'list',
621          description: "List the user's saved pins: index, kind, title, project and id.",
622        }),
623      ])
624    } catch (err) {
625      $.ui.log(`pinboard: registration failed: ${String(err)}`, { to: 'debug' })
626    }
627
628    // The store is the truth; session state mirrors it so the pane redraws on every change.
629    const stored = await $.store.get(STORE_PINS)
630    const list = Array.isArray(stored) ? (stored as PinboardPin[]) : []
631    await update($, pins, () => list)
632    if (e.isInteractive && list.length > 0) await openPane($, { asked: false })
633
634    return next(e)
635  })
636
637  on('command.run', { command: 'pin' }, async ($, e) => ({ text: await runPin($, e.args) }))
638  on('command.run', { command: 'unpin' }, async ($, e) => ({ text: await runRemove($, e.args) }))
639  on('command.run', { command: 'pins' }, async $ => ({ text: await togglePane($) }))
640
641  on('tool.call', { tool: 'mcp__pinboard__pin' }, async ($, e) => {
642    const input = e as unknown as Record<string, unknown>
643    const markdown = typeof input.markdown === 'string' ? input.markdown.trim() : ''
644    if (!markdown) return toolText('error: markdown is empty')
645    const title = typeof input.title === 'string' ? cleanTitle(input.title) : ''
646    const kind = isKind(input.kind) ? input.kind : classify(markdown)
647    const added = await addPins($, [{ kind, title, body: markdown }])
648    await openPane($, { asked: true })
649    const first = added[0]
650    return toolText(first ? `pinned: ${describe(first)} (id ${first.id})` : 'already pinned (identical content exists)')
651  }).catch(() => ({ deny: 'pinboard: pin failed' }))
652
653  on('tool.call', { tool: 'mcp__pinboard__unpin' }, async ($, e) => {
654    const input = e as unknown as Record<string, unknown>
655    const token =
656      typeof input.id === 'string'
657        ? input.id
658        : typeof input.index === 'number'
659          ? String(input.index)
660          : typeof input.title === 'string'
661            ? input.title
662            : ''
663    if (!token) return toolText('error: give id, index or title')
664    const target = await resolvePin($, token)
665    if (!target) return toolText(`error: no pin matches "${token}"`)
666    await removePin($, target.id)
667    return toolText(`unpinned: ${describe(target)} (the user can restore it with /pin undo)`)
668  }).catch(() => ({ deny: 'pinboard: unpin failed' }))
669
670  on('tool.call', { tool: 'mcp__pinboard__list' }, async $ => {
671    const list = await read($, pins)
672    if (list.length === 0) return toolText('no pins')
673    return toolText(
674      list.map((p, i) => `${i + 1}. [${p.kind}] ${p.title} · ${projectName(p.cwd)} · id ${p.id}`).join('\n'),
675    )
676  }).catch(() => ({ deny: 'pinboard: list failed' }))
677
678  // Every block of a reply on the main loop: its tables and artifact links become one-press candidates.
679  on('session.append', { door: 'response' }, async ($, e, next) => {
680    const stored = await next(e)
681    try {
682      if (e.agentId === undefined && e.message.role === 'assistant') {
683        const texts: string[] = []
684        for (const block of e.message.content) {
685          if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
686        }
687        const found = texts.flatMap(t => extractAll(t))
688        if (found.length > 0) {
689          const now = await $.clock.now()
690          const pinned = new Set((await read($, pins)).map(p => bodyKey(p.body)))
691          const fresh = found.filter(f => !pinned.has(bodyKey(f.body)))
692          if (fresh.length > 0) {
693            await update($, candidates, list => {
694              const have = new Set(list.map(c => bodyKey(c.body)))
695              const incoming: PinboardCandidate[] = fresh
696                .filter(f => !have.has(bodyKey(f.body)))
697                .map((f, i) => ({
698                  id: newId(now + i),
699                  kind: f.kind,
700                  title: f.title,
701                  body: f.body,
702                  sourceUuid: e.uuid,
703                  seenAt: now,
704                }))
705              return [...incoming, ...list].slice(0, MAX_CANDIDATES)
706            })
707            // Awaited: work a hook leaves running is dropped when its dispatch ends.
708            if (!(await isPaneOpen($))) await openPane($, { asked: false })
709          }
710        }
711      }
712    } catch {
713      // candidates are best effort; the row is already stored
714    }
715    return stored
716  })
717
718  // The person closed the pane: no unasked reopen until they ask for it again.
719  on('ui.close', { id: PANE }, async ($, e, next) => {
720    if (e.origin.kind === 'person') await update($, autoOpenSuppressed, () => true)
721    return next(e)
722  })
723
724  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
725}
726
hooks/extract.ts 131 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 || '표', 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 = '메모'): 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 relativeTime(now: number, then: number): string {
112  const s = Math.max(0, Math.round((now - then) / 1000))
113  if (s < 60) return '방금'
114  const m = Math.round(s / 60)
115  if (m < 60) return `${m}분 전`
116  const h = Math.round(m / 60)
117  if (h < 24) return `${h}시간 전`
118  const d = Math.round(h / 24)
119  if (d < 7) return `${d}일 전`
120  return new Date(then).toISOString().slice(0, 10)
121}
122
123export function projectName(cwd: string): string {
124  return cwd.split('/').filter(Boolean).pop() ?? cwd
125}
126
127/** Whitespace-insensitive identity of a body, for de-duplication. */
128export function bodyKey(body: string): string {
129  return body.replace(/\s+/g, ' ').trim()
130}
131
types/index.d.ts 41 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}
14
15/** A table or artifact link seen in a reply this session, one press away from being a pin. */
16export type PinboardCandidate = {
17  id: string
18  kind: PinboardKind
19  title: string
20  body: string
21  sourceUuid: string
22  seenAt: number
23}
24
25export type PinboardFilter = 'all' | 'table' | 'artifact' | 'link' | 'project'
26
27declare module 'claude-code' {
28  interface PluginState {
29    pinboard: {
30      pins: PinboardPin[]
31      selectedId: string | null
32      filter: PinboardFilter
33      candidates: PinboardCandidate[]
34      lastRemoved: PinboardPin | null
35      confirmDeleteId: string | null
36      /** True once the person closed the pane this session: no unasked reopen until they open it again. */
37      autoOpenSuppressed: boolean
38    }
39  }
40}
41