SLOPSHOPPER

Pinboard

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

newpanerowsguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pinboard
│ ┃ Pinboard ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Open decisions ⏺ Read(src/auth.ts) │ ┃ No open decisions. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Todos ⎿ Added 2 lines, removed 1 line │ ┃ No todos yet. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Links │ ┃ Nothing created yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Findings ✻ Worked for 42s · done 4:20 PM │ ┃ Nothing found yet. │ ┃ › /pinboard │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pinboard
Open decisions No open decisions. Todos No todos yet. Links l: clear Nothing created yet. Findings f: clear Nothing found yet.
README

Pinboard

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.

Version Claude Code mod Claude Code 2.1.287+ Catalogue License: MIT

<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.

What it pins

  • Open decisions: questions Claude needs you to answer. Each has a reply field in the pane (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.
  • Todos: the session's task list, one action per item. The one Claude is working on is marked ▸ 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.
  • Links: URLs from actions that make something (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.
  • Findings: what you'd want to know without reading the transcript, pinned as it happens: ◆ 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.

How Claude updates it

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.

How it opens

  • The pane opens by itself when an interactive session starts (not under claude -p), and again the first time something lands on an empty board.
  • Opened that way, Claude Code only seats it in a terminal at least 144 columns wide (110 once you've opened it yourself). Below that it waits and appears when the terminal widens, or run /pinboard.
  • /pinboard opens it at any width, even while Claude is working. Ctrl+X then X closes it.
  • Docked beside a fullscreen transcript it takes a quarter of the screen (at least 30 columns), and opens at that width from the next session on. A width you drag it to still wins.

Install

claude plugin marketplace add sirkitree/pinboard
claude plugin install pinboard@pinboard

Develop

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.

Source 2 files
hooks/register.tsx 458 lines
1import { 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}
458
types/index.d.ts 22 lines
1export 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