Keeps open decisions, the task list and links Claude creates in a sidebar pane, updated through its own tool

What needs you, pinned where you can see it.<br> Open decisions, the task list and the links Claude creates stay in a pane beside the conversation, so they don't scroll out of view.
<img src="docs/board-in-progress.png" alt="Pinboard pane beside a Claude Code session, with one open decision and two of four todos done" width="820">
The story of how it came together: Pinboard: a Claude Code mod.
ctrl+x tab puts you in the first one, Tab moves to the next, Enter sends). Your reply goes to Claude as your own message, queued until it finishes the current turn, and shows under the question until Claude acts on it and closes it. Claude doesn't wait on an open decision: it pins the question and keeps working on whatever doesn't depend on it, so a long unattended session never stalls on one.▸ in the warning color (one at a time), open items show ○, and finished items fold into a single dim ✓ N done line so open work stays on top.gh pr|issue|release|repo|gist create, gh pr|issue comment, git push, and MCP tools that create, draft, send, publish, share or upload), newest first, up to 12. GitHub PRs and issues get short labels such as repo PR #12. Press l (or the clear button) to empty the list.◆ something Claude found, ↪ a switch to another approach or task with the reason it was the better move, ▪ the result of a run, test or backtest with its numbers. Each is a short headline with one dim line of why or data beneath, newest first; the pane shows eight and folds older ones into one line. Press f (or clear) to empty the list. With findings on the board, Claude keeps its closing reply to a few lines.Everything is session state: /clear and /resume start an empty board.
Pinboard registers a tool, mcp__pinboard__update, that Claude calls to add todos, start one, check them off or remove them by id, open decisions and close them, and pin findings. Its description carries a few working rules borrowed from opencode's todowrite: update in real time, mark a todo done only after the work (and any verification) is actually done, and when blocked, leave it in progress and add a follow-up todo for the blocker. Each call shows as one dim line in the transcript (Pinboard: +2 todo, 1 decided), so lists don't have to be repeated in replies.
The current board, with ids, is added to the end of the system prompt on every request, so Claude always knows what's open. Pinboard doesn't depend on the TodoWrite or Task tools, which newer models don't get by default.
Questions are the easiest thing to leave in a reply, where they scroll away. When Claude finishes a turn whose last lines end in a question mark (code blocks aside) while the board has no open decision, Pinboard's Stop hook sends it back once to pin the question with open_decisions. It only nudges once per stop, so a rhetorical question can still end the turn. If the question is already pinned but undone todos remain, the same hook sends Claude back once to carry on with the work that doesn't need the answer.
Findings get the same help. After 15 tool calls in a turn with nothing pinned, the board at the end of the system prompt carries a line reminding Claude to pin what it has learned; the line clears once a finding lands. A turn of 15 or more tool calls that ends with no finding is sent back once to pin them, without repeating the reply. Subagents' calls don't count.
claude -p), and again the first time something lands on an empty board./pinboard./pinboard opens it at any width, even while Claude is working. Ctrl+X then X closes it.claude plugin marketplace add sirkitree/pinboard
claude plugin install pinboard@pinboard
claude plugin validate --strict .
claude plugin test .
claude --plugin-dir .
Built on the mods API as of Claude Code 2.1.288. That API is in early access and changes between releases.
hooks/register.tsx 458 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Finding, FindingKind, Pin, Todo } from '../types'
5
6const PANE = 'pinboard'
7const TITLE = 'Pinboard'
8const TOOL = 'mcp__pinboard__update'
9// NightOwl Cyberpunk, matching the terminal scheme. The engine paints panes solid #262626, so the
10// board paints the scheme's own background over it. WezTerm at text_background_opacity 0 draws no
11// painted cell, so there the pane is the terminal's own see-through background; Windows Terminal
12// keeps any painted cell opaque, where this one at least matches the scheme.
13const THEME = {
14 bg: '#011627',
15 heading: '#7FDBCA',
16 text: '#D6DEEB',
17 muted: '#637777',
18 decision: '#C792EA',
19 active: '#FFEB95',
20 open: '#82AAFF',
21 done: '#22DA6E',
22} as const
23
24const decisions = atom({ plugin: 'pinboard', key: 'decisions' } as const, [] as Decision[])
25const todos = atom({ plugin: 'pinboard', key: 'todos' } as const, [] as Todo[])
26const links = atom({ plugin: 'pinboard', key: 'links' } as const, [] as Pin[])
27const findings = atom({ plugin: 'pinboard', key: 'findings' } as const, [] as Finding[])
28
29// Each kind's mark and color in the pane
30const KINDS: Record<FindingKind, { mark: string; color: string }> = {
31 found: { mark: '◆', color: THEME.active },
32 switched: { mark: '↪', color: THEME.decision },
33 result: { mark: '▪', color: THEME.open },
34}
35// Kept newest first, enough for a long session without the pane turning into the transcript
36const MAX_FINDINGS = 20
37// The pane shows the newest few; older ones fold into one line (Claude still sees all of them)
38const SHOWN_FINDINGS = 8
39
40const DESCRIPTION = [
41 "Keep the session's task list and open decisions on the user's Pinboard, a sidebar that stays in view while the transcript scrolls.",
42 'Any question you end a reply on that needs the user to answer goes in open_decisions, however small the task, even a single yes/no.',
43 'Use it in place of writing task lists in your reply whenever the work takes 3+ distinct steps or the user gives new instructions.',
44 'add_todos: one action per item. start_todo: the todo id you are working on now; exactly one is in progress at a time. done_todos / remove_todos: todo ids.',
45 'Update in real time; do not batch completions. Mark a todo done only after the work is actually done, including any verification it needs, never based on intent.',
46 'If blocked or partly done, leave it in progress and add a follow-up todo describing the blocker.',
47 'open_decisions: questions that need the user to choose. Never stop and wait on one, since the user may be away:',
48 'pin it, then carry on with every step that does not depend on the answer, and leave the steps that do as todos saying which decision they wait on.',
49 'The user can answer in the Pinboard pane at any time; the answer arrives as a user message naming the decision id, and the board marks it [answered].',
50 'Act on the answer, then close the decision with decide (do the same when the user answers in chat).',
51 'add_findings: pin what the user should know without reading the transcript, as it happens, not saved for the end:',
52 "kind 'found' for a discovery, 'switched' when you change approach or move to other work (the why says why it was the better move), 'result' for the outcome of a run, test or backtest with its numbers.",
53 'text is a headline of about 12 words; why is one short sentence on why it matters, or the data behind it. remove_findings: finding ids that turned out wrong or no longer matter.',
54 'With the findings on the board, keep the reply that ends your work short: a few lines on what changed and what the user should do next, not a retelling of everything you tried.',
55 'The current board, with ids, is at the end of your system prompt.',
56].join(' ')
57
58const strings = { type: 'array', items: { type: 'string' } }
59const SCHEMA = {
60 type: 'object',
61 properties: {
62 add_todos: strings,
63 start_todo: { type: 'string' },
64 done_todos: strings,
65 remove_todos: strings,
66 open_decisions: strings,
67 decide: {
68 type: 'array',
69 items: { type: 'object', properties: { id: { type: 'string' }, answer: { type: 'string' } }, required: ['id', 'answer'] },
70 },
71 add_findings: {
72 type: 'array',
73 items: {
74 type: 'object',
75 properties: { kind: { type: 'string', enum: Object.keys(KINDS) }, text: { type: 'string' }, why: { type: 'string' } },
76 required: ['kind', 'text'],
77 },
78 },
79 remove_findings: strings,
80 },
81}
82
83export type Update = {
84 add_todos?: string[]
85 start_todo?: string
86 done_todos?: string[]
87 remove_todos?: string[]
88 open_decisions?: string[]
89 decide?: { id: string; answer: string }[]
90 add_findings?: { kind: FindingKind; text: string; why?: string }[]
91 remove_findings?: string[]
92}
93
94type Board = { todos: Todo[]; decisions: Decision[]; findings?: Finding[] }
95
96// The next id for a prefix: one past the highest in use
97const nextId = (prefix: string, ids: string[]) =>
98 prefix + (Math.max(0, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1)
99
100export function applyUpdate(board: Board, change: Update): Board {
101 let { todos: t, decisions: d } = board
102 for (const text of change.add_todos ?? []) t = [...t, { id: nextId('t', t.map(x => x.id)), text, isDone: false }]
103 for (const text of change.open_decisions ?? []) d = [...d, { id: nextId('d', d.map(x => x.id)), text }]
104 const done = new Set(change.done_todos ?? [])
105 const removed = new Set(change.remove_todos ?? [])
106 const decided = new Set((change.decide ?? []).map(x => x.id))
107 t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
108 // One todo in progress at a time; finishing it ends its turn too
109 if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
110 t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
111 d = d.filter(x => !decided.has(x.id))
112 return { todos: t, decisions: d, findings: applyFindings(board.findings ?? [], change) }
113}
114
115// Findings stay in the order they were found; the pane shows the newest first
116function applyFindings(list: Finding[], change: Update): Finding[] {
117 const removed = new Set(change.remove_findings ?? [])
118 let f = list.filter(x => !removed.has(x.id))
119 for (const add of change.add_findings ?? []) {
120 const text = add.text?.trim()
121 if (!text) continue
122 const kind = add.kind in KINDS ? add.kind : 'found'
123 const why = add.why?.trim()
124 const id = nextId('f', [...list, ...f].map(x => x.id))
125 f = [...f, why ? { id, kind, text, why } : { id, kind, text }]
126 }
127 return f.slice(-MAX_FINDINGS)
128}
129
130export function describeBoard(board: Board): string {
131 const found = board.findings ?? []
132 if (board.todos.length + board.decisions.length + found.length === 0) return 'Pinboard is empty.'
133 return [
134 'Pinboard now:',
135 ...board.todos.map(t => `${t.id} [${t.isDone ? 'x' : t.isActive ? '>' : ' '}] ${t.text}`),
136 ...board.decisions.map(d => (d.answer ? `${d.id} [answered] ${d.text} → ${d.answer}` : `${d.id} [?] ${d.text}`)),
137 ...found.map(f => `${f.id} [${f.kind}] ${f.text}${f.why ? ` — ${f.why}` : ''}`),
138 ].join('\n')
139}
140
141// Records the user's reply to a decision; a blank reply or an unknown id leaves the list as it was
142export function answerDecision(list: Decision[], id: string, reply: string): Decision[] {
143 const answer = reply.trim()
144 if (!answer || !list.some(d => d.id === id)) return list
145 return list.map(d => (d.id === id ? { ...d, answer } : d))
146}
147
148// What Claude reads when the user answers from the pane
149const replyText = (d: Decision) => `Answer to Pinboard decision ${d.id} ("${d.text}"): ${d.answer}`
150
151// A reply asks the user something when one of its last lines, outside code, ends in a question mark
152export function asksUser(reply: string): boolean {
153 const prose = reply.replace(/```[\s\S]*?```/g, '')
154 const lines = prose.split('\n').map(l => l.trim()).filter(Boolean).slice(-3)
155 return lines.some(l => /\?[*_`)"'\]]*$/.test(l))
156}
157
158const NUDGE =
159 'Your reply ends on a question for the user, but the Pinboard has no open decision. ' +
160 'Call mcp__pinboard__update with open_decisions for it (close it with decide once answered), then end your turn. ' +
161 'If it was rhetorical, end your turn as is.'
162
163const KEEP_GOING =
164 'Your reply ends on a question, but the user may be away; their answer comes back through the Pinboard whenever they reply. ' +
165 'Do not wait for it: carry on with every open todo that does not depend on the answer, and note on the ones that do which decision they wait on. ' +
166 'End your turn once nothing left can move without an answer.'
167
168const closeAnswered = (ids: string[]) =>
169 `The user replied in chat to the question you asked last turn, but ${ids.join(', ')} is still open on the Pinboard. ` +
170 'If this message answered it, close it with mcp__pinboard__update decide (the answer in a few words), then end your turn; do not repeat your reply. ' +
171 'If it did not, end your turn as is.'
172
173// Findings get pinned as work goes: a stretch this long with none puts a reminder in the board,
174// and a turn this long that ends with none is sent back once to pin them
175const PIN_AFTER_CALLS = 15
176const FINDINGS_REMINDER =
177 `You have made ${PIN_AFTER_CALLS} or more tool calls this turn without pinning a finding. ` +
178 "If anything the user should know has come up (a discovery, a change of approach, a run's numbers), pin it now with add_findings, then carry on."
179const PIN_FINDINGS =
180 'This turn did a lot of work but pinned no findings. ' +
181 'Call mcp__pinboard__update with add_findings for what the user should know (a discovery, a change of approach, a result with its numbers), then end your turn; do not repeat your reply. ' +
182 'If nothing is worth pinning, end your turn as is.'
183
184// This turn's work in the main conversation: tool calls made, calls since the last finding, whether any was pinned
185type Work = { calls: number; sinceFinding: number; pinned: boolean }
186const FRESH_WORK: Work = { calls: 0, sinceFinding: 0, pinned: false }
187
188const MAKES_COMMAND = /\bgh\s+(?:(?:pr|issue|release|repo|gist)\s+create|(?:pr|issue)\s+comment)\b|\bgit\s+push\b/
189const MAKES_MCP = /^mcp__.*(?:create|draft|send|publish|share|canvas|upload)/i
190
191const URL = /https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9\-._~:/?#[\]!$&'()*+,;=%@]*)?/g
192
193export const urlPins = (text: string): Pin[] =>
194 [...new Set([...text.matchAll(URL)].map(m => m[0].replace(/[)\].,;:'!?*]+$/, '')))]
195 .filter(href => !href.includes('@') && href.length <= 2048)
196 .map(href => {
197 const gh = /github\.com\/[^/]+\/([^/]+)\/(pull|issues)\/(\d+)/.exec(href)
198 return { href, label: gh ? `${gh[1]} ${gh[2] === 'pull' ? 'PR' : 'issue'} #${gh[3]}` : href.slice(8) }
199 })
200
201const isEmpty = async ($: EngineInterface) =>
202 (await read($, decisions)).length + (await read($, todos)).length + (await read($, links)).length + (await read($, findings)).length === 0
203
204// The docked pane's share of the screen, and the width it last saw, kept so the next session opens at it
205const DOCK_SHARE = 0.25
206const MIN_DOCK_COLUMNS = 30
207const SCREEN_KEY = 'screenColumns'
208
209const dockColumns = (screen: number) => Math.max(MIN_DOCK_COLUMNS, Math.round(screen * DOCK_SHARE))
210
211async function openPane($: EngineInterface): Promise<void> {
212 // A store that can't be read only costs the remembered width; the engine's share stands in
213 const screen = await $.store.get(SCREEN_KEY).catch(() => undefined)
214 await $.ui.open(typeof screen === 'number' ? { id: PANE, title: TITLE, columns: dockColumns(screen) } : { id: PANE, title: TITLE })
215}
216
217// Runs a capture; opens the pane when it puts the first thing on an empty board
218async function capture($: EngineInterface, change: () => Promise<unknown>): Promise<void> {
219 const wasEmpty = await isEmpty($)
220 await change()
221 if (wasEmpty && !(await isEmpty($))) await openPane($)
222}
223
224// A reply typed in the pane: kept on the board, and queued to Claude as the user's own words,
225// starting a turn as soon as the session is idle
226async function reply($: EngineInterface, id: string, value: string): Promise<void> {
227 const before = await read($, decisions)
228 const after = answerDecision(before, id, value)
229 if (after === before) return
230 await update($, decisions, () => after)
231 const answered = after.find(d => d.id === id)
232 if (answered) await $.prompt.submit({ text: replyText(answered), asUser: true })
233}
234
235export const register: Register = on => {
236 let work = FRESH_WORK
237 // The screen width the dock was last sized for
238 let sizedFor: number | undefined
239 // Decisions open when the last turn ended on a question, and those this turn's message may answer
240 let askedLast: string[] = []
241 let awaiting: string[] = []
242
243 on('turn.start', async ($, e, next) => {
244 work = FRESH_WORK
245 awaiting = askedLast
246 askedLast = []
247 return next(e)
248 })
249
250 on('session.start', async ($, e, next) => {
251 await $.command.register({ name: 'pinboard', description: 'Open the pane of open decisions, todos and links', immediate: true })
252 await $.tool.register({ name: 'update', description: DESCRIPTION, inputSchema: SCHEMA })
253 // Todos parsed from replies by older versions have no id; the tool can't reach them
254 await update($, todos, old => old.filter(t => typeof t.id === 'string'))
255 const started = await next(e)
256 // Open by default, so the board is there without /pinboard; a narrow terminal seats it once it widens
257 if (e.isInteractive) await openPane($)
258 return started
259 })
260
261 on('command.run', { command: 'pinboard' }, async $ => {
262 await openPane($)
263 return {}
264 })
265
266 // The board rides at the end of the system prompt, so it never has to be repeated in replies
267 on('prompt.compose', async ($, e, next) => {
268 const { sections } = await next(e)
269 const board = describeBoard({ todos: await read($, todos), decisions: await read($, decisions), findings: await read($, findings) })
270 // The reminder only flips on and off, so the section stays stable for the prompt cache
271 const text = work.sinceFinding >= PIN_AFTER_CALLS ? `${board}\n\n${FINDINGS_REMINDER}` : board
272 return { sections: [...sections, { id: 'pinboard:board', text, scope: 'session' }] }
273 })
274
275 on('tool.call', { tool: TOOL }, async ($, e) => {
276 if (e.agentId) return { deny: 'Only the main conversation updates the Pinboard.' }
277 let board: Board = { todos: [], decisions: [] }
278 await capture($, async () => {
279 board = applyUpdate({ todos: await read($, todos), decisions: await read($, decisions), findings: await read($, findings) }, e as Update)
280 await update($, todos, () => board.todos)
281 await update($, decisions, () => board.decisions)
282 await update($, findings, () => board.findings ?? [])
283 })
284 if ((e as Update).add_findings?.length) work = { ...work, sinceFinding: 0, pinned: true }
285 return { result: describeBoard(board) }
286 })
287
288 // A question left only in the reply scrolls away; send Claude back once to pin it.
289 // A pinned one with work still open sends it back once to keep going instead of waiting.
290 // A long turn that pinned nothing is sent back once to pin its findings.
291 // An answer typed in chat, not the pane, leaves the question open; it is sent back once to close it.
292 on('classic.Stop', async ($, e, next) => {
293 const ran = await next(e)
294 if (ran.block || e.stop_hook_active) return ran
295 const open = (await read($, decisions)).filter(d => !d.answer).map(d => d.id)
296 const stillOpen = awaiting.filter(id => open.includes(id))
297 awaiting = []
298 if (stillOpen.length) return { ...ran, block: closeAnswered(stillOpen) }
299 if (asksUser(e.last_assistant_message ?? '')) {
300 askedLast = open
301 if (!open.length) return { ...ran, block: NUDGE }
302 if ((await read($, todos)).some(t => !t.isDone)) return { ...ran, block: KEEP_GOING }
303 }
304 return work.calls >= PIN_AFTER_CALLS && !work.pinned ? { ...ran, block: PIN_FINDINGS } : ran
305 })
306
307 // Links only from actions that make something; reads, fetches and test output just mention URLs
308 on('tool.call', async ($, e, next) => {
309 const ran = await next(e)
310 if (!e.agentId && e.tool !== TOOL) work = { ...work, calls: work.calls + 1, sinceFinding: work.sinceFinding + 1 }
311 const makes = e.tool === 'Bash' ? MAKES_COMMAND.test(e.command) : MAKES_MCP.test(e.tool)
312 if (e.agentId || !makes || !('text' in ran) || ran.isError) return ran
313 const found = urlPins(ran.text ?? '')
314 if (found.length > 0 && found.length <= 3) {
315 await capture($, () => update($, links, old => [...found, ...old.filter(p => !found.some(f => f.href === p.href))].slice(0, 12)))
316 }
317 return ran
318 })
319
320 // An update is one dim line in the transcript; the board itself is in the pane
321 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
322 const { Text } = $.ui.resolve(e)
323 const change = (e.props.input ?? {}) as Update
324 const parts = [
325 change.add_todos?.length && `+${change.add_todos.length} todo`,
326 change.start_todo && `started ${change.start_todo}`,
327 change.done_todos?.length && `${change.done_todos.length} done`,
328 change.remove_todos?.length && `-${change.remove_todos.length} todo`,
329 change.open_decisions?.length && `+${change.open_decisions.length} decision`,
330 change.decide?.length && `${change.decide.length} decided`,
331 change.add_findings?.length && `+${change.add_findings.length} finding`,
332 change.remove_findings?.length && `-${change.remove_findings.length} finding`,
333 ].filter(Boolean)
334 return <Text color={THEME.muted}>{'Pinboard: ' + (parts.join(', ') || 'no change')}</Text>
335 })
336
337 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) =>
338 e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
339 )
340
341 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
342 // Docked, ask for a quarter of the screen once per width; a width the user drags to still wins.
343 // Not awaited: the open settles once the pane is drawn, which is this render
344 const screen = e.viewport?.isFullscreen ? e.viewport.columns : undefined
345 if (screen !== undefined && screen !== sizedFor) {
346 sizedFor = screen
347 void $.ui.open({ id: PANE, title: TITLE, columns: dockColumns(screen) })
348 void $.store.set(SCREEN_KEY, screen).catch(() => undefined)
349 }
350 const elements = $.ui.resolve(e)
351 const { Box, Text, Button, Link } = elements
352 // The mobile app draws no text field; there the user answers in chat
353 const Input = 'Input' in elements ? elements.Input : undefined
354 // One cell of padding on every side
355 const inner = Math.max(10, e.props.bodyColumns - 2)
356 const allDecisions = await read($, decisions)
357 const allTodos = await read($, todos)
358 const allLinks = await read($, links)
359 const allFindings = await read($, findings)
360 const doneCount = allTodos.filter(t => t.isDone).length
361 const firstOpen = allDecisions.find(d => !d.answer)
362
363 const header = (title: string, count: string) => (
364 <Text bold color={THEME.heading}>
365 {title} <Text color={THEME.muted}>{count}</Text>
366 </Text>
367 )
368 const empty = (text: string) => <Text color={THEME.muted}> {text}</Text>
369 // The bullet stays in its own column, so wrapped lines indent under the text
370 const item = (bullet: string, text: string, bulletColor: string, textColor: string = THEME.text) => (
371 <Box flexDirection="row" width={inner}>
372 <Text color={bulletColor}>{' ' + bullet + ' '}</Text>
373 <Box flexShrink={1} flexGrow={1}>
374 <Text color={textColor} wrap="wrap">
375 {text}
376 </Text>
377 </Box>
378 </Box>
379 )
380
381 return (
382 <Box
383 flexDirection="column"
384 width={inner + 2}
385 minHeight={e.props.scroll.bodyRows}
386 padding={1}
387 backgroundColor={THEME.bg}
388 >
389 {header('Open decisions', allDecisions.length ? String(allDecisions.length) : '')}
390 {allDecisions.length === 0 && empty('No open decisions.')}
391 {Input && firstOpen && empty('ctrl+x tab to reply · tab for the next one')}
392 {/* Unanswered: a reply field under the question. Answered: the reply, until Claude closes it */}
393 {allDecisions.map(d =>
394 d.answer ? (
395 <Box flexDirection="column" width={inner}>
396 {item('?', d.text, THEME.muted, THEME.muted)}
397 {item(' ↳', d.answer, THEME.done, THEME.done)}
398 </Box>
399 ) : (
400 <Box flexDirection="column" width={inner}>
401 {item('?', d.text, THEME.decision)}
402 {Input && (
403 <Box paddingLeft={4} width={inner}>
404 {/* Focusing the pane lands in the first unanswered field, ready to type */}
405 <Input
406 key={`reply-${d.id}`}
407 placeholder="reply…"
408 submitLabel="send"
409 autoFocus={d.id === firstOpen?.id || undefined}
410 onSubmit={(value: string) => reply($, d.id, value)}
411 />
412 </Box>
413 )}
414 </Box>
415 ),
416 )}
417 <Text> </Text>
418
419 {header('Todos', allTodos.length ? `${doneCount}/${allTodos.length}` : '')}
420 {allTodos.length === 0 && empty('No todos yet.')}
421 {allTodos
422 .filter(t => !t.isDone)
423 .map(t => (t.isActive ? item('▸', t.text, THEME.active, THEME.active) : item('○', t.text, THEME.open)))}
424 {/* Finished todos fold into one line so open work stays on top */}
425 {doneCount > 0 && <Text color={THEME.done}>{` ✓ ${doneCount} done`}</Text>}
426 <Text> </Text>
427
428 <Box flexDirection="row" justifyContent="space-between" width={inner}>
429 {header('Links', allLinks.length ? String(allLinks.length) : '')}
430 <Button key="clear-links" label="clear" hotkey="l" plain dimColor onPress={() => update($, links, () => [])} />
431 </Box>
432 {allLinks.length === 0 && empty('Nothing created yet.')}
433 {allLinks.map(p => (
434 <Text wrap="truncate-middle">
435 {' '}
436 <Link href={p.href} label={p.label} />
437 </Text>
438 ))}
439 <Text> </Text>
440
441 <Box flexDirection="row" justifyContent="space-between" width={inner}>
442 {header('Findings', allFindings.length ? String(allFindings.length) : '')}
443 <Button key="clear-findings" label="clear" hotkey="f" plain dimColor onPress={() => update($, findings, () => [])} />
444 </Box>
445 {allFindings.length === 0 && empty('Nothing found yet.')}
446 {/* Newest first: a headline marked by kind, its why dim beneath */}
447 {[...allFindings].reverse().slice(0, SHOWN_FINDINGS).map(f => (
448 <Box flexDirection="column" width={inner}>
449 {item(KINDS[f.kind].mark, f.text, KINDS[f.kind].color)}
450 {f.why && item(' ', f.why, THEME.muted, THEME.muted)}
451 </Box>
452 ))}
453 {allFindings.length > SHOWN_FINDINGS && empty(`… ${allFindings.length - SHOWN_FINDINGS} earlier`)}
454 </Box>
455 )
456 })
457}
458types/index.d.ts 22 lines1export type Todo = { id: string; text: string; isDone: boolean; isActive?: boolean }
2
3// `answer` is the user's reply from the pane, kept until Claude closes the decision
4export type Decision = { id: string; text: string; answer?: string }
5
6export type Pin = { href: string; label: string }
7
8// What the user should know without reading the transcript: a discovery, a change of course, a run's outcome
9export type FindingKind = 'found' | 'switched' | 'result'
10export type Finding = { id: string; kind: FindingKind; text: string; why?: string }
11
12declare module 'claude-code' {
13 interface PluginState {
14 pinboard: {
15 decisions: Decision[]
16 todos: Todo[]
17 links: Pin[]
18 findings: Finding[]
19 }
20 }
21}
22