Look at beads (bd issues) as a board, a ready frontier, a dependency graph, an epic tree and stats, in a pane.

A Claude Code mod that shows beads (bd issues) in a pane, eight ways.
In a terminal session:
/plugin install beadmod --marketplace lesliesrussell/beadmod
From a shell:
claude plugin install beadmod --marketplace lesliesrussell/beadmod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with lesliesrussell/beadmod, then Add plugin.
Needs the bd CLI on your PATH.
Run /beadview [board|ready|issues|graph|tree|activity|memory|stats] in a directory with a beads workspace.
| View | Key | Shows |
|---|---|---|
| Board | b | Status columns: Ready, In progress, Blocked, Waiting, Done (side by side when wide) |
| Ready | r | The claimable frontier, ranked by priority then by how much each bead unblocks |
| Issues | i | Flat list of every bead; l cycles a filter (all, ready, in progress, blocked, waiting) |
| Graph | g | Blocking dependencies as layers: layer 0 can start now, layer N waits on N-1 |
| Tree | t | Epics and their children with progress bars |
| Activity | a | Timeline from bead timestamps: created, started, updated, closed, grouped by day |
| Memory | m | Persistent memories from bd memories |
| Stats | s | Counts by status, type and priority, open epics, longest-running work |
Press a view's letter, or click its tab, to switch. c toggles closed beads, f refreshes, l cycles the Issues filter, Enter on a row opens its detail (parent, blockers, dependents). The pane refreshes after every turn and after any Bash call that runs bd; the status line carries a one-line summary.
Data comes from bd list --all --json, bd ready --json, bd blocked --json and bd memories --json.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. See LICENSE.
hooks/register.tsx 298 lines1// beadmod-cym
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Filter, View } from '../types'
6import {
7 FILTERS,
8 detail,
9 parseSnapshot,
10 summary,
11 viewActivity,
12 viewBoard,
13 viewGraph,
14 viewIssues,
15 viewMemory,
16 viewReady,
17 viewStats,
18 viewTree,
19} from './model'
20import type { Row, Tone, ViewOut } from './model'
21
22const PANE = 'beads'
23// beadmod-pe3
24const VIEWS: { id: View; key: string; label: string }[] = [
25 { id: 'board', key: 'b', label: 'Board' },
26 { id: 'ready', key: 'r', label: 'Ready' },
27 { id: 'issues', key: 'i', label: 'Issues' },
28 { id: 'graph', key: 'g', label: 'Graph' },
29 { id: 'tree', key: 't', label: 'Tree' },
30 { id: 'activity', key: 'a', label: 'Activity' },
31 { id: 'memory', key: 'm', label: 'Memory' },
32 { id: 'stats', key: 's', label: 'Stats' },
33]
34
35const view = atom({ plugin: 'beadmod', key: 'view' } as const, 'board' as View)
36const snapshot = atom({ plugin: 'beadmod', key: 'snap' } as const, null)
37const selected = atom({ plugin: 'beadmod', key: 'selected' } as const, null)
38const showClosed = atom({ plugin: 'beadmod', key: 'showClosed' } as const, false)
39// beadmod-93m
40const filter = atom({ plugin: 'beadmod', key: 'filter' } as const, 'all' as Filter)
41
42const isView = (s: string): s is View => VIEWS.some(v => v.id === s)
43
44const TONES: Record<Tone, { color?: string; dimColor?: boolean; weight?: 'bold' }> = {
45 plain: {},
46 dim: { dimColor: true },
47 ok: { color: 'success' },
48 warn: { color: 'warning' },
49 bad: { color: 'error' },
50 accent: { color: 'suggestion' },
51 head: { weight: 'bold' },
52}
53
54const bd = async ($: EngineInterface, argv: string[]) => {
55 const ran = await $.process.run(['bd', ...argv, '--json'], { timeoutMs: 20_000 })
56 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || ran.stdout.trim() || `bd exited ${ran.exitCode}`)
57
58 return ran.stdout
59}
60
61let pending: Promise<void> | null = null
62
63const refresh = ($: EngineInterface): Promise<void> => {
64 pending ??= (async () => {
65 try {
66 // beadmod-93m
67 const [list, ready, blocked, memories] = await Promise.all([
68 bd($, ['list', '--all', '--limit', '0']),
69 bd($, ['ready']),
70 bd($, ['blocked']),
71 bd($, ['memories']).catch(() => '{}'),
72 ])
73 const snap = parseSnapshot(list, ready, blocked, memories, await $.clock.now())
74 await update($, snapshot, () => snap)
75 $.ui.status(summary(snap))
76 } catch (err) {
77 const message = err instanceof Error ? err.message : String(err)
78 await update($, snapshot, () => ({
79 beads: [],
80 memories: [],
81 ready: [],
82 blocked: {},
83 at: 0,
84 error: message,
85 }))
86 $.ui.status(undefined)
87 }
88 })().finally(() => {
89 pending = null
90 })
91
92 return pending
93}
94
95export const register: Register = on => {
96 on('session.start', async ($, e, next) => {
97 await $.command.register({
98 name: 'beadview',
99 description: 'Look at beads: /beadview [board|ready|graph|tree|stats]',
100 argumentHint: '[board|ready|graph|tree|stats]',
101 })
102 void refresh($)
103
104 return next(e)
105 })
106
107 on('command.run', { command: 'beadview' }, async ($, e) => {
108 const arg = e.args.trim().toLowerCase()
109 if (arg !== '' && !isView(arg)) {
110 return { text: `Unknown view "${arg}". Views: ${VIEWS.map(v => v.id).join(', ')}.` }
111 }
112 if (isView(arg)) await update($, view, () => arg)
113 await $.ui.open({ id: PANE, title: 'Beads', focus: true, closeOnEscape: true, rows: 22 })
114 await refresh($)
115
116 return { text: 'Beads pane opened.' }
117 })
118
119 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
120 const ran = await next(e)
121 if (/(^|[\s;&|(])bd\s/.test(e.command)) void refresh($)
122
123 return ran
124 })
125
126 on('turn.complete', ($, e, next) => {
127 void refresh($)
128
129 return next(e)
130 })
131
132 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
133 const { Box, Button, Text } = $.ui.resolve(e)
134 const width = Math.max(30, e.props.bodyColumns)
135 const current = await read($, view)
136 const snap = await read($, snapshot)
137 const pick = await read($, selected)
138 const isClosedShown = await read($, showClosed)
139 const activeFilter = await read($, filter) // beadmod-93m
140
141 const draw = (row: Row, key: string) => {
142 const isPicked = row.id !== undefined && row.id === pick
143 const parts = row.parts.map(p => (
144 <Text {...TONES[p.tone ?? 'plain']} {...(isPicked ? { weight: 'bold' as const } : {})}>
145 {p.t}
146 </Text>
147 ))
148 if (row.id === undefined) return <Box key={key}>{parts}</Box>
149 const id = row.id
150
151 return (
152 <Button
153 key={key}
154 plain
155 onPress={() => update($, selected, cur => (cur === id ? null : id))}
156 >
157 {parts}
158 </Button>
159 )
160 }
161 const heading = (title: string, tone: Tone | undefined) => (
162 <Text {...TONES[tone ?? 'head']} weight="bold">
163 {title}
164 </Text>
165 )
166
167 const tabs = (
168 <Box gap={0}>
169 {VIEWS.map(v => (
170 // beadmod-760
171 <Button
172 key={`tab-${v.id}`}
173 hotkey={v.key}
174 variant={v.id === current ? 'primary' : 'secondary'}
175 onPress={() => update($, view, () => v.id)}
176 >
177 {v.key}
178 </Button>
179 ))}
180 <Button
181 key="closed"
182 hotkey="c"
183 plain
184 dimColor
185 onPress={() => update($, showClosed, n => !n)}
186 >
187 {isClosedShown ? 'c: hide closed' : 'c: show closed'}
188 </Button>
189 {/* beadmod-93m */}
190 {current === 'issues' && (
191 <Button
192 key="filter"
193 hotkey="l"
194 plain
195 dimColor
196 onPress={() =>
197 update($, filter, cur => FILTERS[(FILTERS.indexOf(cur) + 1) % FILTERS.length] ?? 'all')
198 }
199 >
200 l: filter
201 </Button>
202 )}
203 <Button key="refresh" hotkey="f" plain dimColor onPress={() => refresh($)}>
204 f: refresh
205 </Button>
206 </Box>
207 )
208
209 if (snap === null) {
210 return (
211 <Box flexDirection="column">
212 {tabs}
213 <Text dimColor>Loading beads…</Text>
214 </Box>
215 )
216 }
217 if (snap.error !== null) {
218 return (
219 <Box flexDirection="column">
220 {tabs}
221 <Text color="error">bd failed: {snap.error}</Text>
222 <Text dimColor>Run this from a directory with a beads workspace (bd init).</Text>
223 </Box>
224 )
225 }
226 if (snap.beads.length === 0) {
227 return (
228 <Box flexDirection="column">
229 {tabs}
230 <Text dimColor>No beads here yet. Create one: bd create "title" -t task -p 2</Text>
231 </Box>
232 )
233 }
234
235 const out: ViewOut =
236 current === 'board'
237 ? viewBoard(snap, width)
238 : current === 'ready'
239 ? viewReady(snap, width)
240 : current === 'graph'
241 ? viewGraph(snap, width, isClosedShown)
242 : current === 'tree'
243 ? viewTree(snap, width, isClosedShown)
244 : current === 'issues'
245 ? viewIssues(snap, width, isClosedShown, activeFilter)
246 : current === 'activity'
247 ? viewActivity(snap, width)
248 : current === 'memory'
249 ? viewMemory(snap, width)
250 : viewStats(snap, width)
251
252 const body =
253 out.layout === 'columns' ? (
254 <Box gap={2}>
255 {out.sections.map((s, i) => (
256 <Box
257 key={`col-${i}`}
258 flexDirection="column"
259 width={Math.floor((width - (out.sections.length - 1) * 2) / out.sections.length)}
260 >
261 {heading(s.title, s.tone)}
262 {s.rows.map((r, j) => draw(r, `${r.id ?? 'x'}-${i}-${j}`))}
263 </Box>
264 ))}
265 </Box>
266 ) : (
267 <Box flexDirection="column">
268 {out.sections.map((s, i) => (
269 <Box key={`sec-${i}`} flexDirection="column" marginBottom={1}>
270 {heading(s.title, s.tone)}
271 {s.rows.map((r, j) => draw(r, `${r.id ?? 'x'}-${i}-${j}`))}
272 </Box>
273 ))}
274 </Box>
275 )
276
277 const focus = pick === null ? [] : detail(snap, pick, width - 2)
278
279 return (
280 <Box flexDirection="column">
281 {tabs}
282 {body}
283 {out.note !== undefined && <Text dimColor>{out.note}</Text>}
284 {focus.length > 0 && (
285 <Box flexDirection="column" borderStyle="round" paddingX={1}>
286 {focus.map((r, j) => draw(r, `d-${j}`))}
287 </Box>
288 )}
289 {/* beadmod-pe3 */}
290 <Text dimColor>
291 {VIEWS.map(v => `${v.key} ${v.label.toLowerCase()}`).join(' · ')}
292 </Text>
293 <Text dimColor>Enter on a row for details · Esc to leave the pane</Text>
294 </Box>
295 )
296 })
297}
298hooks/model.ts 596 lines1// beadmod-cym
2import type { Bead, Filter, Memory, Snapshot } from '../types'
3
4export type Tone = 'plain' | 'dim' | 'ok' | 'warn' | 'bad' | 'accent' | 'head'
5export type Part = { t: string; tone?: Tone }
6export type Row = { id?: string; parts: Part[] }
7export type Section = { title: string; tone?: Tone; rows: Row[] }
8export type ViewOut = { layout: 'columns' | 'stack'; sections: Section[]; note?: string }
9
10type State = 'ready' | 'in_progress' | 'blocked' | 'waiting' | 'closed'
11
12const BLOCKING = ['blocks', 'conditional-blocks', 'waits-for']
13const ICON: Record<State, string> = {
14 ready: '○',
15 in_progress: '◐',
16 blocked: '●',
17 waiting: '❄',
18 closed: '✓',
19}
20const TONE: Record<State, Tone> = {
21 ready: 'ok',
22 in_progress: 'accent',
23 blocked: 'bad',
24 waiting: 'warn',
25 closed: 'dim',
26}
27const DONE_LIMIT = 12
28const DAY = 86_400_000
29
30const asText = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
31
32const parseJson = (text: string): unknown => {
33 try {
34 return JSON.parse(text)
35 } catch {
36 return null
37 }
38}
39
40const toBead = (raw: Record<string, unknown>): Bead | null => {
41 const id = asText(raw.id)
42 if (id === null) return null
43 const deps = (Array.isArray(raw.dependencies) ? raw.dependencies : []).flatMap(d => {
44 const dep = d as Record<string, unknown>
45 const to = asText(dep.depends_on_id) ?? asText(dep.id)
46 const type = asText(dep.type) ?? asText(dep.dependency_type)
47 return to !== null && type !== null ? [{ to, type }] : []
48 })
49 const parent = asText(raw.parent) ?? deps.find(d => d.type === 'parent-child')?.to ?? null
50
51 return {
52 id,
53 title: asText(raw.title) ?? id,
54 status: asText(raw.status) ?? 'open',
55 priority: typeof raw.priority === 'number' ? raw.priority : 2,
56 type: asText(raw.issue_type) ?? 'task',
57 assignee: asText(raw.assignee),
58 parent,
59 description: asText(raw.description),
60 createdAt: asText(raw.created_at),
61 updatedAt: asText(raw.updated_at),
62 closedAt: asText(raw.closed_at), // beadmod-93m
63 deps,
64 }
65}
66
67// beadmod-93m
68export const parseMemories = (memText: string): Memory[] => {
69 const raw = parseJson(memText)
70 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return []
71
72 return Object.entries(raw as Record<string, unknown>)
73 .filter(([k, v]) => k !== 'schema_version' && typeof v === 'string')
74 .map(([key, text]) => ({ key, text: text as string }))
75 .sort((a, b) => a.key.localeCompare(b.key))
76}
77
78export const parseSnapshot = (
79 listText: string,
80 readyText: string,
81 blockedText: string,
82 memText: string,
83 now: number,
84): Snapshot => {
85 const list = parseJson(listText)
86 if (!Array.isArray(list)) {
87 return {
88 beads: [],
89 memories: [],
90 ready: [],
91 blocked: {},
92 at: now,
93 error: 'bd list gave no JSON array',
94 }
95 }
96 const beads = list.flatMap(r => {
97 const b = toBead(r as Record<string, unknown>)
98 return b === null ? [] : [b]
99 })
100 const readyRaw = parseJson(readyText)
101 const ready = (Array.isArray(readyRaw) ? readyRaw : []).flatMap(r => {
102 const id = asText((r as Record<string, unknown>).id)
103 return id === null ? [] : [id]
104 })
105 const blockedRaw = parseJson(blockedText)
106 const blocked: Record<string, string[]> = {}
107 for (const r of Array.isArray(blockedRaw) ? blockedRaw : []) {
108 const rec = r as Record<string, unknown>
109 const id = asText(rec.id)
110 if (id !== null) {
111 blocked[id] = (Array.isArray(rec.blocked_by) ? rec.blocked_by : []).filter(
112 (x): x is string => typeof x === 'string',
113 )
114 }
115 }
116
117 return { beads, memories: parseMemories(memText), ready, blocked, at: now, error: null }
118}
119
120export const stateOf = (snap: Snapshot, b: Bead): State => {
121 if (b.status === 'closed') return 'closed'
122 if (b.status === 'in_progress') return 'in_progress'
123 if (b.id in snap.blocked || b.status === 'blocked') return 'blocked'
124 if (snap.ready.includes(b.id)) return 'ready'
125 return 'waiting'
126}
127
128export const summary = (snap: Snapshot): string => {
129 const counts = { ready: 0, in_progress: 0, blocked: 0 }
130 for (const b of snap.beads) {
131 const s = stateOf(snap, b)
132 if (s === 'ready' || s === 'in_progress' || s === 'blocked') counts[s] += 1
133 }
134
135 return `beads: ${counts.ready} ready · ${counts.in_progress} wip · ${counts.blocked} blocked`
136}
137
138const byId = (snap: Snapshot): Map<string, Bead> => new Map(snap.beads.map(b => [b.id, b]))
139
140const blockersOf = (b: Bead): string[] =>
141 b.deps.filter(d => BLOCKING.includes(d.type)).map(d => d.to)
142
143const text = (r: Row): string => r.parts.map(p => p.t).join('')
144
145export const clip = (parts: Part[], width: number): Part[] => {
146 const out: Part[] = []
147 let left = Math.max(4, width)
148 for (const p of parts) {
149 if (p.t.length <= left) {
150 out.push(p)
151 left -= p.t.length
152 } else {
153 out.push({ ...p, t: `${p.t.slice(0, Math.max(0, left - 1))}…` })
154 return out
155 }
156 }
157
158 return out
159}
160
161const prio = (b: Bead): Part => ({
162 t: `P${b.priority} `,
163 tone: b.priority === 0 ? 'bad' : b.priority === 1 ? 'warn' : 'dim',
164})
165
166const kind = (b: Bead): Part[] => (b.type === 'task' ? [] : [{ t: `${b.type} `, tone: 'dim' }])
167
168const beadRow = (
169 snap: Snapshot,
170 b: Bead,
171 width: number,
172 opts: { prefix?: string; extra?: Part[]; idLast?: boolean } = {},
173): Row => {
174 const s = stateOf(snap, b)
175 const head: Part[] = [
176 ...(opts.prefix ? [{ t: opts.prefix, tone: 'dim' as const }] : []),
177 { t: `${ICON[s]} `, tone: TONE[s] },
178 ]
179 const id: Part = { t: opts.idLast ? ` ${b.id}` : `${b.id} `, tone: 'dim' }
180 const body: Part[] = [prio(b), ...kind(b), { t: b.title, tone: s === 'closed' ? 'dim' : 'plain' }]
181 const parts = opts.idLast
182 ? [...head, ...body, id, ...(opts.extra ?? [])]
183 : [...head, id, ...body, ...(opts.extra ?? [])]
184
185 return { id: b.id, parts: clip(parts, width) }
186}
187
188const order = (a: Bead, b: Bead): number =>
189 a.priority - b.priority || (a.createdAt ?? '').localeCompare(b.createdAt ?? '') || a.id.localeCompare(b.id)
190
191const unblocks = (snap: Snapshot): Map<string, number> => {
192 const n = new Map<string, number>()
193 for (const b of snap.beads) {
194 if (b.status === 'closed') continue
195 for (const to of blockersOf(b)) n.set(to, (n.get(to) ?? 0) + 1)
196 }
197
198 return n
199}
200
201export const viewBoard = (snap: Snapshot, width: number): ViewOut => {
202 const cols: { title: string; tone: Tone; keep: (s: State) => boolean }[] = [
203 { title: 'Ready', tone: 'ok', keep: s => s === 'ready' },
204 { title: 'In progress', tone: 'accent', keep: s => s === 'in_progress' },
205 { title: 'Blocked', tone: 'bad', keep: s => s === 'blocked' },
206 { title: 'Waiting', tone: 'warn', keep: s => s === 'waiting' },
207 { title: 'Done', tone: 'dim', keep: s => s === 'closed' },
208 ]
209 const used = cols.filter(
210 c => c.title !== 'Waiting' || snap.beads.some(b => c.keep(stateOf(snap, b))),
211 )
212 const isColumns = width >= 90
213 const colWidth = isColumns ? Math.floor((width - (used.length - 1) * 2) / used.length) : width
214 const sections = used.map(c => {
215 const all = snap.beads.filter(b => c.keep(stateOf(snap, b)))
216 const sorted =
217 c.title === 'Done'
218 ? [...all].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
219 : [...all].sort(order)
220 const shown = c.title === 'Done' ? sorted.slice(0, DONE_LIMIT) : sorted
221 const rows = shown.map(b => beadRow(snap, b, colWidth, { idLast: isColumns }))
222 if (shown.length < all.length) {
223 rows.push({ parts: [{ t: `… ${all.length - shown.length} more`, tone: 'dim' }] })
224 }
225
226 return { title: `${c.title} (${all.length})`, tone: c.tone, rows }
227 })
228
229 return { layout: isColumns ? 'columns' : 'stack', sections }
230}
231
232export const viewReady = (snap: Snapshot, width: number): ViewOut => {
233 const gain = unblocks(snap)
234 const lookup = byId(snap)
235 const ready = snap.beads
236 .filter(b => stateOf(snap, b) === 'ready')
237 .sort((a, b) => a.priority - b.priority || (gain.get(b.id) ?? 0) - (gain.get(a.id) ?? 0) || order(a, b))
238 const rows = ready.map(b => {
239 const n = gain.get(b.id) ?? 0
240 const extra: Part[] = n > 0 ? [{ t: ` unblocks ${n}`, tone: 'accent' }] : []
241 const parent = b.parent === null ? undefined : lookup.get(b.parent)
242 if (parent !== undefined) extra.push({ t: ` in ${parent.id}`, tone: 'dim' })
243
244 return beadRow(snap, b, width, { extra })
245 })
246
247 return {
248 layout: 'stack',
249 sections: [{ title: `Ready frontier (${ready.length})`, tone: 'ok', rows }],
250 note: 'Open, no open blockers. Ranked by priority, then by how much each unblocks. Claim: bd update <id> --claim',
251 }
252}
253
254export const viewGraph = (snap: Snapshot, width: number, showClosed: boolean): ViewOut => {
255 const lookup = byId(snap)
256 const alive = (b: Bead): boolean => showClosed || b.status !== 'closed'
257 const live = new Set(snap.beads.filter(alive).map(b => b.id))
258 const connected = new Set<string>()
259 for (const b of snap.beads) {
260 if (!live.has(b.id)) continue
261 for (const to of blockersOf(b)) {
262 if (live.has(to)) {
263 connected.add(b.id)
264 connected.add(to)
265 }
266 }
267 }
268 const layer = new Map<string, number>()
269 const walking = new Set<string>()
270 const depth = (id: string): number => {
271 const known = layer.get(id)
272 if (known !== undefined) return known
273 if (walking.has(id)) return 0
274 walking.add(id)
275 const b = lookup.get(id)
276 const above = (b === undefined ? [] : blockersOf(b)).filter(x => connected.has(x))
277 const n = above.length === 0 ? 0 : 1 + Math.max(...above.map(depth))
278 walking.delete(id)
279 layer.set(id, n)
280
281 return n
282 }
283 const nodes = snap.beads.filter(b => connected.has(b.id))
284 for (const b of nodes) depth(b.id)
285 const top = nodes.reduce((m, b) => Math.max(m, layer.get(b.id) ?? 0), 0)
286 const sections: Section[] = []
287 for (let i = 0; i <= top && nodes.length > 0; i += 1) {
288 const here = nodes.filter(b => layer.get(b.id) === i).sort(order)
289 const rows = here.map(b => {
290 const from = blockersOf(b).filter(x => connected.has(x))
291 const extra: Part[] = from.length > 0 ? [{ t: ` ← ${from.join(', ')}`, tone: 'dim' }] : []
292
293 return beadRow(snap, b, width, { extra })
294 })
295 sections.push({
296 title: i === 0 ? 'Layer 0 · no blockers, start here' : `Layer ${i} · after layer ${i - 1}`,
297 tone: i === 0 ? 'ok' : 'plain',
298 rows,
299 })
300 }
301 const loose = snap.beads.filter(b => alive(b) && !connected.has(b.id)).length
302
303 return {
304 layout: 'stack',
305 sections,
306 note:
307 nodes.length === 0
308 ? 'No blocking dependencies yet. Add one: bd dep add <blocked> <blocker>'
309 : `${loose} bead${loose === 1 ? '' : 's'} with no blocking edges not shown.`,
310 }
311}
312
313const bar = (done: number, total: number, cells = 8): string => {
314 const full = total === 0 ? 0 : Math.round((done / total) * cells)
315
316 return `${'█'.repeat(full)}${'░'.repeat(cells - full)}`
317}
318
319export const viewTree = (snap: Snapshot, width: number, showClosed: boolean): ViewOut => {
320 const lookup = byId(snap)
321 const kids = new Map<string, Bead[]>()
322 const roots: Bead[] = []
323 for (const b of snap.beads) {
324 if (b.parent !== null && lookup.has(b.parent)) {
325 kids.set(b.parent, [...(kids.get(b.parent) ?? []), b])
326 } else {
327 roots.push(b)
328 }
329 }
330 const below = (id: string, seen = new Set<string>()): Bead[] => {
331 if (seen.has(id)) return []
332 seen.add(id)
333
334 return (kids.get(id) ?? []).flatMap(k => [k, ...below(k.id, seen)])
335 }
336 const rows: Row[] = []
337 const walk = (b: Bead, prefix: string, isLast: boolean, isRoot: boolean, seen: Set<string>): void => {
338 if (seen.has(b.id)) return
339 seen.add(b.id)
340 const all = below(b.id)
341 const done = all.filter(x => x.status === 'closed').length
342 const extra: Part[] =
343 all.length > 0 ? [{ t: ` [${bar(done, all.length)}] ${done}/${all.length}`, tone: 'accent' }] : []
344 const lead = isRoot ? '' : `${prefix}${isLast ? '└─ ' : '├─ '}`
345 rows.push(beadRow(snap, b, width, { prefix: lead, extra }))
346 const shown = (kids.get(b.id) ?? []).filter(k => showClosed || k.status !== 'closed').sort(order)
347 const next = isRoot ? '' : `${prefix}${isLast ? ' ' : '│ '}`
348 shown.forEach((k, i) => walk(k, next, i === shown.length - 1, false, seen))
349 }
350 const seen = new Set<string>()
351 const parents = roots.filter(r => (kids.get(r.id) ?? []).length > 0).sort(order)
352 for (const r of parents) walk(r, '', true, true, seen)
353 const loneAll = roots.filter(r => !(kids.get(r.id) ?? []).length)
354 const lone = loneAll.filter(r => showClosed || r.status !== 'closed').sort(order)
355 const sections: Section[] = []
356 if (rows.length > 0) sections.push({ title: `Hierarchy (${parents.length})`, tone: 'head', rows })
357 if (lone.length > 0) {
358 sections.push({
359 title: `No parent or children (${lone.length})`,
360 tone: 'dim',
361 rows: lone.map(b => beadRow(snap, b, width)),
362 })
363 }
364
365 return {
366 layout: 'stack',
367 sections,
368 note: parents.length === 0 ? 'No epics yet. Group work: bd create "..." --parent <epic>' : undefined,
369 }
370}
371
372const count = <T>(items: T[], key: (x: T) => string): [string, number][] => {
373 const m = new Map<string, number>()
374 for (const x of items) m.set(key(x), (m.get(key(x)) ?? 0) + 1)
375
376 return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
377}
378
379export const viewStats = (snap: Snapshot, width: number): ViewOut => {
380 const total = snap.beads.length
381 const states = count(snap.beads, b => stateOf(snap, b))
382 const max = Math.max(1, ...states.map(s => s[1]))
383 const line = (label: string, n: number, tone: Tone, of = max): Row => ({
384 parts: [
385 { t: label.padEnd(13), tone: 'plain' },
386 { t: bar(n, of, 20), tone },
387 { t: ` ${n}`, tone: 'dim' },
388 ],
389 })
390 const label: Record<string, string> = {
391 ready: 'ready',
392 in_progress: 'in progress',
393 blocked: 'blocked',
394 waiting: 'waiting',
395 closed: 'closed',
396 }
397 const stateRows = (['ready', 'in_progress', 'blocked', 'waiting', 'closed'] as State[])
398 .map(s => [s, states.find(x => x[0] === s)?.[1] ?? 0] as const)
399 .filter(([, n]) => n > 0)
400 .map(([s, n]) => line(label[s] ?? s, n, TONE[s]))
401 const types = count(snap.beads, b => b.type)
402 const prios = count(snap.beads, b => `P${b.priority}`).sort((a, b) => a[0].localeCompare(b[0]))
403 const epics = snap.beads.filter(b => b.type === 'epic' && b.status !== 'closed')
404 const progress = epics.map(e => {
405 const subs = snap.beads.filter(b => b.parent === e.id)
406 const done = subs.filter(b => b.status === 'closed').length
407
408 return {
409 parts: clip(
410 [
411 { t: `${bar(done, subs.length)} `, tone: 'accent' as const },
412 { t: `${done}/${subs.length} `, tone: 'dim' as const },
413 { t: e.title },
414 ],
415 width,
416 ),
417 }
418 })
419 const wip = snap.beads
420 .filter(b => b.status === 'in_progress')
421 .sort((a, b) => (a.updatedAt ?? '').localeCompare(b.updatedAt ?? ''))
422 .slice(0, 5)
423 .map(b => {
424 const age = b.updatedAt === null ? 0 : Math.floor((snap.at - Date.parse(b.updatedAt)) / DAY)
425 const idle: Part[] = [{ t: ` idle ${Math.max(0, age)}d`, tone: age >= 3 ? 'warn' : 'dim' }]
426
427 return beadRow(snap, b, width, { extra: idle })
428 })
429 const sections: Section[] = [
430 { title: `Beads (${total})`, tone: 'head', rows: stateRows },
431 { title: 'By type', tone: 'head', rows: types.map(([k, n]) => line(k, n, 'plain', total)) },
432 { title: 'By priority', tone: 'head', rows: prios.map(([k, n]) => line(k, n, 'plain', total)) },
433 ]
434 if (progress.length > 0) sections.push({ title: 'Open epics', tone: 'head', rows: progress })
435 if (wip.length > 0) sections.push({ title: 'Longest in progress', tone: 'head', rows: wip })
436
437 return { layout: 'stack', sections }
438}
439
440export const detail = (snap: Snapshot, id: string, width: number): Row[] => {
441 const lookup = byId(snap)
442 const b = lookup.get(id)
443 if (b === undefined) return []
444 const s = stateOf(snap, b)
445 const rows: Row[] = [
446 {
447 parts: clip(
448 [
449 { t: `${ICON[s]} `, tone: TONE[s] },
450 { t: `${b.id} `, tone: 'head' },
451 { t: `${s.replace('_', ' ')} · P${b.priority} · ${b.type}`, tone: 'dim' },
452 ...(b.assignee === null ? [] : [{ t: ` · ${b.assignee}`, tone: 'dim' as const }]),
453 ],
454 width,
455 ),
456 },
457 { parts: clip([{ t: b.title, tone: 'head' }], width) },
458 ]
459 const link = (label: string, other: Bead): Row => ({
460 id: other.id,
461 parts: clip(
462 [
463 { t: `${label} `, tone: 'dim' },
464 { t: `${ICON[stateOf(snap, other)]} ${other.id} `, tone: TONE[stateOf(snap, other)] },
465 { t: other.title },
466 ],
467 width,
468 ),
469 })
470 if (b.parent !== null) {
471 const p = lookup.get(b.parent)
472 if (p !== undefined) rows.push(link('parent ', p))
473 }
474 for (const to of blockersOf(b)) {
475 const o = lookup.get(to)
476 if (o !== undefined) rows.push(link('blocked by', o))
477 }
478 for (const o of snap.beads.filter(x => blockersOf(x).includes(b.id))) rows.push(link('blocks ', o))
479 const lines = (b.description ?? '').split('\n').filter(l => l.trim() !== '')
480 for (const l of lines.slice(0, 5)) rows.push({ parts: clip([{ t: l, tone: 'dim' }], width) })
481 if (lines.length > 5) rows.push({ parts: [{ t: `… ${lines.length - 5} more lines (bd show ${b.id})`, tone: 'dim' }] })
482
483 return rows
484}
485
486export const plain = (out: ViewOut): string[] =>
487 out.sections.flatMap(s => [s.title, ...s.rows.map(text)])
488
489// beadmod-93m
490export const FILTERS: Filter[] = ['all', 'ready', 'in_progress', 'blocked', 'waiting']
491
492export const viewIssues = (
493 snap: Snapshot,
494 width: number,
495 showClosed: boolean,
496 filter: Filter,
497): ViewOut => {
498 const shown = snap.beads
499 .filter(b => (filter === 'all' ? showClosed || b.status !== 'closed' : stateOf(snap, b) === filter))
500 .sort(order)
501 const rows = shown.map(b => {
502 const extra: Part[] = b.assignee === null ? [] : [{ t: ` @${b.assignee}`, tone: 'dim' }]
503
504 return beadRow(snap, b, width, { extra })
505 })
506 const label = filter === 'all' ? 'all' : filter.replace('_', ' ')
507
508 return {
509 layout: 'stack',
510 sections: [{ title: `Issues (${shown.length}) · ${label}`, tone: 'head', rows }],
511 note: 'l cycles the filter: all, ready, in progress, blocked, waiting.',
512 }
513}
514
515const pad2 = (n: number): string => String(n).padStart(2, '0')
516const hhmm = (ms: number): string => `${pad2(new Date(ms).getHours())}:${pad2(new Date(ms).getMinutes())}`
517const dayOf = (ms: number): string => {
518 const d = new Date(ms)
519
520 return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
521}
522
523type Event = { ms: number; verb: string; tone: Tone; bead: Bead }
524
525export const viewActivity = (snap: Snapshot, width: number): ViewOut => {
526 const events: Event[] = []
527 for (const b of snap.beads) {
528 const made = Date.parse(b.createdAt ?? '')
529 const touched = Date.parse(b.updatedAt ?? '')
530 const closed = Date.parse(b.closedAt ?? b.updatedAt ?? '')
531 if (!Number.isNaN(made)) events.push({ ms: made, verb: 'created', tone: 'accent', bead: b })
532 if (b.status === 'closed' && !Number.isNaN(closed)) {
533 events.push({ ms: closed, verb: 'closed', tone: 'ok', bead: b })
534 } else if (!Number.isNaN(touched) && (Number.isNaN(made) || touched - made > 2000)) {
535 const started = b.status === 'in_progress'
536 events.push({
537 ms: touched,
538 verb: started ? 'started' : 'updated',
539 tone: started ? 'warn' : 'dim',
540 bead: b,
541 })
542 }
543 }
544 events.sort((a, b) => b.ms - a.ms || a.bead.id.localeCompare(b.bead.id))
545 const today = dayOf(snap.at)
546 const yesterday = dayOf(snap.at - DAY)
547 const sections: Section[] = []
548 for (const ev of events.slice(0, 80)) {
549 const day = dayOf(ev.ms)
550 const title = day === today ? 'Today' : day === yesterday ? 'Yesterday' : day
551 let sec = sections[sections.length - 1]
552 if (sec === undefined || sec.title !== title) {
553 sec = { title, tone: 'head', rows: [] }
554 sections.push(sec)
555 }
556 sec.rows.push({
557 id: ev.bead.id,
558 parts: clip(
559 [
560 { t: `${hhmm(ev.ms)} `, tone: 'dim' },
561 { t: ev.verb.padEnd(8), tone: ev.tone },
562 { t: `${ev.bead.id} `, tone: 'dim' },
563 { t: ev.bead.title, tone: ev.bead.status === 'closed' ? 'dim' : 'plain' },
564 ],
565 width,
566 ),
567 })
568 }
569
570 return {
571 layout: 'stack',
572 sections,
573 note: 'Built from bead timestamps: created, started, updated and closed.',
574 }
575}
576
577export const viewMemory = (snap: Snapshot, width: number): ViewOut => {
578 const rows = snap.memories.flatMap(m => {
579 const room = Math.max(10, width - 2)
580 const chunks: string[] = []
581 for (let i = 0; i < m.text.length && chunks.length < 4; i += room) chunks.push(m.text.slice(i, i + room))
582 if (chunks.length === 4 && m.text.length > room * 4) chunks[3] = `${chunks[3]?.slice(0, room - 1)}…`
583
584 return [
585 { parts: [{ t: m.key, tone: 'accent' as const }] },
586 ...chunks.map(c => ({ parts: [{ t: ` ${c}`, tone: 'plain' as const }] })),
587 ]
588 })
589
590 return {
591 layout: 'stack',
592 sections: [{ title: `Memories (${snap.memories.length})`, tone: 'head', rows }],
593 note: snap.memories.length === 0 ? 'Nothing remembered yet.' : undefined,
594 }
595}
596types/index.d.ts 55 lines1// beadmod-cym
2export type Dep = { to: string; type: string }
3
4export type Bead = {
5 id: string
6 title: string
7 status: string
8 priority: number
9 type: string
10 assignee: string | null
11 parent: string | null
12 description: string | null
13 createdAt: string | null
14 updatedAt: string | null
15 closedAt: string | null // beadmod-93m
16 deps: Dep[]
17}
18
19// beadmod-93m
20export type Memory = { key: string; text: string }
21
22export type Snapshot = {
23 beads: Bead[]
24 memories: Memory[]
25 ready: string[]
26 blocked: Record<string, string[]>
27 at: number
28 error: string | null
29}
30
31// beadmod-93m
32export type View =
33 | 'board'
34 | 'ready'
35 | 'issues'
36 | 'graph'
37 | 'tree'
38 | 'activity'
39 | 'memory'
40 | 'stats'
41
42export type Filter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'waiting'
43
44declare module 'claude-code' {
45 interface PluginState {
46 beadmod: {
47 view: View
48 snap: Snapshot | null
49 selected: string | null
50 showClosed: boolean
51 filter: Filter // beadmod-93m
52 }
53 }
54}
55