SLOPSHOPPER

beadmod

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

paneguardcommandstatusprocess
v0.1.0MITupdated 2026-10-17lesliesrussell/beadmod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · beadmod
│ ┃ Beads ✕ › fix the failing auth test and add an audit log call │ ┃ [ b ][ r ][ i ][ g ][ t ][ a ][ m ][ s ]c: c │ ┃ bd failed: bd list gave no JSON array ⏺ Read(src/auth.ts) │ ┃ Run this from a directory with a beads ⎿ Read 6 lines │ ┃ workspace (bd init). ⏺ 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 │ │ › /beadview │ ⎿ beadmod: Beads pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ beadmod: beads: 0 ready · 0 wip · 0 blocked

Draws

Pane · Beads
[ b ][ r ][ i ][ g ][ t ][ a ][ m ][ s ]c: c: show closedf: bd failed: bd list gave no JSON array Run this from a directory with a beads workspace (bd init).
README

beadmod

A Claude Code mod that shows beads (bd issues) in a pane, eight ways.

Install

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.

Use

Run /beadview [board|ready|issues|graph|tree|activity|memory|stats] in a directory with a beads workspace.

ViewKeyShows
BoardbStatus columns: Ready, In progress, Blocked, Waiting, Done (side by side when wide)
ReadyrThe claimable frontier, ranked by priority then by how much each bead unblocks
IssuesiFlat list of every bead; l cycles a filter (all, ready, in progress, blocked, waiting)
GraphgBlocking dependencies as layers: layer 0 can start now, layer N waits on N-1
TreetEpics and their children with progress bars
ActivityaTimeline from bead timestamps: created, started, updated, closed, grouped by day
MemorymPersistent memories from bd memories
StatssCounts 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.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 298 lines
1// 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}
298
hooks/model.ts 596 lines
1// 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}
596
types/index.d.ts 55 lines
1// 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