bdtui 0.3.2 → 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 +34 -0
- package/package.json +1 -1
- package/src/app.js +96 -29
- package/src/bd.js +12 -3
- package/src/keys.js +6 -5
- package/src/state.js +55 -1
- package/src/views/list.js +15 -4
- package/src/views/modals.js +69 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
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
|
+
|
|
24
|
+
## [0.4.0] - 2026-06-07
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Lazy pagination for the `all` and `closed` tabs: every bead loads in a single `bd` call (flat, no per-bead dependency fetch) and rows are revealed in batches as you scroll to the bottom, with a "showing X of Y" count. This lifts the previous silent 50-row cap so the full history is reachable. `G` jumps to the true bottom; `/` searches the whole list.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Epic filter picker now lists the most recently created epics first.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `stdout maxBuffer length exceeded` error when a `bd list` payload exceeded 1 MB (e.g. the `all` tab on a large repo) — the output buffer is now 64 MB.
|
|
37
|
+
- Epic filter picker could miss epics beyond the first 50 results; it now enumerates all epics.
|
|
38
|
+
|
|
5
39
|
## [0.3.2] - 2026-05-24
|
|
6
40
|
|
|
7
41
|
### Changed
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import blessed from 'blessed';
|
|
2
2
|
import { execFile } from 'child_process';
|
|
3
|
-
import { state, loadList, loadDetail, applyMutation, applyTypeFilter } from './state.js';
|
|
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;
|
|
@@ -88,9 +88,23 @@ export async function run(cwd) {
|
|
|
88
88
|
screen.render();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Lazy "load more": the paged tabs (all / closed) load every bead but render
|
|
92
|
+
// them pageSize at a time; reveal the next batch once selection reaches the
|
|
93
|
+
// last visible row (infinite-scroll style).
|
|
94
|
+
function maybeLoadMore() {
|
|
95
|
+
if (!isPaged()) return;
|
|
96
|
+
if (state.visibleCount >= state.listOrder.length) return;
|
|
97
|
+
if (list.selected < state.visibleCount - 1) return;
|
|
98
|
+
state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.listOrder.length);
|
|
99
|
+
renderList(list);
|
|
100
|
+
setStatus(`Showing ${state.visibleCount} of ${state.listOrder.length}`, false, true);
|
|
101
|
+
screen.render();
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
// When list selection changes, update selectedId and debounce detail fetch
|
|
92
105
|
function onNav() {
|
|
93
106
|
setImmediate(() => {
|
|
107
|
+
maybeLoadMore();
|
|
94
108
|
const idx = list.selected;
|
|
95
109
|
const id = state.listOrder[idx];
|
|
96
110
|
if (!id || id === state.selectedId) return;
|
|
@@ -125,6 +139,7 @@ export async function run(cwd) {
|
|
|
125
139
|
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
126
140
|
state.selectedId = state.listOrder[0] || null;
|
|
127
141
|
}
|
|
142
|
+
ensureSelectedVisible();
|
|
128
143
|
if (state.selectedId) await loadDetail(state.selectedId);
|
|
129
144
|
render();
|
|
130
145
|
setStatus('Ready');
|
|
@@ -158,6 +173,7 @@ export async function run(cwd) {
|
|
|
158
173
|
} else if (!state.selectedId) {
|
|
159
174
|
state.selectedId = state.listOrder[0] || null;
|
|
160
175
|
}
|
|
176
|
+
ensureSelectedVisible();
|
|
161
177
|
render();
|
|
162
178
|
if (state.selectedId) {
|
|
163
179
|
try {
|
|
@@ -185,7 +201,12 @@ export async function run(cwd) {
|
|
|
185
201
|
|
|
186
202
|
key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
187
203
|
|
|
188
|
-
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
|
+
});
|
|
189
210
|
key(['tab'], () => cycleFilter(1));
|
|
190
211
|
key(['S-tab'], () => cycleFilter(-1));
|
|
191
212
|
key(['t'], cycleTypeFilter);
|
|
@@ -246,10 +267,63 @@ export async function run(cwd) {
|
|
|
246
267
|
if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
|
|
247
268
|
});
|
|
248
269
|
|
|
249
|
-
key(['
|
|
270
|
+
key(['escape'], () => {
|
|
250
271
|
if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
|
|
251
272
|
});
|
|
252
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
|
+
|
|
253
327
|
key(['g'], () => {
|
|
254
328
|
if (screen.focused !== list) return;
|
|
255
329
|
list.select(0);
|
|
@@ -259,6 +333,11 @@ export async function run(cwd) {
|
|
|
259
333
|
|
|
260
334
|
key(['G'], () => {
|
|
261
335
|
if (screen.focused !== list) return;
|
|
336
|
+
// Jump to the true bottom — in paged mode reveal every remaining batch first.
|
|
337
|
+
if (isPaged() && state.visibleCount < state.listOrder.length) {
|
|
338
|
+
state.visibleCount = state.listOrder.length;
|
|
339
|
+
renderList(list);
|
|
340
|
+
}
|
|
262
341
|
list.select(state.listOrder.length - 1);
|
|
263
342
|
onNav();
|
|
264
343
|
screen.render();
|
|
@@ -275,6 +354,9 @@ export async function run(cwd) {
|
|
|
275
354
|
const b = state.beadsById.get(id);
|
|
276
355
|
return b?.title?.toLowerCase().includes(q);
|
|
277
356
|
});
|
|
357
|
+
state.visibleCount = isPaged()
|
|
358
|
+
? Math.min(state.pageSize, state.listOrder.length)
|
|
359
|
+
: state.listOrder.length;
|
|
278
360
|
state.selectedId = state.listOrder[0] || null;
|
|
279
361
|
renderList(list);
|
|
280
362
|
renderDetail(detail);
|
|
@@ -315,6 +397,10 @@ export async function run(cwd) {
|
|
|
315
397
|
setStatus(`Closing ${id}…`);
|
|
316
398
|
await applyMutation(id, () => bdClose(id, reason, state.cwd));
|
|
317
399
|
await loadList();
|
|
400
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
401
|
+
state.selectedId = state.listOrder[0] || null;
|
|
402
|
+
}
|
|
403
|
+
ensureSelectedVisible();
|
|
318
404
|
render();
|
|
319
405
|
setStatus(`Closed ${id}`, false, true);
|
|
320
406
|
} catch (err) {
|
|
@@ -345,6 +431,10 @@ export async function run(cwd) {
|
|
|
345
431
|
try {
|
|
346
432
|
await applyMutation(id, () => bdReopen(id, state.cwd));
|
|
347
433
|
await loadList();
|
|
434
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
435
|
+
state.selectedId = state.listOrder[0] || null;
|
|
436
|
+
}
|
|
437
|
+
ensureSelectedVisible();
|
|
348
438
|
render();
|
|
349
439
|
setStatus(`Reopened ${id}`, false, true);
|
|
350
440
|
} catch (err) {
|
|
@@ -370,29 +460,6 @@ export async function run(cwd) {
|
|
|
370
460
|
}
|
|
371
461
|
});
|
|
372
462
|
|
|
373
|
-
key(['D'], async () => {
|
|
374
|
-
if (screen.focused !== list || !state.selectedId) return;
|
|
375
|
-
const id = state.selectedId;
|
|
376
|
-
try {
|
|
377
|
-
const result = await depMenu(screen);
|
|
378
|
-
list.focus();
|
|
379
|
-
if (result.action === 'add') {
|
|
380
|
-
setStatus(`Adding dep: ${id} → ${result.targetId} [${result.type}]…`);
|
|
381
|
-
await applyMutation(id, () => bdDepAdd(id, result.targetId, result.type, state.cwd));
|
|
382
|
-
setStatus(`Added: ${id} → ${result.targetId} [${result.type}]`, false, true);
|
|
383
|
-
} else {
|
|
384
|
-
setStatus(`Removing dep: ${id} → ${result.targetId}…`);
|
|
385
|
-
await applyMutation(id, () => bdDepRemove(id, result.targetId, state.cwd));
|
|
386
|
-
setStatus(`Removed dep: ${id} → ${result.targetId}`, false, true);
|
|
387
|
-
}
|
|
388
|
-
renderDetail(detail);
|
|
389
|
-
screen.render();
|
|
390
|
-
} catch (err) {
|
|
391
|
-
list.focus();
|
|
392
|
-
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
|
|
396
463
|
async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
|
|
397
464
|
setStatus(busyMsg || `Copying…`);
|
|
398
465
|
const write = (cmd, args) => new Promise((resolve, reject) => {
|
package/src/bd.js
CHANGED
|
@@ -3,24 +3,33 @@ import { promisify } from 'util';
|
|
|
3
3
|
|
|
4
4
|
const execFileP = promisify(execFile);
|
|
5
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
|
+
|
|
6
10
|
async function runBd(args, cwd) {
|
|
7
11
|
try {
|
|
8
|
-
const { stdout } = await execFileP('bd', args, { cwd });
|
|
12
|
+
const { stdout } = await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
|
|
9
13
|
return JSON.parse(stdout);
|
|
10
14
|
} catch (err) {
|
|
11
15
|
throw new Error(err.stderr?.trim() || err.message);
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
export const bdList = (filter, cwd) => {
|
|
19
|
+
export const bdList = (filter, cwd, { unlimited = false } = {}) => {
|
|
16
20
|
if (filter === 'ready') return runBd(['ready', '--json'], cwd);
|
|
17
21
|
if (filter === 'blocked') return runBd(['blocked', '--json'], cwd);
|
|
18
22
|
const args = ['list', '--json'];
|
|
19
23
|
if (filter === 'all') args.push('--all');
|
|
20
24
|
else if (filter) args.push('--status', filter);
|
|
25
|
+
// `--limit 0` lifts bd's default 50-row cap for the paginated flat views.
|
|
26
|
+
if (unlimited) args.push('--limit', '0');
|
|
21
27
|
return runBd(args, cwd);
|
|
22
28
|
};
|
|
23
29
|
|
|
30
|
+
// bdList('all') with the 50-row cap lifted — used for enumerating every epic.
|
|
31
|
+
export const bdListAll = (cwd) => bdList('all', cwd, { unlimited: true });
|
|
32
|
+
|
|
24
33
|
export const bdShow = async (id, cwd) => {
|
|
25
34
|
const res = await runBd(['show', id, '--json'], cwd);
|
|
26
35
|
return Array.isArray(res) ? res[0] : res;
|
|
@@ -69,6 +78,6 @@ export const bdDepRemove = (child, parent, cwd) =>
|
|
|
69
78
|
export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
|
|
70
79
|
|
|
71
80
|
export const bdEpics = async (cwd) => {
|
|
72
|
-
const all = await
|
|
81
|
+
const all = await bdListAll(cwd);
|
|
73
82
|
return Array.isArray(all) ? all.filter((b) => b.issue_type === 'epic') : [];
|
|
74
83
|
};
|
package/src/keys.js
CHANGED
|
@@ -9,16 +9,17 @@ 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
|
+
' (all & closed tabs load more rows as you scroll to the bottom)',
|
|
22
23
|
'',
|
|
23
24
|
' Mutations',
|
|
24
25
|
' ──────────────────────────────────────',
|
|
@@ -27,7 +28,7 @@ export const HELP_TEXT = [
|
|
|
27
28
|
' C Claim bead (in_progress + assign self)',
|
|
28
29
|
' o Reopen',
|
|
29
30
|
' p Change priority',
|
|
30
|
-
'
|
|
31
|
+
' h Change parent (epic / standalone)',
|
|
31
32
|
'',
|
|
32
33
|
' Other',
|
|
33
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)
|
|
@@ -10,9 +16,32 @@ export const state = {
|
|
|
10
16
|
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
11
17
|
epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
|
|
12
18
|
blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
|
|
19
|
+
pageSize: 100, // lazy "load more" batch size for the flat all view
|
|
20
|
+
visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
|
|
13
21
|
cwd: process.cwd(),
|
|
14
22
|
};
|
|
15
23
|
|
|
24
|
+
// The "all" and "closed" tabs (no epic filter) can hold huge numbers of beads,
|
|
25
|
+
// so they use a flat, lazily-revealed list; every other view loads fully and
|
|
26
|
+
// renders the epic→children tree.
|
|
27
|
+
export function isPaged() {
|
|
28
|
+
return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Paged mode only renders the first visibleCount rows. After a reload that keeps
|
|
32
|
+
// a deep selection, grow the window (to its batch boundary) so the selected bead
|
|
33
|
+
// stays rendered — otherwise the cursor and state.selectedId desync and mutation
|
|
34
|
+
// keys would act on an off-screen bead. Call AFTER selectedId is finalized.
|
|
35
|
+
export function ensureSelectedVisible() {
|
|
36
|
+
if (!isPaged() || !state.selectedId) return;
|
|
37
|
+
const idx = state.listOrder.indexOf(state.selectedId);
|
|
38
|
+
if (idx < state.visibleCount) return;
|
|
39
|
+
state.visibleCount = Math.min(
|
|
40
|
+
state.listOrder.length,
|
|
41
|
+
Math.ceil((idx + 1) / state.pageSize) * state.pageSize,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
function scopeToEpic(items, epicId) {
|
|
17
46
|
const out = [];
|
|
18
47
|
let inside = false;
|
|
@@ -39,10 +68,14 @@ export function applyTypeFilter() {
|
|
|
39
68
|
if (state.typeFilter !== 'all') {
|
|
40
69
|
items = items
|
|
41
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)))
|
|
42
72
|
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
43
73
|
}
|
|
44
74
|
state.listOrder = items.map((t) => t.id);
|
|
45
75
|
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
76
|
+
state.visibleCount = isPaged()
|
|
77
|
+
? Math.min(state.pageSize, state.listOrder.length)
|
|
78
|
+
: state.listOrder.length;
|
|
46
79
|
}
|
|
47
80
|
|
|
48
81
|
function buildTreeOrder(beads, parentOf) {
|
|
@@ -58,11 +91,12 @@ function buildTreeOrder(beads, parentOf) {
|
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
for (const children of childrenOf.values()) {
|
|
61
|
-
children.sort(
|
|
94
|
+
children.sort(byUpdatedDesc);
|
|
62
95
|
}
|
|
63
96
|
|
|
64
97
|
const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
|
|
65
98
|
const roots = beads.filter((b) => !childIds.has(b.id));
|
|
99
|
+
roots.sort(byUpdatedDesc);
|
|
66
100
|
|
|
67
101
|
const ordered = [];
|
|
68
102
|
for (const root of roots) {
|
|
@@ -76,7 +110,27 @@ function buildTreeOrder(beads, parentOf) {
|
|
|
76
110
|
return ordered;
|
|
77
111
|
}
|
|
78
112
|
|
|
113
|
+
// Flat load for the paged tabs ("all" / "closed"). Skips the per-bead
|
|
114
|
+
// parent-child dep fetch (which spawns one bd subprocess per bead) and the tree
|
|
115
|
+
// build, so even very large repos load in a single bd call; the UI reveals rows
|
|
116
|
+
// in pageSize batches.
|
|
117
|
+
async function loadListFlat() {
|
|
118
|
+
const [beads, blocked] = await Promise.all([
|
|
119
|
+
bdList(state.filter, state.cwd, { unlimited: true }),
|
|
120
|
+
bdList('blocked', state.cwd).catch(() => []),
|
|
121
|
+
]);
|
|
122
|
+
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
123
|
+
for (const b of beads) {
|
|
124
|
+
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
125
|
+
}
|
|
126
|
+
beads.sort(byUpdatedDesc);
|
|
127
|
+
state.fullTreeItems = beads.map((b) => ({ id: b.id, depth: 0, isLast: false }));
|
|
128
|
+
applyTypeFilter();
|
|
129
|
+
return beads;
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
export async function loadList() {
|
|
133
|
+
if (isPaged()) return loadListFlat();
|
|
80
134
|
const [beads, blocked] = await Promise.all([
|
|
81
135
|
bdList(state.filter, state.cwd),
|
|
82
136
|
bdList('blocked', state.cwd).catch(() => []),
|
package/src/views/list.js
CHANGED
|
@@ -67,17 +67,28 @@ export function createList(screen) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export function renderList(list) {
|
|
70
|
-
const
|
|
70
|
+
const total = state.listOrder.length;
|
|
71
|
+
// In paged ("load more") mode only the first visibleCount rows are rendered;
|
|
72
|
+
// everything else renders the full list.
|
|
73
|
+
const shown = state.visibleCount > 0 && state.visibleCount < total
|
|
74
|
+
? state.visibleCount
|
|
75
|
+
: total;
|
|
76
|
+
const ids = state.listOrder.slice(0, shown);
|
|
77
|
+
|
|
78
|
+
const items = ids.map((id) => {
|
|
71
79
|
const b = state.beadsById.get(id);
|
|
72
80
|
const { depth = 0, isLast = false } = state.treeMeta.get(id) ?? {};
|
|
73
81
|
return b ? formatRow(b, depth, isLast) : t('gray', id);
|
|
74
82
|
});
|
|
75
83
|
|
|
76
84
|
const selIdx = state.selectedId
|
|
77
|
-
? Math.max(0,
|
|
85
|
+
? Math.max(0, ids.indexOf(state.selectedId))
|
|
78
86
|
: 0;
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
const label = shown < total
|
|
89
|
+
? ` Beads ${t('gray', `(showing ${shown} of ${total})`)} `
|
|
90
|
+
: ` Beads ${t('gray', `(${total})`)} `;
|
|
91
|
+
list.setLabel(label);
|
|
81
92
|
list.setItems(items);
|
|
82
|
-
if (
|
|
93
|
+
if (ids.length > 0) list.select(Math.min(selIdx, ids.length - 1));
|
|
83
94
|
}
|
package/src/views/modals.js
CHANGED
|
@@ -91,7 +91,15 @@ const CLEAR_FILTER_TAG = '__clear__';
|
|
|
91
91
|
|
|
92
92
|
export function epicPicker(screen, epics, currentEpicId = null) {
|
|
93
93
|
return new Promise((resolve, reject) => {
|
|
94
|
-
|
|
94
|
+
// Newest epics on top: sort by created_at descending (ISO-8601 UTC strings
|
|
95
|
+
// sort lexicographically in chronological order). Fall back to id for ties
|
|
96
|
+
// or missing timestamps.
|
|
97
|
+
const sorted = [...epics].sort((a, b) => {
|
|
98
|
+
const ta = a.created_at || '';
|
|
99
|
+
const tb = b.created_at || '';
|
|
100
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
101
|
+
return b.id.localeCompare(a.id);
|
|
102
|
+
});
|
|
95
103
|
const rows = [];
|
|
96
104
|
const ids = [];
|
|
97
105
|
|
|
@@ -158,6 +166,12 @@ export function epicPicker(screen, epics, currentEpicId = null) {
|
|
|
158
166
|
screen.render();
|
|
159
167
|
reject(new Error('cancelled'));
|
|
160
168
|
});
|
|
169
|
+
|
|
170
|
+
picker.key(['x'], () => {
|
|
171
|
+
picker.destroy();
|
|
172
|
+
screen.render();
|
|
173
|
+
resolve(null);
|
|
174
|
+
});
|
|
161
175
|
});
|
|
162
176
|
}
|
|
163
177
|
|
|
@@ -172,26 +186,62 @@ export function skillPicker(screen, beadId) {
|
|
|
172
186
|
return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
});
|
|
198
|
+
|
|
199
|
+
const rows = ['{yellow-fg}Standalone bead{/}'];
|
|
200
|
+
const ids = [null];
|
|
179
201
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|
|
208
|
+
|
|
209
|
+
const width = 72;
|
|
210
|
+
const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
|
|
211
|
+
|
|
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
|
+
});
|
|
187
231
|
|
|
188
|
-
|
|
189
|
-
|
|
232
|
+
picker.focus();
|
|
233
|
+
screen.render();
|
|
190
234
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
picker.once('select', (_item, index) => {
|
|
236
|
+
picker.destroy();
|
|
237
|
+
screen.render();
|
|
238
|
+
resolve(ids[index]);
|
|
239
|
+
});
|
|
195
240
|
|
|
196
|
-
|
|
241
|
+
picker.key(['escape', 'q'], () => {
|
|
242
|
+
picker.destroy();
|
|
243
|
+
screen.render();
|
|
244
|
+
reject(new Error('cancelled'));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
197
247
|
}
|