A To do / Doing / Done board in a side pane, filled by Claude's to-do list, approved plans and a board tool, plus your own cards

A To do / Doing / Done board in a side pane, filled as Claude works, and by you.
<img src="docs/board.png" alt="The Board pane: a progress bar, To do, Doing and Done columns of cards marked as Claude's or from a plan, and a New card button" width="380">
<sub>The screenshot shows made-up cards.</sub>
✦ marks Claude's cards, ◆ a plan's, ≡ one with a description. /board opens the pane at any time. The board belongs to the session: a new session starts with an empty one. Done shows its latest five cards; a board keeps at most 80, dropping the oldest done ones first.
Claude's to-do list is the truth for its own cards: when Claude updates the list, its cards follow, even ones you moved. Cards you or the board tool added are yours to move.
/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install task-board@marco-mods
/reload-pluginshooks/register.tsx 521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, Column } from '../types'
5import {
6 BOARD_TOOL_DESCRIPTION,
7 BOARD_TOOL_SCHEMA,
8 COLUMNS,
9 COLUMN_NAME,
10 DONE_SHOWN,
11 addCard,
12 editCard,
13 moveCard,
14 neighbour,
15 planSteps,
16 removeCard,
17 runBoardTool,
18 sourceName,
19 syncTodos,
20 taskCreated,
21 taskUpdated,
22 workPrompt,
23} from './lib'
24import type { BoardToolInput } from './lib'
25import { columnsHeightFor } from './columns'
26import type { ColumnsMessage, ColumnsProps } from './columns'
27
28const PANE = 'task-board'
29const TITLE = 'Board'
30const TOOL = 'board'
31// Theme colors follow the light or dark scheme by themselves; the green is one that reads on both.
32const ACCENT = 'claude'
33const GREEN = '#2da44e'
34/** Each column's dot. */
35const DOT: Record<Column, { color?: string }> = { todo: {}, doing: { color: ACCENT }, done: { color: GREEN } }
36/** The mark in a card's footer: who put it there (✦ Claude, ◆ a plan); the card's details say it in words. */
37const TAG: Record<Card['source'], string> = { you: '', todo: '✦', claude: '✦', plan: '◆' }
38/** Rows above the columns (the progress row and the blank under it) and below them (the pane's last blank). */
39const HEADER_ROWS = 3
40/** Cells in the progress bar. */
41const BAR = 12
42
43const cards = atom({ plugin: 'task-board', key: 'cards' } as const, [])
44const selected = atom({ plugin: 'task-board', key: 'selected' } as const, null)
45const composer = atom({ plugin: 'task-board', key: 'composer' } as const, null)
46const working = atom({ plugin: 'task-board', key: 'working' } as const, null)
47
48/** The board's full tool name, as Claude calls it: `mcp__task-board__board`, else what registering answered. */
49let toolName = `mcp__task-board__${TOOL}`
50
51/**
52 * Writes the board; the first cards Claude puts on an empty board open the
53 * pane, so the board shows up as Claude starts working.
54 */
55async function setCards($: EngineInterface, next: (current: Card[]) => Card[], isClaude: boolean): Promise<void> {
56 const before = await read($, cards)
57 const after = next(before)
58 if (JSON.stringify(after) === JSON.stringify(before)) return
59 await update($, cards, () => after)
60 if (isClaude && before.length === 0 && after.length > 0) await $.ui.open({ id: PANE, title: TITLE })
61}
62
63/** Opens the composer: empty for a new card, or holding `card` to edit it. */
64async function compose($: EngineInterface, card: Card | null): Promise<void> {
65 await update($, composer, () =>
66 card === null
67 ? { id: null, title: '', note: '', hasNote: false }
68 : { id: card.id, title: card.title, note: card.note, hasNote: false },
69 )
70 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
71}
72
73/** Swaps the Title field for the Description field, and moves the cursor into it. */
74async function showNote($: EngineInterface): Promise<void> {
75 await update($, composer, current => (current === null ? current : { ...current, hasNote: true }))
76 await $.ui.focus({ requestId: PANE, key: 'note' }).catch(() => undefined)
77}
78
79/** Back to the Title field. */
80async function showTitle($: EngineInterface): Promise<void> {
81 await update($, composer, current => (current === null ? current : { ...current, hasNote: false }))
82 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
83}
84
85async function typeIn($: EngineInterface, field: 'title' | 'note', text: string): Promise<void> {
86 await update($, composer, current => (current === null ? current : { ...current, [field]: text }))
87}
88
89/** Saves what the composer holds: a new card in To do, or the edited card; then closes it. */
90async function saveComposer($: EngineInterface): Promise<void> {
91 const draft = await read($, composer)
92 if (draft === null) return
93 if (draft.title.trim() === '') {
94 $.ui.toast('Give the card a title.')
95 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
96 return
97 }
98 const at = await $.clock.now()
99 const id = draft.id
100 await setCards(
101 $,
102 current =>
103 id === null
104 ? addCard(current, { title: draft.title, note: draft.note, source: 'you' }, at).cards
105 : editCard(current, id, { title: draft.title, note: draft.note }),
106 false,
107 )
108 await update($, composer, () => null)
109}
110
111async function select($: EngineInterface, id: string | null): Promise<void> {
112 await update($, selected, current => (current === id ? null : id))
113}
114
115async function move($: EngineInterface, id: string, column: Column): Promise<void> {
116 await setCards($, current => moveCard(current, id, column), false)
117}
118
119async function remove($: EngineInterface, id: string): Promise<void> {
120 await setCards($, current => removeCard(current, id), false)
121 if ((await read($, selected)) === id) await update($, selected, () => null)
122}
123
124/**
125 * Hands Claude a card: the card moves to Doing and Claude gets a prompt to
126 * work on it. Claude Code holds a prompt a mod sends until Claude is idle, so
127 * a toast says when it waits.
128 */
129async function workOn($: EngineInterface, card: Card): Promise<void> {
130 if ((await read($, working)) === card.id) {
131 $.ui.toast('Claude already has this card.')
132 return
133 }
134 await update($, working, () => card.id)
135 await move($, card.id, 'doing')
136 let isStarted = false
137 const submitted = $.prompt.submit({ text: workPrompt(card) }).then(
138 result => {
139 isStarted = result.drop === undefined
140 },
141 () => {
142 isStarted = false
143 },
144 )
145 let isSettled = false
146 void submitted.then(() => {
147 isSettled = true
148 })
149 await Promise.race([submitted, $.clock.sleep(500)])
150 if (!isSettled) $.ui.toast('Queued: Claude starts on this card once it finishes the current task.', { timeoutMs: 8_000 })
151 await submitted
152 if (!isStarted && (await read($, working)) === card.id) {
153 await update($, working, () => null)
154 $.ui.toast('The card was not handed to Claude.')
155 }
156}
157
158export const register: Register = on => {
159 on('session.start', async ($, e, next) => {
160 await $.command.register({ name: 'board', description: "Show the session's task board: To do, Doing and Done" })
161 const registered = await $.tool.register({ name: TOOL, description: BOARD_TOOL_DESCRIPTION, inputSchema: BOARD_TOOL_SCHEMA })
162 toolName = registered.tool
163
164 return next(e)
165 })
166
167 on('command.run', { command: 'board' }, async $ => {
168 await $.ui.open({ id: PANE, title: TITLE })
169
170 return { text: 'Board opened.' }
171 })
172
173 // Claude's to-do list, in either of the tools it keeps it with.
174 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
175 const ran = await next(e)
176 if (ran.deny === undefined && ran.isError !== true) {
177 const at = await $.clock.now()
178 await setCards($, current => syncTodos(current, e.todos, at), true)
179 }
180
181 return ran
182 })
183
184 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
185 const ran = await next(e)
186 const task = (ran.result as { task?: { id?: unknown } } | undefined)?.task
187 if (ran.deny === undefined && ran.isError !== true && typeof task?.id === 'string') {
188 const at = await $.clock.now()
189 const id = task.id
190 await setCards($, current => taskCreated(current, { id, subject: e.subject, description: e.description }, at), true)
191 }
192
193 return ran
194 })
195
196 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
197 const ran = await next(e)
198 if (ran.deny === undefined && ran.isError !== true) {
199 await setCards(
200 $,
201 current =>
202 taskUpdated(current, {
203 taskId: e.taskId,
204 ...(e.status === undefined ? {} : { status: e.status }),
205 ...(e.subject === undefined ? {} : { subject: e.subject }),
206 ...(e.description === undefined ? {} : { description: e.description }),
207 }),
208 true,
209 )
210 }
211
212 return ran
213 })
214
215 // An approved plan: its steps go to To do.
216 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
217 const ran = await next(e)
218 const plan = (ran.result as { plan?: unknown } | undefined)?.plan
219 if (ran.deny === undefined && ran.isError !== true && typeof plan === 'string') {
220 const at = await $.clock.now()
221 await setCards(
222 $,
223 current => planSteps(plan).reduce((board, step) => addCard(board, { title: step, source: 'plan' }, at).cards, current),
224 true,
225 )
226 }
227
228 return ran
229 })
230
231 // The board tool Claude calls: answered here, never passed on.
232 on('tool.call', async ($, e, next) => {
233 if (e.tool !== toolName) return next(e)
234 const at = await $.clock.now()
235 const before = await read($, cards)
236 const outcome = runBoardTool(before, e as unknown as BoardToolInput, at)
237 await setCards($, () => outcome.cards, true)
238
239 // A registered tool's result is text: what Claude reads back.
240 return outcome.isError === true
241 ? { result: outcome.text, text: outcome.text, isError: true as const }
242 : { result: outcome.text, text: outcome.text }
243 })
244
245 // The draggable columns: a card dropped on another column, or clicked to open it.
246 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
247 const data = e.data as Partial<ColumnsMessage> | null
248 const id = typeof data?.id === 'string' ? data.id : null
249 if (id === null || !(await read($, cards)).some(card => card.id === id)) return {}
250 if (data?.type === 'open') await select($, id)
251 if (data?.type === 'move' && typeof data.column === 'number') {
252 const column = COLUMNS[data.column]
253 if (column !== undefined) await move($, id, column)
254 }
255
256 return {}
257 })
258
259 on('turn.complete', async ($, e, next) => {
260 const done = await next(e)
261 // The turn Work on it started has ended: the card is Claude's no longer.
262 if (e.agentId === undefined && (await read($, working)) !== null) await update($, working, () => null)
263
264 return done
265 })
266
267 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268 const table = $.ui.resolve(e)
269 const { Box, Button, Text } = table
270 const Input = 'Input' in table ? table.Input : undefined
271 // The desktop app and the terminal draw a Client; VS Code's table lists one but draws none.
272 const Client = (e.surface === 'desktop' || e.surface === 'terminal') && 'Client' in table ? table.Client : undefined
273 const board = await read($, cards)
274 const openId = await read($, selected)
275 const writing = await read($, composer)
276 const saveLabel = writing?.id == null ? 'Add card' : 'Save'
277 const busy = await read($, working)
278 const open = board.find(card => card.id === openId)
279
280 const cardView = (card: Card) => {
281 const isOpen = card.id === openId
282 const isBusy = card.id === busy
283 const isDone = card.column === 'done'
284 const next = neighbour(card.column, 1)
285 const tag = `${isBusy ? '◐' : TAG[card.source]}${card.note === '' ? '' : ' ≡'}`.trim()
286 return (
287 <Box
288 key={`card:${card.id}`}
289 flexDirection="column"
290 borderStyle="round"
291 {...(isOpen || isBusy ? { borderColor: ACCENT } : { borderDimColor: true })}
292 hover={{ borderColor: ACCENT }}
293 paddingX={1}
294 >
295 <Text wrap="wrap" dimColor={isDone}>
296 {isDone ? `✓ ${card.title}` : card.title}
297 </Text>
298 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
299 <Text {...(isBusy ? { color: ACCENT } : { dimColor: true })} wrap="truncate-end">
300 {tag}
301 </Text>
302 <Box flexDirection="row" columnGap={1}>
303 <Button key={`open:${card.id}`} label={isOpen ? 'Close' : 'Open'} plain dimColor onPress={() => void select($, card.id)} />
304 {next !== null && (
305 <Button key={`next:${card.id}`} label="›" plain dimColor onPress={() => void move($, card.id, next)} />
306 )}
307 </Box>
308 </Box>
309 </Box>
310 )
311 }
312
313 // The cards as the draggable columns draw them, column by column, Done's latest first.
314 const shownIn = (column: Column) => {
315 const inColumn = board.filter(card => card.column === column)
316 return column === 'done' ? inColumn.slice(-DONE_SHOWN).reverse() : inColumn
317 }
318 const columnsProps: ColumnsProps = {
319 cards: COLUMNS.flatMap((column, index) =>
320 shownIn(column).map(card => ({
321 id: card.id,
322 title: card.title,
323 column: index,
324 tag: `${card.id === busy ? '◐' : TAG[card.source]}${card.note === '' ? '' : ' ≡'}`.trim(),
325 isDone: column === 'done',
326 isOpen: card.id === openId,
327 isBusy: card.id === busy,
328 })),
329 ),
330 names: COLUMNS.map(column => COLUMN_NAME[column]),
331 counts: COLUMNS.map(column => board.filter(card => card.column === column).length),
332 dots: COLUMNS.map(column => DOT[column].color ?? null),
333 }
334
335 // The columns fill the pane below the header, so a card can be dragged anywhere under them; with a
336 // card's details or the composer open, they keep their own height and those sit right under them.
337 const contentRows = columnsHeightFor(columnsProps.cards, e.props.bodyColumns)
338 const fillRows = e.props.scroll.bodyRows - HEADER_ROWS
339 const columnsHeight = open !== undefined || writing !== null ? contentRows : Math.max(contentRows, fillRows)
340
341 const columnView = (column: Column) => {
342 const inColumn = board.filter(card => card.column === column)
343 // Done keeps the latest few: the column that only grows.
344 const shown = column === 'done' ? inColumn.slice(-DONE_SHOWN).reverse() : inColumn
345 return (
346 <Box key={`column:${column}`} flexDirection="column" width="33%" flexShrink={1} rowGap={1}>
347 <Box flexDirection="row" columnGap={1}>
348 <Text {...DOT[column]} dimColor={DOT[column].color === undefined}>
349 ●
350 </Text>
351 <Text bold>{COLUMN_NAME[column]}</Text>
352 <Text dimColor>{String(inColumn.length)}</Text>
353 </Box>
354 {shown.map(cardView)}
355 {shown.length === 0 && (
356 <Box borderStyle="round" borderDimColor paddingX={1}>
357 <Text dimColor>No cards</Text>
358 </Box>
359 )}
360 {inColumn.length > shown.length && <Text dimColor>{`+${inColumn.length - shown.length} earlier`}</Text>}
361 </Box>
362 )
363 }
364
365 // How far along the board is: the done cards of all.
366 const doneCount = board.filter(card => card.column === 'done').length
367 const filled = board.length === 0 ? 0 : Math.round((doneCount / board.length) * BAR)
368 const newCard =
369 Input === undefined || writing !== null ? null : (
370 <Button key="new" label="+ New card" variant="secondary" onPress={() => void compose($, null)} />
371 )
372 const progress = (
373 <Box flexDirection="row" columnGap={1} alignItems="center">
374 <Box flexDirection="row">
375 {filled > 0 && <Text color={GREEN}>{'■'.repeat(filled)}</Text>}
376 {filled < BAR && <Text dimColor>{'□'.repeat(BAR - filled)}</Text>}
377 </Box>
378 <Text dimColor>{`${doneCount} of ${board.length} done`}</Text>
379 </Box>
380 )
381 const header = (
382 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} marginBottom={1}>
383 {board.length === 0 ? <Text bold>Nothing on the board yet</Text> : progress}
384 {newCard}
385 </Box>
386 )
387
388 const details = (card: Card) => {
389 const left = neighbour(card.column, -1)
390 const right = neighbour(card.column, 1)
391 return (
392 <Box key={`details:${card.id}`} flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} marginTop={1}>
393 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
394 <Box flexDirection="row" columnGap={1} flexShrink={1}>
395 <Text {...DOT[card.column]} dimColor={DOT[card.column].color === undefined}>
396 ●
397 </Text>
398 <Text bold wrap="wrap">
399 {card.title}
400 </Text>
401 </Box>
402 <Button key="close" label="✕" plain dimColor onPress={() => void select($, null)} />
403 </Box>
404 <Text dimColor>{`${COLUMN_NAME[card.column]} · ${sourceName(card.source)}`}</Text>
405 {card.note !== '' && (
406 <Box marginTop={1}>
407 <Text wrap="wrap">{card.note}</Text>
408 </Box>
409 )}
410 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1} marginBottom={1}>
411 {card.column !== 'done' && (
412 <Button
413 key="work"
414 label={card.id === busy ? 'Claude is on it' : '▶ Work on it'}
415 variant="secondary"
416 onPress={() => void workOn($, card)}
417 />
418 )}
419 {left !== null && (
420 <Button key="left" label={`‹ ${COLUMN_NAME[left]}`} variant="secondary" onPress={() => void move($, card.id, left)} />
421 )}
422 {right !== null && (
423 <Button key="right" label={`${COLUMN_NAME[right]} ›`} variant="secondary" onPress={() => void move($, card.id, right)} />
424 )}
425 <Button key="edit" label="Edit" plain dimColor onPress={() => void compose($, card)} />
426 <Button key="delete" label="Delete" plain dimColor onPress={() => void remove($, card.id)} />
427 </Box>
428 </Box>
429 )
430 }
431
432 return (
433 // A focus ring at the content's top or bottom edge is cut off: keep a line below the last element.
434 <Box flexDirection="column" paddingBottom={1}>
435 {board.length === 0 ? (
436 <Box flexDirection="column">
437 {header}
438 <Text dimColor wrap="wrap">
439 Add a card with New card. Claude's to-do list and the plans you approve show up here by themselves, and Claude can add
440 cards when you ask it to put something on the board.
441 </Text>
442 </Box>
443 ) : (
444 <Box flexDirection="column">
445 {header}
446 {Client !== undefined ? (
447 // Dragging needs the pointer, which only a Client hears: it draws the columns itself.
448 <Client key="columns" module="./columns.tsx" props={columnsProps} width="100%" height={columnsHeight} />
449 ) : (
450 <Box flexDirection="row" columnGap={1}>
451 {COLUMNS.map(columnView)}
452 </Box>
453 )}
454 </Box>
455 )}
456 {writing !== null && Input !== undefined && (
457 <Box
458 key="composer"
459 flexDirection="column"
460 borderStyle="round"
461 borderColor={ACCENT}
462 paddingX={1}
463 paddingY={1}
464 rowGap={1}
465 marginTop={1}
466 >
467 <Box flexDirection="row" justifyContent="space-between">
468 <Text bold>{writing.id === null ? 'New card' : 'Edit card'}</Text>
469 <Button key="cancel-x" label="✕" plain dimColor onPress={() => void update($, composer, () => null)} />
470 </Box>
471 {/* One field at a time: each field's Enter label is drawn as a button, always. */}
472 {!writing.hasNote ? (
473 <Box flexDirection="column" rowGap={1}>
474 <Input
475 key="title"
476 label="Title"
477 placeholder="What needs doing"
478 value={writing.title}
479 submitLabel={saveLabel}
480 autoFocus
481 onInput={text => void typeIn($, 'title', text)}
482 onSubmit={text => void typeIn($, 'title', text).then(() => saveComposer($))}
483 />
484 <Box>
485 <Button
486 key="add-note"
487 label={writing.note === '' ? '+ Description' : 'Edit description'}
488 plain
489 dimColor
490 onPress={() => void showNote($)}
491 />
492 </Box>
493 </Box>
494 ) : (
495 <Box flexDirection="column" rowGap={1}>
496 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
497 <Text bold wrap="wrap">
498 {writing.title === '' ? 'Untitled' : writing.title}
499 </Text>
500 <Button key="back-title" label="‹ Title" plain dimColor onPress={() => void showTitle($)} />
501 </Box>
502 <Input
503 key="note"
504 label="Description"
505 placeholder="Why, where, what done means"
506 value={writing.note}
507 submitLabel={saveLabel}
508 autoFocus
509 onInput={text => void typeIn($, 'note', text)}
510 onSubmit={text => void typeIn($, 'note', text).then(() => saveComposer($))}
511 />
512 </Box>
513 )}
514 </Box>
515 )}
516 {open !== undefined && details(open)}
517 </Box>
518 )
519 })
520}
521hooks/lib.ts 275 lines1import type { Card, CardSource, Column } from '../types'
2
3export const COLUMNS: readonly Column[] = ['todo', 'doing', 'done']
4
5export const COLUMN_NAME: Record<Column, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
6
7/** How many Done cards the column shows; the rest are counted. */
8export const DONE_SHOWN = 5
9
10/** The most cards a board keeps: the oldest Done ones go first. */
11export const CARD_LIMIT = 80
12
13const SOURCE_NAME: Record<CardSource, string> = {
14 you: 'Added by you',
15 todo: "From Claude's to-do list",
16 plan: 'From the approved plan',
17 claude: 'Added by Claude',
18}
19
20export function sourceName(source: CardSource): string {
21 return SOURCE_NAME[source]
22}
23
24const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
25
26/** A card's title as it is kept: one line, trimmed, at most 160 characters. */
27export function cleanTitle(text: string): string {
28 return clip(text.replace(/\s+/g, ' ').trim(), 160)
29}
30
31/** A new card's id, unique within the board. */
32export function newId(cards: readonly Card[], at: number): string {
33 let n = cards.length
34 let id = `c${at.toString(36)}${n.toString(36)}`
35 while (cards.some(card => card.id === id)) {
36 n += 1
37 id = `c${at.toString(36)}${n.toString(36)}`
38 }
39
40 return id
41}
42
43/** The board with a card added at the end of `column`, and the card; a card already there by that title is kept as is. */
44export function addCard(
45 cards: readonly Card[],
46 fields: { title: string; note?: string; column?: Column; source: CardSource; ref?: string | null },
47 at: number,
48): { cards: Card[]; card: Card } {
49 const title = cleanTitle(fields.title)
50 const same = cards.find(card => card.title.toLowerCase() === title.toLowerCase() && card.column !== 'done')
51 if (same !== undefined) return { cards: [...cards], card: same }
52 const card: Card = {
53 id: newId(cards, at),
54 title,
55 note: clip((fields.note ?? '').trim(), 1_000),
56 column: fields.column ?? 'todo',
57 source: fields.source,
58 ref: fields.ref ?? null,
59 createdAt: at,
60 }
61
62 return { cards: trim([...cards, card]), card }
63}
64
65/** At most `CARD_LIMIT` cards: the oldest Done ones go first. */
66function trim(cards: Card[]): Card[] {
67 const out = [...cards]
68 while (out.length > CARD_LIMIT) {
69 const at = out.findIndex(card => card.column === 'done')
70 out.splice(at === -1 ? 0 : at, 1)
71 }
72
73 return out
74}
75
76/** The board with card `id` in `column`, moved to the end of it. */
77export function moveCard(cards: readonly Card[], id: string, column: Column): Card[] {
78 const card = cards.find(one => one.id === id)
79 if (card === undefined || card.column === column) return [...cards]
80
81 return [...cards.filter(one => one.id !== id), { ...card, column }]
82}
83
84/** The board with card `id` retitled and its description replaced. */
85export function editCard(cards: readonly Card[], id: string, fields: { title: string; note: string }): Card[] {
86 return cards.map(card => (card.id === id ? { ...card, title: cleanTitle(fields.title), note: clip(fields.note.trim(), 1_000) } : card))
87}
88
89export function removeCard(cards: readonly Card[], id: string): Card[] {
90 return cards.filter(card => card.id !== id)
91}
92
93/** The column left or right of `column`, if any. */
94export function neighbour(column: Column, step: -1 | 1): Column | null {
95 return COLUMNS[COLUMNS.indexOf(column) + step] ?? null
96}
97
98const STATUS_COLUMN: Record<string, Column> = { pending: 'todo', in_progress: 'doing', completed: 'done' }
99
100/**
101 * The board after Claude wrote its to-do list (TodoWrite): each item a card
102 * in the column its status says, matched to the card it made before by its
103 * text; the cards of items it dropped go. Claude's list is the truth for its
104 * own cards.
105 */
106export function syncTodos(
107 cards: readonly Card[],
108 todos: ReadonlyArray<{ content: string; status: string }>,
109 at: number,
110): Card[] {
111 const kept = new Set(todos.map(todo => cleanTitle(todo.content)))
112 let out = cards.filter(card => card.source !== 'todo' || card.ref === null || kept.has(card.ref) || card.ref.startsWith('task:'))
113 for (const todo of todos) {
114 const title = cleanTitle(todo.content)
115 const column = STATUS_COLUMN[todo.status] ?? 'todo'
116 const mine = out.find(card => card.source === 'todo' && card.ref === title)
117 if (mine === undefined) {
118 out = addCard(out, { title, column, source: 'todo', ref: title }, at).cards
119 } else if (mine.column !== column) {
120 out = moveCard(out, mine.id, column)
121 }
122 }
123
124 return out
125}
126
127/** The board after Claude created a task in its to-do list (TaskCreate). */
128export function taskCreated(cards: readonly Card[], task: { id: string; subject: string; description?: string }, at: number): Card[] {
129 return addCard(cards, { title: task.subject, note: task.description ?? '', source: 'todo', ref: `task:${task.id}` }, at).cards
130}
131
132/** The board after Claude updated a task in its to-do list (TaskUpdate): its status, title or note; deleted, it goes. */
133export function taskUpdated(
134 cards: readonly Card[],
135 update: { taskId: string; status?: string; subject?: string; description?: string },
136): Card[] {
137 const card = cards.find(one => one.ref === `task:${update.taskId}`)
138 if (card === undefined) return [...cards]
139 if (update.status === 'deleted') return removeCard(cards, card.id)
140 const changed: Card = {
141 ...card,
142 ...(update.subject === undefined ? {} : { title: cleanTitle(update.subject) }),
143 ...(update.description === undefined ? {} : { note: clip(update.description.trim(), 1_000) }),
144 }
145 const column = update.status === undefined ? card.column : (STATUS_COLUMN[update.status] ?? card.column)
146 const replaced = cards.map(one => (one.id === card.id ? changed : one))
147
148 return column === card.column ? replaced : moveCard(replaced, card.id, column)
149}
150
151/** Markdown as plain text: emphasis, code marks and links reduced to their words. */
152function plainText(text: string): string {
153 return text
154 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
155 .replace(/(\*\*|__)(.+?)\1/g, '$2')
156 .replace(/`([^`]+)`/g, '$1')
157 .replace(/[::]\s*$/, '')
158 .trim()
159}
160
161// Headings that frame a plan rather than name a step in it.
162const FRAMING = /^(context|overview|summary|background|goal|goals|plan|approach|verification|testing|test plan|risks|notes|open questions|files|critical files)\b/i
163
164/**
165 * The steps of an approved plan, as cards: its numbered items, else its
166 * top-level bullets, else its section headings (not the ones that frame it,
167 * such as Context or Verification). At most 20.
168 */
169export function planSteps(plan: string): string[] {
170 const lines = plan.split('\n')
171 const pick = (pattern: RegExp) =>
172 lines
173 .map(line => line.match(pattern)?.[1])
174 .filter((step): step is string => step !== undefined)
175 .map(plainText)
176 .filter(step => step !== '')
177 const numbered = pick(/^ {0,3}\d+[.)]\s+(.+)$/)
178 const bullets = pick(/^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/)
179 const headings = pick(/^#{2,4}\s+(?:(?:step|phase)\s*\d+\s*[:.\-–]\s*)?(.+)$/i).filter(step => !FRAMING.test(step))
180 const steps = numbered.length >= 2 ? numbered : bullets.length >= 2 ? bullets : headings
181
182 return steps.slice(0, 20).map(cleanTitle)
183}
184
185/** What Work on it sends Claude. */
186export function workPrompt(card: Card): string {
187 return [`Work on this card from the task board: "${card.title}"`, ...(card.note === '' ? [] : ['', card.note])].join('\n')
188}
189
190/** The board as text, for the board tool's `list`: each column, each card with its id. */
191export function boardText(cards: readonly Card[]): string {
192 if (cards.length === 0) return 'The board is empty.'
193
194 return COLUMNS.map(column => {
195 const inColumn = cards.filter(card => card.column === column)
196 return [`${COLUMN_NAME[column]} (${inColumn.length})`, ...inColumn.map(card => `- [${card.id}] ${card.title}`)].join('\n')
197 }).join('\n\n')
198}
199
200/** What the board tool takes, as the model sends it: checked here, never trusted. */
201export type BoardToolInput = { action?: unknown; title?: unknown; note?: unknown; card?: unknown; column?: unknown }
202
203export const BOARD_TOOL_SCHEMA = {
204 type: 'object',
205 properties: {
206 action: { type: 'string', enum: ['list', 'add', 'move', 'remove'], description: 'What to do.' },
207 title: { type: 'string', description: 'add: the card, a short imperative ("Add an offline banner").' },
208 note: { type: 'string', description: 'add: optional detail: why, where, what done means.' },
209 card: { type: 'string', description: 'move, remove: the card id from list, or its exact title.' },
210 column: { type: 'string', enum: ['todo', 'doing', 'done'], description: 'add (default todo), move: the column.' },
211 },
212 required: ['action'],
213} as const
214
215export const BOARD_TOOL_DESCRIPTION = [
216 "The task board in the person's side pane: To do, Doing and Done columns of cards.",
217 'Use it when the person asks to put something on the board, move a card or mark one done, and to note follow-up work you notice but are not doing now.',
218 'Your own to-do list and approved plans already appear on the board by themselves: do not add their items again.',
219 'Actions: list (the cards and their ids), add (title, optional note and column), move (card, column), remove (card).',
220].join(' ')
221
222const isColumn = (value: unknown): value is Column => value === 'todo' || value === 'doing' || value === 'done'
223
224/** Finds a card by its id, else by its title (exact, ignoring case). */
225function findCard(cards: readonly Card[], key: string): Card | undefined {
226 const wanted = key.trim().toLowerCase()
227
228 return cards.find(card => card.id === key.trim()) ?? cards.find(card => card.title.toLowerCase() === wanted)
229}
230
231/** Runs one board tool call: the board after it, and what the model reads back. */
232export function runBoardTool(
233 cards: readonly Card[],
234 input: BoardToolInput,
235 at: number,
236): { cards: Card[]; text: string; isError?: true } {
237 const fail = (text: string) => ({ cards: [...cards], text, isError: true as const })
238 switch (input.action) {
239 case 'list':
240 return { cards: [...cards], text: boardText(cards) }
241 case 'add': {
242 if (typeof input.title !== 'string' || input.title.trim() === '') return fail('add needs a title.')
243 if (input.column !== undefined && !isColumn(input.column)) return fail('column is todo, doing or done.')
244 const before = cards.length
245 const added = addCard(
246 cards,
247 { title: input.title, note: typeof input.note === 'string' ? input.note : '', column: input.column ?? 'todo', source: 'claude' },
248 at,
249 )
250 return {
251 cards: added.cards,
252 text:
253 added.cards.length === before
254 ? `"${added.card.title}" is already on the board (${COLUMN_NAME[added.card.column]}, ${added.card.id}).`
255 : `Added "${added.card.title}" to ${COLUMN_NAME[added.card.column]} (${added.card.id}).`,
256 }
257 }
258 case 'move': {
259 if (typeof input.card !== 'string') return fail('move needs a card: its id or title.')
260 if (!isColumn(input.column)) return fail('move needs a column: todo, doing or done.')
261 const card = findCard(cards, input.card)
262 if (card === undefined) return fail(`No card "${input.card}". Use list to see the cards and their ids.`)
263 return { cards: moveCard(cards, card.id, input.column), text: `Moved "${card.title}" to ${COLUMN_NAME[input.column]}.` }
264 }
265 case 'remove': {
266 if (typeof input.card !== 'string') return fail('remove needs a card: its id or title.')
267 const card = findCard(cards, input.card)
268 if (card === undefined) return fail(`No card "${input.card}". Use list to see the cards and their ids.`)
269 return { cards: removeCard(cards, card.id), text: `Removed "${card.title}".` }
270 }
271 default:
272 return fail('action is list, add, move or remove.')
273 }
274}
275hooks/columns.tsx 250 lines1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3/** A card as the columns draw it: plain data, handed over by the board's render hook. */
4export type ColumnCard = { id: string; title: string; column: number; tag: string; isDone: boolean; isOpen: boolean; isBusy: boolean }
5
6export type ColumnsProps = { cards: ColumnCard[]; names: string[]; counts: number[]; dots: Array<string | null> }
7
8/** What the module sends the board: a card dropped on another column, or a card clicked. */
9export type ColumnsMessage = { type: 'move'; id: string; column: number } | { type: 'open'; id: string }
10
11/**
12 * A card being dragged: where the press was, where the pointer is now, and
13 * where on the card it was grabbed, so the ghost stays under the pointer as it was picked up.
14 */
15type Drag = { id: string; from: number; x: number; y: number; moved: boolean; over: number; atX: number; atY: number; grabX: number; grabY: number }
16type State = { drag: Drag | null }
17
18const ACCENT = 'claude'
19/** The dragged card's fill and text. */
20const GHOST_FILL = '#ffffff'
21const GHOST_INK = '#1f1f1f'
22/** Lines a card's title may take before it is cut. */
23const TITLE_LINES = 4
24
25/** `text` broken into lines of at most `width` characters, at spaces where it can; cut with an ellipsis past `max`. */
26export function wrap(text: string, width: number, max = TITLE_LINES): string[] {
27 const lines: string[] = []
28 let line = ''
29 for (const word of text.split(/\s+/).filter(Boolean)) {
30 let rest = word
31 while (rest.length > width) {
32 if (line !== '') {
33 lines.push(line)
34 line = ''
35 }
36 lines.push(rest.slice(0, width))
37 rest = rest.slice(width)
38 }
39 if (line === '') line = rest
40 else if (line.length + 1 + rest.length <= width) line = `${line} ${rest}`
41 else {
42 lines.push(line)
43 line = rest
44 }
45 }
46 if (line !== '') lines.push(line)
47 if (lines.length <= max) return lines
48 const kept = lines.slice(0, max)
49 const last = kept[max - 1] ?? ''
50 kept[max - 1] = `${last.slice(0, Math.max(0, width - 1))}…`
51
52 return kept
53}
54
55/** Where each card sits, in the region's cells: what the pointer is checked against. */
56export type Layout = {
57 columnWidth: number
58 cards: Array<{ id: string; column: number; top: number; bottom: number; lines: string[] }>
59}
60
61/**
62 * The columns' layout: three columns a cell apart, a header row, then each
63 * column's cards one under another, each its border, its title's lines and
64 * a footer row.
65 */
66export function layout(cards: readonly ColumnCard[], regionColumns: number): Layout {
67 const columnWidth = Math.max(12, Math.floor((Math.max(regionColumns, 38) - 2) / 3))
68 const inner = columnWidth - 4
69 const tops = [1, 1, 1]
70 const placed: Layout['cards'] = []
71 for (const card of cards) {
72 const lines = wrap(card.isDone ? `✓ ${card.title}` : card.title, inner)
73 const top = tops[card.column] ?? 1
74 const bottom = top + lines.length + 2 // the border's two rows and the footer, the title between
75 placed.push({ id: card.id, column: card.column, top, bottom, lines })
76 tops[card.column] = bottom + 2 // the blank row between cards
77 }
78
79 return { columnWidth, cards: placed }
80}
81
82/**
83 * How many rows the columns take as laid out: the header row, then the
84 * tallest column's cards, a blank row after each, or its one placeholder.
85 */
86export function columnsHeightFor(cards: readonly ColumnCard[], regionColumns: number): number {
87 const at = layout(cards, regionColumns)
88 const bottoms = [0, 1, 2].map(column => {
89 const last = at.cards.filter(card => card.column === column).at(-1)
90 return last === undefined ? 4 : last.bottom + 2
91 })
92
93 return Math.max(...bottoms)
94}
95
96/** The column under the pointer's column `x`. */
97export function columnAt(x: number, columnWidth: number): number {
98 return Math.max(0, Math.min(2, Math.floor(x / (columnWidth + 1))))
99}
100
101/** The card under the pointer, if any. */
102export function cardAt(at: Layout, x: number, y: number): Layout['cards'][number] | undefined {
103 const column = columnAt(x, at.columnWidth)
104
105 return at.cards.find(card => card.column === column && y >= card.top && y <= card.bottom)
106}
107
108/**
109 * The board's three columns as a `Client`: drawn here so the pointer can be
110 * read against where each card sits. Press a card and drag it onto another
111 * column to move it there; press and release without moving to open it.
112 */
113const Columns: ClientModule<ColumnsProps, State> = (props, surface) => {
114 const { Box, Text } = surface.elements
115 const drag = surface.state?.drag ?? null
116 const at = layout(props.cards, surface.columns)
117
118 surface.onPointer((event: ClientPointerEvent) => {
119 const current = surface.state?.drag ?? null
120 if (event.type === 'down' && event.button === 'left') {
121 const card = cardAt(at, event.x, event.y)
122 if (card !== undefined) {
123 const left = card.column * (at.columnWidth + 1)
124 surface.setState({
125 drag: {
126 id: card.id,
127 from: card.column,
128 x: event.x,
129 y: event.y,
130 moved: false,
131 over: card.column,
132 atX: event.x,
133 atY: event.y,
134 grabX: event.x - left,
135 grabY: event.y - card.top,
136 },
137 })
138 }
139 return
140 }
141 if (current === null) return
142 if (event.type === 'move') {
143 const moved = current.moved || Math.abs(event.x - current.x) + Math.abs(event.y - current.y) >= 2
144 const over = columnAt(event.x, at.columnWidth)
145 // Every move redraws while dragging: the ghost follows the pointer.
146 if (moved || moved !== current.moved) surface.setState({ drag: { ...current, moved, over, atX: event.x, atY: event.y } })
147 return
148 }
149 if (event.type === 'up') {
150 if (!current.moved) surface.post({ type: 'open', id: current.id })
151 else if (current.over !== current.from) surface.post({ type: 'move', id: current.id, column: current.over })
152 surface.setState({ drag: null })
153 return
154 }
155 if (event.type === 'leave' && current.moved) {
156 // Released outside the region: the card stays where it was.
157 surface.setState({ drag: null })
158 }
159 })
160
161 const isDragging = drag !== null && drag.moved
162 const carried = isDragging ? at.cards.find(card => card.id === drag.id) : undefined
163 const carriedCard = carried === undefined ? undefined : props.cards.find(card => card.id === carried.id)
164 return (
165 <Box flexDirection="row" columnGap={1}>
166 {isDragging && carried !== undefined && carriedCard !== undefined && (
167 // The ghost: the card under the pointer, held where it was grabbed. A white card with dark
168 // text in either theme: a mod's colors are fixed, so the text's is set as well as the fill.
169 <Box
170 key="ghost"
171 position="absolute"
172 // Kept inside the region, which clips what it draws: at its bottom edge, not cut.
173 top={Math.max(0, Math.min(drag.atY - drag.grabY, surface.rows - carried.lines.length - 3))}
174 left={Math.max(0, drag.atX - drag.grabX)}
175 width={at.columnWidth}
176 flexDirection="column"
177 borderStyle="bold"
178 borderColor={ACCENT}
179 backgroundColor={GHOST_FILL}
180 paddingX={1}
181 >
182 {carried.lines.map(line => (
183 <Text bold color={GHOST_INK} wrap="truncate-end">
184 {line}
185 </Text>
186 ))}
187 <Text color={ACCENT} wrap="truncate-end">
188 {carriedCard.tag === '' ? ' ' : carriedCard.tag}
189 </Text>
190 </Box>
191 )}
192 {[0, 1, 2].map(column => {
193 const isTarget = isDragging && drag.over === column && drag.from !== column
194 const dot = props.dots[column] ?? null
195 return (
196 <Box key={`column:${column}`} flexDirection="column" width={at.columnWidth}>
197 <Box flexDirection="row" columnGap={1}>
198 <Text {...(dot === null ? { dimColor: true } : { color: dot })}>●</Text>
199 <Text bold {...(isTarget ? { color: ACCENT } : {})}>
200 {props.names[column] ?? ''}
201 </Text>
202 <Text dimColor>{String(props.counts[column] ?? 0)}</Text>
203 </Box>
204 {at.cards
205 .filter(placed => placed.column === column)
206 .map(placed => {
207 const card = props.cards.find(one => one.id === placed.id)
208 if (card === undefined) return null
209 // The card picked up stays in place, dimmed; its ghost is what moves.
210 const isCarried = isDragging && drag.id === card.id
211 const isLit = !isCarried && (card.isOpen || card.isBusy)
212 return (
213 <Box
214 key={`card:${card.id}`}
215 flexDirection="column"
216 borderStyle="round"
217 {...(isLit ? { borderColor: ACCENT } : { borderDimColor: true })}
218 paddingX={1}
219 marginBottom={1}
220 >
221 {placed.lines.map(line => (
222 <Text wrap="truncate-end" dimColor={card.isDone || isCarried}>
223 {line}
224 </Text>
225 ))}
226 <Text dimColor wrap="truncate-end">
227 {card.tag === '' ? ' ' : card.tag}
228 </Text>
229 </Box>
230 )
231 })}
232 {isTarget && (
233 <Box borderStyle="round" borderColor={ACCENT} paddingX={1}>
234 <Text color={ACCENT}>Drop here</Text>
235 </Box>
236 )}
237 {(props.counts[column] ?? 0) === 0 && !isTarget && (
238 <Box borderStyle="round" borderDimColor paddingX={1}>
239 <Text dimColor>No cards</Text>
240 </Box>
241 )}
242 </Box>
243 )
244 })}
245 </Box>
246 )
247}
248
249export default Columns
250types/index.d.ts 32 lines1export type Column = 'todo' | 'doing' | 'done'
2
3/** Who put a card on the board: you, Claude's to-do list, an approved plan, or Claude through the board tool. */
4export type CardSource = 'you' | 'todo' | 'plan' | 'claude'
5
6export type Card = {
7 id: string
8 title: string
9 /** More about it, when whoever added it said more; else empty. */
10 note: string
11 column: Column
12 source: CardSource
13 /** What ties a card to Claude's to-do list: the item's text (TodoWrite) or its task id (TaskCreate). */
14 ref: string | null
15 createdAt: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'task-board': {
21 /** The session's cards, in the order they were added. */
22 cards: Card[]
23 /** The card whose details are open under the board. */
24 selected: string | null
25 /** The card being written: a new one (id null) or one being edited; null when the composer is closed. */
26 composer: { id: string | null; title: string; note: string; hasNote: boolean } | null
27 /** The card handed to Claude with Work on it, until Claude's turn on it ends. */
28 working: string | null
29 }
30 }
31}
32