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/bd.js CHANGED
@@ -1,130 +1,130 @@
1
- import { execFile } from 'child_process';
2
- import { promisify } from 'util';
3
-
4
- const execFileP = promisify(execFile);
5
-
6
- // Default execFile maxBuffer is 1MB; bd JSON output (esp. `list --all` with many
7
- // closed beads) can exceed that and throw "stdout maxBuffer length exceeded".
8
- const MAX_BUFFER = 64 * 1024 * 1024; // 64MB
9
-
10
- // bd reports failures with a non-zero exit and a structured JSON body
11
- // ({"error": "…", "schema_version": 1}) — usually on stderr, occasionally on
12
- // stdout. Surface the human-readable `error` field; fall back to the raw text,
13
- // then execFile's own message. Without this the whole multi-line JSON blob —
14
- // leading "{" and all — gets thrown verbatim and dumped into the one-line
15
- // status bar, which is the "error with {" users see when a tab fails to load.
16
- function bdErrorMessage(err) {
17
- const raw = (err.stderr?.trim() || err.stdout?.trim() || '');
18
- if (raw) {
19
- try {
20
- const parsed = JSON.parse(raw);
21
- if (parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
22
- return parsed.error.trim();
23
- }
24
- } catch {}
25
- return raw;
26
- }
27
- return err.message;
28
- }
29
-
30
- async function runBd(args, cwd) {
31
- try {
32
- const { stdout } = await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
33
- return JSON.parse(stdout);
34
- } catch (err) {
35
- throw new Error(bdErrorMessage(err));
36
- }
37
- }
38
-
39
- // Run a bd command whose stdout we don't consume (e.g. `dep add`/`dep remove`,
40
- // which print a human-readable "✓ Added dependency…" line, not JSON). We can't
41
- // JSON.parse that — doing so was the source of the "Unexpected token '✓'" error.
42
- // Failures still surface: a non-zero exit rejects execFile and we throw stderr.
43
- async function runBdVoid(args, cwd) {
44
- try {
45
- await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
46
- } catch (err) {
47
- throw new Error(bdErrorMessage(err));
48
- }
49
- }
50
-
51
- // `bd blocked` only returns *derived*-blocked beads (open beads whose blockers
52
- // aren't all closed); it omits beads whose status was explicitly set to
53
- // "blocked". The Blocked tab should show both, so merge the two sources, deduped.
54
- async function bdBlocked(cwd) {
55
- const [derived, explicit] = await Promise.all([
56
- runBd(['blocked', '--json'], cwd).catch(() => []),
57
- runBd(['list', '--status', 'blocked', '--json', '--limit', '0'], cwd).catch(() => []),
58
- ]);
59
- const byId = new Map();
60
- for (const b of [...(derived || []), ...(explicit || [])]) {
61
- if (b?.id && !byId.has(b.id)) byId.set(b.id, b);
62
- }
63
- return [...byId.values()];
64
- }
65
-
66
- export const bdList = (filter, cwd, { unlimited = false } = {}) => {
67
- if (filter === 'ready') return runBd(['ready', '--json'], cwd);
68
- if (filter === 'blocked') return bdBlocked(cwd);
69
- const args = ['list', '--json'];
70
- if (filter === 'all') args.push('--all');
71
- else if (filter) args.push('--status', filter);
72
- // `--limit 0` lifts bd's default 50-row cap for the paginated flat views.
73
- if (unlimited) args.push('--limit', '0');
74
- return runBd(args, cwd);
75
- };
76
-
77
- // bdList('all') with the 50-row cap lifted — used for enumerating every epic.
78
- export const bdListAll = (cwd) => bdList('all', cwd, { unlimited: true });
79
-
80
- export const bdShow = async (id, cwd) => {
81
- const res = await runBd(['show', id, '--json'], cwd);
82
- return Array.isArray(res) ? res[0] : res;
83
- };
84
-
85
- export const bdDeps = async (id, cwd) => {
86
- const [down, up] = await Promise.all([
87
- runBd(['dep', 'list', id, '--json'], cwd).catch(() => []),
88
- runBd(['dep', 'list', id, '--direction=up', '--json'], cwd).catch(() => []),
89
- ]);
90
- return { down: Array.isArray(down) ? down : [], up: Array.isArray(up) ? up : [] };
91
- };
92
-
93
- export const bdDepListDown = async (id, cwd) => {
94
- const res = await runBd(['dep', 'list', id, '--json'], cwd).catch(() => []);
95
- return Array.isArray(res) ? res : [];
96
- };
97
-
98
- export const bdUpdate = (id, opts, cwd) => {
99
- const args = ['update', id];
100
- if (opts.status) args.push('--status', opts.status);
101
- if (opts.priority !== undefined) args.push('--priority', String(opts.priority));
102
- return runBd([...args, '--json'], cwd);
103
- };
104
-
105
- export const bdClose = (id, reason, cwd) => {
106
- const args = ['close', id];
107
- if (reason) args.push('--reason', reason);
108
- return runBd([...args, '--json'], cwd);
109
- };
110
-
111
- export const bdClaim = (id, cwd) => runBd(['update', id, '--claim', '--json'], cwd);
112
-
113
- export const bdReopen = (id, cwd) => runBd(['reopen', id, '--json'], cwd);
114
-
115
- export const bdDepAdd = (child, parent, type, cwd) => {
116
- const args = ['dep', 'add', child, parent];
117
- if (type && type !== 'blocks') args.push('--type', type);
118
- return runBdVoid(args, cwd);
119
- };
120
-
121
- export const bdDepRemove = (child, parent, cwd) =>
122
- runBdVoid(['dep', 'remove', child, parent], cwd);
123
-
124
- // Returns all children of an epic (all statuses — bd children includes closed by default)
125
- export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
126
-
127
- export const bdEpics = async (cwd) => {
128
- const all = await bdListAll(cwd);
129
- return Array.isArray(all) ? all.filter((b) => b.issue_type === 'epic') : [];
130
- };
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileP = promisify(execFile);
5
+
6
+ // Default execFile maxBuffer is 1MB; bd JSON output (esp. `list --all` with many
7
+ // closed beads) can exceed that and throw "stdout maxBuffer length exceeded".
8
+ const MAX_BUFFER = 64 * 1024 * 1024; // 64MB
9
+
10
+ // bd reports failures with a non-zero exit and a structured JSON body
11
+ // ({"error": "…", "schema_version": 1}) — usually on stderr, occasionally on
12
+ // stdout. Surface the human-readable `error` field; fall back to the raw text,
13
+ // then execFile's own message. Without this the whole multi-line JSON blob —
14
+ // leading "{" and all — gets thrown verbatim and dumped into the one-line
15
+ // status bar, which is the "error with {" users see when a tab fails to load.
16
+ function bdErrorMessage(err) {
17
+ const raw = (err.stderr?.trim() || err.stdout?.trim() || '');
18
+ if (raw) {
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
22
+ return parsed.error.trim();
23
+ }
24
+ } catch {}
25
+ return raw;
26
+ }
27
+ return err.message;
28
+ }
29
+
30
+ async function runBd(args, cwd) {
31
+ try {
32
+ const { stdout } = await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
33
+ return JSON.parse(stdout);
34
+ } catch (err) {
35
+ throw new Error(bdErrorMessage(err));
36
+ }
37
+ }
38
+
39
+ // Run a bd command whose stdout we don't consume (e.g. `dep add`/`dep remove`,
40
+ // which print a human-readable "✓ Added dependency…" line, not JSON). We can't
41
+ // JSON.parse that — doing so was the source of the "Unexpected token '✓'" error.
42
+ // Failures still surface: a non-zero exit rejects execFile and we throw stderr.
43
+ async function runBdVoid(args, cwd) {
44
+ try {
45
+ await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
46
+ } catch (err) {
47
+ throw new Error(bdErrorMessage(err));
48
+ }
49
+ }
50
+
51
+ // `bd blocked` only returns *derived*-blocked beads (open beads whose blockers
52
+ // aren't all closed); it omits beads whose status was explicitly set to
53
+ // "blocked". The Blocked tab should show both, so merge the two sources, deduped.
54
+ async function bdBlocked(cwd) {
55
+ const [derived, explicit] = await Promise.all([
56
+ runBd(['blocked', '--json'], cwd).catch(() => []),
57
+ runBd(['list', '--status', 'blocked', '--json', '--limit', '0'], cwd).catch(() => []),
58
+ ]);
59
+ const byId = new Map();
60
+ for (const b of [...(derived || []), ...(explicit || [])]) {
61
+ if (b?.id && !byId.has(b.id)) byId.set(b.id, b);
62
+ }
63
+ return [...byId.values()];
64
+ }
65
+
66
+ export const bdList = (filter, cwd, { unlimited = false } = {}) => {
67
+ if (filter === 'ready') return runBd(['ready', '--json'], cwd);
68
+ if (filter === 'blocked') return bdBlocked(cwd);
69
+ const args = ['list', '--json'];
70
+ if (filter === 'all') args.push('--all');
71
+ else if (filter) args.push('--status', filter);
72
+ // `--limit 0` lifts bd's default 50-row cap for the paginated flat views.
73
+ if (unlimited) args.push('--limit', '0');
74
+ return runBd(args, cwd);
75
+ };
76
+
77
+ // bdList('all') with the 50-row cap lifted — used for enumerating every epic.
78
+ export const bdListAll = (cwd) => bdList('all', cwd, { unlimited: true });
79
+
80
+ export const bdShow = async (id, cwd) => {
81
+ const res = await runBd(['show', id, '--json'], cwd);
82
+ return Array.isArray(res) ? res[0] : res;
83
+ };
84
+
85
+ export const bdDeps = async (id, cwd) => {
86
+ const [down, up] = await Promise.all([
87
+ runBd(['dep', 'list', id, '--json'], cwd).catch(() => []),
88
+ runBd(['dep', 'list', id, '--direction=up', '--json'], cwd).catch(() => []),
89
+ ]);
90
+ return { down: Array.isArray(down) ? down : [], up: Array.isArray(up) ? up : [] };
91
+ };
92
+
93
+ export const bdDepListDown = async (id, cwd) => {
94
+ const res = await runBd(['dep', 'list', id, '--json'], cwd).catch(() => []);
95
+ return Array.isArray(res) ? res : [];
96
+ };
97
+
98
+ export const bdUpdate = (id, opts, cwd) => {
99
+ const args = ['update', id];
100
+ if (opts.status) args.push('--status', opts.status);
101
+ if (opts.priority !== undefined) args.push('--priority', String(opts.priority));
102
+ return runBd([...args, '--json'], cwd);
103
+ };
104
+
105
+ export const bdClose = (id, reason, cwd) => {
106
+ const args = ['close', id];
107
+ if (reason) args.push('--reason', reason);
108
+ return runBd([...args, '--json'], cwd);
109
+ };
110
+
111
+ export const bdClaim = (id, cwd) => runBd(['update', id, '--claim', '--json'], cwd);
112
+
113
+ export const bdReopen = (id, cwd) => runBd(['reopen', id, '--json'], cwd);
114
+
115
+ export const bdDepAdd = (child, parent, type, cwd) => {
116
+ const args = ['dep', 'add', child, parent];
117
+ if (type && type !== 'blocks') args.push('--type', type);
118
+ return runBdVoid(args, cwd);
119
+ };
120
+
121
+ export const bdDepRemove = (child, parent, cwd) =>
122
+ runBdVoid(['dep', 'remove', child, parent], cwd);
123
+
124
+ // Returns all children of an epic (all statuses — bd children includes closed by default)
125
+ export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
126
+
127
+ export const bdEpics = async (cwd) => {
128
+ const all = await bdListAll(cwd);
129
+ return Array.isArray(all) ? all.filter((b) => b.issue_type === 'epic') : [];
130
+ };
package/src/keys.js CHANGED
@@ -1,98 +1,100 @@
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
- ' Shift+g Jump to bottom',
11
- ' Enter / l Focus detail pane',
12
- ' h / Esc Back to list (from detail)',
13
- '',
14
- ' Filters & refresh',
15
- ' ──────────────────────────────────────',
16
- ' Tab Next status filter (blocked/ready/in_progress/closed/all)',
17
- ' Shift+Tab Previous status filter',
18
- ' t Cycle type filter (all/epic/task/chore/bug)',
19
- ' e Filter by epic (modal picker; x clears; switches status to all)',
20
- ' / In-memory title filter (empty submit clears it)',
21
- ' r Reload (keeps active filters)',
22
- ' Shift+r Reset all filters (title / epic / type) and reload',
23
- ' (all & closed tabs load more rows as you scroll to the bottom)',
24
- '',
25
- ' Mutations',
26
- ' ──────────────────────────────────────',
27
- ' s Change status',
28
- ' c Close with reason',
29
- ' Shift+c Claim bead (in_progress + assign self)',
30
- ' o Reopen',
31
- ' p Change priority',
32
- ' h Change parent (epic / standalone)',
33
- '',
34
- ' Other',
35
- ' ──────────────────────────────────────',
36
- ' y Yank bead ID to clipboard',
37
- ' w Copy workflow command (tasks: executors; epics: epic-sequential)',
38
- ' ? Toggle this help',
39
- ' q / Ctrl-C Quit',
40
- '',
41
- ' Press ? q or Esc to close',
42
- ].join('\n');
43
-
44
- export function showHelp(screen, onClose) {
45
- const box = blessed.box({
46
- parent: screen,
47
- label: ' Keybindings ',
48
- border: { type: 'line' },
49
- top: 'center',
50
- left: 'center',
51
- width: '55%',
52
- height: '80%',
53
- content: HELP_TEXT,
54
- tags: false,
55
- keys: true,
56
- scrollable: true,
57
- alwaysScroll: true,
58
- scrollbar: { ch: '│' },
59
- style: {
60
- border: { fg: 'cyan' },
61
- label: { fg: 'cyan', bold: true },
62
- },
63
- });
64
-
65
- box.focus();
66
- screen.render();
67
-
68
- // '?' is deliberately NOT a close key here — the caller (app.js) drives it as
69
- // an open/close toggle. Binding '?' as a close key would re-enter on the very
70
- // keypress that opens the modal: the toggle adds this listener mid-dispatch
71
- // and blessed's emitter would then fire it in the same 'key ?' pass, slamming
72
- // the help shut the instant it opened. q/Esc/h are safe — they never open the
73
- // help, so they can only ever close it.
74
- const CLOSE_KEYS = ['q', 'escape', 'h'];
75
- let closed = false;
76
- const close = () => {
77
- if (closed) return;
78
- closed = true;
79
- screen.unkey(CLOSE_KEYS, close);
80
- box.destroy();
81
- screen.render();
82
- onClose?.();
83
- };
84
-
85
- // Bind the close keys on the screen, not the box. A box-level binding only
86
- // fires while the box holds keyboard focus, but the Beads list and Detail
87
- // pane are both mouse:true — a click on either steals focus from the help
88
- // box. app.js suppresses every global key while a modal is open, so a
89
- // focus-stealing click would otherwise leave the help permanently stuck
90
- // open (its own close keys dead, every global key inert). Screen-level keys
91
- // fire regardless of which pane holds focus, so q/Esc/h always dismiss it.
92
- // unkey() on close keeps the binding from leaking across repeated opens.
93
- screen.key(CLOSE_KEYS, close);
94
-
95
- // Returned so the caller's '?' toggle can dismiss the help regardless of
96
- // which pane currently holds focus.
97
- return close;
98
- }
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
+ ' Shift+g Jump to bottom',
11
+ ' Enter / l Focus detail pane',
12
+ ' h / Esc Back to list (from detail)',
13
+ ' j / k Scroll detail (when focused; g / Shift+g top/bottom, Ctrl-d/u half page)',
14
+ '',
15
+ ' Filters & refresh',
16
+ ' ──────────────────────────────────────',
17
+ ' Tab Next status filter (deferred/blocked/ready/in_progress/closed/all)',
18
+ ' Shift+Tab Previous status filter',
19
+ ' t Cycle type filter (all/epic/task/chore/bug)',
20
+ ' e Filter by epic (modal picker; x clears; switches status to all)',
21
+ ' / In-memory title filter (empty submit clears it)',
22
+ ' r Reload (keeps active filters)',
23
+ ' Shift+r Reset all filters (title / epic / type) and reload',
24
+ ' (all & closed tabs load more rows as you scroll to the bottom)',
25
+ '',
26
+ ' Mutations',
27
+ ' ──────────────────────────────────────',
28
+ ' s Change status',
29
+ ' c Close with reason',
30
+ ' Shift+c Claim bead (in_progress + assign self)',
31
+ ' o Reopen',
32
+ ' p Change priority',
33
+ ' h Change parent (epic / standalone)',
34
+ '',
35
+ ' Other',
36
+ ' ──────────────────────────────────────',
37
+ ' y Yank bead ID to clipboard',
38
+ ' w Copy workflow command (Claude / OpenCode sections; tasks: executors; epics: epic-sequential)',
39
+ ' m Release mouse to select/copy detail text (toggle)',
40
+ ' ? Toggle this help',
41
+ ' q / Ctrl-C Quit',
42
+ '',
43
+ ' Press ? q or Esc to close',
44
+ ].join('\n');
45
+
46
+ export function showHelp(screen, onClose) {
47
+ const box = blessed.box({
48
+ parent: screen,
49
+ label: ' Keybindings ',
50
+ border: { type: 'line' },
51
+ top: 'center',
52
+ left: 'center',
53
+ width: '55%',
54
+ height: '80%',
55
+ content: HELP_TEXT,
56
+ tags: false,
57
+ keys: true,
58
+ scrollable: true,
59
+ alwaysScroll: true,
60
+ scrollbar: { ch: '│' },
61
+ style: {
62
+ border: { fg: 'cyan' },
63
+ label: { fg: 'cyan', bold: true },
64
+ },
65
+ });
66
+
67
+ box.focus();
68
+ screen.render();
69
+
70
+ // '?' is deliberately NOT a close key here — the caller (app.js) drives it as
71
+ // an open/close toggle. Binding '?' as a close key would re-enter on the very
72
+ // keypress that opens the modal: the toggle adds this listener mid-dispatch
73
+ // and blessed's emitter would then fire it in the same 'key ?' pass, slamming
74
+ // the help shut the instant it opened. q/Esc/h are safe — they never open the
75
+ // help, so they can only ever close it.
76
+ const CLOSE_KEYS = ['q', 'escape', 'h'];
77
+ let closed = false;
78
+ const close = () => {
79
+ if (closed) return;
80
+ closed = true;
81
+ screen.unkey(CLOSE_KEYS, close);
82
+ box.destroy();
83
+ screen.render();
84
+ onClose?.();
85
+ };
86
+
87
+ // Bind the close keys on the screen, not the box. A box-level binding only
88
+ // fires while the box holds keyboard focus, but the Beads list and Detail
89
+ // pane are both mouse:true — a click on either steals focus from the help
90
+ // box. app.js suppresses every global key while a modal is open, so a
91
+ // focus-stealing click would otherwise leave the help permanently stuck
92
+ // open (its own close keys dead, every global key inert). Screen-level keys
93
+ // fire regardless of which pane holds focus, so q/Esc/h always dismiss it.
94
+ // unkey() on close keeps the binding from leaking across repeated opens.
95
+ screen.key(CLOSE_KEYS, close);
96
+
97
+ // Returned so the caller's '?' toggle can dismiss the help regardless of
98
+ // which pane currently holds focus.
99
+ return close;
100
+ }