bdtui 0.6.0 → 0.7.1

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
@@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.1] - 2026-06-21
8
+
9
+ ### Fixed
10
+
11
+ - The **Keybindings** (`?`) help can now be dismissed after a mouse click moves focus to the Beads list or Detail pane. Its close keys were bound to the help box itself, so once a click stole focus the modal became unclosable (every global key is suppressed while a modal is open). `q`/`Esc`/`h` are now bound at the screen level and dismiss it regardless of which pane holds focus.
12
+ - Reopening the Keybindings help with `?` works again. `?` is now a single open/close toggle, fixing a re-entrancy where the freshly-registered `?` close handler fired on the very keypress that opened the modal and slammed it shut.
13
+ - The boot status bar no longer shows the `? help` hint twice. `setStatus` already appends it, so the redundant hint baked into the "Ready" message was removed.
14
+
15
+ ## [0.7.0] - 2026-06-21
16
+
17
+ ### Added
18
+
19
+ - `Shift+r`: reset all narrowing filters (title search, epic scope, type) and reload fresh.
20
+ - The status bar now summarizes every active narrowing filter together — e.g. `"login" · epic: bd-12 · type: task — 3 results | Shift+r to reset` — instead of only the most recent one. The epic filter now shows the same result-count/reset hint that title search did.
21
+ - The tab bar gains a persistent `search:` indicator for the active title filter, alongside the existing `type:` and `epic:` indicators.
22
+
23
+ ### Changed
24
+
25
+ - `r` (reload) now **keeps** active filters instead of clearing the epic filter; use `Shift+r` to clear filters. This reverses the `0.5.0` behavior where `r` cleared the epic filter.
26
+ - The `/` title filter is now persistent state: it survives a reload (`r`) and composes with the epic and type filters. The prompt pre-fills with the current search, and submitting an empty value clears just the title filter.
27
+
28
+ ### Fixed
29
+
30
+ - `Shift+c` (claim) and `Shift+g` (jump to bottom) now actually fire. They were registered as bare uppercase `C`/`G`, but blessed reports a shifted letter as `S-<lower>`, so the handlers were dead — claim did nothing and `Shift+g` only reached the last *loaded* page instead of revealing all rows and jumping to the true bottom.
31
+ - Reloading or filtering while scoped to an epic on the **All**/**Closed** tabs no longer caps the fetch at bd's default 50 rows, which could drop an epic's children from the scoped view.
32
+ - The title search is now tag-escaped before it reaches the status bar, so a query containing `{`/`}` no longer corrupts the status line.
33
+ - Reparenting with `h` no longer throws `Unexpected token '✓' … is not valid JSON`. `bd dep add`/`bd dep remove` print a human-readable line rather than JSON, so their output is no longer parsed as JSON.
34
+ - `h` (change parent) now **detaches every existing parent first, then attaches** the chosen one — previously it added the new parent before removing the old, so the JSON error above left the bead with two parents. It also clears *all* existing parent-child links (not just the first), repairing beads that already ended up with multiple parents.
35
+
7
36
  ## [0.6.0] - 2026-06-14
8
37
 
9
38
  ### Fixed
package/README.md CHANGED
@@ -66,18 +66,21 @@ be-12 P2 ready T └ Another child
66
66
  |-----|--------|
67
67
  | `j` / `↓` | Move down |
68
68
  | `k` / `↑` | Move up |
69
- | `g` / `G` | Jump to top / bottom |
69
+ | `g` / `Shift+g` | Jump to top / bottom |
70
70
  | `Enter` / `l` | Focus detail pane |
71
- | `h` / `Esc` | Back to list |
72
- | `f` | Cycle filter: ready → open → in_progress → all |
73
- | `r` | Reload current filter |
74
- | `/` | In-memory title filter |
71
+ | `h` / `Esc` | Back to list (from detail) |
72
+ | `Tab` / `Shift+Tab` | Next / previous status filter (blocked/ready/in_progress/closed/all) |
73
+ | `t` | Cycle type filter (all/epic/task/chore/bug) |
74
+ | `e` | Filter by epic (modal picker; `x` clears) |
75
+ | `/` | In-memory title filter (empty submit clears it) |
76
+ | `r` | Reload (keeps active filters) |
77
+ | `Shift+r` | Reset all filters (title / epic / type) and reload |
75
78
  | `s` | Change status |
76
79
  | `c` | Close with reason |
77
- | `C` | Claim (in_progress + assign self) |
80
+ | `Shift+c` | Claim (in_progress + assign self) |
78
81
  | `o` | Reopen |
79
82
  | `p` | Change priority |
80
- | `D` | Dependency menu (add / remove) |
83
+ | `h` | Change parent (epic / standalone) |
81
84
  | `y` | Yank bead ID to clipboard |
82
85
  | `w` | Pick a workflow skill (`/executor-task`, `/executor-task-worktree`, `/executor-epic-task`, `/executor-epic-task-worktree`) and copy it with the selected task's ID. Disabled for epics. See [workflow-template](https://github.com/jsiovn/workflow-template). |
83
86
  | `?` | Help overlay |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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, isPaged, ensureSelectedVisible } from './state.js';
3
+ import { state, loadList, loadDetail, applyMutation, applyFilters, 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, parentPicker, skillPicker, epicPicker } from './views/modals.js';
@@ -10,6 +10,11 @@ import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListD
10
10
  const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
11
11
  const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
12
12
 
13
+ // Neutralize blessed tag markup in free text before it lands in a tags:true
14
+ // box (mirrors esc() in views/detail.js). The title search is user-typed, so a
15
+ // query like "{bold}" would otherwise corrupt the status line.
16
+ const escTags = (s) => String(s ?? '').replace(/\{/g, '{open}').replace(/\}/g, '{close}');
17
+
13
18
  export async function run(cwd) {
14
19
  state.cwd = cwd;
15
20
 
@@ -38,8 +43,19 @@ export async function run(cwd) {
38
43
  let debounceTimer = null;
39
44
 
40
45
  let statusTimer = null;
46
+ // Describe every active narrowing filter (title search, epic scope, type) in
47
+ // one line with the result count and reset hint. Returns null when none are
48
+ // active so the caller can fall back to a plain "Ready".
49
+ function filterSummary() {
50
+ const parts = [];
51
+ if (state.textFilter) parts.push(`"${escTags(state.textFilter)}"`);
52
+ if (state.epicFilter) parts.push(`epic: ${state.epicFilter}`);
53
+ if (state.typeFilter !== 'all') parts.push(`type: ${state.typeFilter}`);
54
+ if (parts.length === 0) return null;
55
+ return `${parts.join(' · ')} — ${state.listOrder.length} results | Shift+r to reset`;
56
+ }
41
57
  function defaultStatus() {
42
- return state.filter ? `Filter: ${state.filter}` : 'Ready';
58
+ return filterSummary() || 'Ready';
43
59
  }
44
60
  function setStatus(msg, isError = false, transient = false) {
45
61
  if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
@@ -67,8 +83,11 @@ export async function run(cwd) {
67
83
  const epicInfo = state.epicFilter
68
84
  ? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
69
85
  : '';
86
+ const textInfo = state.textFilter
87
+ ? ` {gray-fg}│{/} {green-fg}search:{/} "${escTags(state.textFilter)}"`
88
+ : '';
70
89
  const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
71
- tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + info);
90
+ tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + textInfo + info);
72
91
  }
73
92
 
74
93
  function setFocusBorder(focused) {
@@ -142,7 +161,7 @@ export async function run(cwd) {
142
161
  ensureSelectedVisible();
143
162
  if (state.selectedId) await loadDetail(state.selectedId);
144
163
  render();
145
- setStatus('Ready');
164
+ setStatus(defaultStatus());
146
165
  } catch (err) {
147
166
  setStatus(err.message, true, true);
148
167
  }
@@ -158,7 +177,7 @@ export async function run(cwd) {
158
177
  state.selectedId = state.listOrder[0] || null;
159
178
  if (state.selectedId) await loadDetail(state.selectedId);
160
179
  render();
161
- setStatus(`Filter: ${state.filter}`);
180
+ setStatus(defaultStatus());
162
181
  } catch (err) {
163
182
  setStatus(err.message, true, true);
164
183
  }
@@ -167,7 +186,7 @@ export async function run(cwd) {
167
186
  async function cycleTypeFilter() {
168
187
  const idx = TYPE_FILTERS.indexOf(state.typeFilter);
169
188
  state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
170
- applyTypeFilter();
189
+ applyFilters();
171
190
  if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
172
191
  state.selectedId = state.listOrder[0] || null;
173
192
  } else if (!state.selectedId) {
@@ -185,7 +204,7 @@ export async function run(cwd) {
185
204
  return;
186
205
  }
187
206
  }
188
- setStatus(`Type: ${state.typeFilter}`, false, true);
207
+ setStatus(defaultStatus());
189
208
  }
190
209
 
191
210
  // ── Global keys ────────────────────────────────────────────────────────────
@@ -201,11 +220,20 @@ export async function run(cwd) {
201
220
 
202
221
  key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
203
222
 
223
+ // r reloads data while preserving every active filter; Shift+r clears all
224
+ // narrowing filters (title search, epic scope, type) and reloads fresh.
225
+ // blessed reports a shifted letter as `S-<lower>` (never bare uppercase), so
226
+ // the binding must be 'S-r' — 'R' would be dead. Same for S-c / S-g below.
204
227
  key(['r'], async () => {
205
- const wasEpic = !!state.epicFilter;
228
+ await refresh();
229
+ });
230
+ key(['S-r'], async () => {
231
+ const had = !!(state.textFilter || state.epicFilter || state.typeFilter !== 'all');
232
+ state.textFilter = null;
206
233
  state.epicFilter = null;
234
+ state.typeFilter = 'all';
207
235
  await refresh();
208
- if (wasEpic) setStatus('Epic filter cleared', false, true);
236
+ if (had) setStatus('Filters reset', false, true);
209
237
  });
210
238
  key(['tab'], () => cycleFilter(1));
211
239
  key(['S-tab'], () => cycleFilter(-1));
@@ -241,7 +269,7 @@ export async function run(cwd) {
241
269
  return;
242
270
  }
243
271
  } else {
244
- applyTypeFilter();
272
+ applyFilters();
245
273
  }
246
274
  state.selectedId = state.listOrder[0] || null;
247
275
  if (state.selectedId) {
@@ -251,12 +279,22 @@ export async function run(cwd) {
251
279
  }
252
280
  }
253
281
  render();
254
- setStatus(picked ? `Epic: ${picked}` : 'Epic filter cleared', false, true);
282
+ setStatus(defaultStatus());
255
283
  });
256
284
 
257
- key(['?'], () => {
285
+ // '?' toggles the help modal. Registered directly (NOT through key(), which
286
+ // swallows every global while a modal is open) so the same key that opens the
287
+ // help can also close it. Driving open AND close from this one handler — kept
288
+ // out of showHelp's own close keys — avoids re-entrancy: a separate '?' close
289
+ // binding would fire on the very keypress that registered it and slam the
290
+ // modal shut again. closeHelp is non-null exactly while the help is open.
291
+ let closeHelp = null;
292
+ screen.key(['?'], () => {
293
+ if (closeHelp) { closeHelp(); return; }
294
+ if (modalOpen) return; // another modal (a picker) owns the screen
258
295
  modalOpen = true;
259
- showHelp(screen, () => {
296
+ closeHelp = showHelp(screen, () => {
297
+ closeHelp = null;
260
298
  modalOpen = false;
261
299
  list.focus();
262
300
  screen.render();
@@ -289,10 +327,15 @@ export async function run(cwd) {
289
327
  setStatus(err.message, true, true);
290
328
  return;
291
329
  }
292
- const currentParentId = downDeps.find((d) => d.dependency_type === 'parent-child')?.id ?? null;
330
+ // A bead should have at most one parent, but a prior bug could leave it with
331
+ // several parent-child deps — collect them all so we can clear every one.
332
+ const currentParentIds = downDeps
333
+ .filter((d) => d.dependency_type === 'parent-child')
334
+ .map((d) => d.id)
335
+ .filter(Boolean);
293
336
  let newParent;
294
337
  try {
295
- newParent = await parentPicker(screen, epics, { selfId: id, currentParentId });
338
+ newParent = await parentPicker(screen, epics, { selfId: id, currentParentIds });
296
339
  } catch (err) {
297
340
  list.focus();
298
341
  if (err.message !== 'cancelled') setStatus(err.message, true, true);
@@ -302,13 +345,13 @@ export async function run(cwd) {
302
345
  setStatus(`Reparenting ${id}…`);
303
346
  try {
304
347
  await applyMutation(id, async () => {
305
- if (newParent) {
348
+ // Detach every existing parent first so the bead is never left with two
349
+ // parents, then attach the chosen one (skip if it's already a parent).
350
+ for (const pid of currentParentIds) {
351
+ if (pid !== newParent) await bdDepRemove(id, pid, state.cwd);
352
+ }
353
+ if (newParent && !currentParentIds.includes(newParent)) {
306
354
  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
355
  }
313
356
  });
314
357
  await loadList();
@@ -331,7 +374,7 @@ export async function run(cwd) {
331
374
  screen.render();
332
375
  });
333
376
 
334
- key(['G'], () => {
377
+ key(['S-g'], () => {
335
378
  if (screen.focused !== list) return;
336
379
  // Jump to the true bottom — in paged mode reveal every remaining batch first.
337
380
  if (isPaged() && state.visibleCount < state.listOrder.length) {
@@ -346,27 +389,20 @@ export async function run(cwd) {
346
389
  key(['/'], async () => {
347
390
  if (screen.focused !== list) return;
348
391
  try {
349
- const query = await textPrompt(screen, 'Filter by title');
392
+ // Pre-fill with the active search so it can be edited; an empty submit
393
+ // clears the title filter (other filters stay put).
394
+ const query = await textPrompt(screen, 'Filter by title', state.textFilter || '');
350
395
  list.focus();
351
- if (!query) { render(); return; }
352
- const q = query.toLowerCase();
353
- state.listOrder = state.listOrder.filter((id) => {
354
- const b = state.beadsById.get(id);
355
- return b?.title?.toLowerCase().includes(q);
356
- });
357
- // A title-search result set is no longer a tree, so flatten every surviving
358
- // row to depth 0 — otherwise a matched child whose parent was filtered out
359
- // renders as an orphan indented under a branch glyph (mirrors how
360
- // applyTypeFilter flattens the type-filtered list).
361
- state.treeMeta = new Map(state.listOrder.map((id) => [id, { depth: 0, isLast: false }]));
362
- state.visibleCount = isPaged()
363
- ? Math.min(state.pageSize, state.listOrder.length)
364
- : state.listOrder.length;
365
- state.selectedId = state.listOrder[0] || null;
396
+ state.textFilter = query || null;
397
+ applyFilters();
398
+ if (!state.selectedId || !state.listOrder.includes(state.selectedId)) {
399
+ state.selectedId = state.listOrder[0] || null;
400
+ }
401
+ ensureSelectedVisible();
366
402
  renderList(list);
367
403
  renderDetail(detail);
368
404
  screen.render();
369
- setStatus(`"${query}" — ${state.listOrder.length} results | r to reset`);
405
+ setStatus(defaultStatus());
370
406
  } catch {
371
407
  list.focus();
372
408
  }
@@ -414,7 +450,7 @@ export async function run(cwd) {
414
450
  }
415
451
  });
416
452
 
417
- key(['C'], async () => {
453
+ key(['S-c'], async () => {
418
454
  if (screen.focused !== list || !state.selectedId) return;
419
455
  const id = state.selectedId;
420
456
  setStatus(`Claiming ${id}…`);
@@ -538,5 +574,7 @@ export async function run(cwd) {
538
574
  setStatus(err.message, true);
539
575
  }
540
576
  render();
541
- setStatus('Ready | ? help');
577
+ // setStatus already appends "? help · q quit"; defaultStatus() yields "Ready"
578
+ // (or the active-filter summary) without duplicating the help hint.
579
+ setStatus(defaultStatus());
542
580
  }
package/src/bd.js CHANGED
@@ -16,6 +16,18 @@ async function runBd(args, cwd) {
16
16
  }
17
17
  }
18
18
 
19
+ // Run a bd command whose stdout we don't consume (e.g. `dep add`/`dep remove`,
20
+ // which print a human-readable "✓ Added dependency…" line, not JSON). We can't
21
+ // JSON.parse that — doing so was the source of the "Unexpected token '✓'" error.
22
+ // Failures still surface: a non-zero exit rejects execFile and we throw stderr.
23
+ async function runBdVoid(args, cwd) {
24
+ try {
25
+ await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
26
+ } catch (err) {
27
+ throw new Error(err.stderr?.trim() || err.message);
28
+ }
29
+ }
30
+
19
31
  // `bd blocked` only returns *derived*-blocked beads (open beads whose blockers
20
32
  // aren't all closed); it omits beads whose status was explicitly set to
21
33
  // "blocked". The Blocked tab should show both, so merge the two sources, deduped.
@@ -83,11 +95,11 @@ export const bdReopen = (id, cwd) => runBd(['reopen', id, '--json'], cwd);
83
95
  export const bdDepAdd = (child, parent, type, cwd) => {
84
96
  const args = ['dep', 'add', child, parent];
85
97
  if (type && type !== 'blocks') args.push('--type', type);
86
- return runBd(args, cwd);
98
+ return runBdVoid(args, cwd);
87
99
  };
88
100
 
89
101
  export const bdDepRemove = (child, parent, cwd) =>
90
- runBd(['dep', 'remove', child, parent], cwd);
102
+ runBdVoid(['dep', 'remove', child, parent], cwd);
91
103
 
92
104
  // Returns all children of an epic (all statuses — bd children includes closed by default)
93
105
  export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
package/src/keys.js CHANGED
@@ -7,7 +7,7 @@ export const HELP_TEXT = [
7
7
  ' j / ↓ Move down in list',
8
8
  ' k / ↑ Move up in list',
9
9
  ' g Jump to top',
10
- ' G Jump to bottom',
10
+ ' Shift+g Jump to bottom',
11
11
  ' Enter / l Focus detail pane',
12
12
  ' h / Esc Back to list (from detail)',
13
13
  '',
@@ -17,15 +17,16 @@ export const HELP_TEXT = [
17
17
  ' Shift+Tab Previous status filter',
18
18
  ' t Cycle type filter (all/epic/task/chore/bug)',
19
19
  ' e Filter by epic (modal picker; x clears; switches status to all)',
20
- ' r Reload (also clears epic filter)',
21
- ' / In-memory title filter',
20
+ ' / In-memory title filter (empty submit clears it)',
21
+ ' r Reload (keeps active filters)',
22
+ ' Shift+r Reset all filters (title / epic / type) and reload',
22
23
  ' (all & closed tabs load more rows as you scroll to the bottom)',
23
24
  '',
24
25
  ' Mutations',
25
26
  ' ──────────────────────────────────────',
26
27
  ' s Change status',
27
28
  ' c Close with reason',
28
- ' C Claim bead (in_progress + assign self)',
29
+ ' Shift+c Claim bead (in_progress + assign self)',
29
30
  ' o Reopen',
30
31
  ' p Change priority',
31
32
  ' h Change parent (epic / standalone)',
@@ -64,11 +65,34 @@ export function showHelp(screen, onClose) {
64
65
  box.focus();
65
66
  screen.render();
66
67
 
68
+ // '?' is deliberately NOT a close key here — the caller (app.js) drives it as
69
+ // an open/close toggle. Binding '?' as a close key would re-enter on the very
70
+ // keypress that opens the modal: the toggle adds this listener mid-dispatch
71
+ // and blessed's emitter would then fire it in the same 'key ?' pass, slamming
72
+ // the help shut the instant it opened. q/Esc/h are safe — they never open the
73
+ // help, so they can only ever close it.
74
+ const CLOSE_KEYS = ['q', 'escape', 'h'];
75
+ let closed = false;
67
76
  const close = () => {
77
+ if (closed) return;
78
+ closed = true;
79
+ screen.unkey(CLOSE_KEYS, close);
68
80
  box.destroy();
69
81
  screen.render();
70
82
  onClose?.();
71
83
  };
72
84
 
73
- box.key(['?', 'q', 'escape', 'h'], close);
85
+ // Bind the close keys on the screen, not the box. A box-level binding only
86
+ // fires while the box holds keyboard focus, but the Beads list and Detail
87
+ // pane are both mouse:true — a click on either steals focus from the help
88
+ // box. app.js suppresses every global key while a modal is open, so a
89
+ // focus-stealing click would otherwise leave the help permanently stuck
90
+ // open (its own close keys dead, every global key inert). Screen-level keys
91
+ // fire regardless of which pane holds focus, so q/Esc/h always dismiss it.
92
+ // unkey() on close keeps the binding from leaking across repeated opens.
93
+ screen.key(CLOSE_KEYS, close);
94
+
95
+ // Returned so the caller's '?' toggle can dismiss the help regardless of
96
+ // which pane currently holds focus.
97
+ return close;
74
98
  }
package/src/state.js CHANGED
@@ -15,6 +15,7 @@ export const state = {
15
15
  filter: 'ready',
16
16
  typeFilter: 'all', // 'all' | 'epic' | 'task'
17
17
  epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
18
+ textFilter: null, // null or lowercase-matched title substring (in-memory)
18
19
  blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
19
20
  pageSize: 100, // lazy "load more" batch size for the flat all view
20
21
  visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
@@ -60,7 +61,11 @@ function scopeToEpic(items, epicId) {
60
61
  return out;
61
62
  }
62
63
 
63
- export function applyTypeFilter() {
64
+ // Apply every active narrowing filter (epic → type → text) to fullTreeItems and
65
+ // rebuild listOrder/treeMeta. Kept in one place so a reload (`r`) can replay the
66
+ // same filters and the status bar can describe them. Filters compose: an epic
67
+ // scope, a type filter, and a title search can all be active at once.
68
+ export function applyFilters() {
64
69
  let items = state.fullTreeItems;
65
70
  if (state.epicFilter) {
66
71
  items = scopeToEpic(items, state.epicFilter);
@@ -71,6 +76,15 @@ export function applyTypeFilter() {
71
76
  .sort((x, y) => byUpdatedDesc(state.beadsById.get(x.id), state.beadsById.get(y.id)))
72
77
  .map((t) => ({ id: t.id, depth: 0, isLast: false }));
73
78
  }
79
+ if (state.textFilter) {
80
+ const q = state.textFilter.toLowerCase();
81
+ // A title-search result set is no longer a tree, so flatten every surviving
82
+ // row to depth 0 — otherwise a matched child whose parent was filtered out
83
+ // renders as an orphan indented under a branch glyph.
84
+ items = items
85
+ .filter((t) => state.beadsById.get(t.id)?.title?.toLowerCase().includes(q))
86
+ .map((t) => ({ id: t.id, depth: 0, isLast: false }));
87
+ }
74
88
  state.listOrder = items.map((t) => t.id);
75
89
  state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
76
90
  state.visibleCount = isPaged()
@@ -122,11 +136,15 @@ function buildTreeOrder(beads, parentOf) {
122
136
  }
123
137
 
124
138
  export async function loadList() {
125
- const paged = isPaged();
139
+ // The all/closed tabs lift bd's 50-row cap so the full tree is available; the
140
+ // renderer still reveals rows in pageSize batches (see isPaged). This is keyed
141
+ // off the tab itself, NOT isPaged(): an epic filter makes isPaged() false but
142
+ // we still need every bead fetched so scopeToEpic can find the epic's children
143
+ // (otherwise reloading while epic-scoped would silently drop children past
144
+ // row 50).
145
+ const unlimited = state.filter === 'all' || state.filter === 'closed';
126
146
  const [beads, blocked] = await Promise.all([
127
- // Paged tabs (all / closed) lift bd's 50-row cap; rows are still revealed in
128
- // pageSize batches by the renderer.
129
- bdList(state.filter, state.cwd, { unlimited: paged }),
147
+ bdList(state.filter, state.cwd, { unlimited }),
130
148
  bdList('blocked', state.cwd).catch(() => []),
131
149
  ]);
132
150
  state.blockedIds = new Set((blocked || []).map((b) => b.id));
@@ -180,7 +198,7 @@ export async function loadList() {
180
198
  }
181
199
 
182
200
  state.fullTreeItems = buildTreeOrder(beads, parentOf);
183
- applyTypeFilter();
201
+ applyFilters();
184
202
  return beads;
185
203
  }
186
204
 
@@ -186,10 +186,11 @@ export function skillPicker(screen, beadId) {
186
186
  return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
187
187
  }
188
188
 
189
- export function parentPicker(screen, epics, { selfId, currentParentId } = {}) {
189
+ export function parentPicker(screen, epics, { selfId, currentParentIds = [] } = {}) {
190
190
  return new Promise((resolve, reject) => {
191
+ const exclude = new Set([selfId, ...currentParentIds]);
191
192
  const sorted = [...epics]
192
- .filter((e) => e.id !== selfId && e.id !== currentParentId)
193
+ .filter((e) => !exclude.has(e.id))
193
194
  .sort((a, b) => {
194
195
  const ta = a.updated_at || '', tb = b.updated_at || '';
195
196
  if (ta !== tb) return tb.localeCompare(ta);