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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.3.2",
3
+ "version": "0.5.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,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, depMenu, skillPicker, epicPicker } from './views/modals.js';
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'], refresh);
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(['h', 'escape'], () => {
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 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
@@ -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 current filter from bd',
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
- ' D Dependency menu (add / remove)',
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((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || a.id.localeCompare(b.id));
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 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
 
@@ -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
- const DEP_TYPES = [
176
- 'blocks', 'tracks', 'related', 'relates-to',
177
- 'validates', 'caused-by', 'parent-child',
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
- export async function depMenu(screen) {
181
- const action = await listPicker(
182
- screen,
183
- 'Dependency',
184
- ['Add dep', 'Remove dep'],
185
- 'magenta',
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
- const targetId = await textPrompt(screen, 'Target bead ID');
189
- if (!targetId) throw new Error('cancelled');
232
+ picker.focus();
233
+ screen.render();
190
234
 
191
- if (action === 'Add dep') {
192
- const type = await listPicker(screen, 'Dep type', DEP_TYPES, 'magenta');
193
- return { action: 'add', targetId, type };
194
- }
235
+ picker.once('select', (_item, index) => {
236
+ picker.destroy();
237
+ screen.render();
238
+ resolve(ids[index]);
239
+ });
195
240
 
196
- return { action: 'remove', targetId };
241
+ picker.key(['escape', 'q'], () => {
242
+ picker.destroy();
243
+ screen.render();
244
+ reject(new Error('cancelled'));
245
+ });
246
+ });
197
247
  }