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

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의 순수 함수입니다. 헤더 줄 다음에 |---| 구분 줄이 오는 마크다운 표를 찾고, 바로 위의 짧은 줄이나 헤딩, 콜론으로 끝나는 줄을 제목으로 씁니다.
/pin은 마지막 답변 추출로 동작합니다.ctrl+x tab 또는 클릭) 눌립니다.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)
hooks/register.tsx 726 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 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}
726hooks/extract.ts 131 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 || '표', 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}
131types/index.d.ts 41 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}
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