bdtui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # bdtui
2
+
3
+ Fast blessed-based terminal UI for the [`bd`](https://github.com/gastownhall/beads) beads issue tracker.
4
+
5
+ ## Requirements
6
+
7
+ - Node ≥ 20
8
+ - `bd` installed and on `$PATH`
9
+ - A project with a `.beads/` database (created by `bd init`)
10
+
11
+ ## Install
12
+
13
+ First install `bd`:
14
+
15
+ ```bash
16
+ brew install beads # macOS / Linux
17
+ npm install -g @beads/bd # via Node.js
18
+ ```
19
+
20
+ Then install bdtui:
21
+
22
+ ```bash
23
+ npm install -g bdtui
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ bdtui # use current directory
30
+ bdtui . # explicit current directory
31
+ bdtui ~/www/myapp # explicit project path
32
+ ```
33
+
34
+ ## Layout
35
+
36
+ ```
37
+ ┌─[ready open in_progress all]────────────────────────────┐
38
+ │ list (40%) │ detail (60%) │
39
+ │ > be-12 P1 in_prog … │ be-12 — Some bead title │
40
+ │ be-13 P2 open … │ Status: in_progress Priority: P1 │
41
+ │ │ Type: feature Owner: — │
42
+ │ │ … │
43
+ ├───────────────────────┴─────────────────────────────────────┤
44
+ │ ● Ready │ ? help · q quit │
45
+ └─────────────────────────────────────────────────────────────┘
46
+ ```
47
+
48
+ Beads with parent–child dependencies are shown as a tree inside the active filter:
49
+
50
+ ```
51
+ be-10 P1 open E Epic title
52
+ be-11 P2 open T ├ Child task
53
+ be-12 P2 ready T └ Another child
54
+ ```
55
+
56
+ ## Keybindings
57
+
58
+ | Key | Action |
59
+ |-----|--------|
60
+ | `j` / `↓` | Move down |
61
+ | `k` / `↑` | Move up |
62
+ | `g` / `G` | Jump to top / bottom |
63
+ | `Enter` / `l` | Focus detail pane |
64
+ | `h` / `Esc` | Back to list |
65
+ | `f` | Cycle filter: ready → open → in_progress → all |
66
+ | `r` | Reload current filter |
67
+ | `/` | In-memory title filter |
68
+ | `s` | Change status |
69
+ | `c` | Close with reason |
70
+ | `C` | Claim (in_progress + assign self) |
71
+ | `o` | Reopen |
72
+ | `p` | Change priority |
73
+ | `D` | Dependency menu (add / remove) |
74
+ | `y` | Yank bead ID to clipboard |
75
+ | `?` | Help overlay |
76
+ | `q` / `Ctrl-C` | Quit |
77
+
78
+ ## Author
79
+
80
+ [JSIOVN](https://github.com/jsiovn)
81
+
82
+ ## License
83
+
84
+ MIT
package/bin/bdtui.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import path from 'path';
3
+
4
+ const arg = process.argv[2];
5
+ const cwd = arg ? path.resolve(arg) : process.cwd();
6
+ process.chdir(cwd);
7
+
8
+ const { run } = await import('../src/app.js');
9
+ await run(cwd);
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "bdtui",
3
+ "version": "0.1.0",
4
+ "description": "Terminal UI for the bd beads issue tracker",
5
+ "type": "module",
6
+ "bin": { "bdtui": "bin/bdtui.js" },
7
+ "files": ["bin", "src", "README.md"],
8
+ "author": "JSIOVN",
9
+ "license": "MIT",
10
+ "engines": { "node": ">=20" },
11
+ "dependencies": {
12
+ "blessed": "^0.1.81"
13
+ }
14
+ }
package/src/app.js ADDED
@@ -0,0 +1,324 @@
1
+ import blessed from 'blessed';
2
+ import { execFile } from 'child_process';
3
+ import { state, loadList, loadDetail, applyMutation } from './state.js';
4
+ import { createList, renderList } from './views/list.js';
5
+ import { createDetail, renderDetail } from './views/detail.js';
6
+ import { statusPicker, priorityPicker, textPrompt, depMenu } from './views/modals.js';
7
+ import { showHelp } from './keys.js';
8
+ import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove } from './bd.js';
9
+
10
+ const FILTERS = ['ready', 'open', 'in_progress', 'all'];
11
+
12
+ export async function run(cwd) {
13
+ state.cwd = cwd;
14
+
15
+ const screen = blessed.screen({ smartCSR: true, title: 'bdtui', fullUnicode: true });
16
+
17
+ const tabBar = blessed.box({
18
+ parent: screen,
19
+ top: 0, left: 0,
20
+ width: '100%', height: 1,
21
+ content: '', tags: true,
22
+ style: { bg: 'black', fg: 'white' },
23
+ });
24
+
25
+ const statusBar = blessed.box({
26
+ parent: screen,
27
+ bottom: 0, left: 0,
28
+ width: '100%', height: 1,
29
+ content: ' Loading… | {gray-fg}? help{/}',
30
+ tags: true,
31
+ style: { bg: 'black', fg: 'white' },
32
+ });
33
+
34
+ const list = createList(screen);
35
+ const detail = createDetail(screen);
36
+
37
+ let debounceTimer = null;
38
+
39
+ function setStatus(msg, isError = false) {
40
+ const icon = isError ? '{red-fg}✗{/}' : '{green-fg}●{/}';
41
+ const text = isError ? `{red-fg}${msg}{/}` : msg;
42
+ statusBar.setContent(` ${icon} ${text} {gray-fg}? help · q quit{/}`);
43
+ screen.render();
44
+ }
45
+
46
+ function renderTabBar() {
47
+ const tabs = FILTERS.map((f) => {
48
+ return f === state.filter
49
+ ? `{blue-bg}{white-fg}{bold} ${f} {/bold}{/}`
50
+ : `{gray-fg} ${f} {/}`;
51
+ });
52
+ const count = state.listOrder.length;
53
+ const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
54
+ tabBar.setContent(tabs.join('{gray-fg}│{/}') + info);
55
+ }
56
+
57
+ function setFocusBorder(focused) {
58
+ if (focused === 'list') {
59
+ list.style.border.fg = 'cyan';
60
+ detail.style.border.fg = 'gray';
61
+ } else {
62
+ list.style.border.fg = 'gray';
63
+ detail.style.border.fg = 'cyan';
64
+ }
65
+ }
66
+
67
+ function render() {
68
+ renderTabBar();
69
+ renderList(list);
70
+ renderDetail(detail);
71
+ screen.render();
72
+ }
73
+
74
+ // When list selection changes, update selectedId and debounce detail fetch
75
+ function onNav() {
76
+ setImmediate(() => {
77
+ const idx = list.selected;
78
+ const id = state.listOrder[idx];
79
+ if (!id || id === state.selectedId) return;
80
+ state.selectedId = id;
81
+ renderDetail(detail);
82
+ screen.render();
83
+
84
+ clearTimeout(debounceTimer);
85
+ debounceTimer = setTimeout(async () => {
86
+ try {
87
+ await loadDetail(id);
88
+ renderDetail(detail);
89
+ screen.render();
90
+ } catch (err) {
91
+ setStatus(err.message, true);
92
+ }
93
+ }, 80);
94
+ });
95
+ }
96
+
97
+ // Capture all keypresses on the list to detect navigation
98
+ list.on('keypress', onNav);
99
+
100
+ // Focus border highlighting
101
+ list.on('focus', () => { setFocusBorder('list'); screen.render(); });
102
+ detail.on('focus', () => { setFocusBorder('detail'); screen.render(); });
103
+
104
+ async function refresh() {
105
+ setStatus('Refreshing…');
106
+ try {
107
+ await loadList();
108
+ if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
109
+ state.selectedId = state.listOrder[0] || null;
110
+ }
111
+ if (state.selectedId) await loadDetail(state.selectedId);
112
+ render();
113
+ setStatus('Ready');
114
+ } catch (err) {
115
+ setStatus(err.message, true);
116
+ }
117
+ }
118
+
119
+ async function cycleFilter() {
120
+ const idx = FILTERS.indexOf(state.filter);
121
+ state.filter = FILTERS[(idx + 1) % FILTERS.length];
122
+ setStatus(`Filter: ${state.filter} — loading…`);
123
+ try {
124
+ await loadList();
125
+ state.selectedId = state.listOrder[0] || null;
126
+ if (state.selectedId) await loadDetail(state.selectedId);
127
+ render();
128
+ setStatus(`Filter: ${state.filter}`);
129
+ } catch (err) {
130
+ setStatus(err.message, true);
131
+ }
132
+ }
133
+
134
+ // ── Global keys ────────────────────────────────────────────────────────────
135
+
136
+ screen.key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
137
+
138
+ screen.key(['r'], refresh);
139
+ screen.key(['f'], cycleFilter);
140
+
141
+ screen.key(['?'], () => showHelp(screen, () => { list.focus(); screen.render(); }));
142
+
143
+ screen.key(['enter', 'l'], () => {
144
+ if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
145
+ });
146
+
147
+ screen.key(['h', 'escape'], () => {
148
+ if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
149
+ });
150
+
151
+ screen.key(['g'], () => {
152
+ if (screen.focused !== list) return;
153
+ list.select(0);
154
+ onNav();
155
+ screen.render();
156
+ });
157
+
158
+ screen.key(['G'], () => {
159
+ if (screen.focused !== list) return;
160
+ list.select(state.listOrder.length - 1);
161
+ onNav();
162
+ screen.render();
163
+ });
164
+
165
+ screen.key(['/'], async () => {
166
+ if (screen.focused !== list) return;
167
+ try {
168
+ const query = await textPrompt(screen, 'Filter by title');
169
+ list.focus();
170
+ if (!query) { render(); return; }
171
+ const q = query.toLowerCase();
172
+ state.listOrder = state.listOrder.filter((id) => {
173
+ const b = state.beadsById.get(id);
174
+ return b?.title?.toLowerCase().includes(q);
175
+ });
176
+ state.selectedId = state.listOrder[0] || null;
177
+ renderList(list);
178
+ renderDetail(detail);
179
+ screen.render();
180
+ setStatus(`"${query}" — ${state.listOrder.length} results | r to reset`);
181
+ } catch {
182
+ list.focus();
183
+ }
184
+ });
185
+
186
+ // ── Mutation keys (list focus only) ────────────────────────────────────────
187
+
188
+ screen.key(['s'], async () => {
189
+ if (screen.focused !== list || !state.selectedId) return;
190
+ const id = state.selectedId;
191
+ try {
192
+ const newStatus = await statusPicker(screen);
193
+ list.focus();
194
+ const oldStatus = state.beadsById.get(id)?.status;
195
+ setStatus(`${id}: ${oldStatus} → ${newStatus}…`);
196
+ await applyMutation(id, () => bdUpdate(id, { status: newStatus }, state.cwd));
197
+ renderList(list);
198
+ renderDetail(detail);
199
+ screen.render();
200
+ setStatus(`${id}: ${oldStatus} → ${newStatus}`);
201
+ } catch (err) {
202
+ list.focus();
203
+ if (err.message !== 'cancelled') setStatus(err.message, true);
204
+ }
205
+ });
206
+
207
+ screen.key(['c'], async () => {
208
+ if (screen.focused !== list || !state.selectedId) return;
209
+ const id = state.selectedId;
210
+ try {
211
+ const reason = await textPrompt(screen, 'Close reason');
212
+ list.focus();
213
+ setStatus(`Closing ${id}…`);
214
+ await applyMutation(id, () => bdClose(id, reason, state.cwd));
215
+ await loadList();
216
+ render();
217
+ setStatus(`Closed ${id}`);
218
+ } catch (err) {
219
+ list.focus();
220
+ if (err.message !== 'cancelled') setStatus(err.message, true);
221
+ }
222
+ });
223
+
224
+ screen.key(['C'], async () => {
225
+ if (screen.focused !== list || !state.selectedId) return;
226
+ const id = state.selectedId;
227
+ setStatus(`Claiming ${id}…`);
228
+ try {
229
+ await applyMutation(id, () => bdClaim(id, state.cwd));
230
+ renderList(list);
231
+ renderDetail(detail);
232
+ screen.render();
233
+ setStatus(`Claimed ${id}`);
234
+ } catch (err) {
235
+ setStatus(err.message, true);
236
+ }
237
+ });
238
+
239
+ screen.key(['o'], async () => {
240
+ if (screen.focused !== list || !state.selectedId) return;
241
+ const id = state.selectedId;
242
+ setStatus(`Reopening ${id}…`);
243
+ try {
244
+ await applyMutation(id, () => bdReopen(id, state.cwd));
245
+ await loadList();
246
+ render();
247
+ setStatus(`Reopened ${id}`);
248
+ } catch (err) {
249
+ setStatus(err.message, true);
250
+ }
251
+ });
252
+
253
+ screen.key(['p'], async () => {
254
+ if (screen.focused !== list || !state.selectedId) return;
255
+ const id = state.selectedId;
256
+ try {
257
+ const priority = await priorityPicker(screen);
258
+ list.focus();
259
+ setStatus(`Setting priority ${priority} on ${id}…`);
260
+ await applyMutation(id, () => bdUpdate(id, { priority }, state.cwd));
261
+ renderList(list);
262
+ renderDetail(detail);
263
+ screen.render();
264
+ setStatus(`${id}: priority → ${priority}`);
265
+ } catch (err) {
266
+ list.focus();
267
+ if (err.message !== 'cancelled') setStatus(err.message, true);
268
+ }
269
+ });
270
+
271
+ screen.key(['D'], async () => {
272
+ if (screen.focused !== list || !state.selectedId) return;
273
+ const id = state.selectedId;
274
+ try {
275
+ const result = await depMenu(screen);
276
+ list.focus();
277
+ if (result.action === 'add') {
278
+ setStatus(`Adding dep: ${id} → ${result.targetId} [${result.type}]…`);
279
+ await applyMutation(id, () => bdDepAdd(id, result.targetId, result.type, state.cwd));
280
+ setStatus(`Added: ${id} → ${result.targetId} [${result.type}]`);
281
+ } else {
282
+ setStatus(`Removing dep: ${id} → ${result.targetId}…`);
283
+ await applyMutation(id, () => bdDepRemove(id, result.targetId, state.cwd));
284
+ setStatus(`Removed dep: ${id} → ${result.targetId}`);
285
+ }
286
+ renderDetail(detail);
287
+ screen.render();
288
+ } catch (err) {
289
+ list.focus();
290
+ if (err.message !== 'cancelled') setStatus(err.message, true);
291
+ }
292
+ });
293
+
294
+ screen.key(['y'], () => {
295
+ const id = state.selectedId;
296
+ if (!id) return;
297
+ const write = (cmd, args) => new Promise((res) => {
298
+ const p = execFile(cmd, args, res);
299
+ p.stdin?.end(id);
300
+ });
301
+ write('wl-copy', [])
302
+ .catch(() => write('xclip', ['-selection', 'clipboard']))
303
+ .catch(() => write('xsel', ['--clipboard', '--input']))
304
+ .catch(() => write('pbcopy', []))
305
+ .then((err) => {
306
+ setStatus(err ? `${id} (clipboard unavailable)` : `Copied ${id}`);
307
+ });
308
+ });
309
+
310
+ // ── Boot ───────────────────────────────────────────────────────────────────
311
+
312
+ setFocusBorder('list');
313
+ list.focus();
314
+ setStatus('Loading…');
315
+ try {
316
+ await loadList();
317
+ state.selectedId = state.listOrder[0] || null;
318
+ if (state.selectedId) await loadDetail(state.selectedId);
319
+ } catch (err) {
320
+ setStatus(err.message, true);
321
+ }
322
+ render();
323
+ setStatus('Ready | ? help');
324
+ }
package/src/bd.js ADDED
@@ -0,0 +1,68 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileP = promisify(execFile);
5
+
6
+ async function runBd(args, cwd) {
7
+ try {
8
+ const { stdout } = await execFileP('bd', args, { cwd });
9
+ return JSON.parse(stdout);
10
+ } catch (err) {
11
+ throw new Error(err.stderr?.trim() || err.message);
12
+ }
13
+ }
14
+
15
+ export const bdList = (filter, cwd) => {
16
+ if (filter === 'ready') return runBd(['ready', '--json'], cwd);
17
+ const args = ['list', '--json'];
18
+ if (filter === 'all') args.push('--all');
19
+ else if (filter) args.push('--status', filter);
20
+ return runBd(args, cwd);
21
+ };
22
+
23
+ export const bdShow = async (id, cwd) => {
24
+ const res = await runBd(['show', id, '--json'], cwd);
25
+ return Array.isArray(res) ? res[0] : res;
26
+ };
27
+
28
+ export const bdDeps = async (id, cwd) => {
29
+ const [down, up] = await Promise.all([
30
+ runBd(['dep', 'list', id, '--json'], cwd).catch(() => []),
31
+ runBd(['dep', 'list', id, '--direction=up', '--json'], cwd).catch(() => []),
32
+ ]);
33
+ return { down: Array.isArray(down) ? down : [], up: Array.isArray(up) ? up : [] };
34
+ };
35
+
36
+ export const bdDepListDown = async (id, cwd) => {
37
+ const res = await runBd(['dep', 'list', id, '--json'], cwd).catch(() => []);
38
+ return Array.isArray(res) ? res : [];
39
+ };
40
+
41
+ export const bdUpdate = (id, opts, cwd) => {
42
+ const args = ['update', id];
43
+ if (opts.status) args.push('--status', opts.status);
44
+ if (opts.priority !== undefined) args.push('--priority', String(opts.priority));
45
+ return runBd([...args, '--json'], cwd);
46
+ };
47
+
48
+ export const bdClose = (id, reason, cwd) => {
49
+ const args = ['close', id];
50
+ if (reason) args.push('--reason', reason);
51
+ return runBd([...args, '--json'], cwd);
52
+ };
53
+
54
+ export const bdClaim = (id, cwd) => runBd(['update', id, '--claim', '--json'], cwd);
55
+
56
+ export const bdReopen = (id, cwd) => runBd(['reopen', id, '--json'], cwd);
57
+
58
+ export const bdDepAdd = (child, parent, type, cwd) => {
59
+ const args = ['dep', 'add', child, parent];
60
+ if (type && type !== 'blocks') args.push('--type', type);
61
+ return runBd(args, cwd);
62
+ };
63
+
64
+ export const bdDepRemove = (child, parent, cwd) =>
65
+ runBd(['dep', 'remove', child, parent], cwd);
66
+
67
+ // Returns all children of an epic (all statuses — bd children includes closed by default)
68
+ export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
package/src/keys.js ADDED
@@ -0,0 +1,69 @@
1
+ import blessed from 'blessed';
2
+
3
+ export const HELP_TEXT = [
4
+ '',
5
+ ' Navigation',
6
+ ' ──────────────────────────────────────',
7
+ ' j / ↓ Move down in list',
8
+ ' k / ↑ Move up in list',
9
+ ' g Jump to top',
10
+ ' G Jump to bottom',
11
+ ' Enter / l Focus detail pane',
12
+ ' h / Esc Back to list',
13
+ '',
14
+ ' Filters & refresh',
15
+ ' ──────────────────────────────────────',
16
+ ' f Cycle filter (ready/open/in_progress/all)',
17
+ ' r Reload current filter from bd',
18
+ ' / In-memory title filter',
19
+ '',
20
+ ' Mutations',
21
+ ' ──────────────────────────────────────',
22
+ ' s Change status',
23
+ ' c Close with reason',
24
+ ' C Claim bead (in_progress + assign self)',
25
+ ' o Reopen',
26
+ ' p Change priority',
27
+ ' D Dependency menu (add / remove)',
28
+ '',
29
+ ' Other',
30
+ ' ──────────────────────────────────────',
31
+ ' y Yank bead ID to clipboard',
32
+ ' ? Toggle this help',
33
+ ' q / Ctrl-C Quit',
34
+ '',
35
+ ' Press ? q or Esc to close',
36
+ ].join('\n');
37
+
38
+ export function showHelp(screen, onClose) {
39
+ const box = blessed.box({
40
+ parent: screen,
41
+ label: ' Keybindings ',
42
+ border: { type: 'line' },
43
+ top: 'center',
44
+ left: 'center',
45
+ width: '55%',
46
+ height: '80%',
47
+ content: HELP_TEXT,
48
+ tags: false,
49
+ keys: true,
50
+ scrollable: true,
51
+ alwaysScroll: true,
52
+ scrollbar: { ch: '│' },
53
+ style: {
54
+ border: { fg: 'cyan' },
55
+ label: { fg: 'cyan', bold: true },
56
+ },
57
+ });
58
+
59
+ box.focus();
60
+ screen.render();
61
+
62
+ const close = () => {
63
+ box.destroy();
64
+ screen.render();
65
+ onClose?.();
66
+ };
67
+
68
+ box.key(['?', 'q', 'escape', 'h'], close);
69
+ }
package/src/state.js ADDED
@@ -0,0 +1,99 @@
1
+ import { bdList, bdShow, bdDeps, bdDepListDown } from './bd.js';
2
+
3
+ export const state = {
4
+ beadsById: new Map(),
5
+ listOrder: [],
6
+ treeMeta: new Map(), // id -> { depth: 0|1, isLast: bool }
7
+ selectedId: null,
8
+ filter: 'ready',
9
+ cwd: process.cwd(),
10
+ };
11
+
12
+ function buildTreeOrder(beads, parentOf) {
13
+ const byId = new Map(beads.map((b) => [b.id, b]));
14
+ const childrenOf = new Map();
15
+
16
+ for (const b of beads) {
17
+ const pid = parentOf.get(b.id);
18
+ if (pid && byId.has(pid)) {
19
+ if (!childrenOf.has(pid)) childrenOf.set(pid, []);
20
+ childrenOf.get(pid).push(b);
21
+ }
22
+ }
23
+
24
+ for (const children of childrenOf.values()) {
25
+ children.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || a.id.localeCompare(b.id));
26
+ }
27
+
28
+ const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
29
+ const roots = beads.filter((b) => !childIds.has(b.id));
30
+
31
+ const ordered = [];
32
+ for (const root of roots) {
33
+ ordered.push({ id: root.id, depth: 0, isLast: false });
34
+ const children = childrenOf.get(root.id) || [];
35
+ children.forEach((child, i) => {
36
+ ordered.push({ id: child.id, depth: 1, isLast: i === children.length - 1 });
37
+ });
38
+ }
39
+
40
+ return ordered;
41
+ }
42
+
43
+ export async function loadList() {
44
+ const beads = await bdList(state.filter, state.cwd);
45
+ const byId = new Map(beads.map((b) => [b.id, b]));
46
+
47
+ // Build parent-child map from real bd deps (dependency_type === 'parent-child').
48
+ // Fetch only for non-epic beads — epics don't have epic parents in this model.
49
+ const candidates = beads.filter((b) => b.issue_type !== 'epic');
50
+ const depLists = await Promise.all(
51
+ candidates.map((b) => bdDepListDown(b.id, state.cwd))
52
+ );
53
+
54
+ const parentOf = new Map();
55
+ candidates.forEach((b, i) => {
56
+ const pc = depLists[i].find((d) => d.dependency_type === 'parent-child');
57
+ if (pc?.id) parentOf.set(b.id, pc.id);
58
+ });
59
+
60
+ // Bring in parent epics that aren't already in the filtered list so the tree
61
+ // can root under them. Only add the epic itself, NOT its other children —
62
+ // that's what caused closed beads to leak into ready/open filters.
63
+ if (state.filter !== 'all') {
64
+ const missingParents = [...new Set(parentOf.values())].filter((id) => !byId.has(id));
65
+ const fetched = await Promise.all(
66
+ missingParents.map((id) => bdShow(id, state.cwd).catch(() => null))
67
+ );
68
+ for (const p of fetched) {
69
+ if (p && p.id && !byId.has(p.id)) {
70
+ beads.push(p);
71
+ byId.set(p.id, p);
72
+ }
73
+ }
74
+ }
75
+
76
+ const treeItems = buildTreeOrder(beads, parentOf);
77
+ state.listOrder = treeItems.map((t) => t.id);
78
+ state.treeMeta = new Map(treeItems.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
79
+
80
+ for (const b of beads) {
81
+ state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
82
+ }
83
+ return beads;
84
+ }
85
+
86
+ export async function loadDetail(id) {
87
+ const [bead, deps] = await Promise.all([
88
+ bdShow(id, state.cwd),
89
+ bdDeps(id, state.cwd),
90
+ ]);
91
+ const merged = { ...bead, depsDown: deps.down, depsUp: deps.up };
92
+ state.beadsById.set(id, merged);
93
+ return merged;
94
+ }
95
+
96
+ export async function applyMutation(id, mutatorFn) {
97
+ await mutatorFn();
98
+ return loadDetail(id);
99
+ }
@@ -0,0 +1,137 @@
1
+ import blessed from 'blessed';
2
+ import { state } from '../state.js';
3
+
4
+ const STATUS_COLOR = {
5
+ open: 'white', in_progress: 'cyan', blocked: 'red',
6
+ deferred: 'yellow', closed: 'gray',
7
+ };
8
+ const PRIORITY_COLOR = ['red', 'yellow', 'green', 'cyan', 'gray'];
9
+ const PRIORITY_LABEL = ['Critical', 'High', 'Normal', 'Low', 'Trivial'];
10
+
11
+ function t(color, text) { return `{${color}-fg}${text}{/}`; }
12
+ function bold(text) { return `{bold}${text}{/bold}`; }
13
+ function fmtDate(iso) { return iso ? iso.slice(0, 10) : '—'; }
14
+
15
+ function field(label, value, labelWidth = 10) {
16
+ return `${t('gray', (label + ':').padEnd(labelWidth))} ${value}`;
17
+ }
18
+
19
+ function sectionHeader(title) {
20
+ return `\n${bold(t('cyan', title))}\n${t('gray', '─'.repeat(44))}`;
21
+ }
22
+
23
+ function depLine(dep, arrow, color) {
24
+ const id = dep.id || String(dep);
25
+ const type = dep.dep_type || dep.type || 'blocks';
26
+ const title = dep.title ? ` ${t('gray', '— ' + dep.title)}` : '';
27
+ return ` ${t(color, arrow)} {bold}${id}{/bold} ${t('gray', `[${type}]`)}${title}`;
28
+ }
29
+
30
+ function renderDescription(lines, text) {
31
+ for (const l of text.split('\n')) {
32
+ if (l.startsWith('## ')) lines.push(` ${bold(t('cyan', l.slice(3)))}`);
33
+ else if (l.startsWith('# ')) lines.push(` ${bold(t('white', l.slice(2)))}`);
34
+ else if (l.match(/^[-*] /)) lines.push(` ${t('gray', '•')} ${l.slice(2)}`);
35
+ else lines.push(` ${l}`);
36
+ }
37
+ }
38
+
39
+ let lastId = null;
40
+
41
+ export function createDetail(screen) {
42
+ return blessed.box({
43
+ parent: screen,
44
+ top: 1,
45
+ left: '40%',
46
+ width: '60%',
47
+ bottom: 1,
48
+ border: { type: 'line' },
49
+ label: ' Detail ',
50
+ tags: true,
51
+ scrollable: true,
52
+ alwaysScroll: true,
53
+ keys: true,
54
+ mouse: true,
55
+ scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
56
+ style: {
57
+ border: { fg: 'gray' },
58
+ label: { fg: 'white', bold: true },
59
+ },
60
+ });
61
+ }
62
+
63
+ export function renderDetail(box) {
64
+ const id = state.selectedId;
65
+ if (!id) { box.setContent(t('gray', 'No bead selected.')); return; }
66
+
67
+ const b = state.beadsById.get(id);
68
+ if (!b) { box.setContent(t('gray', `Loading ${id}…`)); return; }
69
+
70
+ const resetScroll = id !== lastId;
71
+ lastId = id;
72
+
73
+ const prio = b.priority ?? 2;
74
+ const prioColor = PRIORITY_COLOR[prio] || 'white';
75
+ const prioLabel = PRIORITY_LABEL[prio] || String(prio);
76
+ const statColor = STATUS_COLOR[b.status] || 'white';
77
+
78
+ const lines = [];
79
+
80
+ // ── Header ─────────────────────────────────────────────────────────────────
81
+ lines.push(`{bold}{white-fg}${b.id}{/} {gray-fg}—{/} ${b.title || ''}`);
82
+ lines.push(t('gray', '═'.repeat(50)));
83
+ lines.push('');
84
+
85
+ // ── Fields ─────────────────────────────────────────────────────────────────
86
+ lines.push(
87
+ field('Status', `{${statColor}-fg}{bold}${b.status || '—'}{/bold}{/}`) +
88
+ ' ' +
89
+ field('Priority', `{${prioColor}-fg}{bold}P${prio} ${prioLabel}{/bold}{/}`)
90
+ );
91
+ lines.push(
92
+ field('Type', t('white', b.issue_type || '—')) +
93
+ ' ' +
94
+ field('Owner', t('white', b.owner || '—'))
95
+ );
96
+ lines.push(
97
+ field('Created', t('gray', fmtDate(b.created_at))) +
98
+ ' ' +
99
+ field('Updated', t('gray', fmtDate(b.updated_at)))
100
+ );
101
+
102
+ if (b.labels?.length > 0) {
103
+ lines.push(field('Labels', b.labels.map((l) => t('blue', l)).join(' ')));
104
+ }
105
+
106
+ // ── Dependencies ───────────────────────────────────────────────────────────
107
+ if (b.depsDown?.length > 0) {
108
+ lines.push(sectionHeader(`Depends on (${b.depsDown.length})`));
109
+ for (const d of b.depsDown) lines.push(depLine(d, '→', 'yellow'));
110
+ }
111
+ if (b.depsUp?.length > 0) {
112
+ lines.push(sectionHeader(`Needed by (${b.depsUp.length})`));
113
+ for (const d of b.depsUp) lines.push(depLine(d, '←', 'green'));
114
+ }
115
+
116
+ // ── Description ────────────────────────────────────────────────────────────
117
+ if (b.description) {
118
+ lines.push(sectionHeader('Description'));
119
+ lines.push('');
120
+ renderDescription(lines, b.description);
121
+ }
122
+
123
+ if (b.acceptance) {
124
+ lines.push(sectionHeader('Acceptance'));
125
+ lines.push('');
126
+ renderDescription(lines, b.acceptance);
127
+ }
128
+
129
+ if (b.notes) {
130
+ lines.push(sectionHeader('Notes'));
131
+ lines.push('');
132
+ renderDescription(lines, b.notes);
133
+ }
134
+
135
+ box.setContent(lines.join('\n'));
136
+ if (resetScroll) box.scrollTo(0);
137
+ }
@@ -0,0 +1,83 @@
1
+ import blessed from 'blessed';
2
+ import { state } from '../state.js';
3
+
4
+ const STATUS_SHORT = {
5
+ open: 'open ',
6
+ in_progress: 'in_prog',
7
+ blocked: 'blocked',
8
+ deferred: 'deferrd',
9
+ closed: 'closed ',
10
+ };
11
+
12
+ const PRIORITY_COLOR = ['red', 'yellow', 'green', 'cyan', 'gray'];
13
+ const STATUS_COLOR = {
14
+ open: 'white', in_progress: 'cyan', blocked: 'red',
15
+ deferred: 'yellow', closed: 'gray',
16
+ };
17
+
18
+ const TYPE_BADGE = {
19
+ epic: '{magenta-fg}E{/}',
20
+ task: '{blue-fg}T{/}',
21
+ chore: '{gray-fg}C{/}',
22
+ bug: '{red-fg}B{/}',
23
+ story: '{cyan-fg}S{/}',
24
+ feature: '{green-fg}F{/}',
25
+ };
26
+
27
+ function t(color, text) {
28
+ return `{${color}-fg}${text}{/}`;
29
+ }
30
+
31
+ function formatRow(bead, depth = 0, isLast = false) {
32
+ const id = bead.id.padEnd(14);
33
+ const p = `P${bead.priority ?? 2}`;
34
+ const stat = bead.status || 'open';
35
+ const s = (STATUS_SHORT[stat] || stat.slice(0, 7)).padEnd(7);
36
+ const badge = TYPE_BADGE[bead.issue_type] ?? t('gray', '?');
37
+ const prefix = depth > 0 ? t('gray', isLast ? ' └ ' : ' ├ ') : ' ';
38
+ const title = bead.title || '';
39
+
40
+ const idCol = t('gray', id);
41
+ const pCol = t(PRIORITY_COLOR[bead.priority ?? 2] ?? 'white', p);
42
+ const sCol = t(STATUS_COLOR[stat] ?? 'white', s);
43
+
44
+ return `${idCol} ${pCol} ${sCol} ${badge}${prefix}${title}`;
45
+ }
46
+
47
+ export function createList(screen) {
48
+ return blessed.list({
49
+ parent: screen,
50
+ top: 1,
51
+ left: 0,
52
+ width: '40%',
53
+ bottom: 1,
54
+ border: { type: 'line' },
55
+ label: ' Beads ',
56
+ tags: true,
57
+ keys: true,
58
+ vi: true,
59
+ mouse: true,
60
+ scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
61
+ style: {
62
+ selected: { bg: 'blue', fg: 'white', bold: true },
63
+ border: { fg: 'gray' },
64
+ label: { fg: 'white', bold: true },
65
+ },
66
+ });
67
+ }
68
+
69
+ export function renderList(list) {
70
+ const items = state.listOrder.map((id) => {
71
+ const b = state.beadsById.get(id);
72
+ const { depth = 0, isLast = false } = state.treeMeta.get(id) ?? {};
73
+ return b ? formatRow(b, depth, isLast) : t('gray', id);
74
+ });
75
+
76
+ const selIdx = state.selectedId
77
+ ? Math.max(0, state.listOrder.indexOf(state.selectedId))
78
+ : 0;
79
+
80
+ list.setLabel(` Beads ${t('gray', `(${state.listOrder.length})`)} `);
81
+ list.setItems(items);
82
+ if (state.listOrder.length > 0) list.select(selIdx);
83
+ }
@@ -0,0 +1,112 @@
1
+ import blessed from 'blessed';
2
+
3
+ function listPicker(screen, label, items, borderColor = 'cyan') {
4
+ return new Promise((resolve, reject) => {
5
+ const height = Math.min(items.length + 4, 20);
6
+ const picker = blessed.list({
7
+ parent: screen,
8
+ label: ` ${label} `,
9
+ border: { type: 'line' },
10
+ top: 'center',
11
+ left: 'center',
12
+ width: 36,
13
+ height,
14
+ items,
15
+ keys: true,
16
+ vi: true,
17
+ tags: true,
18
+ style: {
19
+ selected: { bg: 'blue', fg: 'white', bold: true },
20
+ border: { fg: borderColor },
21
+ label: { fg: borderColor, bold: true },
22
+ },
23
+ });
24
+
25
+ picker.focus();
26
+ screen.render();
27
+
28
+ picker.once('select', (item) => {
29
+ picker.destroy();
30
+ screen.render();
31
+ resolve(item.content.trim());
32
+ });
33
+
34
+ picker.key(['escape', 'q', 'h'], () => {
35
+ picker.destroy();
36
+ screen.render();
37
+ reject(new Error('cancelled'));
38
+ });
39
+ });
40
+ }
41
+
42
+ export function statusPicker(screen) {
43
+ return listPicker(
44
+ screen,
45
+ 'Change Status',
46
+ ['open', 'in_progress', 'blocked', 'deferred', 'closed'],
47
+ 'cyan',
48
+ );
49
+ }
50
+
51
+ export function priorityPicker(screen) {
52
+ return listPicker(
53
+ screen,
54
+ 'Change Priority',
55
+ ['0 — Critical', '1 — High', '2 — Normal', '3 — Low', '4 — Trivial'],
56
+ 'yellow',
57
+ ).then((val) => parseInt(val[0], 10));
58
+ }
59
+
60
+ export function textPrompt(screen, label, defaultVal = '') {
61
+ return new Promise((resolve, reject) => {
62
+ const prompt = blessed.prompt({
63
+ parent: screen,
64
+ border: 'line',
65
+ height: 'shrink',
66
+ width: '60%',
67
+ top: 'center',
68
+ left: 'center',
69
+ label: ` ${label} `,
70
+ tags: true,
71
+ keys: true,
72
+ vi: true,
73
+ style: {
74
+ border: { fg: 'green' },
75
+ label: { fg: 'green', bold: true },
76
+ },
77
+ });
78
+
79
+ prompt.input(`${label}:`, defaultVal, (err, value) => {
80
+ prompt.destroy();
81
+ screen.render();
82
+ if (err || value == null) return reject(new Error('cancelled'));
83
+ resolve(value.trim());
84
+ });
85
+
86
+ screen.render();
87
+ });
88
+ }
89
+
90
+ const DEP_TYPES = [
91
+ 'blocks', 'tracks', 'related', 'relates-to',
92
+ 'validates', 'caused-by', 'parent-child',
93
+ ];
94
+
95
+ export async function depMenu(screen) {
96
+ const action = await listPicker(
97
+ screen,
98
+ 'Dependency',
99
+ ['Add dep', 'Remove dep'],
100
+ 'magenta',
101
+ );
102
+
103
+ const targetId = await textPrompt(screen, 'Target bead ID');
104
+ if (!targetId) throw new Error('cancelled');
105
+
106
+ if (action === 'Add dep') {
107
+ const type = await listPicker(screen, 'Dep type', DEP_TYPES, 'magenta');
108
+ return { action: 'add', targetId, type };
109
+ }
110
+
111
+ return { action: 'remove', targetId };
112
+ }