bdtui 0.2.3 → 0.3.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/CHANGELOG.md +12 -0
- package/bin/bdtui.js +17 -0
- package/package.json +1 -1
- package/src/app.js +50 -4
- package/src/bd.js +5 -0
- package/src/keys.js +1 -0
- package/src/state.js +22 -0
- package/src/views/modals.js +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-05-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-start Dolt on launch: `bdtui` runs `bd dolt test` and falls back to `bd dolt start` if the project's Dolt server is down, so the TUI no longer opens against a dead backend. Skipped for `-v`/`--version`.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-05-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `e` shortcut: filter the list by epic via a modal picker; selecting an epic scopes the view to that epic and its children, with an option to clear the filter
|
|
16
|
+
|
|
5
17
|
## [0.2.3] - 2026-05-13
|
|
6
18
|
|
|
7
19
|
### Added
|
package/bin/bdtui.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
3
4
|
import { createRequire } from 'module';
|
|
4
5
|
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
@@ -15,5 +16,21 @@ if (arg === '-v' || arg === '--version') {
|
|
|
15
16
|
const cwd = arg ? path.resolve(arg) : process.cwd();
|
|
16
17
|
process.chdir(cwd);
|
|
17
18
|
|
|
19
|
+
ensureDoltRunning(cwd);
|
|
20
|
+
|
|
18
21
|
const { run } = await import('../src/app.js');
|
|
19
22
|
await run(cwd);
|
|
23
|
+
|
|
24
|
+
function ensureDoltRunning(cwd) {
|
|
25
|
+
const test = spawnSync('bd', ['dolt', 'test'], { cwd, stdio: 'ignore' });
|
|
26
|
+
if (test.status === 0) return;
|
|
27
|
+
// `bd` missing or otherwise unusable — let the TUI surface the real error.
|
|
28
|
+
if (test.error && test.error.code === 'ENOENT') return;
|
|
29
|
+
|
|
30
|
+
process.stderr.write("Dolt server not running — starting via 'bd dolt start'…\n");
|
|
31
|
+
const start = spawnSync('bd', ['dolt', 'start'], { cwd, stdio: 'inherit' });
|
|
32
|
+
if (start.status !== 0) {
|
|
33
|
+
process.stderr.write("Failed to start Dolt server. Run 'bd dolt start' manually.\n");
|
|
34
|
+
process.exit(start.status ?? 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -3,9 +3,9 @@ import { execFile } from 'child_process';
|
|
|
3
3
|
import { state, loadList, loadDetail, applyMutation, applyTypeFilter } from './state.js';
|
|
4
4
|
import { createList, renderList } from './views/list.js';
|
|
5
5
|
import { createDetail, renderDetail } from './views/detail.js';
|
|
6
|
-
import { statusPicker, priorityPicker, textPrompt, depMenu, skillPicker } from './views/modals.js';
|
|
6
|
+
import { statusPicker, priorityPicker, textPrompt, depMenu, skillPicker, epicPicker } from './views/modals.js';
|
|
7
7
|
import { showHelp } from './keys.js';
|
|
8
|
-
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove } from './bd.js';
|
|
8
|
+
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdEpics } from './bd.js';
|
|
9
9
|
|
|
10
10
|
const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
11
|
const TYPE_FILTERS = ['all', 'epic', 'task'];
|
|
@@ -63,9 +63,12 @@ export async function run(cwd) {
|
|
|
63
63
|
});
|
|
64
64
|
const count = state.listOrder.length;
|
|
65
65
|
const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
|
|
66
|
-
const typeInfo = ` {gray-fg}│{/} {yellow-fg}
|
|
66
|
+
const typeInfo = ` {gray-fg}│{/} {yellow-fg}type:{/} ${typeLabel}`;
|
|
67
|
+
const epicInfo = state.epicFilter
|
|
68
|
+
? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
|
|
69
|
+
: '';
|
|
67
70
|
const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
|
|
68
|
-
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + info);
|
|
71
|
+
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + info);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
function setFocusBorder(focused) {
|
|
@@ -187,6 +190,49 @@ export async function run(cwd) {
|
|
|
187
190
|
key(['S-tab'], () => cycleFilter(-1));
|
|
188
191
|
key(['t'], cycleTypeFilter);
|
|
189
192
|
|
|
193
|
+
key(['e'], async () => {
|
|
194
|
+
if (screen.focused !== list) return;
|
|
195
|
+
setStatus('Loading epics…');
|
|
196
|
+
let epics;
|
|
197
|
+
try {
|
|
198
|
+
epics = await bdEpics(state.cwd);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
setStatus(err.message, true, true);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
setStatus(defaultStatus());
|
|
204
|
+
let picked;
|
|
205
|
+
try {
|
|
206
|
+
picked = await epicPicker(screen, epics, state.epicFilter);
|
|
207
|
+
} catch {
|
|
208
|
+
list.focus();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
list.focus();
|
|
212
|
+
state.epicFilter = picked;
|
|
213
|
+
if (picked) {
|
|
214
|
+
state.filter = 'all';
|
|
215
|
+
setStatus(`Epic: ${picked} — loading…`);
|
|
216
|
+
try {
|
|
217
|
+
await loadList();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
setStatus(err.message, true, true);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
applyTypeFilter();
|
|
224
|
+
}
|
|
225
|
+
state.selectedId = state.listOrder[0] || null;
|
|
226
|
+
if (state.selectedId) {
|
|
227
|
+
try { await loadDetail(state.selectedId); } catch (err) {
|
|
228
|
+
setStatus(err.message, true, true);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
render();
|
|
233
|
+
setStatus(picked ? `Epic: ${picked}` : 'Epic filter cleared', false, true);
|
|
234
|
+
});
|
|
235
|
+
|
|
190
236
|
key(['?'], () => {
|
|
191
237
|
modalOpen = true;
|
|
192
238
|
showHelp(screen, () => {
|
package/src/bd.js
CHANGED
|
@@ -67,3 +67,8 @@ export const bdDepRemove = (child, parent, cwd) =>
|
|
|
67
67
|
|
|
68
68
|
// Returns all children of an epic (all statuses — bd children includes closed by default)
|
|
69
69
|
export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
|
|
70
|
+
|
|
71
|
+
export const bdEpics = async (cwd) => {
|
|
72
|
+
const all = await runBd(['list', '--all', '--json'], cwd);
|
|
73
|
+
return Array.isArray(all) ? all.filter((b) => b.issue_type === 'epic') : [];
|
|
74
|
+
};
|
package/src/keys.js
CHANGED
|
@@ -16,6 +16,7 @@ export const HELP_TEXT = [
|
|
|
16
16
|
' Tab Next status filter (blocked/ready/in_progress/closed/all)',
|
|
17
17
|
' Shift+Tab Previous status filter',
|
|
18
18
|
' t Cycle type filter (all/epic/task)',
|
|
19
|
+
' e Filter by epic (modal picker; switches status to all)',
|
|
19
20
|
' r Reload current filter from bd',
|
|
20
21
|
' / In-memory title filter',
|
|
21
22
|
'',
|
package/src/state.js
CHANGED
|
@@ -8,11 +8,33 @@ export const state = {
|
|
|
8
8
|
selectedId: null,
|
|
9
9
|
filter: 'ready',
|
|
10
10
|
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
11
|
+
epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
|
|
11
12
|
cwd: process.cwd(),
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
function scopeToEpic(items, epicId) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let inside = false;
|
|
18
|
+
for (const it of items) {
|
|
19
|
+
if (it.depth === 0) {
|
|
20
|
+
inside = it.id === epicId;
|
|
21
|
+
if (inside) out.push(it);
|
|
22
|
+
} else if (inside) {
|
|
23
|
+
out.push(it);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Fix isLast on the last child after scoping (cheap, optional polish).
|
|
27
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
28
|
+
if (out[i].depth > 0) { out[i] = { ...out[i], isLast: true }; break; }
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
export function applyTypeFilter() {
|
|
15
34
|
let items = state.fullTreeItems;
|
|
35
|
+
if (state.epicFilter) {
|
|
36
|
+
items = scopeToEpic(items, state.epicFilter);
|
|
37
|
+
}
|
|
16
38
|
if (state.typeFilter !== 'all') {
|
|
17
39
|
items = items
|
|
18
40
|
.filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
|
package/src/views/modals.js
CHANGED
|
@@ -87,6 +87,80 @@ export function textPrompt(screen, label, defaultVal = '') {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
const CLEAR_FILTER_TAG = '__clear__';
|
|
91
|
+
|
|
92
|
+
export function epicPicker(screen, epics, currentEpicId = null) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const sorted = [...epics].sort((a, b) => a.id.localeCompare(b.id));
|
|
95
|
+
const rows = [];
|
|
96
|
+
const ids = [];
|
|
97
|
+
|
|
98
|
+
if (currentEpicId) {
|
|
99
|
+
rows.push('{yellow-fg}✗ Clear epic filter{/}');
|
|
100
|
+
ids.push(CLEAR_FILTER_TAG);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const e of sorted) {
|
|
104
|
+
const marker = e.id === currentEpicId ? '{cyan-fg}●{/} ' : ' ';
|
|
105
|
+
const id = e.id.padEnd(12);
|
|
106
|
+
const title = (e.title || '').slice(0, 60);
|
|
107
|
+
rows.push(`${marker}{gray-fg}${id}{/} ${title}`);
|
|
108
|
+
ids.push(e.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (rows.length === 0) {
|
|
112
|
+
rows.push('{gray-fg}(no epics found){/}');
|
|
113
|
+
ids.push(null);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const width = 72;
|
|
117
|
+
const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
|
|
118
|
+
|
|
119
|
+
const picker = blessed.list({
|
|
120
|
+
parent: screen,
|
|
121
|
+
label: ' Filter by Epic ',
|
|
122
|
+
border: { type: 'line' },
|
|
123
|
+
top: 'center',
|
|
124
|
+
left: 'center',
|
|
125
|
+
width,
|
|
126
|
+
height,
|
|
127
|
+
items: rows,
|
|
128
|
+
keys: true,
|
|
129
|
+
vi: true,
|
|
130
|
+
tags: true,
|
|
131
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'magenta' } },
|
|
132
|
+
style: {
|
|
133
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
134
|
+
border: { fg: 'magenta' },
|
|
135
|
+
label: { fg: 'magenta', bold: true },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Select current epic by default if set
|
|
140
|
+
if (currentEpicId) {
|
|
141
|
+
const idx = ids.indexOf(currentEpicId);
|
|
142
|
+
if (idx >= 0) picker.select(idx);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
picker.focus();
|
|
146
|
+
screen.render();
|
|
147
|
+
|
|
148
|
+
picker.once('select', (_item, index) => {
|
|
149
|
+
const picked = ids[index];
|
|
150
|
+
picker.destroy();
|
|
151
|
+
screen.render();
|
|
152
|
+
if (picked === null) return reject(new Error('cancelled'));
|
|
153
|
+
resolve(picked === CLEAR_FILTER_TAG ? null : picked);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
picker.key(['escape', 'q'], () => {
|
|
157
|
+
picker.destroy();
|
|
158
|
+
screen.render();
|
|
159
|
+
reject(new Error('cancelled'));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
90
164
|
export function skillPicker(screen, beadId) {
|
|
91
165
|
const items = [
|
|
92
166
|
`/executor-task ${beadId}`,
|