bdtui 0.5.0 → 0.6.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
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-06-14
8
+
9
+ ### Fixed
10
+
11
+ - The **Closed** and **All** tabs now render the epic→children tree like the other tabs; previously they showed a flat, ungrouped list with every bead at the top level. Closed children nest under their epic (parent epics that aren't themselves closed are pulled in as the grouping anchor).
12
+
13
+ ### Changed
14
+
15
+ - Tree views now build from the `parent` field that `bd` embeds in each `bd list`/`bd ready` row instead of fetching one `bd dep list` per bead. The Closed/All tabs gain the tree at no extra per-bead subprocess cost (lazy "load more" pagination is unchanged), and the Ready/Blocked/In-progress tabs load with fewer `bd` calls. The Blocked tab keeps a per-row `bd dep list` fallback for the derived-blocked rows `bd blocked` returns without a `parent` field.
16
+
17
+ ## [0.5.1] - 2026-06-14
18
+
19
+ ### Fixed
20
+
21
+ - Blocked tab now also lists beads whose status was explicitly set to `blocked`. Previously it ran only `bd blocked` (which returns *derived*-blocked beads — open beads with unclosed blockers), so explicitly-blocked beads were missing from the tab; the filter now merges `bd blocked` with `bd list --status blocked`.
22
+ - List rows for open beads that are derived-blocked now show the `blocked` status label and color, matching the detail view's effective-status logic.
23
+
7
24
  ## [0.5.0] - 2026-06-13
8
25
 
9
26
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Terminal UI for the bd beads issue tracker",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -354,6 +354,11 @@ export async function run(cwd) {
354
354
  const b = state.beadsById.get(id);
355
355
  return b?.title?.toLowerCase().includes(q);
356
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 }]));
357
362
  state.visibleCount = isPaged()
358
363
  ? Math.min(state.pageSize, state.listOrder.length)
359
364
  : state.listOrder.length;
package/src/bd.js CHANGED
@@ -16,9 +16,24 @@ async function runBd(args, cwd) {
16
16
  }
17
17
  }
18
18
 
19
+ // `bd blocked` only returns *derived*-blocked beads (open beads whose blockers
20
+ // aren't all closed); it omits beads whose status was explicitly set to
21
+ // "blocked". The Blocked tab should show both, so merge the two sources, deduped.
22
+ async function bdBlocked(cwd) {
23
+ const [derived, explicit] = await Promise.all([
24
+ runBd(['blocked', '--json'], cwd).catch(() => []),
25
+ runBd(['list', '--status', 'blocked', '--json', '--limit', '0'], cwd).catch(() => []),
26
+ ]);
27
+ const byId = new Map();
28
+ for (const b of [...(derived || []), ...(explicit || [])]) {
29
+ if (b?.id && !byId.has(b.id)) byId.set(b.id, b);
30
+ }
31
+ return [...byId.values()];
32
+ }
33
+
19
34
  export const bdList = (filter, cwd, { unlimited = false } = {}) => {
20
35
  if (filter === 'ready') return runBd(['ready', '--json'], cwd);
21
- if (filter === 'blocked') return runBd(['blocked', '--json'], cwd);
36
+ if (filter === 'blocked') return bdBlocked(cwd);
22
37
  const args = ['list', '--json'];
23
38
  if (filter === 'all') args.push('--all');
24
39
  else if (filter) args.push('--status', filter);
package/src/state.js CHANGED
@@ -22,8 +22,8 @@ export const state = {
22
22
  };
23
23
 
24
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.
25
+ // so they reveal rows lazily in pageSize batches; every other view renders its
26
+ // rows all at once. All tabs build the same epic→children tree (see loadList).
27
27
  export function isPaged() {
28
28
  return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
29
29
  }
@@ -80,22 +80,33 @@ export function applyTypeFilter() {
80
80
 
81
81
  function buildTreeOrder(beads, parentOf) {
82
82
  const byId = new Map(beads.map((b) => [b.id, b]));
83
- const childrenOf = new Map();
83
+ // A bead is a root when it has no parent present in this bead set (epics and
84
+ // standalone beads). The tree is intentionally two levels deep — epics on top,
85
+ // their direct children indented under them.
86
+ const isRoot = (id) => {
87
+ const pid = parentOf.get(id);
88
+ return !pid || !byId.has(pid);
89
+ };
84
90
 
91
+ const childrenOf = new Map();
92
+ const roots = [];
85
93
  for (const b of beads) {
86
94
  const pid = parentOf.get(b.id);
87
- if (pid && byId.has(pid)) {
95
+ // Nest a bead only when its parent is itself a root. Anything else — no
96
+ // parent, a parent filtered out of this view, or a parent that is itself
97
+ // nested (deeper than two levels) — renders at the top level so no bead is
98
+ // ever silently dropped from the list.
99
+ if (pid && byId.has(pid) && isRoot(pid)) {
88
100
  if (!childrenOf.has(pid)) childrenOf.set(pid, []);
89
101
  childrenOf.get(pid).push(b);
102
+ } else {
103
+ roots.push(b);
90
104
  }
91
105
  }
92
106
 
93
107
  for (const children of childrenOf.values()) {
94
108
  children.sort(byUpdatedDesc);
95
109
  }
96
-
97
- const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
98
- const roots = beads.filter((b) => !childIds.has(b.id));
99
110
  roots.sort(byUpdatedDesc);
100
111
 
101
112
  const ordered = [];
@@ -110,50 +121,47 @@ function buildTreeOrder(beads, parentOf) {
110
121
  return ordered;
111
122
  }
112
123
 
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
-
132
124
  export async function loadList() {
133
- if (isPaged()) return loadListFlat();
125
+ const paged = isPaged();
134
126
  const [beads, blocked] = await Promise.all([
135
- bdList(state.filter, state.cwd),
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 }),
136
130
  bdList('blocked', state.cwd).catch(() => []),
137
131
  ]);
138
132
  state.blockedIds = new Set((blocked || []).map((b) => b.id));
139
133
  const byId = new Map(beads.map((b) => [b.id, b]));
140
134
 
141
- // Build parent-child map from real bd deps (dependency_type === 'parent-child').
142
- // Fetch only for non-epic beads — epics don't have epic parents in this model.
143
- const candidates = beads.filter((b) => b.issue_type !== 'epic');
144
- const depLists = await Promise.all(
145
- candidates.map((b) => bdDepListDown(b.id, state.cwd))
146
- );
147
-
135
+ // Parent-child relationships come straight from the `parent` field bd embeds in
136
+ // every list row (the target of the bead's parent-child dependency). This is
137
+ // the same data `bd dep list` returns, so the tree builds from the single
138
+ // `bd list` call above — no per-bead subprocess fan-out, even on huge repos.
148
139
  const parentOf = new Map();
149
- candidates.forEach((b, i) => {
150
- const pc = depLists[i].find((d) => d.dependency_type === 'parent-child');
151
- if (pc?.id) parentOf.set(b.id, pc.id);
152
- });
140
+ for (const b of beads) {
141
+ if (b.parent && b.parent !== b.id) parentOf.set(b.id, b.parent);
142
+ }
143
+
144
+ // The Blocked tab merges in `bd blocked` (derived-blocked) rows, and that
145
+ // command — unlike `bd list` — omits the `parent` field entirely. Recover the
146
+ // parent for those rows with a dep-list lookup. Scoped to the blocked filter
147
+ // (the only parent-less source) and to non-epic rows without an embedded
148
+ // parent, so it stays a handful of calls on an already-small view.
149
+ if (state.filter === 'blocked') {
150
+ const needsLookup = beads.filter((b) => !('parent' in b) && b.issue_type !== 'epic');
151
+ const depLists = await Promise.all(
152
+ needsLookup.map((b) => bdDepListDown(b.id, state.cwd))
153
+ );
154
+ needsLookup.forEach((b, i) => {
155
+ const pc = depLists[i].find((d) => d.dependency_type === 'parent-child');
156
+ if (pc?.id && pc.id !== b.id) parentOf.set(b.id, pc.id);
157
+ });
158
+ }
153
159
 
154
160
  // Bring in parent epics that aren't already in the filtered list so the tree
155
161
  // can root under them. Only add the epic itself, NOT its other children —
156
- // that's what caused closed beads to leak into ready/open filters.
162
+ // that's what caused closed beads to leak into ready/open filters. Skipped for
163
+ // "all", which already contains every bead. The fetch is bounded by the number
164
+ // of distinct missing parents (≈ epic count), not the bead count.
157
165
  if (state.filter !== 'all') {
158
166
  const missingParents = [...new Set(parentOf.values())].filter((id) => !byId.has(id));
159
167
  const fetched = await Promise.all(
package/src/views/list.js CHANGED
@@ -31,7 +31,12 @@ function t(color, text) {
31
31
  function formatRow(bead, depth = 0, isLast = false) {
32
32
  const id = bead.id.padEnd(14);
33
33
  const p = `P${bead.priority ?? 2}`;
34
- const stat = bead.status || 'open';
34
+ // Effective status mirrors how `bd blocked` derives the Blocked tab:
35
+ // an open bead whose blockers aren't all closed reads as "blocked",
36
+ // not "open". The raw `bd list` rows only carry the stored status.
37
+ const stat = (bead.status === 'open' && state.blockedIds.has(bead.id))
38
+ ? 'blocked'
39
+ : (bead.status || 'open');
35
40
  const s = (STATUS_SHORT[stat] || stat.slice(0, 7)).padEnd(7);
36
41
  const badge = TYPE_BADGE[bead.issue_type] ?? t('gray', '?');
37
42
  const prefix = depth > 0 ? t('gray', isLast ? ' └ ' : ' ├ ') : ' ';