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…

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.
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.
/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 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
## 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.- 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.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 }
}
claude plugin validate mods/board-view
claude plugin test mods/board-view
MIT, as the rest of this repository.
hooks/register.tsx 336 lines1import { 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}
336hooks/board.ts 123 lines1/** 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(/</g, '<').replace(/>/g, '>').replace(/&/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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
123hooks/lanes.tsx 184 lines1import 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
184types/index.d.ts 13 lines1/** 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