bdtui 0.2.3 → 0.3.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,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-05-15
6
+
7
+ ### Added
8
+
9
+ - `e` shortcut: filter the list by epic via a modal picker; selecting an epic scopes the view to that epic and its children, with an option to clear the filter
10
+
5
11
  ## [0.2.3] - 2026-05-13
6
12
 
7
13
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bdtui",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal UI for the bd beads issue tracker",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -3,9 +3,9 @@ import { execFile } from 'child_process';
3
3
  import { state, loadList, loadDetail, applyMutation, applyTypeFilter } 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 } from './views/modals.js';
6
+ import { statusPicker, priorityPicker, textPrompt, depMenu, skillPicker, epicPicker } from './views/modals.js';
7
7
  import { showHelp } from './keys.js';
8
- import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove } from './bd.js';
8
+ import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdEpics } from './bd.js';
9
9
 
10
10
  const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
11
11
  const TYPE_FILTERS = ['all', 'epic', 'task'];
@@ -63,9 +63,12 @@ export async function run(cwd) {
63
63
  });
64
64
  const count = state.listOrder.length;
65
65
  const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
66
- const typeInfo = ` {gray-fg}│{/} {yellow-fg}filter:{/} ${typeLabel}`;
66
+ const typeInfo = ` {gray-fg}│{/} {yellow-fg}type:{/} ${typeLabel}`;
67
+ const epicInfo = state.epicFilter
68
+ ? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
69
+ : '';
67
70
  const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
68
- tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + info);
71
+ tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + info);
69
72
  }
70
73
 
71
74
  function setFocusBorder(focused) {
@@ -187,6 +190,49 @@ export async function run(cwd) {
187
190
  key(['S-tab'], () => cycleFilter(-1));
188
191
  key(['t'], cycleTypeFilter);
189
192
 
193
+ key(['e'], async () => {
194
+ if (screen.focused !== list) return;
195
+ setStatus('Loading epics…');
196
+ let epics;
197
+ try {
198
+ epics = await bdEpics(state.cwd);
199
+ } catch (err) {
200
+ setStatus(err.message, true, true);
201
+ return;
202
+ }
203
+ setStatus(defaultStatus());
204
+ let picked;
205
+ try {
206
+ picked = await epicPicker(screen, epics, state.epicFilter);
207
+ } catch {
208
+ list.focus();
209
+ return;
210
+ }
211
+ list.focus();
212
+ state.epicFilter = picked;
213
+ if (picked) {
214
+ state.filter = 'all';
215
+ setStatus(`Epic: ${picked} — loading…`);
216
+ try {
217
+ await loadList();
218
+ } catch (err) {
219
+ setStatus(err.message, true, true);
220
+ return;
221
+ }
222
+ } else {
223
+ applyTypeFilter();
224
+ }
225
+ state.selectedId = state.listOrder[0] || null;
226
+ if (state.selectedId) {
227
+ try { await loadDetail(state.selectedId); } catch (err) {
228
+ setStatus(err.message, true, true);
229
+ return;
230
+ }
231
+ }
232
+ render();
233
+ setStatus(picked ? `Epic: ${picked}` : 'Epic filter cleared', false, true);
234
+ });
235
+
190
236
  key(['?'], () => {
191
237
  modalOpen = true;
192
238
  showHelp(screen, () => {
package/src/bd.js CHANGED
@@ -67,3 +67,8 @@ export const bdDepRemove = (child, parent, cwd) =>
67
67
 
68
68
  // Returns all children of an epic (all statuses — bd children includes closed by default)
69
69
  export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
70
+
71
+ export const bdEpics = async (cwd) => {
72
+ const all = await runBd(['list', '--all', '--json'], cwd);
73
+ return Array.isArray(all) ? all.filter((b) => b.issue_type === 'epic') : [];
74
+ };
package/src/keys.js CHANGED
@@ -16,6 +16,7 @@ export const HELP_TEXT = [
16
16
  ' Tab Next status filter (blocked/ready/in_progress/closed/all)',
17
17
  ' Shift+Tab Previous status filter',
18
18
  ' t Cycle type filter (all/epic/task)',
19
+ ' e Filter by epic (modal picker; switches status to all)',
19
20
  ' r Reload current filter from bd',
20
21
  ' / In-memory title filter',
21
22
  '',
package/src/state.js CHANGED
@@ -8,11 +8,33 @@ export const state = {
8
8
  selectedId: null,
9
9
  filter: 'ready',
10
10
  typeFilter: 'all', // 'all' | 'epic' | 'task'
11
+ epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
11
12
  cwd: process.cwd(),
12
13
  };
13
14
 
15
+ function scopeToEpic(items, epicId) {
16
+ const out = [];
17
+ let inside = false;
18
+ for (const it of items) {
19
+ if (it.depth === 0) {
20
+ inside = it.id === epicId;
21
+ if (inside) out.push(it);
22
+ } else if (inside) {
23
+ out.push(it);
24
+ }
25
+ }
26
+ // Fix isLast on the last child after scoping (cheap, optional polish).
27
+ for (let i = out.length - 1; i >= 0; i--) {
28
+ if (out[i].depth > 0) { out[i] = { ...out[i], isLast: true }; break; }
29
+ }
30
+ return out;
31
+ }
32
+
14
33
  export function applyTypeFilter() {
15
34
  let items = state.fullTreeItems;
35
+ if (state.epicFilter) {
36
+ items = scopeToEpic(items, state.epicFilter);
37
+ }
16
38
  if (state.typeFilter !== 'all') {
17
39
  items = items
18
40
  .filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
@@ -87,6 +87,80 @@ export function textPrompt(screen, label, defaultVal = '') {
87
87
  });
88
88
  }
89
89
 
90
+ const CLEAR_FILTER_TAG = '__clear__';
91
+
92
+ export function epicPicker(screen, epics, currentEpicId = null) {
93
+ return new Promise((resolve, reject) => {
94
+ const sorted = [...epics].sort((a, b) => a.id.localeCompare(b.id));
95
+ const rows = [];
96
+ const ids = [];
97
+
98
+ if (currentEpicId) {
99
+ rows.push('{yellow-fg}✗ Clear epic filter{/}');
100
+ ids.push(CLEAR_FILTER_TAG);
101
+ }
102
+
103
+ for (const e of sorted) {
104
+ const marker = e.id === currentEpicId ? '{cyan-fg}●{/} ' : ' ';
105
+ const id = e.id.padEnd(12);
106
+ const title = (e.title || '').slice(0, 60);
107
+ rows.push(`${marker}{gray-fg}${id}{/} ${title}`);
108
+ ids.push(e.id);
109
+ }
110
+
111
+ if (rows.length === 0) {
112
+ rows.push('{gray-fg}(no epics found){/}');
113
+ ids.push(null);
114
+ }
115
+
116
+ const width = 72;
117
+ const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
118
+
119
+ const picker = blessed.list({
120
+ parent: screen,
121
+ label: ' Filter by Epic ',
122
+ border: { type: 'line' },
123
+ top: 'center',
124
+ left: 'center',
125
+ width,
126
+ height,
127
+ items: rows,
128
+ keys: true,
129
+ vi: true,
130
+ tags: true,
131
+ scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'magenta' } },
132
+ style: {
133
+ selected: { bg: 'blue', fg: 'white', bold: true },
134
+ border: { fg: 'magenta' },
135
+ label: { fg: 'magenta', bold: true },
136
+ },
137
+ });
138
+
139
+ // Select current epic by default if set
140
+ if (currentEpicId) {
141
+ const idx = ids.indexOf(currentEpicId);
142
+ if (idx >= 0) picker.select(idx);
143
+ }
144
+
145
+ picker.focus();
146
+ screen.render();
147
+
148
+ picker.once('select', (_item, index) => {
149
+ const picked = ids[index];
150
+ picker.destroy();
151
+ screen.render();
152
+ if (picked === null) return reject(new Error('cancelled'));
153
+ resolve(picked === CLEAR_FILTER_TAG ? null : picked);
154
+ });
155
+
156
+ picker.key(['escape', 'q'], () => {
157
+ picker.destroy();
158
+ screen.render();
159
+ reject(new Error('cancelled'));
160
+ });
161
+ });
162
+ }
163
+
90
164
  export function skillPicker(screen, beadId) {
91
165
  const items = [
92
166
  `/executor-task ${beadId}`,