SLOPSHOPPER

board-view

The project's kanban BOARD.md in a pane: a column per section with counts, the progress figure, and each card's full text on a press. Reads the file itself…

newpanecommandtoasttimer
v0.1.0MITupdated 2026-10-24kengomatsuo/agent-skills/mods/board-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · board-view
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ No board in this project │ ┃ The pane shows docs/plans/**/BOARD.md under ⏺ Read(src/auth.ts) │ ┃ /work/app. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /kanban │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Board
No board in this project The pane shows docs/plans/**/BOARD.md under /work/app.
README

board-view

A Claude Code mod that shows a project's kanban board in a pane. On top: the board's title, its done/total figure and a progress bar. Below: a picker with one segment per section (Backlog, Ready, Doing, Review, Done) and its count, and the lanes themselves, side by side, each card showing its title over two lines, who holds it, its commit, how many notes it has and its id. Open on a card shows all of it: the full title, every field and every note.

It reads the board file and parses it inside the mod, so it never calls the model and spends no tokens. It never writes the file.

Install

In a terminal session:

claude plugin marketplace add https://github.com/kengomatsuo/agent-skills.git
claude plugin install board-view@kengomatsuo-skills

In the Desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with https://github.com/kengomatsuo/agent-skills.git, then add board-view.

Opening it

  • On its own: when a session starts in a project that has a board, the pane opens beside the conversation. A narrow window holds a pane it was not asked for; /kanban places it. Turn this off with the openOnStart option in /config.
  • /kanban shows the pane, or hides it when it is open. The command answers with nothing, so it adds no reply to the conversation.

A narrow pane shows as many lanes as fit. Drag the lanes sideways, use the arrow keys after a click on them, or click a segment in the picker to jump to that section. A mod's pane only scrolls up and down, so the lanes are a region of their own that reads the drag.

The pane checks the file every 4 seconds while it is open and redraws when it changed.

The board file

The mod reads the newest docs/plans/**/BOARD.md under the git root of the session's folder. A project with several boards gets a button for each above the lanes; a project with none shows "No board in this project".

# Release 2.0: board

## Backlog
- **API-12** Rate-limit the export endpoint · source: incident 2026-10-02

## Ready

## Doing (limit 1 per session)
- **UI-7** Empty state for the search page · claim: fixer 2026-10-20T09:00:00Z

## Review
- **DB-3** Index orders by customer · commit: 3b6785f · check: 41 tests pass · the migration ran on staging

## Done
  • A ## heading is a section; its words before any ( are the name, and what sits in the brackets shows beside it. A ## Handoff section is skipped.
  • A card is one line: - ID title, then any number of · parts. A part written name: value (one lowercase word before the colon) is a field; any other part is a note.
  • claim shows who holds the card, a 7-character commit shows on the card face, and the notes are counted.
  • Review and Done count as done in the figure.

Where it runs

Any project, on macOS, Linux, WSL and Windows: the mod uses only Claude Code's own file reads. In a Claude Code cloud session, add the marketplace and the plugin to the project's .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "kengomatsuo-skills": {
      "source": { "source": "git", "url": "https://github.com/kengomatsuo/agent-skills.git" }
    }
  },
  "enabledPlugins": { "board-view@kengomatsuo-skills": true }
}

Checks

claude plugin validate mods/board-view
claude plugin test mods/board-view

MIT, as the rest of this repository.

Source 4 files
hooks/register.tsx 336 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, Space, TextProps } from 'claude-code'
3
4import type { BoardViewState } from '../types'
5import { chipsFor, clip, decode, findCard, headerSvg, parseBoard, plain } from './board'
6import type { CardDetail, LaneData, LanesProps } from './lanes'
7import type { Board, Card, Column } from './board'
8
9const PANE = 'board'
10const PAGE = 30
11const POLL_MS = 4000
12/** A lane keeps this many cells; a narrow pane scrolls sideways. */
13const LANE = 34
14/** Cards a lane hands the lanes module; Client props stay under 100,000 characters. */
15const LANE_CARDS = 120
16/** Translucent greys read on light and dark alike. */
17const LANE_FILL = '#8080801f'
18const HAIRLINE = '#80808040'
19const RESCAN_MS = 60_000
20
21const view = atom({ plugin: 'board-view', key: 'view' } as const, { openId: null, shown: {}, chosen: null } as BoardViewState)
22const stamp = atom({ plugin: 'board-view', key: 'stamp' } as const, 0)
23
24const COLOR: Record<string, string> = {
25  backlog: 'inactive',
26  ready: 'suggestion',
27  doing: 'warning',
28  review: 'merged',
29  done: 'success',
30}
31const FIELD_LABEL: Record<string, string> = {
32  source: 'Source',
33  claim: 'Claimed by',
34  commit: 'Commit',
35  check: 'Checked',
36}
37
38type Loaded = { root: string; boards: string[]; path: string; mtimeMs: number; board: Board }
39type Status = { kind: 'none'; root: string } | { kind: 'failed'; reason: string } | ({ kind: 'ready' } & Loaded)
40
41let cache: Status | null = null
42let scannedAt = 0
43
44type Gaps = { xs: Space; sm: Space; md: Space }
45/** Gaps in cells; a graphical surface takes halves. */
46type Ui = ReturnType<EngineInterface['ui']['resolve']>
47type TextNode = TextProps & { children?: unknown }
48
49/** Text without the props this engine build still refuses. */
50const textFor = (ui: Ui) => (props: TextNode) => {
51  const { size: _size, mono: _mono, tabular: _tabular, weight, ...rest } = props
52  return ui.Text({ ...rest, ...(weight === 'semibold' || weight === 'bold' ? { bold: true } : {}) } as never)
53}
54
55const gapsFor = (surface: string): Gaps => (surface === 'terminal' ? { xs: 0, sm: 1, md: 1 } : { xs: 0.5, sm: 1, md: 2 })
56
57const parent = (dir: string) => dir.replace(/[\\/][^\\/]+[\\/]?$/, '')
58const join = (dir: string, name: string) => `${dir.replace(/[\\/]$/, '')}/${name}`
59const relative = (root: string, path: string) => (path.startsWith(root) ? path.slice(root.length + 1) : path)
60
61async function gitRoot($: EngineInterface, start: string): Promise<string> {
62  for (let dir = start; dir; ) {
63    if (await $.fs.exists(join(dir, '.git'))) return dir
64    const up = parent(dir)
65    if (!up || up === dir) break
66    dir = up
67  }
68  return start
69}
70
71async function findBoards($: EngineInterface, dir: string, depth: number): Promise<{ path: string; mtimeMs: number }[]> {
72  const entries = await $.fs.list(dir).catch(() => [])
73  const found: { path: string; mtimeMs: number }[] = []
74  for (const entry of entries) {
75    if (entry.kind === 'file' && entry.name === 'BOARD.md') found.push({ path: join(dir, entry.name), mtimeMs: entry.mtimeMs })
76    else if (entry.kind === 'dir' && depth > 0 && !entry.name.startsWith('.') && entry.name !== 'node_modules')
77      found.push(...(await findBoards($, join(dir, entry.name), depth - 1)))
78  }
79  return found
80}
81
82/** Reads the board when its file changed; answers whether the pane must redraw. */
83async function load($: EngineInterface): Promise<boolean> {
84  try {
85    if (cache?.kind === 'ready' && Date.now() - scannedAt < RESCAN_MS) {
86      const stat = await $.fs.stat(cache.path)
87      if (stat.mtimeMs === cache.mtimeMs) return false
88    }
89    scannedAt = Date.now()
90    const root = await gitRoot($, await $.session.cwd())
91    const found = (await findBoards($, join(root, 'docs/plans'), 4)).sort((a, b) => b.mtimeMs - a.mtimeMs)
92    if (!found.length) {
93      const changed = cache?.kind !== 'none'
94      cache = { kind: 'none', root }
95      return changed
96    }
97    const { chosen } = await read($, view)
98    const pick = found.find(f => f.path === chosen) ?? found[0]!
99    const stat = await $.fs.stat(pick.path)
100    const boards = found.map(f => f.path)
101    if (cache?.kind === 'ready' && cache.path === pick.path && cache.mtimeMs === stat.mtimeMs && cache.boards.join() === boards.join())
102      return false
103    const text = await $.fs.read(pick.path)
104    cache = { kind: 'ready', root, boards, path: pick.path, mtimeMs: stat.mtimeMs, board: parseBoard(text) }
105    return true
106  } catch (err) {
107    cache = { kind: 'failed', reason: String((err as Error)?.message ?? err) }
108    return true
109  }
110}
111
112async function refresh($: EngineInterface) {
113  if (await load($)) await update($, stamp, n => n + 1)
114}
115
116async function isOpen($: EngineInterface) {
117  return (await $.ui.panes()).some(p => p.id === PANE)
118}
119
120function lanesOf(board: Board): LaneData[] {
121  return board.columns.map(column => ({
122    key: column.key,
123    name: column.name,
124    color: COLOR[column.key] ?? 'inactive',
125    count: column.cards.length,
126    hint: column.hint,
127    cards: column.cards.slice(0, LANE_CARDS).map(card => ({
128      id: card.id,
129      title: clip(plain(card.title), 160),
130      meta: chipsFor(card).join('   '),
131      isDone: column.key === 'review' || column.key === 'done',
132    })),
133  }))
134}
135
136/** What the lanes region draws: the lanes, or one card's full text over them. */
137function lanesProps(board: Board, openId: string | null): LanesProps {
138  const found = openId ? findCard(board, openId) : null
139  const detail: CardDetail | null = found
140    ? {
141        id: found.card.id,
142        columnKey: found.column.key,
143        columnName: found.column.name,
144        title: decode(found.card.title),
145        fields: found.card.fields.map(f => ({ label: FIELD_LABEL[f.key] ?? f.key.charAt(0).toUpperCase() + f.key.slice(1), value: decode(f.value) })),
146        notes: found.card.notes.map(decode),
147      }
148    : null
149  return { lanes: lanesOf(board), minWidth: LANE, fill: LANE_FILL, hairline: HAIRLINE, card: 'composerSidebarBackground', detail }
150}
151
152async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
153  await read($, stamp)
154  const state = await read($, view)
155  if (!cache) await load($)
156  const ui = $.ui.resolve(e)
157  const { Box, Button } = ui
158  const Text = textFor(ui)
159  const width = Math.max(30, e.props.bodyColumns || 60)
160  const gap = gapsFor(e.surface)
161  const status = cache!
162
163  if (status.kind === 'failed')
164    return (
165      <Box flexDirection="column" gap={gap.xs}>
166        <Text weight="semibold">The board could not be read</Text>
167        <Text dimColor>{status.reason}</Text>
168      </Box>
169    )
170  if (status.kind === 'none')
171    return (
172      <Box flexDirection="column" gap={gap.xs}>
173        <Text weight="semibold">No board in this project</Text>
174        <Text dimColor>The pane shows docs/plans/**/BOARD.md under {status.root}.</Text>
175      </Box>
176    )
177
178  const { board } = status
179  const setView = (fn: (v: BoardViewState) => BoardViewState) => update($, view, fn)
180  const dot = (key: string) => <Text color={COLOR[key] ?? 'inactive'}>●</Text>
181
182  const opened = state.openId ? findCard(board, state.openId) : null
183  if (opened && !('Client' in ui)) return drawCard(ui, Text, gap, opened.card, opened.column, dot, () => setView(v => ({ ...v, openId: null })))
184
185  const W = Math.max(240, Math.min(1600, width * 8 - 8))
186  const figure = `${board.done}/${board.total}`
187  const header =
188    e.surface !== 'terminal' && 'Svg' in ui ? (
189      <ui.Svg source={headerSvg(W, board.title, figure, board.percent)} alt={`${board.title} ${figure} ${board.percent}%`} width={W} height={44} />
190    ) : (
191      <Box flexDirection="column">
192        <Text bold>
193          {board.title} <Text dimColor>{figure} {board.percent}%</Text>
194        </Text>
195        <Text>
196          <Text color="success">{'█'.repeat(Math.round((board.percent / 100) * (width - 2)))}</Text>
197          <Text dimColor>{'░'.repeat(width - 2 - Math.round((board.percent / 100) * (width - 2)))}</Text>
198        </Text>
199      </Box>
200    )
201  const boardPicker = status.boards.length > 1 && (
202    <Box flexDirection="row" flexWrap="wrap" gap={gap.sm}>
203      {status.boards.map(path => (
204        <Button
205          key={`pick-${path}`}
206          plain
207          dimColor={path !== status.path}
208          label={relative(status.root, path).replace(/^docs\/plans\//, '').replace(/\/BOARD\.md$/, '')}
209          onPress={async () => {
210            await setView(v => ({ ...v, chosen: path, openId: null }))
211            scannedAt = 0
212            await refresh($)
213          }}
214        />
215      ))}
216    </Box>
217  )
218
219  const lanes = lanesOf(board)
220
221  return (
222    <Box flexDirection="column" gap={gap.md}>
223      {header}
224      {boardPicker}
225      {'Client' in ui ? (
226        <ui.Client key="lanes" module="./lanes.tsx" props={lanesProps(board, state.openId)} />
227      ) : (
228        lanes.map(l => (
229          <Box key={`lane-${l.key}`} flexDirection="column">
230            <Text bold>{`${l.name} ${l.count}`}</Text>
231            {l.cards.slice(0, 10).map(c => (
232              <Button key={`card-${c.id}`} plain label={`${c.id} ${c.title}`} onPress={() => setView(v => ({ ...v, openId: c.id }))} />
233            ))}
234          </Box>
235        ))
236      )}
237    </Box>
238  )
239}
240
241function drawCard(
242  ui: Ui,
243  Text: (props: TextNode) => RenderElement,
244  gap: Gaps,
245  card: Card,
246  column: Column,
247  dot: (key: string) => RenderElement,
248  back: () => unknown,
249) {
250  const { Box, Button, Markdown } = ui
251  return (
252    <Box flexDirection="column" gap={gap.md}>
253      <Box flexDirection="row">
254        <Button key="back" label="‹ Board" plain dimColor onPress={() => void back()} />
255      </Box>
256      <Box flexDirection="column" gap={gap.xs}>
257        <Box flexDirection="row" gap={gap.xs} alignItems="center">
258          {dot(column.key)}
259          <Text dimColor>{column.name}</Text>
260        </Box>
261        <Text mono size="heading" weight="semibold">
262          {card.id}
263        </Text>
264        <Markdown text={decode(card.title)} />
265      </Box>
266      {card.fields.map((field, i) => (
267        <Box key={`field-${i}`} flexDirection="column" gap={gap.xs}>
268          <Text size="caption" dimColor>
269            {FIELD_LABEL[field.key] ?? field.key.charAt(0).toUpperCase() + field.key.slice(1)}
270          </Text>
271          <Markdown text={decode(field.value)} />
272        </Box>
273      ))}
274      {card.notes.length > 0 && (
275        <Box flexDirection="column" gap={gap.xs}>
276          <Text size="caption" dimColor>
277            Notes
278          </Text>
279          <Markdown text={card.notes.map(n => `- ${decode(n)}`).join('\n')} />
280        </Box>
281      )}
282    </Box>
283  )
284}
285
286async function toggle($: EngineInterface) {
287  if (await isOpen($)) return $.ui.close({ id: PANE })
288  await refresh($)
289  await $.ui.open({ id: PANE, title: 'Board', columns: 180 })
290}
291
292export const register: Register = (on, options) => {
293  on('session.start', async ($, e, next) => {
294    await $.command.register({ name: 'kanban', description: "Show or hide the project's kanban board" }).catch((err: unknown) => $.ui.toast(`/kanban not registered: ${err instanceof Error ? err.message : String(err)}`))
295    const started = await next(e)
296    await refresh($)
297    if (options.openOnStart !== false && cache?.kind === 'ready') void $.ui.open({ id: PANE, title: 'Board', columns: 180 })
298    $.clock.every(POLL_MS, () => {
299      void isOpen($).then(open => (open ? refresh($) : undefined))
300    })
301    return started
302  })
303
304  on('command.run', { command: 'kanban' }, async $ => {
305    await toggle($)
306    return {}
307  })
308
309  on('turn.complete', async ($, e, next) => {
310    const done = await next(e)
311    void refresh($)
312    return done
313  })
314
315  on('ui.message', { element: 'lanes' }, async ($, e) => {
316    const data = e.data as { open?: string; close?: boolean }
317    if (!cache) await load($)
318    if (cache?.kind !== 'ready') return {}
319    if (typeof data?.open === 'string' || data?.close) {
320      const openId = typeof data.open === 'string' ? data.open : null
321      await update($, view, v => ({ ...v, openId }))
322      return { props: lanesProps(cache.board, openId) }
323    }
324    return {}
325  })
326
327  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
328    try {
329      return await drawPane($, e)
330    } catch (err) {
331      const { Text } = $.ui.resolve(e)
332      return <Text dimColor>Board: {String((err as Error)?.message ?? err)}</Text>
333    }
334  })
335}
336
hooks/board.ts 123 lines
1/** A card as the board script writes it: `- **ID** text · field: value · note`. */
2export type Field = { key: string; value: string }
3export type Card = { id: string; title: string; fields: Field[]; notes: string[] }
4export type Column = { key: string; name: string; hint: string; cards: Card[] }
5export type Board = { title: string; columns: Column[]; done: number; total: number; percent: number }
6
7const ID = /^- \*\*([\w.-]+)\*\*\s?(.*)$/
8const STANDARD = ['backlog', 'ready', 'doing', 'review', 'done']
9const DONE_COLUMNS = ['review', 'done']
10const FIELD = /^([a-z][a-z-]{0,23}): ([\s\S]*)$/
11
12export const decode = (s: string): string => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
13
14/** One line of plain text: no markdown marks, no entities. */
15export const plain = (s: string): string =>
16  decode(s)
17    .replace(/\*\*([^*]+)\*\*/g, '$1')
18    .replace(/`([^`]*)`/g, '$1')
19    .replace(/\s+/g, ' ')
20    .trim()
21
22export const clip = (s: string, max: number): string =>
23  s.length <= max ? s : `${s.slice(0, Math.max(1, max - 1)).trimEnd()}…`
24
25export function parseCard(id: string, rest: string): Card {
26  const parts = rest.split(' · ')
27  const fields: Field[] = []
28  const notes: string[] = []
29  for (const part of parts.slice(1)) {
30    const text = part.trim()
31    const field = text.match(FIELD)
32    if (field) fields.push({ key: field[1]!, value: field[2]!.trim() })
33    else if (text) notes.push(text)
34  }
35  return { id, title: parts[0]!.trim(), fields, notes }
36}
37
38const columnKey = (heading: string) => heading.split(/[\s(]/)[0]!.toLowerCase()
39const titleCase = (key: string) => key.charAt(0).toUpperCase() + key.slice(1)
40
41export function parseBoard(text: string): Board {
42  let title = ''
43  const columns: Column[] = []
44  let column: Column | null = null
45  for (const line of text.split('\n')) {
46    if (!title && line.startsWith('# ')) title = line.slice(2).replace(/:\s*board\s*$/i, '').trim()
47    if (line.startsWith('## ')) {
48      const heading = line.slice(3).trim()
49      const key = columnKey(heading)
50      if (key === 'handoff') {
51        column = null
52        continue
53      }
54      column = columns.find(c => c.key === key) ?? null
55      if (!column) {
56        column = { key, name: heading.replace(/\s*\(.*\)\s*$/, ''), hint: heading.match(/\(([^)]*)\)/)?.[1] ?? '', cards: [] }
57        columns.push(column)
58      }
59      continue
60    }
61    const card = line.match(ID)
62    if (card && column) column.cards.push(parseCard(card[1]!, card[2]!))
63  }
64  for (const key of STANDARD)
65    if (!columns.some(c => c.key === key)) columns.push({ key, name: titleCase(key), hint: '', cards: [] })
66  const rank = (c: Column) => {
67    const i = STANDARD.indexOf(c.key)
68    return i < 0 ? STANDARD.length + columns.indexOf(c) : i
69  }
70  columns.sort((a, b) => rank(a) - rank(b))
71  const total = columns.reduce((n, c) => n + c.cards.length, 0)
72  const done = columns.filter(c => DONE_COLUMNS.includes(c.key)).reduce((n, c) => n + c.cards.length, 0)
73  return { title: title || 'Board', columns, done, total, percent: total ? Math.floor((done * 100) / total) : 0 }
74}
75
76export function findCard(board: Board, id: string): { card: Card; column: Column } | null {
77  for (const column of board.columns) {
78    const card = column.cards.find(c => c.id === id)
79    if (card) return { card, column }
80  }
81  return null
82}
83
84const FIELD_OF = (card: Card, key: string) => card.fields.find(f => f.key === key)?.value
85
86/** The card face's meta: who holds it, its commit, its notes. */
87export function chipsFor(card: Card): string[] {
88  const chips: string[] = []
89  const claim = FIELD_OF(card, 'claim')
90  if (claim) chips.push(claim.split(' ')[0]!)
91  const commit = FIELD_OF(card, 'commit')
92  if (commit && /^[0-9a-f]{7,}$/i.test(commit)) chips.push(commit.slice(0, 7))
93  if (card.notes.length) chips.push(`${card.notes.length} ${card.notes.length === 1 ? 'note' : 'notes'}`)
94  return chips.slice(0, 3)
95}
96
97const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
98const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
99const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
100export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
101
102/** Cuts `s` to `maxW` pixels of system text, with an ellipsis when cut. */
103export function fitText(s: string, size: number, maxW: number): string {
104  if (textWidth(s, size) <= maxW) return s
105  let out = ''
106  for (const ch of s) {
107    if (textWidth(out + ch + '…', size) > maxW) break
108    out += ch
109  }
110  return out + '…'
111}
112
113/** The board's title, figure and a thin bar, in savvy-progress's palette and dark theme. */
114export function headerSvg(W: number, title: string, figure: string, percent: number): string {
115  const right = `${figure}  ${percent}%`
116  const name = fitText(title, 14, Math.max(60, W - textWidth(right, 12.5) - 16))
117  const fill = Math.round((W * percent) / 100)
118  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="44" viewBox="0 0 ${W} 44"><style>.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}}</style>
119<text class="t" x="0" y="17" font-family="${FONT}" font-size="14" font-weight="600">${xml(name)}</text>
120<text class="m" x="${W}" y="17" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(right)}</text>
121<rect class="k" x="0" y="30" width="${W}" height="6" rx="3"/><rect x="0" y="30" width="${fill}" height="6" rx="3" fill="#5fbf8f"/></svg>`
122}
123
hooks/lanes.tsx 184 lines
1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3/** One lane as the hooks module hands it over: plain data only. */
4export type LaneCard = { id: string; title: string; meta: string; isDone: boolean }
5export type LaneData = { key: string; name: string; color: string; count: number; hint: string; cards: LaneCard[] }
6export type CardDetail = { id: string; columnKey: string; columnName: string; title: string; fields: { label: string; value: string }[]; notes: string[] }
7export type LanesProps = { lanes: LaneData[]; minWidth: number; fill: string; hairline: string; card: string; detail: CardDetail | null }
8type LanesState = { first: number; dragFrom: number | null; downAt: { x: number; y: number } | null; shown: Record<string, number> }
9
10const PAGE = 30
11const NBSP = '\u00a0'
12/** One line glyph per section, picked for equal size in the system font. */
13const GLYPH: Record<string, string> = { backlog: '□', ready: '→', doing: '↻', review: '⊙', done: '✓' }
14/** The picker's rows: a rounded segment is a border, its label and a border. */
15const PICKER_ROWS = 3
16
17const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, Math.max(1, max - 1)).trimEnd()}…`)
18
19/** Wraps words as the lane would and cuts at the last line, so no third line shows. */
20export function clipLines(text: string, perLine: number, lines: number): string {
21  const words = text.split(/\s+/).filter(Boolean)
22  const out: string[] = []
23  let line = ''
24  for (let i = 0; i < words.length; i++) {
25    const word = words[i]!
26    const next = line ? `${line} ${word}` : word
27    if (next.length <= perLine) {
28      line = next
29      continue
30    }
31    if (out.length === lines - 1) return [...out, clip(`${next} ${words.slice(i + 1).join(' ')}`.trim(), perLine)].join(' ')
32    if (line) out.push(line)
33    line = word.length > perLine ? clip(word, perLine) : word
34  }
35  return [...out, line].filter(Boolean).join(' ')
36}
37
38/** Lanes side by side; a drag or the arrow keys move them sideways a lane at a time. */
39const Lanes: ClientModule<LanesProps, LanesState> = (props, surface) => {
40  const { Box, Text, Button, Markdown } = surface.elements
41  const state = surface.state ?? { first: 0, dragFrom: null, downAt: null, shown: {} }
42  const lanes = props.lanes
43  /** Read live, so a drag after a resize uses the new width. */
44  const geometry = () => {
45    const columns = surface.columns || props.minWidth
46    const fits = Math.max(1, Math.floor((columns + 1) / (props.minWidth + 1)))
47    return { fits, last: Math.max(0, lanes.length - fits), laneW: Math.floor((columns + 1) / Math.min(fits, lanes.length || 1)) - 1 }
48  }
49  const { fits, last, laneW } = geometry()
50  const first = Math.min(state.first, last)
51  /* a bold line holds ~1.25 glyphs per cell */
52  const lineChars = Math.floor((laneW - 4) * 1.25)
53  const go = (to: number) => surface.setState({ ...state, first: Math.max(0, Math.min(last, to)), dragFrom: null, downAt: null })
54
55  if (surface.state === undefined) {
56    surface.onPointer((event: ClientPointerEvent) => {
57      const now = surface.state ?? state
58      const g = geometry()
59      const x = event.fine?.x ?? event.x
60      if (event.type === 'down') surface.setState({ ...now, dragFrom: x, downAt: { x: event.x, y: event.y } })
61      else if (event.type === 'up' && now.downAt && now.downAt.y < PICKER_ROWS && event.y < PICKER_ROWS && Math.abs(event.x - now.downAt.x) <= 1) {
62        const width = surface.columns || props.minWidth
63        const index = Math.max(0, Math.min(lanes.length - 1, Math.floor((event.x * lanes.length) / Math.max(1, width))))
64        surface.setState({ ...now, first: Math.max(0, Math.min(g.last, index)), dragFrom: null, downAt: null })
65      } else if (event.type === 'up' || event.type === 'leave') surface.setState({ ...now, dragFrom: null, downAt: null })
66      else if (event.type === 'move' && now.dragFrom !== null && Math.abs(x - now.dragFrom) >= (g.laneW + 1) / 2) {
67        const step = x < now.dragFrom ? 1 : -1
68        surface.setState({ ...now, first: Math.max(0, Math.min(g.last, now.first + step)), dragFrom: x })
69      }
70    })
71    surface.onKey(event => {
72      const now = surface.state ?? state
73      const g = geometry()
74      if (event.key === 'right') surface.setState({ ...now, first: Math.min(g.last, now.first + 1) })
75      if (event.key === 'left') surface.setState({ ...now, first: Math.max(0, now.first - 1) })
76    })
77    surface.setState(state)
78  }
79
80  if (props.detail) {
81    const d = props.detail
82    return (
83      <Box flexDirection="column" gap={1}>
84        <Button key="back" label="‹ Board" plain dimColor onPress={() => surface.post({ close: true })} />
85        <Text>
86          <Text dimColor>{GLYPH[d.columnKey] ?? '□'}</Text> <Text dimColor>{d.columnName}</Text>
87        </Text>
88        <Text bold>{d.id}</Text>
89        <Markdown text={d.title} />
90        {d.fields.map((f, i) => (
91          <Box key={`field-${i}`} flexDirection="column">
92            <Text dimColor>{f.label}</Text>
93            <Markdown text={f.value} />
94          </Box>
95        ))}
96        {d.notes.length ? (
97          <Box flexDirection="column">
98            <Text dimColor>Notes</Text>
99            <Markdown text={d.notes.map(n => `- ${n}`).join('\n')} />
100          </Box>
101        ) : null}
102      </Box>
103    )
104  }
105
106  const visible = lanes.slice(first, first + fits)
107  const card = (c: LaneCard) => (
108    <Box key={`box-${c.id}`} flexDirection="column" backgroundColor={props.card} borderStyle="round" borderColor={props.hairline} paddingX={1}>
109      <Text bold wrap="wrap">
110        {clipLines(c.title, lineChars, 2)}
111      </Text>
112      {c.meta ? <Text dimColor>{clip(c.meta, lineChars)}</Text> : null}
113      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
114        <Text dimColor>
115          {c.id}
116          {c.isDone ? <Text color="success"> ✓</Text> : ''}
117        </Text>
118        <Button key={`card-${c.id}`} label="Open" plain dimColor onPress={() => surface.post({ open: c.id })} />
119      </Box>
120    </Box>
121  )
122  const lane = (l: LaneData) => {
123    const shown = state.shown[l.key] ?? PAGE
124    const left = l.cards.length - shown
125    return (
126      <Box key={`lane-${l.key}`} flexDirection="column" gap={0.5} width={0} flexGrow={1} backgroundColor={props.fill} borderStyle="round" borderColor={props.fill} paddingX={1}>
127        <Text>
128          <Text dimColor>{GLYPH[l.key] ?? '□'}</Text> <Text bold>{l.name}</Text> <Text dimColor>{l.count}</Text>
129          {l.hint ? <Text dimColor>{`  ${l.hint}`}</Text> : ''}
130        </Text>
131        {l.cards.length === 0 ? <Text dimColor>None</Text> : l.cards.slice(0, shown).map(card)}
132        {left > 0 ? (
133          <Button
134            key={`more-${l.key}`}
135            label={`+${Math.min(PAGE, left)}`}
136            plain
137            dimColor
138            onPress={() => surface.setState({ ...state, shown: { ...state.shown, [l.key]: shown + PAGE } })}
139          />
140        ) : null}
141      </Box>
142    )
143  }
144
145  const segW = Math.floor(((surface.columns || props.minWidth) - (lanes.length - 1)) / Math.max(1, lanes.length))
146  const segment = (l: LaneData, i: number) => {
147    const isShown = i >= first && i < first + fits
148    const glyph = GLYPH[l.key] ?? '□'
149    const full = `${glyph}${NBSP}${l.name}${NBSP}${l.count}`
150    return (
151      <Box
152        key={`seg-${l.key}`}
153        width={0}
154        flexGrow={1}
155        justifyContent="center"
156        borderStyle="round"
157        borderColor={isShown ? props.fill : props.hairline}
158        backgroundColor={isShown ? props.fill : undefined}
159      >
160        <Button
161          key={`lane-pick-${l.key}`}
162          plain
163          dimColor={!isShown}
164          label={full.length + 4 <= segW ? full : `${glyph}${NBSP}${l.count}`}
165          onPress={() => go(i)}
166        />
167      </Box>
168    )
169  }
170
171  return (
172    <Box flexDirection="column" gap={1}>
173      <Box flexDirection="row" gap={1}>
174        {lanes.map(segment)}
175      </Box>
176      <Box flexDirection="row" gap={1} alignItems="flex-start">
177        {visible.map(lane)}
178      </Box>
179    </Box>
180  )
181}
182
183export default Lanes
184
types/index.d.ts 13 lines
1/** What the pane shows: the card opened, cards shown per column, the board picked. */
2export type BoardViewState = {
3  openId: string | null
4  shown: Record<string, number>
5  chosen: string | null
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'board-view': { view: BoardViewState; stamp: number }
11  }
12}
13