bdtui 0.3.1 → 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,27 @@
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
+
20
+ ## [0.3.2] - 2026-05-24
21
+
22
+ ### Changed
23
+
24
+ - Detail-pane dependency rows redesigned: status-colored icons (○ open, ◐ in_progress, ● blocked, ✓ closed, ❄ deferred) with the whole row tinted by effective status. Open beads whose blockers aren't all closed now render as blocked, mirroring how `bd blocked` derives the Blocked tab.
25
+
5
26
  ## [0.3.1] - 2026-05-16
6
27
 
7
28
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.3.1",
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
@@ -9,9 +9,33 @@ export const state = {
9
9
  filter: 'ready',
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
+ 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)
12
15
  cwd: process.cwd(),
13
16
  };
14
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
+
15
39
  function scopeToEpic(items, epicId) {
16
40
  const out = [];
17
41
  let inside = false;
@@ -42,6 +66,9 @@ export function applyTypeFilter() {
42
66
  }
43
67
  state.listOrder = items.map((t) => t.id);
44
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;
45
72
  }
46
73
 
47
74
  function buildTreeOrder(beads, parentOf) {
@@ -75,8 +102,31 @@ function buildTreeOrder(beads, parentOf) {
75
102
  return ordered;
76
103
  }
77
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
+
78
123
  export async function loadList() {
79
- const beads = await bdList(state.filter, state.cwd);
124
+ if (isPaged()) return loadListFlat();
125
+ const [beads, blocked] = await Promise.all([
126
+ bdList(state.filter, state.cwd),
127
+ bdList('blocked', state.cwd).catch(() => []),
128
+ ]);
129
+ state.blockedIds = new Set((blocked || []).map((b) => b.id));
80
130
  const byId = new Map(beads.map((b) => [b.id, b]));
81
131
 
82
132
  // Build parent-child map from real bd deps (dependency_type === 'parent-child').
@@ -5,6 +5,22 @@ const STATUS_COLOR = {
5
5
  open: 'white', in_progress: 'cyan', blocked: 'red',
6
6
  deferred: 'yellow', closed: 'gray',
7
7
  };
8
+
9
+ // Dep-row palette: tuned for at-a-glance scanning in the dependency list.
10
+ // open / ready → green (go)
11
+ // in_progress → yellow (active)
12
+ // blocked → red (stop)
13
+ // deferred → gray (parked)
14
+ // closed → gray (done, recede)
15
+ const DEP_STATUS_COLOR = {
16
+ open: 'green', in_progress: 'yellow', blocked: 'red',
17
+ deferred: 'gray', closed: 'gray',
18
+ };
19
+
20
+ const STATUS_ICON = {
21
+ open: '○', in_progress: '◐', blocked: '●',
22
+ closed: '✓', deferred: '❄',
23
+ };
8
24
  const PRIORITY_COLOR = ['red', 'yellow', 'green', 'cyan', 'gray'];
9
25
  const PRIORITY_LABEL = ['Critical', 'High', 'Normal', 'Low', 'Trivial'];
10
26
 
@@ -30,11 +46,26 @@ function sectionHeader(title) {
30
46
  return `\n${bold(t('cyan', title))}\n${t('gray', '─'.repeat(44))}`;
31
47
  }
32
48
 
33
- function depLine(dep, arrow, color) {
34
- const id = esc(dep.id || String(dep));
35
- const type = esc(dep.dep_type || dep.type || 'blocks');
36
- const title = dep.title ? ` ${t('gray', '— ' + esc(dep.title))}` : '';
37
- return ` ${t(color, arrow)} {bold}${id}{/bold} ${t('gray', `[${type}]`)}${title}`;
49
+ function depLine(dep) {
50
+ const rawId = esc(dep.id || String(dep));
51
+ const rawTitle = dep.title ? esc(dep.title) : '';
52
+ const issueType = dep.issue_type ? esc(dep.issue_type) : null;
53
+
54
+ // Effective status mirrors how `bd blocked` derives the Blocked tab:
55
+ // an open bead whose blockers aren't all closed reads as "blocked",
56
+ // not "open". `bd dep list` only returns the raw stored status.
57
+ const effectiveStatus = (dep.status === 'open' && state.blockedIds.has(dep.id))
58
+ ? 'blocked'
59
+ : dep.status;
60
+
61
+ const statusColor = DEP_STATUS_COLOR[effectiveStatus] || 'white';
62
+ const icon = STATUS_ICON[effectiveStatus] || ' ';
63
+ const idText = `{bold}${rawId}{/bold}`;
64
+ const typeTag = issueType ? ` [${issueType}]` : '';
65
+ const titleText = rawTitle ? ` — ${rawTitle}` : '';
66
+
67
+ const row = `${icon} ${idText}${typeTag}${titleText}`;
68
+ return ` {${statusColor}-fg}${row}{/${statusColor}-fg}`;
38
69
  }
39
70
 
40
71
  // Apply inline markdown styling to already-escaped text. Order matters:
@@ -153,11 +184,11 @@ export function renderDetail(box) {
153
184
  // ── Dependencies ───────────────────────────────────────────────────────────
154
185
  if (b.depsDown?.length > 0) {
155
186
  lines.push(sectionHeader(`Depends on (${b.depsDown.length})`));
156
- for (const d of b.depsDown) lines.push(depLine(d, '→', 'yellow'));
187
+ for (const d of b.depsDown) lines.push(depLine(d));
157
188
  }
158
189
  if (b.depsUp?.length > 0) {
159
190
  lines.push(sectionHeader(`Needed by (${b.depsUp.length})`));
160
- for (const d of b.depsUp) lines.push(depLine(d, '←', 'green'));
191
+ for (const d of b.depsUp) lines.push(depLine(d));
161
192
  }
162
193
 
163
194
  // ── Description ────────────────────────────────────────────────────────────
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