bdtui 0.3.2 → 0.4.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 +15 -0
- package/package.json +1 -1
- package/src/app.js +33 -1
- package/src/bd.js +12 -3
- package/src/keys.js +1 -0
- package/src/state.js +45 -0
- package/src/views/list.js +15 -4
- package/src/views/modals.js +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-06-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Epic filter picker now lists the most recently created epics first.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
- Epic filter picker could miss epics beyond the first 50 results; it now enumerates all epics.
|
|
19
|
+
|
|
5
20
|
## [0.3.2] - 2026-05-24
|
|
6
21
|
|
|
7
22
|
### Changed
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
import { statusPicker, priorityPicker, textPrompt, depMenu, skillPicker, epicPicker } from './views/modals.js';
|
|
@@ -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 {
|
|
@@ -259,6 +275,11 @@ export async function run(cwd) {
|
|
|
259
275
|
|
|
260
276
|
key(['G'], () => {
|
|
261
277
|
if (screen.focused !== list) return;
|
|
278
|
+
// Jump to the true bottom — in paged mode reveal every remaining batch first.
|
|
279
|
+
if (isPaged() && state.visibleCount < state.listOrder.length) {
|
|
280
|
+
state.visibleCount = state.listOrder.length;
|
|
281
|
+
renderList(list);
|
|
282
|
+
}
|
|
262
283
|
list.select(state.listOrder.length - 1);
|
|
263
284
|
onNav();
|
|
264
285
|
screen.render();
|
|
@@ -275,6 +296,9 @@ export async function run(cwd) {
|
|
|
275
296
|
const b = state.beadsById.get(id);
|
|
276
297
|
return b?.title?.toLowerCase().includes(q);
|
|
277
298
|
});
|
|
299
|
+
state.visibleCount = isPaged()
|
|
300
|
+
? Math.min(state.pageSize, state.listOrder.length)
|
|
301
|
+
: state.listOrder.length;
|
|
278
302
|
state.selectedId = state.listOrder[0] || null;
|
|
279
303
|
renderList(list);
|
|
280
304
|
renderDetail(detail);
|
|
@@ -315,6 +339,10 @@ export async function run(cwd) {
|
|
|
315
339
|
setStatus(`Closing ${id}…`);
|
|
316
340
|
await applyMutation(id, () => bdClose(id, reason, state.cwd));
|
|
317
341
|
await loadList();
|
|
342
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
343
|
+
state.selectedId = state.listOrder[0] || null;
|
|
344
|
+
}
|
|
345
|
+
ensureSelectedVisible();
|
|
318
346
|
render();
|
|
319
347
|
setStatus(`Closed ${id}`, false, true);
|
|
320
348
|
} catch (err) {
|
|
@@ -345,6 +373,10 @@ export async function run(cwd) {
|
|
|
345
373
|
try {
|
|
346
374
|
await applyMutation(id, () => bdReopen(id, state.cwd));
|
|
347
375
|
await loadList();
|
|
376
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
377
|
+
state.selectedId = state.listOrder[0] || null;
|
|
378
|
+
}
|
|
379
|
+
ensureSelectedVisible();
|
|
348
380
|
render();
|
|
349
381
|
setStatus(`Reopened ${id}`, false, true);
|
|
350
382
|
} catch (err) {
|
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
|
@@ -19,6 +19,7 @@ export const HELP_TEXT = [
|
|
|
19
19
|
' e Filter by epic (modal picker; switches status to all)',
|
|
20
20
|
' r Reload current filter from bd',
|
|
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
|
' ──────────────────────────────────────',
|
package/src/state.js
CHANGED
|
@@ -10,9 +10,32 @@ export const state = {
|
|
|
10
10
|
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
11
11
|
epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
|
|
12
12
|
blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
|
|
13
|
+
pageSize: 100, // lazy "load more" batch size for the flat all view
|
|
14
|
+
visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
|
|
13
15
|
cwd: process.cwd(),
|
|
14
16
|
};
|
|
15
17
|
|
|
18
|
+
// The "all" and "closed" tabs (no epic filter) can hold huge numbers of beads,
|
|
19
|
+
// so they use a flat, lazily-revealed list; every other view loads fully and
|
|
20
|
+
// renders the epic→children tree.
|
|
21
|
+
export function isPaged() {
|
|
22
|
+
return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Paged mode only renders the first visibleCount rows. After a reload that keeps
|
|
26
|
+
// a deep selection, grow the window (to its batch boundary) so the selected bead
|
|
27
|
+
// stays rendered — otherwise the cursor and state.selectedId desync and mutation
|
|
28
|
+
// keys would act on an off-screen bead. Call AFTER selectedId is finalized.
|
|
29
|
+
export function ensureSelectedVisible() {
|
|
30
|
+
if (!isPaged() || !state.selectedId) return;
|
|
31
|
+
const idx = state.listOrder.indexOf(state.selectedId);
|
|
32
|
+
if (idx < state.visibleCount) return;
|
|
33
|
+
state.visibleCount = Math.min(
|
|
34
|
+
state.listOrder.length,
|
|
35
|
+
Math.ceil((idx + 1) / state.pageSize) * state.pageSize,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
function scopeToEpic(items, epicId) {
|
|
17
40
|
const out = [];
|
|
18
41
|
let inside = false;
|
|
@@ -43,6 +66,9 @@ export function applyTypeFilter() {
|
|
|
43
66
|
}
|
|
44
67
|
state.listOrder = items.map((t) => t.id);
|
|
45
68
|
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
69
|
+
state.visibleCount = isPaged()
|
|
70
|
+
? Math.min(state.pageSize, state.listOrder.length)
|
|
71
|
+
: state.listOrder.length;
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
function buildTreeOrder(beads, parentOf) {
|
|
@@ -76,7 +102,26 @@ function buildTreeOrder(beads, parentOf) {
|
|
|
76
102
|
return ordered;
|
|
77
103
|
}
|
|
78
104
|
|
|
105
|
+
// Flat load for the paged tabs ("all" / "closed"). Skips the per-bead
|
|
106
|
+
// parent-child dep fetch (which spawns one bd subprocess per bead) and the tree
|
|
107
|
+
// build, so even very large repos load in a single bd call; the UI reveals rows
|
|
108
|
+
// in pageSize batches.
|
|
109
|
+
async function loadListFlat() {
|
|
110
|
+
const [beads, blocked] = await Promise.all([
|
|
111
|
+
bdList(state.filter, state.cwd, { unlimited: true }),
|
|
112
|
+
bdList('blocked', state.cwd).catch(() => []),
|
|
113
|
+
]);
|
|
114
|
+
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
115
|
+
for (const b of beads) {
|
|
116
|
+
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
117
|
+
}
|
|
118
|
+
state.fullTreeItems = beads.map((b) => ({ id: b.id, depth: 0, isLast: false }));
|
|
119
|
+
applyTypeFilter();
|
|
120
|
+
return beads;
|
|
121
|
+
}
|
|
122
|
+
|
|
79
123
|
export async function loadList() {
|
|
124
|
+
if (isPaged()) return loadListFlat();
|
|
80
125
|
const [beads, blocked] = await Promise.all([
|
|
81
126
|
bdList(state.filter, state.cwd),
|
|
82
127
|
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
|
|