bdtui 0.7.2 → 0.8.1

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/src/app.js CHANGED
@@ -1,586 +1,617 @@
1
- import blessed from 'blessed';
2
- import { execFile } from 'child_process';
3
- import { state, loadList, loadDetail, applyMutation, applyFilters, isPaged, ensureSelectedVisible } from './state.js';
4
- import { createList, renderList } from './views/list.js';
5
- import { createDetail, renderDetail } from './views/detail.js';
6
- import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
7
- import { showHelp } from './keys.js';
8
- import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListDown, bdEpics } from './bd.js';
9
-
10
- const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
11
- const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
12
-
13
- // Neutralize blessed tag markup in free text before it lands in a tags:true
14
- // box (mirrors esc() in views/detail.js). The title search is user-typed, so a
15
- // query like "{bold}" would otherwise corrupt the status line.
16
- // Single pass: a two-step .replace(/\{/).replace(/\}/) would re-process the "}"
17
- // it just inserted into "{open}", corrupting any text that contains a literal
18
- // "}" (e.g. a bd JSON error blob) into a stray "{open}" token.
19
- const escTags = (s) => String(s ?? '').replace(/[{}]/g, (c) => (c === '{' ? '{open}' : '{close}'));
20
-
21
- export async function run(cwd) {
22
- state.cwd = cwd;
23
-
24
- const screen = blessed.screen({ smartCSR: true, title: 'bdtui', fullUnicode: true });
25
-
26
- const tabBar = blessed.box({
27
- parent: screen,
28
- top: 0, left: 0,
29
- width: '100%', height: 1,
30
- content: '', tags: true,
31
- style: { bg: 'black', fg: 'white' },
32
- });
33
-
34
- const statusBar = blessed.box({
35
- parent: screen,
36
- bottom: 0, left: 0,
37
- width: '100%', height: 1,
38
- content: ' Loading… | {gray-fg}? help{/}',
39
- tags: true,
40
- style: { bg: 'black', fg: 'white' },
41
- });
42
-
43
- const list = createList(screen);
44
- const detail = createDetail(screen);
45
-
46
- let debounceTimer = null;
47
-
48
- let statusTimer = null;
49
- // Describe every active narrowing filter (title search, epic scope, type) in
50
- // one line with the result count and reset hint. Returns null when none are
51
- // active so the caller can fall back to a plain "Ready".
52
- function filterSummary() {
53
- const parts = [];
54
- // Plain text only — setStatus() escapes the whole summary, so pre-escaping
55
- // textFilter here would double-escape any braces in the search query.
56
- if (state.textFilter) parts.push(`"${state.textFilter}"`);
57
- if (state.epicFilter) parts.push(`epic: ${state.epicFilter}`);
58
- if (state.typeFilter !== 'all') parts.push(`type: ${state.typeFilter}`);
59
- if (parts.length === 0) return null;
60
- return `${parts.join(' · ')} — ${state.listOrder.length} results | Shift+r to reset`;
61
- }
62
- function defaultStatus() {
63
- return filterSummary() || 'Ready';
64
- }
65
- function setStatus(msg, isError = false, transient = false) {
66
- if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
67
- // The status bar is a single tags:true row. Neutralize blessed tag markup
68
- // and collapse newlines so dynamic text — bd error messages especially,
69
- // which can be multi-line and contain "{" — renders as plain text instead
70
- // of corrupting the markup or overflowing the one-line height.
71
- const safe = escTags(String(msg ?? '').replace(/\s*\n\s*/g, ' ').trim());
72
- const icon = isError ? '{red-fg}✗{/}' : '{green-fg}●{/}';
73
- const text = isError ? `{red-fg}${safe}{/}` : safe;
74
- statusBar.setContent(` ${icon} ${text} {gray-fg}? help · q quit{/}`);
75
- screen.render();
76
- if (transient) {
77
- statusTimer = setTimeout(() => {
78
- statusTimer = null;
79
- setStatus(defaultStatus());
80
- }, 3000);
81
- }
82
- }
83
-
84
- function renderTabBar() {
85
- const tabs = FILTERS.map((f) => {
86
- return f === state.filter
87
- ? `{blue-bg}{white-fg}{bold} ${f} {/bold}{/}`
88
- : `{gray-fg} ${f} {/}`;
89
- });
90
- const count = state.listOrder.length;
91
- const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
92
- const typeInfo = ` {gray-fg}│{/} {yellow-fg}type:{/} ${typeLabel}`;
93
- const epicInfo = state.epicFilter
94
- ? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
95
- : '';
96
- const textInfo = state.textFilter
97
- ? ` {gray-fg}│{/} {green-fg}search:{/} "${escTags(state.textFilter)}"`
98
- : '';
99
- const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
100
- tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + textInfo + info);
101
- }
102
-
103
- function setFocusBorder(focused) {
104
- if (focused === 'list') {
105
- list.style.border.fg = 'cyan';
106
- detail.style.border.fg = 'gray';
107
- } else {
108
- list.style.border.fg = 'gray';
109
- detail.style.border.fg = 'cyan';
110
- }
111
- }
112
-
113
- function render() {
114
- renderTabBar();
115
- renderList(list);
116
- renderDetail(detail);
117
- screen.render();
118
- }
119
-
120
- // Lazy "load more": the paged tabs (all / closed) load every bead but render
121
- // them pageSize at a time; reveal the next batch once selection reaches the
122
- // last visible row (infinite-scroll style).
123
- function maybeLoadMore() {
124
- if (!isPaged()) return;
125
- if (state.visibleCount >= state.listOrder.length) return;
126
- if (list.selected < state.visibleCount - 1) return;
127
- state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.listOrder.length);
128
- renderList(list);
129
- setStatus(`Showing ${state.visibleCount} of ${state.listOrder.length}`, false, true);
130
- screen.render();
131
- }
132
-
133
- // When list selection changes, update selectedId and debounce detail fetch
134
- function onNav() {
135
- setImmediate(() => {
136
- maybeLoadMore();
137
- const idx = list.selected;
138
- const id = state.listOrder[idx];
139
- if (!id || id === state.selectedId) return;
140
- state.selectedId = id;
141
- renderDetail(detail);
142
- screen.render();
143
-
144
- clearTimeout(debounceTimer);
145
- debounceTimer = setTimeout(async () => {
146
- try {
147
- await loadDetail(id);
148
- renderDetail(detail);
149
- screen.render();
150
- } catch (err) {
151
- setStatus(err.message, true, true);
152
- }
153
- }, 80);
154
- });
155
- }
156
-
157
- // Capture all keypresses on the list to detect navigation
158
- list.on('keypress', onNav);
159
-
160
- // Focus border highlighting
161
- list.on('focus', () => { setFocusBorder('list'); screen.render(); });
162
- detail.on('focus', () => { setFocusBorder('detail'); screen.render(); });
163
-
164
- async function refresh() {
165
- setStatus('Refreshing…');
166
- try {
167
- await loadList();
168
- if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
169
- state.selectedId = state.listOrder[0] || null;
170
- }
171
- ensureSelectedVisible();
172
- if (state.selectedId) await loadDetail(state.selectedId);
173
- render();
174
- setStatus(defaultStatus());
175
- } catch (err) {
176
- setStatus(err.message, true, true);
177
- }
178
- }
179
-
180
- async function cycleFilter(dir = 1) {
181
- const idx = FILTERS.indexOf(state.filter);
182
- const next = (idx + dir + FILTERS.length) % FILTERS.length;
183
- state.filter = FILTERS[next];
184
- setStatus(`Filter: ${state.filter} — loading…`);
185
- try {
186
- await loadList();
187
- state.selectedId = state.listOrder[0] || null;
188
- if (state.selectedId) await loadDetail(state.selectedId);
189
- render();
190
- setStatus(defaultStatus());
191
- } catch (err) {
192
- setStatus(err.message, true, true);
193
- }
194
- }
195
-
196
- async function cycleTypeFilter() {
197
- const idx = TYPE_FILTERS.indexOf(state.typeFilter);
198
- state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
199
- applyFilters();
200
- if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
201
- state.selectedId = state.listOrder[0] || null;
202
- } else if (!state.selectedId) {
203
- state.selectedId = state.listOrder[0] || null;
204
- }
205
- ensureSelectedVisible();
206
- render();
207
- if (state.selectedId) {
208
- try {
209
- await loadDetail(state.selectedId);
210
- renderDetail(detail);
211
- screen.render();
212
- } catch (err) {
213
- setStatus(err.message, true, true);
214
- return;
215
- }
216
- }
217
- setStatus(defaultStatus());
218
- }
219
-
220
- // ── Global keys ────────────────────────────────────────────────────────────
221
- // Modal open? swallow global keys so modals own their own keybindings
222
- // (e.g. Esc/q close the help dialog instead of quitting the app).
223
- let modalOpen = false;
224
- function key(keys, handler) {
225
- screen.key(keys, (...args) => {
226
- if (modalOpen) return;
227
- handler(...args);
228
- });
229
- }
230
-
231
- key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
232
-
233
- // r reloads data while preserving every active filter; Shift+r clears all
234
- // narrowing filters (title search, epic scope, type) and reloads fresh.
235
- // blessed reports a shifted letter as `S-<lower>` (never bare uppercase), so
236
- // the binding must be 'S-r' — 'R' would be dead. Same for S-c / S-g below.
237
- key(['r'], async () => {
238
- await refresh();
239
- });
240
- key(['S-r'], async () => {
241
- const had = !!(state.textFilter || state.epicFilter || state.typeFilter !== 'all');
242
- state.textFilter = null;
243
- state.epicFilter = null;
244
- state.typeFilter = 'all';
245
- await refresh();
246
- if (had) setStatus('Filters reset', false, true);
247
- });
248
- key(['tab'], () => cycleFilter(1));
249
- key(['S-tab'], () => cycleFilter(-1));
250
- key(['t'], cycleTypeFilter);
251
-
252
- key(['e'], async () => {
253
- if (screen.focused !== list) return;
254
- setStatus('Loading epics…');
255
- let epics;
256
- try {
257
- epics = await bdEpics(state.cwd);
258
- } catch (err) {
259
- setStatus(err.message, true, true);
260
- return;
261
- }
262
- setStatus(defaultStatus());
263
- let picked;
264
- try {
265
- picked = await epicPicker(screen, epics, state.epicFilter);
266
- } catch {
267
- list.focus();
268
- return;
269
- }
270
- list.focus();
271
- state.epicFilter = picked;
272
- if (picked) {
273
- state.filter = 'all';
274
- setStatus(`Epic: ${picked} — loading…`);
275
- try {
276
- await loadList();
277
- } catch (err) {
278
- setStatus(err.message, true, true);
279
- return;
280
- }
281
- } else {
282
- applyFilters();
283
- }
284
- state.selectedId = state.listOrder[0] || null;
285
- if (state.selectedId) {
286
- try { await loadDetail(state.selectedId); } catch (err) {
287
- setStatus(err.message, true, true);
288
- return;
289
- }
290
- }
291
- render();
292
- setStatus(defaultStatus());
293
- });
294
-
295
- // '?' toggles the help modal. Registered directly (NOT through key(), which
296
- // swallows every global while a modal is open) so the same key that opens the
297
- // help can also close it. Driving open AND close from this one handler — kept
298
- // out of showHelp's own close keys — avoids re-entrancy: a separate '?' close
299
- // binding would fire on the very keypress that registered it and slam the
300
- // modal shut again. closeHelp is non-null exactly while the help is open.
301
- let closeHelp = null;
302
- screen.key(['?'], () => {
303
- if (closeHelp) { closeHelp(); return; }
304
- if (modalOpen) return; // another modal (a picker) owns the screen
305
- modalOpen = true;
306
- closeHelp = showHelp(screen, () => {
307
- closeHelp = null;
308
- modalOpen = false;
309
- list.focus();
310
- screen.render();
311
- });
312
- });
313
-
314
- key(['enter', 'l'], () => {
315
- if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
316
- });
317
-
318
- key(['escape'], () => {
319
- if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
320
- });
321
-
322
- key(['h'], async () => {
323
- if (screen.focused === detail) { setFocusBorder('list'); list.focus(); screen.render(); return; }
324
- if (screen.focused !== list) return;
325
- if (!state.selectedId) return;
326
- const id = state.selectedId;
327
- const bead = state.beadsById.get(id);
328
- if (bead?.issue_type === 'epic') {
329
- setStatus(`${id} is an epic — epics are top-level`, true, true);
330
- return;
331
- }
332
- setStatus('Loading parents…');
333
- let epics, downDeps;
334
- try {
335
- [epics, downDeps] = await Promise.all([bdEpics(state.cwd), bdDepListDown(id, state.cwd)]);
336
- } catch (err) {
337
- setStatus(err.message, true, true);
338
- return;
339
- }
340
- // A bead should have at most one parent, but a prior bug could leave it with
341
- // several parent-child deps — collect them all so we can clear every one.
342
- const currentParentIds = downDeps
343
- .filter((d) => d.dependency_type === 'parent-child')
344
- .map((d) => d.id)
345
- .filter(Boolean);
346
- let newParent;
347
- try {
348
- newParent = await parentPicker(screen, epics, { selfId: id, currentParentIds });
349
- } catch (err) {
350
- list.focus();
351
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
352
- return;
353
- }
354
- list.focus();
355
- setStatus(`Reparenting ${id}…`);
356
- try {
357
- await applyMutation(id, async () => {
358
- // Detach every existing parent first so the bead is never left with two
359
- // parents, then attach the chosen one (skip if it's already a parent).
360
- for (const pid of currentParentIds) {
361
- if (pid !== newParent) await bdDepRemove(id, pid, state.cwd);
362
- }
363
- if (newParent && !currentParentIds.includes(newParent)) {
364
- await bdDepAdd(id, newParent, 'parent-child', state.cwd);
365
- }
366
- });
367
- await loadList();
368
- ensureSelectedVisible();
369
- render();
370
- setStatus(
371
- newParent ? `Reparented ${id} → ${newParent}` : `${id} is now standalone`,
372
- false,
373
- true,
374
- );
375
- } catch (err) {
376
- setStatus(err.message, true, true);
377
- }
378
- });
379
-
380
- key(['g'], () => {
381
- if (screen.focused !== list) return;
382
- list.select(0);
383
- onNav();
384
- screen.render();
385
- });
386
-
387
- key(['S-g'], () => {
388
- if (screen.focused !== list) return;
389
- // Jump to the true bottom — in paged mode reveal every remaining batch first.
390
- if (isPaged() && state.visibleCount < state.listOrder.length) {
391
- state.visibleCount = state.listOrder.length;
392
- renderList(list);
393
- }
394
- list.select(state.listOrder.length - 1);
395
- onNav();
396
- screen.render();
397
- });
398
-
399
- key(['/'], async () => {
400
- if (screen.focused !== list) return;
401
- try {
402
- // Pre-fill with the active search so it can be edited; an empty submit
403
- // clears the title filter (other filters stay put).
404
- const query = await textPrompt(screen, 'Filter by title', state.textFilter || '');
405
- list.focus();
406
- state.textFilter = query || null;
407
- applyFilters();
408
- if (!state.selectedId || !state.listOrder.includes(state.selectedId)) {
409
- state.selectedId = state.listOrder[0] || null;
410
- }
411
- ensureSelectedVisible();
412
- renderList(list);
413
- renderDetail(detail);
414
- screen.render();
415
- setStatus(defaultStatus());
416
- } catch {
417
- list.focus();
418
- }
419
- });
420
-
421
- // ── Mutation keys (list focus only) ────────────────────────────────────────
422
-
423
- key(['s'], async () => {
424
- if (screen.focused !== list || !state.selectedId) return;
425
- const id = state.selectedId;
426
- try {
427
- const newStatus = await statusPicker(screen);
428
- list.focus();
429
- const oldStatus = state.beadsById.get(id)?.status;
430
- setStatus(`${id}: ${oldStatus} → ${newStatus}…`);
431
- await applyMutation(id, () => bdUpdate(id, { status: newStatus }, state.cwd));
432
- renderList(list);
433
- renderDetail(detail);
434
- screen.render();
435
- setStatus(`${id}: ${oldStatus} → ${newStatus}`, false, true);
436
- } catch (err) {
437
- list.focus();
438
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
439
- }
440
- });
441
-
442
- key(['c'], async () => {
443
- if (screen.focused !== list || !state.selectedId) return;
444
- const id = state.selectedId;
445
- try {
446
- const reason = await textPrompt(screen, 'Close reason');
447
- list.focus();
448
- setStatus(`Closing ${id}…`);
449
- await applyMutation(id, () => bdClose(id, reason, state.cwd));
450
- await loadList();
451
- if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
452
- state.selectedId = state.listOrder[0] || null;
453
- }
454
- ensureSelectedVisible();
455
- render();
456
- setStatus(`Closed ${id}`, false, true);
457
- } catch (err) {
458
- list.focus();
459
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
460
- }
461
- });
462
-
463
- key(['S-c'], async () => {
464
- if (screen.focused !== list || !state.selectedId) return;
465
- const id = state.selectedId;
466
- setStatus(`Claiming ${id}…`);
467
- try {
468
- await applyMutation(id, () => bdClaim(id, state.cwd));
469
- renderList(list);
470
- renderDetail(detail);
471
- screen.render();
472
- setStatus(`Claimed ${id}`, false, true);
473
- } catch (err) {
474
- setStatus(err.message, true, true);
475
- }
476
- });
477
-
478
- key(['o'], async () => {
479
- if (screen.focused !== list || !state.selectedId) return;
480
- const id = state.selectedId;
481
- setStatus(`Reopening ${id}…`);
482
- try {
483
- await applyMutation(id, () => bdReopen(id, state.cwd));
484
- await loadList();
485
- if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
486
- state.selectedId = state.listOrder[0] || null;
487
- }
488
- ensureSelectedVisible();
489
- render();
490
- setStatus(`Reopened ${id}`, false, true);
491
- } catch (err) {
492
- setStatus(err.message, true, true);
493
- }
494
- });
495
-
496
- key(['p'], async () => {
497
- if (screen.focused !== list || !state.selectedId) return;
498
- const id = state.selectedId;
499
- try {
500
- const priority = await priorityPicker(screen);
501
- list.focus();
502
- setStatus(`Setting priority ${priority} on ${id}…`);
503
- await applyMutation(id, () => bdUpdate(id, { priority }, state.cwd));
504
- renderList(list);
505
- renderDetail(detail);
506
- screen.render();
507
- setStatus(`${id}: priority → ${priority}`, false, true);
508
- } catch (err) {
509
- list.focus();
510
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
511
- }
512
- });
513
-
514
- async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
515
- setStatus(busyMsg || `Copying…`);
516
- const write = (cmd, args) => new Promise((resolve, reject) => {
517
- let settled = false;
518
- const finish = (err) => { if (!settled) { settled = true; err ? reject(err) : resolve(); } };
519
- const p = execFile(cmd, args, (err) => finish(err || null));
520
- p.on('error', finish);
521
- if (p.stdin) {
522
- p.stdin.once('finish', () => setTimeout(() => finish(null), 100));
523
- p.stdin.end(text);
524
- }
525
- });
526
- const candidates = process.platform === 'win32'
527
- ? [['clip', []]]
528
- : [
529
- ['wl-copy', []],
530
- ['xclip', ['-selection', 'clipboard']],
531
- ['xsel', ['--clipboard', '--input']],
532
- ['pbcopy', []],
533
- ];
534
- for (const [cmd, args] of candidates) {
535
- try {
536
- await write(cmd, args);
537
- setStatus(okMsg || `Copied`, false, true);
538
- return;
539
- } catch {}
540
- }
541
- const hint = process.platform === 'win32'
542
- ? 'clip.exe missing'
543
- : 'install wl-clipboard, xclip, or xsel';
544
- setStatus(`clipboard unavailable — ${hint}`, true, true);
545
- }
546
-
547
- key(['y'], () => {
548
- const id = state.selectedId;
549
- if (!id) return;
550
- copyToClipboard(id, { busyMsg: `Copying ${id}…`, okMsg: `Copied ${id}` });
551
- });
552
-
553
- key(['w'], async () => {
554
- if (screen.focused !== list || !state.selectedId) return;
555
- const id = state.selectedId;
556
- const bead = state.beadsById.get(id);
557
- try {
558
- const line = await skillPicker(screen, id, bead?.issue_type);
559
- list.focus();
560
- await copyToClipboard(line, {
561
- busyMsg: `Copying ${line}…`,
562
- okMsg: `Copied: ${line}`,
563
- });
564
- } catch (err) {
565
- list.focus();
566
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
567
- }
568
- });
569
-
570
- // ── Boot ───────────────────────────────────────────────────────────────────
571
-
572
- setFocusBorder('list');
573
- list.focus();
574
- setStatus('Loading…');
575
- try {
576
- await loadList();
577
- state.selectedId = state.listOrder[0] || null;
578
- if (state.selectedId) await loadDetail(state.selectedId);
579
- } catch (err) {
580
- setStatus(err.message, true);
581
- }
582
- render();
583
- // setStatus already appends "? help · q quit"; defaultStatus() yields "Ready"
584
- // (or the active-filter summary) without duplicating the help hint.
585
- setStatus(defaultStatus());
586
- }
1
+ import blessed from 'blessed';
2
+ import { execFile } from 'child_process';
3
+ import { state, loadList, loadDetail, applyMutation, applyFilters, isPaged, ensureSelectedVisible } from './state.js';
4
+ import { createList, renderList } from './views/list.js';
5
+ import { createDetail, renderDetail } from './views/detail.js';
6
+ import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
7
+ import { showHelp } from './keys.js';
8
+ import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListDown, bdEpics } from './bd.js';
9
+
10
+ const FILTERS = ['deferred', 'blocked', 'ready', 'in_progress', 'closed', 'all'];
11
+ const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
12
+
13
+ // Neutralize blessed tag markup in free text before it lands in a tags:true
14
+ // box (mirrors esc() in views/detail.js). The title search is user-typed, so a
15
+ // query like "{bold}" would otherwise corrupt the status line.
16
+ // Single pass: a two-step .replace(/\{/).replace(/\}/) would re-process the "}"
17
+ // it just inserted into "{open}", corrupting any text that contains a literal
18
+ // "}" (e.g. a bd JSON error blob) into a stray "{open}" token.
19
+ const escTags = (s) => String(s ?? '').replace(/[{}]/g, (c) => (c === '{' ? '{open}' : '{close}'));
20
+
21
+ export async function run(cwd) {
22
+ state.cwd = cwd;
23
+
24
+ const screen = blessed.screen({ smartCSR: true, title: 'bdtui', fullUnicode: true });
25
+
26
+ const tabBar = blessed.box({
27
+ parent: screen,
28
+ top: 0, left: 0,
29
+ width: '100%', height: 1,
30
+ content: '', tags: true,
31
+ style: { bg: 'black', fg: 'white' },
32
+ });
33
+
34
+ const statusBar = blessed.box({
35
+ parent: screen,
36
+ bottom: 0, left: 0,
37
+ width: '100%', height: 1,
38
+ content: ' Loading… | {gray-fg}? help{/}',
39
+ tags: true,
40
+ style: { bg: 'black', fg: 'white' },
41
+ });
42
+
43
+ const list = createList(screen);
44
+ const detail = createDetail(screen);
45
+
46
+ // blessed's enableMouse() only asks xterm-like terminals for X10 + UTF-8
47
+ // (?1005) mouse reports. X10 can't encode columns past 223 and xterm.js (the
48
+ // VS Code terminal) ignores ?1005, so on a wide terminal every wheel/click on
49
+ // the right part of the Detail pane is silently dropped. Also request SGR
50
+ // (?1006): blessed parses it, and it has no coordinate limit.
51
+ function enableMouse() {
52
+ screen.program.enableMouse();
53
+ screen.program.setMouse({ sgrMouse: true }, true);
54
+ }
55
+ enableMouse();
56
+
57
+ let debounceTimer = null;
58
+
59
+ let statusTimer = null;
60
+ // Describe every active narrowing filter (title search, epic scope, type) in
61
+ // one line with the result count and reset hint. Returns null when none are
62
+ // active so the caller can fall back to a plain "Ready".
63
+ function filterSummary() {
64
+ const parts = [];
65
+ // Plain text only — setStatus() escapes the whole summary, so pre-escaping
66
+ // textFilter here would double-escape any braces in the search query.
67
+ if (state.textFilter) parts.push(`"${state.textFilter}"`);
68
+ if (state.epicFilter) parts.push(`epic: ${state.epicFilter}`);
69
+ if (state.typeFilter !== 'all') parts.push(`type: ${state.typeFilter}`);
70
+ if (parts.length === 0) return null;
71
+ return `${parts.join(' · ')} — ${state.listOrder.length} results | Shift+r to reset`;
72
+ }
73
+ function defaultStatus() {
74
+ return filterSummary() || 'Ready';
75
+ }
76
+ function setStatus(msg, isError = false, transient = false) {
77
+ if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
78
+ // The status bar is a single tags:true row. Neutralize blessed tag markup
79
+ // and collapse newlines so dynamic text — bd error messages especially,
80
+ // which can be multi-line and contain "{" — renders as plain text instead
81
+ // of corrupting the markup or overflowing the one-line height.
82
+ const safe = escTags(String(msg ?? '').replace(/\s*\n\s*/g, ' ').trim());
83
+ const icon = isError ? '{red-fg}✗{/}' : '{green-fg}●{/}';
84
+ const text = isError ? `{red-fg}${safe}{/}` : safe;
85
+ statusBar.setContent(` ${icon} ${text} {gray-fg}? help · q quit{/}`);
86
+ screen.render();
87
+ if (transient) {
88
+ statusTimer = setTimeout(() => {
89
+ statusTimer = null;
90
+ setStatus(defaultStatus());
91
+ }, 3000);
92
+ }
93
+ }
94
+
95
+ function renderTabBar() {
96
+ const tabs = FILTERS.map((f) => {
97
+ return f === state.filter
98
+ ? `{blue-bg}{white-fg}{bold} ${f} {/bold}{/}`
99
+ : `{gray-fg} ${f} {/}`;
100
+ });
101
+ const count = state.listOrder.length;
102
+ const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
103
+ const typeInfo = ` {gray-fg}│{/} {yellow-fg}type:{/} ${typeLabel}`;
104
+ const epicInfo = state.epicFilter
105
+ ? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
106
+ : '';
107
+ const textInfo = state.textFilter
108
+ ? ` {gray-fg}│{/} {green-fg}search:{/} "${escTags(state.textFilter)}"`
109
+ : '';
110
+ const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
111
+ tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + textInfo + info);
112
+ }
113
+
114
+ function setFocusBorder(focused) {
115
+ if (focused === 'list') {
116
+ list.style.border.fg = 'cyan';
117
+ detail.style.border.fg = 'gray';
118
+ } else {
119
+ list.style.border.fg = 'gray';
120
+ detail.style.border.fg = 'cyan';
121
+ }
122
+ }
123
+
124
+ function render() {
125
+ renderTabBar();
126
+ renderList(list);
127
+ renderDetail(detail);
128
+ screen.render();
129
+ }
130
+
131
+ // Lazy "load more": the paged tabs (all / closed) load every bead but render
132
+ // them pageSize at a time; reveal the next batch once selection reaches the
133
+ // last visible row (infinite-scroll style).
134
+ function maybeLoadMore() {
135
+ if (!isPaged()) return;
136
+ if (state.visibleCount >= state.listOrder.length) return;
137
+ if (list.selected < state.visibleCount - 1) return;
138
+ state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.listOrder.length);
139
+ renderList(list);
140
+ setStatus(`Showing ${state.visibleCount} of ${state.listOrder.length}`, false, true);
141
+ screen.render();
142
+ }
143
+
144
+ // When list selection changes, update selectedId and debounce detail fetch
145
+ function onNav() {
146
+ setImmediate(() => {
147
+ maybeLoadMore();
148
+ const idx = list.selected;
149
+ const id = state.listOrder[idx];
150
+ if (!id || id === state.selectedId) return;
151
+ state.selectedId = id;
152
+ renderDetail(detail);
153
+ screen.render();
154
+
155
+ clearTimeout(debounceTimer);
156
+ debounceTimer = setTimeout(async () => {
157
+ try {
158
+ await loadDetail(id);
159
+ renderDetail(detail);
160
+ screen.render();
161
+ } catch (err) {
162
+ setStatus(err.message, true, true);
163
+ }
164
+ }, 80);
165
+ });
166
+ }
167
+
168
+ // Follow every selection change on the list — keyboard nav, a mouse click on
169
+ // a row, or a wheel scroll — not just keypresses, so clicks update Detail too.
170
+ list.on('select item', onNav);
171
+
172
+ // Focus border highlighting
173
+ list.on('focus', () => { setFocusBorder('list'); screen.render(); });
174
+ detail.on('focus', () => { setFocusBorder('detail'); screen.render(); });
175
+
176
+ async function refresh() {
177
+ setStatus('Refreshing…');
178
+ try {
179
+ await loadList();
180
+ if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
181
+ state.selectedId = state.listOrder[0] || null;
182
+ }
183
+ ensureSelectedVisible();
184
+ if (state.selectedId) await loadDetail(state.selectedId);
185
+ render();
186
+ setStatus(defaultStatus());
187
+ } catch (err) {
188
+ setStatus(err.message, true, true);
189
+ }
190
+ }
191
+
192
+ async function cycleFilter(dir = 1) {
193
+ const idx = FILTERS.indexOf(state.filter);
194
+ const next = (idx + dir + FILTERS.length) % FILTERS.length;
195
+ state.filter = FILTERS[next];
196
+ setStatus(`Filter: ${state.filter} — loading…`);
197
+ try {
198
+ await loadList();
199
+ state.selectedId = state.listOrder[0] || null;
200
+ if (state.selectedId) await loadDetail(state.selectedId);
201
+ render();
202
+ setStatus(defaultStatus());
203
+ } catch (err) {
204
+ setStatus(err.message, true, true);
205
+ }
206
+ }
207
+
208
+ async function cycleTypeFilter() {
209
+ const idx = TYPE_FILTERS.indexOf(state.typeFilter);
210
+ state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
211
+ applyFilters();
212
+ if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
213
+ state.selectedId = state.listOrder[0] || null;
214
+ } else if (!state.selectedId) {
215
+ state.selectedId = state.listOrder[0] || null;
216
+ }
217
+ ensureSelectedVisible();
218
+ render();
219
+ if (state.selectedId) {
220
+ try {
221
+ await loadDetail(state.selectedId);
222
+ renderDetail(detail);
223
+ screen.render();
224
+ } catch (err) {
225
+ setStatus(err.message, true, true);
226
+ return;
227
+ }
228
+ }
229
+ setStatus(defaultStatus());
230
+ }
231
+
232
+ // ── Global keys ────────────────────────────────────────────────────────────
233
+ // Modal open? swallow global keys so modals own their own keybindings
234
+ // (e.g. Esc/q close the help dialog instead of quitting the app).
235
+ let modalOpen = false;
236
+ function key(keys, handler) {
237
+ screen.key(keys, (...args) => {
238
+ if (modalOpen) return;
239
+ handler(...args);
240
+ });
241
+ }
242
+
243
+ key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
244
+
245
+ // r reloads data while preserving every active filter; Shift+r clears all
246
+ // narrowing filters (title search, epic scope, type) and reloads fresh.
247
+ // blessed reports a shifted letter as `S-<lower>` (never bare uppercase), so
248
+ // the binding must be 'S-r' — 'R' would be dead. Same for S-c / S-g below.
249
+ key(['r'], async () => {
250
+ await refresh();
251
+ });
252
+ key(['S-r'], async () => {
253
+ const had = !!(state.textFilter || state.epicFilter || state.typeFilter !== 'all');
254
+ state.textFilter = null;
255
+ state.epicFilter = null;
256
+ state.typeFilter = 'all';
257
+ await refresh();
258
+ if (had) setStatus('Filters reset', false, true);
259
+ });
260
+ key(['tab'], () => cycleFilter(1));
261
+ key(['S-tab'], () => cycleFilter(-1));
262
+ key(['t'], cycleTypeFilter);
263
+
264
+ key(['e'], async () => {
265
+ if (screen.focused !== list) return;
266
+ setStatus('Loading epics…');
267
+ let epics;
268
+ try {
269
+ epics = await bdEpics(state.cwd);
270
+ } catch (err) {
271
+ setStatus(err.message, true, true);
272
+ return;
273
+ }
274
+ setStatus(defaultStatus());
275
+ let picked;
276
+ try {
277
+ picked = await epicPicker(screen, epics, state.epicFilter);
278
+ } catch {
279
+ list.focus();
280
+ return;
281
+ }
282
+ list.focus();
283
+ state.epicFilter = picked;
284
+ if (picked) {
285
+ state.filter = 'all';
286
+ setStatus(`Epic: ${picked} — loading…`);
287
+ try {
288
+ await loadList();
289
+ } catch (err) {
290
+ setStatus(err.message, true, true);
291
+ return;
292
+ }
293
+ } else {
294
+ applyFilters();
295
+ }
296
+ state.selectedId = state.listOrder[0] || null;
297
+ if (state.selectedId) {
298
+ try { await loadDetail(state.selectedId); } catch (err) {
299
+ setStatus(err.message, true, true);
300
+ return;
301
+ }
302
+ }
303
+ render();
304
+ setStatus(defaultStatus());
305
+ });
306
+
307
+ // '?' toggles the help modal. Registered directly (NOT through key(), which
308
+ // swallows every global while a modal is open) so the same key that opens the
309
+ // help can also close it. Driving open AND close from this one handler — kept
310
+ // out of showHelp's own close keys — avoids re-entrancy: a separate '?' close
311
+ // binding would fire on the very keypress that registered it and slam the
312
+ // modal shut again. closeHelp is non-null exactly while the help is open.
313
+ let closeHelp = null;
314
+ screen.key(['?'], () => {
315
+ if (closeHelp) { closeHelp(); return; }
316
+ if (modalOpen) return; // another modal (a picker) owns the screen
317
+ modalOpen = true;
318
+ closeHelp = showHelp(screen, () => {
319
+ closeHelp = null;
320
+ modalOpen = false;
321
+ list.focus();
322
+ screen.render();
323
+ });
324
+ });
325
+
326
+ key(['enter', 'l'], () => {
327
+ if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
328
+ });
329
+
330
+ key(['escape'], () => {
331
+ if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
332
+ });
333
+
334
+ key(['h'], async () => {
335
+ if (screen.focused === detail) { setFocusBorder('list'); list.focus(); screen.render(); return; }
336
+ if (screen.focused !== list) return;
337
+ if (!state.selectedId) return;
338
+ const id = state.selectedId;
339
+ const bead = state.beadsById.get(id);
340
+ if (bead?.issue_type === 'epic') {
341
+ setStatus(`${id} is an epic — epics are top-level`, true, true);
342
+ return;
343
+ }
344
+ setStatus('Loading parents…');
345
+ let epics, downDeps;
346
+ try {
347
+ [epics, downDeps] = await Promise.all([bdEpics(state.cwd), bdDepListDown(id, state.cwd)]);
348
+ } catch (err) {
349
+ setStatus(err.message, true, true);
350
+ return;
351
+ }
352
+ // A bead should have at most one parent, but a prior bug could leave it with
353
+ // several parent-child deps — collect them all so we can clear every one.
354
+ const currentParentIds = downDeps
355
+ .filter((d) => d.dependency_type === 'parent-child')
356
+ .map((d) => d.id)
357
+ .filter(Boolean);
358
+ let newParent;
359
+ try {
360
+ newParent = await parentPicker(screen, epics, { selfId: id, currentParentIds });
361
+ } catch (err) {
362
+ list.focus();
363
+ if (err.message !== 'cancelled') setStatus(err.message, true, true);
364
+ return;
365
+ }
366
+ list.focus();
367
+ setStatus(`Reparenting ${id}…`);
368
+ try {
369
+ await applyMutation(id, async () => {
370
+ // Detach every existing parent first so the bead is never left with two
371
+ // parents, then attach the chosen one (skip if it's already a parent).
372
+ for (const pid of currentParentIds) {
373
+ if (pid !== newParent) await bdDepRemove(id, pid, state.cwd);
374
+ }
375
+ if (newParent && !currentParentIds.includes(newParent)) {
376
+ await bdDepAdd(id, newParent, 'parent-child', state.cwd);
377
+ }
378
+ });
379
+ await loadList();
380
+ ensureSelectedVisible();
381
+ render();
382
+ setStatus(
383
+ newParent ? `Reparented ${id} → ${newParent}` : `${id} is now standalone`,
384
+ false,
385
+ true,
386
+ );
387
+ } catch (err) {
388
+ setStatus(err.message, true, true);
389
+ }
390
+ });
391
+
392
+ key(['g'], () => {
393
+ if (screen.focused !== list) return;
394
+ list.select(0);
395
+ onNav();
396
+ screen.render();
397
+ });
398
+
399
+ key(['S-g'], () => {
400
+ if (screen.focused !== list) return;
401
+ // Jump to the true bottom — in paged mode reveal every remaining batch first.
402
+ if (isPaged() && state.visibleCount < state.listOrder.length) {
403
+ state.visibleCount = state.listOrder.length;
404
+ renderList(list);
405
+ }
406
+ list.select(state.listOrder.length - 1);
407
+ onNav();
408
+ screen.render();
409
+ });
410
+
411
+ key(['/'], async () => {
412
+ if (screen.focused !== list) return;
413
+ try {
414
+ // Pre-fill with the active search so it can be edited; an empty submit
415
+ // clears the title filter (other filters stay put).
416
+ const query = await textPrompt(screen, 'Filter by title', state.textFilter || '');
417
+ list.focus();
418
+ state.textFilter = query || null;
419
+ applyFilters();
420
+ if (!state.selectedId || !state.listOrder.includes(state.selectedId)) {
421
+ state.selectedId = state.listOrder[0] || null;
422
+ }
423
+ ensureSelectedVisible();
424
+ renderList(list);
425
+ renderDetail(detail);
426
+ screen.render();
427
+ setStatus(defaultStatus());
428
+ } catch {
429
+ list.focus();
430
+ }
431
+ });
432
+
433
+ // ── Mutation keys (list focus only) ────────────────────────────────────────
434
+
435
+ key(['s'], async () => {
436
+ if (screen.focused !== list || !state.selectedId) return;
437
+ const id = state.selectedId;
438
+ try {
439
+ const newStatus = await statusPicker(screen);
440
+ list.focus();
441
+ const oldStatus = state.beadsById.get(id)?.status;
442
+ setStatus(`${id}: ${oldStatus} → ${newStatus}…`);
443
+ await applyMutation(id, () => bdUpdate(id, { status: newStatus }, state.cwd));
444
+ renderList(list);
445
+ renderDetail(detail);
446
+ screen.render();
447
+ setStatus(`${id}: ${oldStatus} → ${newStatus}`, false, true);
448
+ } catch (err) {
449
+ list.focus();
450
+ if (err.message !== 'cancelled') setStatus(err.message, true, true);
451
+ }
452
+ });
453
+
454
+ key(['c'], async () => {
455
+ if (screen.focused !== list || !state.selectedId) return;
456
+ const id = state.selectedId;
457
+ try {
458
+ const reason = await textPrompt(screen, 'Close reason');
459
+ list.focus();
460
+ setStatus(`Closing ${id}…`);
461
+ await applyMutation(id, () => bdClose(id, reason, state.cwd));
462
+ await loadList();
463
+ if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
464
+ state.selectedId = state.listOrder[0] || null;
465
+ }
466
+ ensureSelectedVisible();
467
+ render();
468
+ setStatus(`Closed ${id}`, false, true);
469
+ } catch (err) {
470
+ list.focus();
471
+ if (err.message !== 'cancelled') setStatus(err.message, true, true);
472
+ }
473
+ });
474
+
475
+ key(['S-c'], async () => {
476
+ if (screen.focused !== list || !state.selectedId) return;
477
+ const id = state.selectedId;
478
+ setStatus(`Claiming ${id}…`);
479
+ try {
480
+ await applyMutation(id, () => bdClaim(id, state.cwd));
481
+ renderList(list);
482
+ renderDetail(detail);
483
+ screen.render();
484
+ setStatus(`Claimed ${id}`, false, true);
485
+ } catch (err) {
486
+ setStatus(err.message, true, true);
487
+ }
488
+ });
489
+
490
+ key(['o'], async () => {
491
+ if (screen.focused !== list || !state.selectedId) return;
492
+ const id = state.selectedId;
493
+ setStatus(`Reopening ${id}…`);
494
+ try {
495
+ await applyMutation(id, () => bdReopen(id, state.cwd));
496
+ await loadList();
497
+ if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
498
+ state.selectedId = state.listOrder[0] || null;
499
+ }
500
+ ensureSelectedVisible();
501
+ render();
502
+ setStatus(`Reopened ${id}`, false, true);
503
+ } catch (err) {
504
+ setStatus(err.message, true, true);
505
+ }
506
+ });
507
+
508
+ key(['p'], async () => {
509
+ if (screen.focused !== list || !state.selectedId) return;
510
+ const id = state.selectedId;
511
+ try {
512
+ const priority = await priorityPicker(screen);
513
+ list.focus();
514
+ setStatus(`Setting priority ${priority} on ${id}…`);
515
+ await applyMutation(id, () => bdUpdate(id, { priority }, state.cwd));
516
+ renderList(list);
517
+ renderDetail(detail);
518
+ screen.render();
519
+ setStatus(`${id}: priority → ${priority}`, false, true);
520
+ } catch (err) {
521
+ list.focus();
522
+ if (err.message !== 'cancelled') setStatus(err.message, true, true);
523
+ }
524
+ });
525
+
526
+ async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
527
+ setStatus(busyMsg || `Copying…`);
528
+ const write = (cmd, args) => new Promise((resolve, reject) => {
529
+ let settled = false;
530
+ const finish = (err) => { if (!settled) { settled = true; err ? reject(err) : resolve(); } };
531
+ const p = execFile(cmd, args, (err) => finish(err || null));
532
+ p.on('error', finish);
533
+ if (p.stdin) {
534
+ p.stdin.once('finish', () => setTimeout(() => finish(null), 100));
535
+ p.stdin.end(text);
536
+ }
537
+ });
538
+ const candidates = process.platform === 'win32'
539
+ ? [['clip', []]]
540
+ : [
541
+ ['wl-copy', []],
542
+ ['xclip', ['-selection', 'clipboard']],
543
+ ['xsel', ['--clipboard', '--input']],
544
+ ['pbcopy', []],
545
+ ];
546
+ for (const [cmd, args] of candidates) {
547
+ try {
548
+ await write(cmd, args);
549
+ setStatus(okMsg || `Copied`, false, true);
550
+ return;
551
+ } catch {}
552
+ }
553
+ const hint = process.platform === 'win32'
554
+ ? 'clip.exe missing'
555
+ : 'install wl-clipboard, xclip, or xsel';
556
+ setStatus(`clipboard unavailable — ${hint}`, true, true);
557
+ }
558
+
559
+ key(['y'], () => {
560
+ const id = state.selectedId;
561
+ if (!id) return;
562
+ copyToClipboard(id, { busyMsg: `Copying ${id}…`, okMsg: `Copied ${id}` });
563
+ });
564
+
565
+ key(['w'], async () => {
566
+ if (screen.focused !== list || !state.selectedId) return;
567
+ const id = state.selectedId;
568
+ const bead = state.beadsById.get(id);
569
+ try {
570
+ const line = await skillPicker(screen, id, bead?.issue_type);
571
+ list.focus();
572
+ await copyToClipboard(line, {
573
+ busyMsg: `Copying ${line}…`,
574
+ okMsg: `Copied: ${line}`,
575
+ });
576
+ } catch (err) {
577
+ list.focus();
578
+ if (err.message !== 'cancelled') setStatus(err.message, true, true);
579
+ }
580
+ });
581
+
582
+ // Release blessed's mouse capture so the terminal can do native click-drag
583
+ // text selection (then copy with the terminal's own copy). blessed grabs the
584
+ // mouse process-wide the moment any element sets mouse:true, which blocks
585
+ // native selection; toggling it off hands the mouse back to the terminal.
586
+ // Re-enable via program.enableMouse() directly (inside enableMouse()) —
587
+ // screen.enableMouse() is a no-op here because blessed's _listenedMouse latch
588
+ // is already set.
589
+ let mouseCaptured = true;
590
+ key(['m'], () => {
591
+ mouseCaptured = !mouseCaptured;
592
+ if (mouseCaptured) {
593
+ enableMouse();
594
+ setStatus('Mouse restored — wheel scroll & click active', false, true);
595
+ } else {
596
+ screen.program.disableMouse();
597
+ setStatus('Mouse released — drag to select text, copy with your terminal · press m to restore');
598
+ }
599
+ });
600
+
601
+ // ── Boot ───────────────────────────────────────────────────────────────────
602
+
603
+ setFocusBorder('list');
604
+ list.focus();
605
+ setStatus('Loading…');
606
+ try {
607
+ await loadList();
608
+ state.selectedId = state.listOrder[0] || null;
609
+ if (state.selectedId) await loadDetail(state.selectedId);
610
+ } catch (err) {
611
+ setStatus(err.message, true);
612
+ }
613
+ render();
614
+ // setStatus already appends "? help · q quit"; defaultStatus() yields "Ready"
615
+ // (or the active-filter summary) without duplicating the help hint.
616
+ setStatus(defaultStatus());
617
+ }