SLOPSHOPPER

task-board

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

newpaneguardcommandtoasttool
★ 1v0.1.0MITupdated 2026-10-07MarcoCarnevali/claude-code-mods/task-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-board
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing on the board yet [ + New card ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Add a card with New card. Claude's to-do ⎿ Read 6 lines │ ┃ list and the plans you approve show up here ⏺ Update(src/auth.ts) │ ┃ by themselves, and Claude can add cards when ⎿ Added 2 lines, removed 1 line │ ┃ you ask it to put something on the board. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ task-board: Board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Board
Nothing on the board yet [ + New card ] 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 cards when you ask it to put something on the board.
README

task-board

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>

  • Claude's to-do list: the list Claude keeps while it works becomes cards, which move to Doing and Done as Claude goes. The first cards open the pane.
  • Approved plans: when you approve a plan, its steps (its numbered items, else its bullets or step headings) go to To do.
  • A board tool for Claude: in any chat, Claude can add, move and remove cards: ask it to "put that on the board", or let it note follow-ups it is not doing now.
  • Your own cards: + New card opens a composer on the title; Enter or Add card adds the card to To do. + Description swaps the title field for a description field (the title stays above it, ‹ Title goes back), whose Enter or Add card adds it too. ✕ cancels.
  • A card's details: Open shows its full text, where it came from, and buttons to move it a column left or right, Edit (its title and description), ▶ Work on it (hands it to Claude and moves it to Doing; Claude Code holds the prompt until Claude finishes what it is doing) and Delete.
  • Drag and drop: in the desktop app and the terminal, press a card and drag it onto another column: a white copy of it follows the pointer, the card stays dimmed where it was, and the column under the pointer shows Drop here. A click (press and release without moving) opens the card. The columns fill the pane, so there is room to drag below the cards. In VS Code and the mobile app, which cannot draw this, each card has Open and › (move it a column on) instead.

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

Install

/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install task-board@marco-mods
/reload-plugins
Source 4 files
hooks/register.tsx 521 lines
1import { 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}
521
hooks/lib.ts 275 lines
1import 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}
275
hooks/columns.tsx 250 lines
1import 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
250
types/index.d.ts 32 lines
1export 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