bdtui 0.4.0 → 0.5.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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/app.js +63 -28
- package/src/keys.js +5 -5
- package/src/state.js +10 -1
- package/src/views/modals.js +60 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.5.0] - 2026-06-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `h` key: dedicated parent-picker to reparent a bead under a different epic or detach it as standalone (epics listed newest-updated-first; current parent excluded).
|
|
12
|
+
- `chore` and `bug` added to the `t` type-filter cycle (`all → epic → task → chore → bug → all`).
|
|
13
|
+
- `x` key in the epic-filter picker clears the filter immediately (same as selecting ✗ Clear).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- All tabs now sort beads by `updated_at` DESC (most-recently-touched first), preserving the epic→children tree grouping for tree tabs and ordering children within each epic the same way.
|
|
18
|
+
- `r` (reload) now also clears any active epic filter before reloading; status bar shows "Epic filter cleared" when it does.
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- `D` dependency menu — replaced by the focused `h` reparent picker.
|
|
23
|
+
|
|
5
24
|
## [0.4.0] - 2026-06-07
|
|
6
25
|
|
|
7
26
|
### Added
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -3,12 +3,12 @@ import { execFile } from 'child_process';
|
|
|
3
3
|
import { state, loadList, loadDetail, applyMutation, applyTypeFilter, isPaged, ensureSelectedVisible } 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,
|
|
6
|
+
import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
|
|
7
7
|
import { showHelp } from './keys.js';
|
|
8
|
-
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdEpics } from './bd.js';
|
|
8
|
+
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListDown, bdEpics } from './bd.js';
|
|
9
9
|
|
|
10
10
|
const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
|
-
const TYPE_FILTERS = ['all', 'epic', 'task'];
|
|
11
|
+
const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
|
|
12
12
|
|
|
13
13
|
export async function run(cwd) {
|
|
14
14
|
state.cwd = cwd;
|
|
@@ -201,7 +201,12 @@ export async function run(cwd) {
|
|
|
201
201
|
|
|
202
202
|
key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
203
203
|
|
|
204
|
-
key(['r'],
|
|
204
|
+
key(['r'], async () => {
|
|
205
|
+
const wasEpic = !!state.epicFilter;
|
|
206
|
+
state.epicFilter = null;
|
|
207
|
+
await refresh();
|
|
208
|
+
if (wasEpic) setStatus('Epic filter cleared', false, true);
|
|
209
|
+
});
|
|
205
210
|
key(['tab'], () => cycleFilter(1));
|
|
206
211
|
key(['S-tab'], () => cycleFilter(-1));
|
|
207
212
|
key(['t'], cycleTypeFilter);
|
|
@@ -262,10 +267,63 @@ export async function run(cwd) {
|
|
|
262
267
|
if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
|
|
263
268
|
});
|
|
264
269
|
|
|
265
|
-
key(['
|
|
270
|
+
key(['escape'], () => {
|
|
266
271
|
if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
|
|
267
272
|
});
|
|
268
273
|
|
|
274
|
+
key(['h'], async () => {
|
|
275
|
+
if (screen.focused === detail) { setFocusBorder('list'); list.focus(); screen.render(); return; }
|
|
276
|
+
if (screen.focused !== list) return;
|
|
277
|
+
if (!state.selectedId) return;
|
|
278
|
+
const id = state.selectedId;
|
|
279
|
+
const bead = state.beadsById.get(id);
|
|
280
|
+
if (bead?.issue_type === 'epic') {
|
|
281
|
+
setStatus(`${id} is an epic — epics are top-level`, true, true);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
setStatus('Loading parents…');
|
|
285
|
+
let epics, downDeps;
|
|
286
|
+
try {
|
|
287
|
+
[epics, downDeps] = await Promise.all([bdEpics(state.cwd), bdDepListDown(id, state.cwd)]);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
setStatus(err.message, true, true);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const currentParentId = downDeps.find((d) => d.dependency_type === 'parent-child')?.id ?? null;
|
|
293
|
+
let newParent;
|
|
294
|
+
try {
|
|
295
|
+
newParent = await parentPicker(screen, epics, { selfId: id, currentParentId });
|
|
296
|
+
} catch (err) {
|
|
297
|
+
list.focus();
|
|
298
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
list.focus();
|
|
302
|
+
setStatus(`Reparenting ${id}…`);
|
|
303
|
+
try {
|
|
304
|
+
await applyMutation(id, async () => {
|
|
305
|
+
if (newParent) {
|
|
306
|
+
await bdDepAdd(id, newParent, 'parent-child', state.cwd);
|
|
307
|
+
if (currentParentId && currentParentId !== newParent) {
|
|
308
|
+
await bdDepRemove(id, currentParentId, state.cwd);
|
|
309
|
+
}
|
|
310
|
+
} else if (currentParentId) {
|
|
311
|
+
await bdDepRemove(id, currentParentId, state.cwd);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
await loadList();
|
|
315
|
+
ensureSelectedVisible();
|
|
316
|
+
render();
|
|
317
|
+
setStatus(
|
|
318
|
+
newParent ? `Reparented ${id} → ${newParent}` : `${id} is now standalone`,
|
|
319
|
+
false,
|
|
320
|
+
true,
|
|
321
|
+
);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
setStatus(err.message, true, true);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
269
327
|
key(['g'], () => {
|
|
270
328
|
if (screen.focused !== list) return;
|
|
271
329
|
list.select(0);
|
|
@@ -402,29 +460,6 @@ export async function run(cwd) {
|
|
|
402
460
|
}
|
|
403
461
|
});
|
|
404
462
|
|
|
405
|
-
key(['D'], async () => {
|
|
406
|
-
if (screen.focused !== list || !state.selectedId) return;
|
|
407
|
-
const id = state.selectedId;
|
|
408
|
-
try {
|
|
409
|
-
const result = await depMenu(screen);
|
|
410
|
-
list.focus();
|
|
411
|
-
if (result.action === 'add') {
|
|
412
|
-
setStatus(`Adding dep: ${id} → ${result.targetId} [${result.type}]…`);
|
|
413
|
-
await applyMutation(id, () => bdDepAdd(id, result.targetId, result.type, state.cwd));
|
|
414
|
-
setStatus(`Added: ${id} → ${result.targetId} [${result.type}]`, false, true);
|
|
415
|
-
} else {
|
|
416
|
-
setStatus(`Removing dep: ${id} → ${result.targetId}…`);
|
|
417
|
-
await applyMutation(id, () => bdDepRemove(id, result.targetId, state.cwd));
|
|
418
|
-
setStatus(`Removed dep: ${id} → ${result.targetId}`, false, true);
|
|
419
|
-
}
|
|
420
|
-
renderDetail(detail);
|
|
421
|
-
screen.render();
|
|
422
|
-
} catch (err) {
|
|
423
|
-
list.focus();
|
|
424
|
-
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
|
|
428
463
|
async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
|
|
429
464
|
setStatus(busyMsg || `Copying…`);
|
|
430
465
|
const write = (cmd, args) => new Promise((resolve, reject) => {
|
package/src/keys.js
CHANGED
|
@@ -9,15 +9,15 @@ export const HELP_TEXT = [
|
|
|
9
9
|
' g Jump to top',
|
|
10
10
|
' G Jump to bottom',
|
|
11
11
|
' Enter / l Focus detail pane',
|
|
12
|
-
' h / Esc Back to list',
|
|
12
|
+
' h / Esc Back to list (from detail)',
|
|
13
13
|
'',
|
|
14
14
|
' Filters & refresh',
|
|
15
15
|
' ──────────────────────────────────────',
|
|
16
16
|
' Tab Next status filter (blocked/ready/in_progress/closed/all)',
|
|
17
17
|
' Shift+Tab Previous status filter',
|
|
18
|
-
' t Cycle type filter (all/epic/task)',
|
|
19
|
-
' e Filter by epic (modal picker; switches status to all)',
|
|
20
|
-
' r Reload
|
|
18
|
+
' t Cycle type filter (all/epic/task/chore/bug)',
|
|
19
|
+
' e Filter by epic (modal picker; x clears; switches status to all)',
|
|
20
|
+
' r Reload (also clears epic filter)',
|
|
21
21
|
' / In-memory title filter',
|
|
22
22
|
' (all & closed tabs load more rows as you scroll to the bottom)',
|
|
23
23
|
'',
|
|
@@ -28,7 +28,7 @@ export const HELP_TEXT = [
|
|
|
28
28
|
' C Claim bead (in_progress + assign self)',
|
|
29
29
|
' o Reopen',
|
|
30
30
|
' p Change priority',
|
|
31
|
-
'
|
|
31
|
+
' h Change parent (epic / standalone)',
|
|
32
32
|
'',
|
|
33
33
|
' Other',
|
|
34
34
|
' ──────────────────────────────────────',
|
package/src/state.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { bdList, bdShow, bdDeps, bdDepListDown } from './bd.js';
|
|
2
2
|
|
|
3
|
+
function byUpdatedDesc(a, b) {
|
|
4
|
+
const ta = a?.updated_at || '', tb = b?.updated_at || '';
|
|
5
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
6
|
+
return (b?.id || '').localeCompare(a?.id || '');
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
export const state = {
|
|
4
10
|
beadsById: new Map(),
|
|
5
11
|
fullTreeItems: [], // unfiltered ordered tree items (before typeFilter)
|
|
@@ -62,6 +68,7 @@ export function applyTypeFilter() {
|
|
|
62
68
|
if (state.typeFilter !== 'all') {
|
|
63
69
|
items = items
|
|
64
70
|
.filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
|
|
71
|
+
.sort((x, y) => byUpdatedDesc(state.beadsById.get(x.id), state.beadsById.get(y.id)))
|
|
65
72
|
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
66
73
|
}
|
|
67
74
|
state.listOrder = items.map((t) => t.id);
|
|
@@ -84,11 +91,12 @@ function buildTreeOrder(beads, parentOf) {
|
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
for (const children of childrenOf.values()) {
|
|
87
|
-
children.sort(
|
|
94
|
+
children.sort(byUpdatedDesc);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
|
|
91
98
|
const roots = beads.filter((b) => !childIds.has(b.id));
|
|
99
|
+
roots.sort(byUpdatedDesc);
|
|
92
100
|
|
|
93
101
|
const ordered = [];
|
|
94
102
|
for (const root of roots) {
|
|
@@ -115,6 +123,7 @@ async function loadListFlat() {
|
|
|
115
123
|
for (const b of beads) {
|
|
116
124
|
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
117
125
|
}
|
|
126
|
+
beads.sort(byUpdatedDesc);
|
|
118
127
|
state.fullTreeItems = beads.map((b) => ({ id: b.id, depth: 0, isLast: false }));
|
|
119
128
|
applyTypeFilter();
|
|
120
129
|
return beads;
|
package/src/views/modals.js
CHANGED
|
@@ -166,6 +166,12 @@ export function epicPicker(screen, epics, currentEpicId = null) {
|
|
|
166
166
|
screen.render();
|
|
167
167
|
reject(new Error('cancelled'));
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
picker.key(['x'], () => {
|
|
171
|
+
picker.destroy();
|
|
172
|
+
screen.render();
|
|
173
|
+
resolve(null);
|
|
174
|
+
});
|
|
169
175
|
});
|
|
170
176
|
}
|
|
171
177
|
|
|
@@ -180,26 +186,62 @@ export function skillPicker(screen, beadId) {
|
|
|
180
186
|
return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
export function parentPicker(screen, epics, { selfId, currentParentId } = {}) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const sorted = [...epics]
|
|
192
|
+
.filter((e) => e.id !== selfId && e.id !== currentParentId)
|
|
193
|
+
.sort((a, b) => {
|
|
194
|
+
const ta = a.updated_at || '', tb = b.updated_at || '';
|
|
195
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
196
|
+
return b.id.localeCompare(a.id);
|
|
197
|
+
});
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
screen,
|
|
191
|
-
'Dependency',
|
|
192
|
-
['Add dep', 'Remove dep'],
|
|
193
|
-
'magenta',
|
|
194
|
-
);
|
|
199
|
+
const rows = ['{yellow-fg}Standalone bead{/}'];
|
|
200
|
+
const ids = [null];
|
|
195
201
|
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
for (const e of sorted) {
|
|
203
|
+
const id = e.id.padEnd(12);
|
|
204
|
+
const title = (e.title || '').slice(0, 60);
|
|
205
|
+
rows.push(`{gray-fg}${id}{/} ${title}`);
|
|
206
|
+
ids.push(e.id);
|
|
207
|
+
}
|
|
198
208
|
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
return { action: 'add', targetId, type };
|
|
202
|
-
}
|
|
209
|
+
const width = 72;
|
|
210
|
+
const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
|
|
203
211
|
|
|
204
|
-
|
|
212
|
+
const picker = blessed.list({
|
|
213
|
+
parent: screen,
|
|
214
|
+
label: ' Change Parent ',
|
|
215
|
+
border: { type: 'line' },
|
|
216
|
+
top: 'center',
|
|
217
|
+
left: 'center',
|
|
218
|
+
width,
|
|
219
|
+
height,
|
|
220
|
+
items: rows,
|
|
221
|
+
keys: true,
|
|
222
|
+
vi: true,
|
|
223
|
+
tags: true,
|
|
224
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'magenta' } },
|
|
225
|
+
style: {
|
|
226
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
227
|
+
border: { fg: 'magenta' },
|
|
228
|
+
label: { fg: 'magenta', bold: true },
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
picker.focus();
|
|
233
|
+
screen.render();
|
|
234
|
+
|
|
235
|
+
picker.once('select', (_item, index) => {
|
|
236
|
+
picker.destroy();
|
|
237
|
+
screen.render();
|
|
238
|
+
resolve(ids[index]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
picker.key(['escape', 'q'], () => {
|
|
242
|
+
picker.destroy();
|
|
243
|
+
screen.render();
|
|
244
|
+
reject(new Error('cancelled'));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
205
247
|
}
|