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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal UI for the bd beads issue tracker",
5
5
  "type": "module",
6
6
  "bin": {
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 runBd(['list', '--all', '--json'], cwd);
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 items = state.listOrder.map((id) => {
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, state.listOrder.indexOf(state.selectedId))
85
+ ? Math.max(0, ids.indexOf(state.selectedId))
78
86
  : 0;
79
87
 
80
- list.setLabel(` Beads ${t('gray', `(${state.listOrder.length})`)} `);
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 (state.listOrder.length > 0) list.select(selIdx);
93
+ if (ids.length > 0) list.select(Math.min(selIdx, ids.length - 1));
83
94
  }
@@ -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
- const sorted = [...epics].sort((a, b) => a.id.localeCompare(b.id));
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