bdtui 0.1.0 → 0.2.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 +39 -0
- package/README.md +8 -0
- package/package.json +13 -4
- package/src/app.js +149 -49
- package/src/bd.js +1 -0
- package/src/keys.js +4 -1
- package/src/state.js +16 -4
- package/src/views/detail.js +72 -25
- package/src/views/modals.js +13 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.2.1] - 2026-05-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Screenshot not rendering on npmjs.com — switched to absolute GitHub raw URL
|
|
10
|
+
|
|
11
|
+
## [0.2.0] - 2026-05-11
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Clipboard action stuck at "Copying" after the first use — now resolves on stdin flush instead of waiting for daemon processes (`wl-copy`, `xclip`) to exit
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `blocked` tab now uses `bd blocked` (dependency-blocked issues) instead of `bd list --status blocked`
|
|
20
|
+
- Workflow skill picker renamed to "Copy workflow command" and positioned on the left side of the screen
|
|
21
|
+
- Detail pane field rows use a `│` column separator with consistent alignment across all rows
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Screenshot in README
|
|
26
|
+
|
|
27
|
+
## [0.1.0] - 2026-04-28
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Initial MVP: blessed two-pane TUI (list + detail) for `bd`
|
|
32
|
+
- Tree view grouping tasks under their parent epic
|
|
33
|
+
- Status and type filter tabs with keyboard cycling
|
|
34
|
+
- Rich color scheme for status, priority, and dependency lines
|
|
35
|
+
- Inline markdown rendering in description, acceptance, and notes fields
|
|
36
|
+
- Mutation keys: change status, close, claim, reopen, change priority, manage deps
|
|
37
|
+
- `y` to yank bead ID to clipboard, `w` to copy a workflow skill command
|
|
38
|
+
- In-memory title filter (`/`)
|
|
39
|
+
- Help overlay (`?`)
|
package/README.md
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Fast blessed-based terminal UI for the [`bd`](https://github.com/gastownhall/beads) beads issue tracker.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
## Requirements
|
|
6
8
|
|
|
7
9
|
- Node ≥ 20
|
|
8
10
|
- `bd` installed and on `$PATH`
|
|
9
11
|
- A project with a `.beads/` database (created by `bd init`)
|
|
12
|
+
- (Optional) A clipboard helper for the `y` yank key:
|
|
13
|
+
- Linux/Wayland: `wl-clipboard` (`sudo apt install wl-clipboard`)
|
|
14
|
+
- Linux/X11: `xclip` (`sudo apt install xclip`) or `xsel`
|
|
15
|
+
- macOS: `pbcopy` (preinstalled)
|
|
16
|
+
- Windows: `clip.exe` (preinstalled on Windows 10/11)
|
|
10
17
|
|
|
11
18
|
## Install
|
|
12
19
|
|
|
@@ -72,6 +79,7 @@ be-12 P2 ready T └ Another child
|
|
|
72
79
|
| `p` | Change priority |
|
|
73
80
|
| `D` | Dependency menu (add / remove) |
|
|
74
81
|
| `y` | Yank bead ID to clipboard |
|
|
82
|
+
| `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). |
|
|
75
83
|
| `?` | Help overlay |
|
|
76
84
|
| `q` / `Ctrl-C` | Quit |
|
|
77
85
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bdtui",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Terminal UI for the bd beads issue tracker",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"bdtui": "bin/bdtui.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md"
|
|
14
|
+
],
|
|
8
15
|
"author": "JSIOVN",
|
|
9
16
|
"license": "MIT",
|
|
10
|
-
"engines": {
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
11
20
|
"dependencies": {
|
|
12
21
|
"blessed": "^0.1.81"
|
|
13
22
|
}
|
package/src/app.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import blessed from 'blessed';
|
|
2
2
|
import { execFile } from 'child_process';
|
|
3
|
-
import { state, loadList, loadDetail, applyMutation } from './state.js';
|
|
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 } from './views/modals.js';
|
|
6
|
+
import { statusPicker, priorityPicker, textPrompt, depMenu, skillPicker } from './views/modals.js';
|
|
7
7
|
import { showHelp } from './keys.js';
|
|
8
8
|
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove } from './bd.js';
|
|
9
9
|
|
|
10
|
-
const FILTERS = ['
|
|
10
|
+
const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
|
+
const TYPE_FILTERS = ['all', 'epic', 'task'];
|
|
11
12
|
|
|
12
13
|
export async function run(cwd) {
|
|
13
14
|
state.cwd = cwd;
|
|
@@ -36,11 +37,22 @@ export async function run(cwd) {
|
|
|
36
37
|
|
|
37
38
|
let debounceTimer = null;
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
let statusTimer = null;
|
|
41
|
+
function defaultStatus() {
|
|
42
|
+
return state.filter ? `Filter: ${state.filter}` : 'Ready';
|
|
43
|
+
}
|
|
44
|
+
function setStatus(msg, isError = false, transient = false) {
|
|
45
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
40
46
|
const icon = isError ? '{red-fg}✗{/}' : '{green-fg}●{/}';
|
|
41
47
|
const text = isError ? `{red-fg}${msg}{/}` : msg;
|
|
42
48
|
statusBar.setContent(` ${icon} ${text} {gray-fg}? help · q quit{/}`);
|
|
43
49
|
screen.render();
|
|
50
|
+
if (transient) {
|
|
51
|
+
statusTimer = setTimeout(() => {
|
|
52
|
+
statusTimer = null;
|
|
53
|
+
setStatus(defaultStatus());
|
|
54
|
+
}, 3000);
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
function renderTabBar() {
|
|
@@ -50,8 +62,10 @@ export async function run(cwd) {
|
|
|
50
62
|
: `{gray-fg} ${f} {/}`;
|
|
51
63
|
});
|
|
52
64
|
const count = state.listOrder.length;
|
|
65
|
+
const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
|
|
66
|
+
const typeInfo = ` {gray-fg}│{/} {yellow-fg}filter:{/} ${typeLabel}`;
|
|
53
67
|
const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
|
|
54
|
-
tabBar.setContent(tabs.join('{gray-fg}│{/}') + info);
|
|
68
|
+
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + info);
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
function setFocusBorder(focused) {
|
|
@@ -88,7 +102,7 @@ export async function run(cwd) {
|
|
|
88
102
|
renderDetail(detail);
|
|
89
103
|
screen.render();
|
|
90
104
|
} catch (err) {
|
|
91
|
-
setStatus(err.message, true);
|
|
105
|
+
setStatus(err.message, true, true);
|
|
92
106
|
}
|
|
93
107
|
}, 80);
|
|
94
108
|
});
|
|
@@ -112,13 +126,14 @@ export async function run(cwd) {
|
|
|
112
126
|
render();
|
|
113
127
|
setStatus('Ready');
|
|
114
128
|
} catch (err) {
|
|
115
|
-
setStatus(err.message, true);
|
|
129
|
+
setStatus(err.message, true, true);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
async function cycleFilter() {
|
|
133
|
+
async function cycleFilter(dir = 1) {
|
|
120
134
|
const idx = FILTERS.indexOf(state.filter);
|
|
121
|
-
|
|
135
|
+
const next = (idx + dir + FILTERS.length) % FILTERS.length;
|
|
136
|
+
state.filter = FILTERS[next];
|
|
122
137
|
setStatus(`Filter: ${state.filter} — loading…`);
|
|
123
138
|
try {
|
|
124
139
|
await loadList();
|
|
@@ -127,42 +142,83 @@ export async function run(cwd) {
|
|
|
127
142
|
render();
|
|
128
143
|
setStatus(`Filter: ${state.filter}`);
|
|
129
144
|
} catch (err) {
|
|
130
|
-
setStatus(err.message, true);
|
|
145
|
+
setStatus(err.message, true, true);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function cycleTypeFilter() {
|
|
150
|
+
const idx = TYPE_FILTERS.indexOf(state.typeFilter);
|
|
151
|
+
state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
|
|
152
|
+
applyTypeFilter();
|
|
153
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
154
|
+
state.selectedId = state.listOrder[0] || null;
|
|
155
|
+
} else if (!state.selectedId) {
|
|
156
|
+
state.selectedId = state.listOrder[0] || null;
|
|
157
|
+
}
|
|
158
|
+
render();
|
|
159
|
+
if (state.selectedId) {
|
|
160
|
+
try {
|
|
161
|
+
await loadDetail(state.selectedId);
|
|
162
|
+
renderDetail(detail);
|
|
163
|
+
screen.render();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
setStatus(err.message, true, true);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
131
168
|
}
|
|
169
|
+
setStatus(`Type: ${state.typeFilter}`, false, true);
|
|
132
170
|
}
|
|
133
171
|
|
|
134
172
|
// ── Global keys ────────────────────────────────────────────────────────────
|
|
173
|
+
// Modal open? swallow global keys so modals own their own keybindings
|
|
174
|
+
// (e.g. Esc/q close the help dialog instead of quitting the app).
|
|
175
|
+
let modalOpen = false;
|
|
176
|
+
function key(keys, handler) {
|
|
177
|
+
screen.key(keys, (...args) => {
|
|
178
|
+
if (modalOpen) return;
|
|
179
|
+
handler(...args);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
135
182
|
|
|
136
|
-
|
|
183
|
+
key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
137
184
|
|
|
138
|
-
|
|
139
|
-
|
|
185
|
+
key(['r'], refresh);
|
|
186
|
+
key(['tab'], () => cycleFilter(1));
|
|
187
|
+
key(['S-tab'], () => cycleFilter(-1));
|
|
188
|
+
key(['t'], cycleTypeFilter);
|
|
140
189
|
|
|
141
|
-
|
|
190
|
+
key(['?'], () => {
|
|
191
|
+
modalOpen = true;
|
|
192
|
+
showHelp(screen, () => {
|
|
193
|
+
modalOpen = false;
|
|
194
|
+
list.focus();
|
|
195
|
+
screen.render();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
142
198
|
|
|
143
|
-
|
|
199
|
+
key(['enter', 'l'], () => {
|
|
144
200
|
if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
|
|
145
201
|
});
|
|
146
202
|
|
|
147
|
-
|
|
203
|
+
key(['h', 'escape'], () => {
|
|
148
204
|
if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
|
|
149
205
|
});
|
|
150
206
|
|
|
151
|
-
|
|
207
|
+
key(['g'], () => {
|
|
152
208
|
if (screen.focused !== list) return;
|
|
153
209
|
list.select(0);
|
|
154
210
|
onNav();
|
|
155
211
|
screen.render();
|
|
156
212
|
});
|
|
157
213
|
|
|
158
|
-
|
|
214
|
+
key(['G'], () => {
|
|
159
215
|
if (screen.focused !== list) return;
|
|
160
216
|
list.select(state.listOrder.length - 1);
|
|
161
217
|
onNav();
|
|
162
218
|
screen.render();
|
|
163
219
|
});
|
|
164
220
|
|
|
165
|
-
|
|
221
|
+
key(['/'], async () => {
|
|
166
222
|
if (screen.focused !== list) return;
|
|
167
223
|
try {
|
|
168
224
|
const query = await textPrompt(screen, 'Filter by title');
|
|
@@ -185,7 +241,7 @@ export async function run(cwd) {
|
|
|
185
241
|
|
|
186
242
|
// ── Mutation keys (list focus only) ────────────────────────────────────────
|
|
187
243
|
|
|
188
|
-
|
|
244
|
+
key(['s'], async () => {
|
|
189
245
|
if (screen.focused !== list || !state.selectedId) return;
|
|
190
246
|
const id = state.selectedId;
|
|
191
247
|
try {
|
|
@@ -197,14 +253,14 @@ export async function run(cwd) {
|
|
|
197
253
|
renderList(list);
|
|
198
254
|
renderDetail(detail);
|
|
199
255
|
screen.render();
|
|
200
|
-
setStatus(`${id}: ${oldStatus} → ${newStatus}
|
|
256
|
+
setStatus(`${id}: ${oldStatus} → ${newStatus}`, false, true);
|
|
201
257
|
} catch (err) {
|
|
202
258
|
list.focus();
|
|
203
|
-
if (err.message !== 'cancelled') setStatus(err.message, true);
|
|
259
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
204
260
|
}
|
|
205
261
|
});
|
|
206
262
|
|
|
207
|
-
|
|
263
|
+
key(['c'], async () => {
|
|
208
264
|
if (screen.focused !== list || !state.selectedId) return;
|
|
209
265
|
const id = state.selectedId;
|
|
210
266
|
try {
|
|
@@ -214,14 +270,14 @@ export async function run(cwd) {
|
|
|
214
270
|
await applyMutation(id, () => bdClose(id, reason, state.cwd));
|
|
215
271
|
await loadList();
|
|
216
272
|
render();
|
|
217
|
-
setStatus(`Closed ${id}
|
|
273
|
+
setStatus(`Closed ${id}`, false, true);
|
|
218
274
|
} catch (err) {
|
|
219
275
|
list.focus();
|
|
220
|
-
if (err.message !== 'cancelled') setStatus(err.message, true);
|
|
276
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
221
277
|
}
|
|
222
278
|
});
|
|
223
279
|
|
|
224
|
-
|
|
280
|
+
key(['C'], async () => {
|
|
225
281
|
if (screen.focused !== list || !state.selectedId) return;
|
|
226
282
|
const id = state.selectedId;
|
|
227
283
|
setStatus(`Claiming ${id}…`);
|
|
@@ -230,13 +286,13 @@ export async function run(cwd) {
|
|
|
230
286
|
renderList(list);
|
|
231
287
|
renderDetail(detail);
|
|
232
288
|
screen.render();
|
|
233
|
-
setStatus(`Claimed ${id}
|
|
289
|
+
setStatus(`Claimed ${id}`, false, true);
|
|
234
290
|
} catch (err) {
|
|
235
|
-
setStatus(err.message, true);
|
|
291
|
+
setStatus(err.message, true, true);
|
|
236
292
|
}
|
|
237
293
|
});
|
|
238
294
|
|
|
239
|
-
|
|
295
|
+
key(['o'], async () => {
|
|
240
296
|
if (screen.focused !== list || !state.selectedId) return;
|
|
241
297
|
const id = state.selectedId;
|
|
242
298
|
setStatus(`Reopening ${id}…`);
|
|
@@ -244,13 +300,13 @@ export async function run(cwd) {
|
|
|
244
300
|
await applyMutation(id, () => bdReopen(id, state.cwd));
|
|
245
301
|
await loadList();
|
|
246
302
|
render();
|
|
247
|
-
setStatus(`Reopened ${id}
|
|
303
|
+
setStatus(`Reopened ${id}`, false, true);
|
|
248
304
|
} catch (err) {
|
|
249
|
-
setStatus(err.message, true);
|
|
305
|
+
setStatus(err.message, true, true);
|
|
250
306
|
}
|
|
251
307
|
});
|
|
252
308
|
|
|
253
|
-
|
|
309
|
+
key(['p'], async () => {
|
|
254
310
|
if (screen.focused !== list || !state.selectedId) return;
|
|
255
311
|
const id = state.selectedId;
|
|
256
312
|
try {
|
|
@@ -261,14 +317,14 @@ export async function run(cwd) {
|
|
|
261
317
|
renderList(list);
|
|
262
318
|
renderDetail(detail);
|
|
263
319
|
screen.render();
|
|
264
|
-
setStatus(`${id}: priority → ${priority}
|
|
320
|
+
setStatus(`${id}: priority → ${priority}`, false, true);
|
|
265
321
|
} catch (err) {
|
|
266
322
|
list.focus();
|
|
267
|
-
if (err.message !== 'cancelled') setStatus(err.message, true);
|
|
323
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
268
324
|
}
|
|
269
325
|
});
|
|
270
326
|
|
|
271
|
-
|
|
327
|
+
key(['D'], async () => {
|
|
272
328
|
if (screen.focused !== list || !state.selectedId) return;
|
|
273
329
|
const id = state.selectedId;
|
|
274
330
|
try {
|
|
@@ -277,34 +333,78 @@ export async function run(cwd) {
|
|
|
277
333
|
if (result.action === 'add') {
|
|
278
334
|
setStatus(`Adding dep: ${id} → ${result.targetId} [${result.type}]…`);
|
|
279
335
|
await applyMutation(id, () => bdDepAdd(id, result.targetId, result.type, state.cwd));
|
|
280
|
-
setStatus(`Added: ${id} → ${result.targetId} [${result.type}]
|
|
336
|
+
setStatus(`Added: ${id} → ${result.targetId} [${result.type}]`, false, true);
|
|
281
337
|
} else {
|
|
282
338
|
setStatus(`Removing dep: ${id} → ${result.targetId}…`);
|
|
283
339
|
await applyMutation(id, () => bdDepRemove(id, result.targetId, state.cwd));
|
|
284
|
-
setStatus(`Removed dep: ${id} → ${result.targetId}
|
|
340
|
+
setStatus(`Removed dep: ${id} → ${result.targetId}`, false, true);
|
|
285
341
|
}
|
|
286
342
|
renderDetail(detail);
|
|
287
343
|
screen.render();
|
|
288
344
|
} catch (err) {
|
|
289
345
|
list.focus();
|
|
290
|
-
if (err.message !== 'cancelled') setStatus(err.message, true);
|
|
346
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
291
347
|
}
|
|
292
348
|
});
|
|
293
349
|
|
|
294
|
-
|
|
350
|
+
async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
|
|
351
|
+
setStatus(busyMsg || `Copying…`);
|
|
352
|
+
const write = (cmd, args) => new Promise((resolve, reject) => {
|
|
353
|
+
let settled = false;
|
|
354
|
+
const finish = (err) => { if (!settled) { settled = true; err ? reject(err) : resolve(); } };
|
|
355
|
+
const p = execFile(cmd, args, (err) => finish(err || null));
|
|
356
|
+
p.on('error', finish);
|
|
357
|
+
if (p.stdin) {
|
|
358
|
+
p.stdin.once('finish', () => setTimeout(() => finish(null), 100));
|
|
359
|
+
p.stdin.end(text);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
const candidates = process.platform === 'win32'
|
|
363
|
+
? [['clip', []]]
|
|
364
|
+
: [
|
|
365
|
+
['wl-copy', []],
|
|
366
|
+
['xclip', ['-selection', 'clipboard']],
|
|
367
|
+
['xsel', ['--clipboard', '--input']],
|
|
368
|
+
['pbcopy', []],
|
|
369
|
+
];
|
|
370
|
+
for (const [cmd, args] of candidates) {
|
|
371
|
+
try {
|
|
372
|
+
await write(cmd, args);
|
|
373
|
+
setStatus(okMsg || `Copied`, false, true);
|
|
374
|
+
return;
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
const hint = process.platform === 'win32'
|
|
378
|
+
? 'clip.exe missing'
|
|
379
|
+
: 'install wl-clipboard, xclip, or xsel';
|
|
380
|
+
setStatus(`clipboard unavailable — ${hint}`, true, true);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
key(['y'], () => {
|
|
295
384
|
const id = state.selectedId;
|
|
296
385
|
if (!id) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
386
|
+
copyToClipboard(id, { busyMsg: `Copying ${id}…`, okMsg: `Copied ${id}` });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
key(['w'], async () => {
|
|
390
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
391
|
+
const id = state.selectedId;
|
|
392
|
+
const bead = state.beadsById.get(id);
|
|
393
|
+
if (bead?.issue_type === 'epic') {
|
|
394
|
+
setStatus(`${id} is an epic — workflow skills apply to tasks only`, true, true);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const line = await skillPicker(screen, id);
|
|
399
|
+
list.focus();
|
|
400
|
+
await copyToClipboard(line, {
|
|
401
|
+
busyMsg: `Copying ${line}…`,
|
|
402
|
+
okMsg: `Copied: ${line}`,
|
|
307
403
|
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
list.focus();
|
|
406
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
407
|
+
}
|
|
308
408
|
});
|
|
309
409
|
|
|
310
410
|
// ── Boot ───────────────────────────────────────────────────────────────────
|
package/src/bd.js
CHANGED
|
@@ -14,6 +14,7 @@ async function runBd(args, cwd) {
|
|
|
14
14
|
|
|
15
15
|
export const bdList = (filter, cwd) => {
|
|
16
16
|
if (filter === 'ready') return runBd(['ready', '--json'], cwd);
|
|
17
|
+
if (filter === 'blocked') return runBd(['blocked', '--json'], cwd);
|
|
17
18
|
const args = ['list', '--json'];
|
|
18
19
|
if (filter === 'all') args.push('--all');
|
|
19
20
|
else if (filter) args.push('--status', filter);
|
package/src/keys.js
CHANGED
|
@@ -13,7 +13,9 @@ export const HELP_TEXT = [
|
|
|
13
13
|
'',
|
|
14
14
|
' Filters & refresh',
|
|
15
15
|
' ──────────────────────────────────────',
|
|
16
|
-
'
|
|
16
|
+
' Tab Next status filter (blocked/ready/in_progress/closed/all)',
|
|
17
|
+
' Shift+Tab Previous status filter',
|
|
18
|
+
' t Cycle type filter (all/epic/task)',
|
|
17
19
|
' r Reload current filter from bd',
|
|
18
20
|
' / In-memory title filter',
|
|
19
21
|
'',
|
|
@@ -29,6 +31,7 @@ export const HELP_TEXT = [
|
|
|
29
31
|
' Other',
|
|
30
32
|
' ──────────────────────────────────────',
|
|
31
33
|
' y Yank bead ID to clipboard',
|
|
34
|
+
' w Copy workflow skill command for selected task',
|
|
32
35
|
' ? Toggle this help',
|
|
33
36
|
' q / Ctrl-C Quit',
|
|
34
37
|
'',
|
package/src/state.js
CHANGED
|
@@ -2,13 +2,26 @@ import { bdList, bdShow, bdDeps, bdDepListDown } from './bd.js';
|
|
|
2
2
|
|
|
3
3
|
export const state = {
|
|
4
4
|
beadsById: new Map(),
|
|
5
|
+
fullTreeItems: [], // unfiltered ordered tree items (before typeFilter)
|
|
5
6
|
listOrder: [],
|
|
6
7
|
treeMeta: new Map(), // id -> { depth: 0|1, isLast: bool }
|
|
7
8
|
selectedId: null,
|
|
8
9
|
filter: 'ready',
|
|
10
|
+
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
9
11
|
cwd: process.cwd(),
|
|
10
12
|
};
|
|
11
13
|
|
|
14
|
+
export function applyTypeFilter() {
|
|
15
|
+
let items = state.fullTreeItems;
|
|
16
|
+
if (state.typeFilter !== 'all') {
|
|
17
|
+
items = items
|
|
18
|
+
.filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
|
|
19
|
+
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
20
|
+
}
|
|
21
|
+
state.listOrder = items.map((t) => t.id);
|
|
22
|
+
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
function buildTreeOrder(beads, parentOf) {
|
|
13
26
|
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
14
27
|
const childrenOf = new Map();
|
|
@@ -73,13 +86,12 @@ export async function loadList() {
|
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
const treeItems = buildTreeOrder(beads, parentOf);
|
|
77
|
-
state.listOrder = treeItems.map((t) => t.id);
|
|
78
|
-
state.treeMeta = new Map(treeItems.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
79
|
-
|
|
80
89
|
for (const b of beads) {
|
|
81
90
|
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
82
91
|
}
|
|
92
|
+
|
|
93
|
+
state.fullTreeItems = buildTreeOrder(beads, parentOf);
|
|
94
|
+
applyTypeFilter();
|
|
83
95
|
return beads;
|
|
84
96
|
}
|
|
85
97
|
|
package/src/views/detail.js
CHANGED
|
@@ -8,6 +8,7 @@ const STATUS_COLOR = {
|
|
|
8
8
|
const PRIORITY_COLOR = ['red', 'yellow', 'green', 'cyan', 'gray'];
|
|
9
9
|
const PRIORITY_LABEL = ['Critical', 'High', 'Normal', 'Low', 'Trivial'];
|
|
10
10
|
|
|
11
|
+
function esc(s) { return String(s ?? '').replace(/\{/g, '{open}').replace(/\}/g, '{close}'); }
|
|
11
12
|
function t(color, text) { return `{${color}-fg}${text}{/}`; }
|
|
12
13
|
function bold(text) { return `{bold}${text}{/bold}`; }
|
|
13
14
|
function fmtDate(iso) { return iso ? iso.slice(0, 10) : '—'; }
|
|
@@ -16,23 +17,72 @@ function field(label, value, labelWidth = 10) {
|
|
|
16
17
|
return `${t('gray', (label + ':').padEnd(labelWidth))} ${value}`;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function visLen(s) { return s.replace(/\{[^}]+\}/g, '').length; }
|
|
21
|
+
function padRight(s, n) { const p = n - visLen(s); return p > 0 ? s + ' '.repeat(p) : s; }
|
|
22
|
+
|
|
23
|
+
function fieldRow(lLabel, lVal, rLabel, rVal, lValWidth = 14) {
|
|
24
|
+
const left = t('gray', (lLabel + ':').padEnd(10)) + ' ' + padRight(lVal, lValWidth);
|
|
25
|
+
const right = ' ' + t('gray', (rLabel + ':').padEnd(10)) + ' ' + rVal;
|
|
26
|
+
return left + ' ' + t('gray', '│') + right;
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
function sectionHeader(title) {
|
|
20
30
|
return `\n${bold(t('cyan', title))}\n${t('gray', '─'.repeat(44))}`;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
function depLine(dep, arrow, color) {
|
|
24
|
-
const id = dep.id || String(dep);
|
|
25
|
-
const type = dep.dep_type || dep.type || 'blocks';
|
|
26
|
-
const title = dep.title ? ` ${t('gray', '— ' + dep.title)}` : '';
|
|
34
|
+
const id = esc(dep.id || String(dep));
|
|
35
|
+
const type = esc(dep.dep_type || dep.type || 'blocks');
|
|
36
|
+
const title = dep.title ? ` ${t('gray', '— ' + esc(dep.title))}` : '';
|
|
27
37
|
return ` ${t(color, arrow)} {bold}${id}{/bold} ${t('gray', `[${type}]`)}${title}`;
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
// Apply inline markdown styling to already-escaped text. Order matters:
|
|
41
|
+
// inline code is captured first to placeholders so its content is not
|
|
42
|
+
// mangled by bold/italic/link regexes.
|
|
43
|
+
function applyInline(s) {
|
|
44
|
+
const codes = [];
|
|
45
|
+
let out = s.replace(/`([^`]+)`/g, (_, c) => {
|
|
46
|
+
codes.push(c);
|
|
47
|
+
return `\x00C${codes.length - 1}\x00`;
|
|
48
|
+
});
|
|
49
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '{bold}{white-fg}$1{/white-fg}{/bold}');
|
|
50
|
+
out = out.replace(/(^|[\s(])\*([^*\s][^*]*?)\*(?=[\s.,;:!?)]|$)/g, '$1{italic}$2{/italic}');
|
|
51
|
+
out = out.replace(/~~([^~]+)~~/g, '{strikethrough}$1{/strikethrough}');
|
|
52
|
+
out = out.replace(
|
|
53
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
54
|
+
'{underline}{cyan-fg}$1{/cyan-fg}{/underline} {gray-fg}($2){/gray-fg}',
|
|
55
|
+
);
|
|
56
|
+
out = out.replace(/\x00C(\d+)\x00/g, (_, i) => t('yellow', '`' + codes[+i] + '`'));
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
function renderDescription(lines, text) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
const src = text.split('\n');
|
|
62
|
+
let inCode = false;
|
|
63
|
+
for (const raw of src) {
|
|
64
|
+
const l = raw;
|
|
65
|
+
|
|
66
|
+
if (l.trim().startsWith('```')) {
|
|
67
|
+
inCode = !inCode;
|
|
68
|
+
lines.push(` ${t('gray', '────')}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (inCode) {
|
|
72
|
+
lines.push(` ${t('green', esc(l))}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (l.startsWith('### ')) lines.push(` ${bold(t('blue', applyInline(esc(l.slice(4)))))}`);
|
|
77
|
+
else if (l.startsWith('## ')) lines.push(` ${bold(t('cyan', applyInline(esc(l.slice(3)))))}`);
|
|
78
|
+
else if (l.startsWith('# ')) lines.push(` ${bold(t('white', applyInline(esc(l.slice(2)))))}`);
|
|
79
|
+
else if (l.match(/^[-*] /)) lines.push(` ${t('gray', '•')} ${applyInline(esc(l.slice(2)))}`);
|
|
80
|
+
else if (l.match(/^\s*\d+\.\s+/)) {
|
|
81
|
+
const m = l.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
82
|
+
lines.push(` ${m[1]}${t('gray', m[2] + '.')} ${applyInline(esc(m[3]))}`);
|
|
83
|
+
}
|
|
84
|
+
else if (l.startsWith('> ')) lines.push(` ${t('gray', '│')} ${t('gray', applyInline(esc(l.slice(2))))}`);
|
|
85
|
+
else lines.push(` ${applyInline(esc(l))}`);
|
|
36
86
|
}
|
|
37
87
|
}
|
|
38
88
|
|
|
@@ -78,29 +128,26 @@ export function renderDetail(box) {
|
|
|
78
128
|
const lines = [];
|
|
79
129
|
|
|
80
130
|
// ── Header ─────────────────────────────────────────────────────────────────
|
|
81
|
-
lines.push(`{bold}{white-fg}${b.id}{/} {gray-fg}—{/} ${b.title || ''}`);
|
|
131
|
+
lines.push(`{bold}{white-fg}${esc(b.id)}{/} {gray-fg}—{/} ${esc(b.title || '')}`);
|
|
82
132
|
lines.push(t('gray', '═'.repeat(50)));
|
|
83
133
|
lines.push('');
|
|
84
134
|
|
|
85
135
|
// ── Fields ─────────────────────────────────────────────────────────────────
|
|
86
|
-
lines.push(
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
' ' +
|
|
99
|
-
field('Updated', t('gray', fmtDate(b.updated_at)))
|
|
100
|
-
);
|
|
136
|
+
lines.push(fieldRow(
|
|
137
|
+
'Status', `{${statColor}-fg}{bold}${esc(b.status || '—')}{/bold}{/}`,
|
|
138
|
+
'Priority', `{${prioColor}-fg}{bold}P${prio} ${prioLabel}{/bold}{/}`,
|
|
139
|
+
));
|
|
140
|
+
lines.push(fieldRow(
|
|
141
|
+
'Type', t('white', esc(b.issue_type || '—')),
|
|
142
|
+
'Owner', t('white', esc(b.owner || '—')),
|
|
143
|
+
));
|
|
144
|
+
lines.push(fieldRow(
|
|
145
|
+
'Created', t('gray', fmtDate(b.created_at)),
|
|
146
|
+
'Updated', t('gray', fmtDate(b.updated_at)),
|
|
147
|
+
));
|
|
101
148
|
|
|
102
149
|
if (b.labels?.length > 0) {
|
|
103
|
-
lines.push(field('Labels', b.labels.map((l) => t('blue', l)).join(' ')));
|
|
150
|
+
lines.push(field('Labels', b.labels.map((l) => t('blue', esc(l))).join(' ')));
|
|
104
151
|
}
|
|
105
152
|
|
|
106
153
|
// ── Dependencies ───────────────────────────────────────────────────────────
|
package/src/views/modals.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import blessed from 'blessed';
|
|
2
2
|
|
|
3
|
-
function listPicker(screen, label, items, borderColor = 'cyan') {
|
|
3
|
+
function listPicker(screen, label, items, borderColor = 'cyan', width = 36, left = 'center') {
|
|
4
4
|
return new Promise((resolve, reject) => {
|
|
5
5
|
const height = Math.min(items.length + 4, 20);
|
|
6
6
|
const picker = blessed.list({
|
|
@@ -8,8 +8,8 @@ function listPicker(screen, label, items, borderColor = 'cyan') {
|
|
|
8
8
|
label: ` ${label} `,
|
|
9
9
|
border: { type: 'line' },
|
|
10
10
|
top: 'center',
|
|
11
|
-
left
|
|
12
|
-
width
|
|
11
|
+
left,
|
|
12
|
+
width,
|
|
13
13
|
height,
|
|
14
14
|
items,
|
|
15
15
|
keys: true,
|
|
@@ -87,6 +87,16 @@ export function textPrompt(screen, label, defaultVal = '') {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export function skillPicker(screen, beadId) {
|
|
91
|
+
const items = [
|
|
92
|
+
`/executor-task ${beadId}`,
|
|
93
|
+
`/executor-task-worktree ${beadId}`,
|
|
94
|
+
`/executor-epic-task ${beadId}`,
|
|
95
|
+
`/executor-epic-task-worktree ${beadId}`,
|
|
96
|
+
];
|
|
97
|
+
return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
const DEP_TYPES = [
|
|
91
101
|
'blocks', 'tracks', 'related', 'relates-to',
|
|
92
102
|
'validates', 'caused-by', 'parent-child',
|