bdtui 0.4.0 → 0.5.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
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.5.1] - 2026-06-14
8
+
9
+ ### Fixed
10
+
11
+ - 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`.
12
+ - 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.
13
+
14
+ ## [0.5.0] - 2026-06-13
15
+
16
+ ### Added
17
+
18
+ - `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).
19
+ - `chore` and `bug` added to the `t` type-filter cycle (`all → epic → task → chore → bug → all`).
20
+ - `x` key in the epic-filter picker clears the filter immediately (same as selecting ✗ Clear).
21
+
22
+ ### Changed
23
+
24
+ - 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.
25
+ - `r` (reload) now also clears any active epic filter before reloading; status bar shows "Epic filter cleared" when it does.
26
+
27
+ ### Removed
28
+
29
+ - `D` dependency menu — replaced by the focused `h` reparent picker.
30
+
5
31
  ## [0.4.0] - 2026-06-07
6
32
 
7
33
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Terminal UI for the bd beads issue tracker",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -3,12 +3,12 @@ import { execFile } from 'child_process';
3
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;
@@ -201,7 +201,12 @@ export async function run(cwd) {
201
201
 
202
202
  key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
203
203
 
204
- 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
+ });
205
210
  key(['tab'], () => cycleFilter(1));
206
211
  key(['S-tab'], () => cycleFilter(-1));
207
212
  key(['t'], cycleTypeFilter);
@@ -262,10 +267,63 @@ export async function run(cwd) {
262
267
  if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
263
268
  });
264
269
 
265
- key(['h', 'escape'], () => {
270
+ key(['escape'], () => {
266
271
  if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
267
272
  });
268
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
+
269
327
  key(['g'], () => {
270
328
  if (screen.focused !== list) return;
271
329
  list.select(0);
@@ -402,29 +460,6 @@ export async function run(cwd) {
402
460
  }
403
461
  });
404
462
 
405
- key(['D'], async () => {
406
- if (screen.focused !== list || !state.selectedId) return;
407
- const id = state.selectedId;
408
- try {
409
- const result = await depMenu(screen);
410
- list.focus();
411
- if (result.action === 'add') {
412
- setStatus(`Adding dep: ${id} → ${result.targetId} [${result.type}]…`);
413
- await applyMutation(id, () => bdDepAdd(id, result.targetId, result.type, state.cwd));
414
- setStatus(`Added: ${id} → ${result.targetId} [${result.type}]`, false, true);
415
- } else {
416
- setStatus(`Removing dep: ${id} → ${result.targetId}…`);
417
- await applyMutation(id, () => bdDepRemove(id, result.targetId, state.cwd));
418
- setStatus(`Removed dep: ${id} → ${result.targetId}`, false, true);
419
- }
420
- renderDetail(detail);
421
- screen.render();
422
- } catch (err) {
423
- list.focus();
424
- if (err.message !== 'cancelled') setStatus(err.message, true, true);
425
- }
426
- });
427
-
428
463
  async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
429
464
  setStatus(busyMsg || `Copying…`);
430
465
  const write = (cmd, args) => new Promise((resolve, reject) => {
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/keys.js CHANGED
@@ -9,15 +9,15 @@ 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
22
  ' (all & closed tabs load more rows as you scroll to the bottom)',
23
23
  '',
@@ -28,7 +28,7 @@ export const HELP_TEXT = [
28
28
  ' C Claim bead (in_progress + assign self)',
29
29
  ' o Reopen',
30
30
  ' p Change priority',
31
- ' D Dependency menu (add / remove)',
31
+ ' h Change parent (epic / standalone)',
32
32
  '',
33
33
  ' Other',
34
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)
@@ -62,6 +68,7 @@ export function applyTypeFilter() {
62
68
  if (state.typeFilter !== 'all') {
63
69
  items = items
64
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)))
65
72
  .map((t) => ({ id: t.id, depth: 0, isLast: false }));
66
73
  }
67
74
  state.listOrder = items.map((t) => t.id);
@@ -84,11 +91,12 @@ function buildTreeOrder(beads, parentOf) {
84
91
  }
85
92
 
86
93
  for (const children of childrenOf.values()) {
87
- children.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2) || a.id.localeCompare(b.id));
94
+ children.sort(byUpdatedDesc);
88
95
  }
89
96
 
90
97
  const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
91
98
  const roots = beads.filter((b) => !childIds.has(b.id));
99
+ roots.sort(byUpdatedDesc);
92
100
 
93
101
  const ordered = [];
94
102
  for (const root of roots) {
@@ -115,6 +123,7 @@ async function loadListFlat() {
115
123
  for (const b of beads) {
116
124
  state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
117
125
  }
126
+ beads.sort(byUpdatedDesc);
118
127
  state.fullTreeItems = beads.map((b) => ({ id: b.id, depth: 0, isLast: false }));
119
128
  applyTypeFilter();
120
129
  return beads;
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 ? ' └ ' : ' ├ ') : ' ';
@@ -166,6 +166,12 @@ export function epicPicker(screen, epics, currentEpicId = null) {
166
166
  screen.render();
167
167
  reject(new Error('cancelled'));
168
168
  });
169
+
170
+ picker.key(['x'], () => {
171
+ picker.destroy();
172
+ screen.render();
173
+ resolve(null);
174
+ });
169
175
  });
170
176
  }
171
177
 
@@ -180,26 +186,62 @@ export function skillPicker(screen, beadId) {
180
186
  return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
181
187
  }
182
188
 
183
- const DEP_TYPES = [
184
- 'blocks', 'tracks', 'related', 'relates-to',
185
- 'validates', 'caused-by', 'parent-child',
186
- ];
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
+ });
187
198
 
188
- export async function depMenu(screen) {
189
- const action = await listPicker(
190
- screen,
191
- 'Dependency',
192
- ['Add dep', 'Remove dep'],
193
- 'magenta',
194
- );
199
+ const rows = ['{yellow-fg}Standalone bead{/}'];
200
+ const ids = [null];
195
201
 
196
- const targetId = await textPrompt(screen, 'Target bead ID');
197
- if (!targetId) throw new Error('cancelled');
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
+ }
198
208
 
199
- if (action === 'Add dep') {
200
- const type = await listPicker(screen, 'Dep type', DEP_TYPES, 'magenta');
201
- return { action: 'add', targetId, type };
202
- }
209
+ const width = 72;
210
+ const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
203
211
 
204
- return { action: 'remove', targetId };
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
+ });
231
+
232
+ picker.focus();
233
+ screen.render();
234
+
235
+ picker.once('select', (_item, index) => {
236
+ picker.destroy();
237
+ screen.render();
238
+ resolve(ids[index]);
239
+ });
240
+
241
+ picker.key(['escape', 'q'], () => {
242
+ picker.destroy();
243
+ screen.render();
244
+ reject(new Error('cancelled'));
245
+ });
246
+ });
205
247
  }