bdtui 0.7.2 → 0.8.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 +189 -165
- package/README.md +117 -95
- package/bin/bdtui.js +35 -35
- package/package.json +48 -48
- package/src/app.js +617 -586
- package/src/bd.js +130 -130
- package/src/keys.js +100 -98
- package/src/state.js +218 -218
- package/src/views/detail.js +231 -218
- package/src/views/list.js +99 -99
- package/src/views/modals.js +269 -255
package/src/views/modals.js
CHANGED
|
@@ -1,255 +1,269 @@
|
|
|
1
|
-
import blessed from 'blessed';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
picker.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
picker.key(['
|
|
171
|
-
picker.destroy();
|
|
172
|
-
screen.render();
|
|
173
|
-
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
|
|
3
|
+
// `headers` holds indexes of non-selectable section-title rows: Enter on one is
|
|
4
|
+
// ignored, and the cursor starts on the first real item.
|
|
5
|
+
function listPicker(screen, label, items, borderColor = 'cyan', width = 36, left = 'center', headers = new Set()) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const height = Math.min(items.length + 4, 20);
|
|
8
|
+
const picker = blessed.list({
|
|
9
|
+
parent: screen,
|
|
10
|
+
label: ` ${label} `,
|
|
11
|
+
border: { type: 'line' },
|
|
12
|
+
top: 'center',
|
|
13
|
+
left,
|
|
14
|
+
width,
|
|
15
|
+
height,
|
|
16
|
+
items,
|
|
17
|
+
keys: true,
|
|
18
|
+
vi: true,
|
|
19
|
+
tags: true,
|
|
20
|
+
style: {
|
|
21
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
22
|
+
border: { fg: borderColor },
|
|
23
|
+
label: { fg: borderColor, bold: true },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const first = items.findIndex((_, i) => !headers.has(i));
|
|
28
|
+
if (first > 0) picker.select(first);
|
|
29
|
+
picker.focus();
|
|
30
|
+
screen.render();
|
|
31
|
+
|
|
32
|
+
picker.on('select', (item, index) => {
|
|
33
|
+
if (headers.has(index)) return;
|
|
34
|
+
picker.destroy();
|
|
35
|
+
screen.render();
|
|
36
|
+
// Strip color tags so callers get the plain text (e.g. the command to copy).
|
|
37
|
+
resolve(blessed.helpers.cleanTags(item.content));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
picker.key(['escape', 'q', 'h'], () => {
|
|
41
|
+
picker.destroy();
|
|
42
|
+
screen.render();
|
|
43
|
+
reject(new Error('cancelled'));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function statusPicker(screen) {
|
|
49
|
+
return listPicker(
|
|
50
|
+
screen,
|
|
51
|
+
'Change Status',
|
|
52
|
+
['open', 'in_progress', 'blocked', 'deferred', 'closed'],
|
|
53
|
+
'cyan',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function priorityPicker(screen) {
|
|
58
|
+
return listPicker(
|
|
59
|
+
screen,
|
|
60
|
+
'Change Priority',
|
|
61
|
+
['0 — Critical', '1 — High', '2 — Normal', '3 — Low', '4 — Trivial'],
|
|
62
|
+
'yellow',
|
|
63
|
+
).then((val) => parseInt(val[0], 10));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function textPrompt(screen, label, defaultVal = '') {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const prompt = blessed.prompt({
|
|
69
|
+
parent: screen,
|
|
70
|
+
border: 'line',
|
|
71
|
+
height: 'shrink',
|
|
72
|
+
width: '60%',
|
|
73
|
+
top: 'center',
|
|
74
|
+
left: 'center',
|
|
75
|
+
label: ` ${label} `,
|
|
76
|
+
tags: true,
|
|
77
|
+
keys: true,
|
|
78
|
+
vi: true,
|
|
79
|
+
style: {
|
|
80
|
+
border: { fg: 'green' },
|
|
81
|
+
label: { fg: 'green', bold: true },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
prompt.input(`${label}:`, defaultVal, (err, value) => {
|
|
86
|
+
prompt.destroy();
|
|
87
|
+
screen.render();
|
|
88
|
+
if (err || value == null) return reject(new Error('cancelled'));
|
|
89
|
+
resolve(value.trim());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
screen.render();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const CLEAR_FILTER_TAG = '__clear__';
|
|
97
|
+
|
|
98
|
+
export function epicPicker(screen, epics, currentEpicId = null) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
// Newest epics on top: sort by created_at descending (ISO-8601 UTC strings
|
|
101
|
+
// sort lexicographically in chronological order). Fall back to id for ties
|
|
102
|
+
// or missing timestamps.
|
|
103
|
+
const sorted = [...epics].sort((a, b) => {
|
|
104
|
+
const ta = a.created_at || '';
|
|
105
|
+
const tb = b.created_at || '';
|
|
106
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
107
|
+
return b.id.localeCompare(a.id);
|
|
108
|
+
});
|
|
109
|
+
const rows = [];
|
|
110
|
+
const ids = [];
|
|
111
|
+
|
|
112
|
+
if (currentEpicId) {
|
|
113
|
+
rows.push('{yellow-fg}✗ Clear epic filter{/}');
|
|
114
|
+
ids.push(CLEAR_FILTER_TAG);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const e of sorted) {
|
|
118
|
+
const marker = e.id === currentEpicId ? '{cyan-fg}●{/} ' : ' ';
|
|
119
|
+
const id = e.id.padEnd(12);
|
|
120
|
+
const title = (e.title || '').slice(0, 60);
|
|
121
|
+
rows.push(`${marker}{gray-fg}${id}{/} ${title}`);
|
|
122
|
+
ids.push(e.id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (rows.length === 0) {
|
|
126
|
+
rows.push('{gray-fg}(no epics found){/}');
|
|
127
|
+
ids.push(null);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const width = 72;
|
|
131
|
+
const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
|
|
132
|
+
|
|
133
|
+
const picker = blessed.list({
|
|
134
|
+
parent: screen,
|
|
135
|
+
label: ' Filter by Epic ',
|
|
136
|
+
border: { type: 'line' },
|
|
137
|
+
top: 'center',
|
|
138
|
+
left: 'center',
|
|
139
|
+
width,
|
|
140
|
+
height,
|
|
141
|
+
items: rows,
|
|
142
|
+
keys: true,
|
|
143
|
+
vi: true,
|
|
144
|
+
tags: true,
|
|
145
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'magenta' } },
|
|
146
|
+
style: {
|
|
147
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
148
|
+
border: { fg: 'magenta' },
|
|
149
|
+
label: { fg: 'magenta', bold: true },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Select current epic by default if set
|
|
154
|
+
if (currentEpicId) {
|
|
155
|
+
const idx = ids.indexOf(currentEpicId);
|
|
156
|
+
if (idx >= 0) picker.select(idx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
picker.focus();
|
|
160
|
+
screen.render();
|
|
161
|
+
|
|
162
|
+
picker.once('select', (_item, index) => {
|
|
163
|
+
const picked = ids[index];
|
|
164
|
+
picker.destroy();
|
|
165
|
+
screen.render();
|
|
166
|
+
if (picked === null) return reject(new Error('cancelled'));
|
|
167
|
+
resolve(picked === CLEAR_FILTER_TAG ? null : picked);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
picker.key(['escape', 'q'], () => {
|
|
171
|
+
picker.destroy();
|
|
172
|
+
screen.render();
|
|
173
|
+
reject(new Error('cancelled'));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
picker.key(['x'], () => {
|
|
177
|
+
picker.destroy();
|
|
178
|
+
screen.render();
|
|
179
|
+
resolve(null);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function skillPicker(screen, beadId, issueType) {
|
|
185
|
+
// Epics get the epic-level executor (runs every ready child sequentially on a
|
|
186
|
+
// single branch); tasks/chores/bugs get the per-bead executors. The OpenCode
|
|
187
|
+
// section repeats them with the `opencode` argument, which delegates only the
|
|
188
|
+
// implementation step to a headless OpenCode implementor
|
|
189
|
+
// (agent-workflow-beads --with-opencode).
|
|
190
|
+
const skills = issueType === 'epic'
|
|
191
|
+
? ['executor-epic-sequential', 'executor-epic-sequential-worktree']
|
|
192
|
+
: [
|
|
193
|
+
'executor-task',
|
|
194
|
+
'executor-task-worktree',
|
|
195
|
+
'executor-epic-task',
|
|
196
|
+
'executor-epic-task-worktree',
|
|
197
|
+
'executor-rework-in-place',
|
|
198
|
+
];
|
|
199
|
+
const cmd = (skill) => `{green-fg}/${skill}{/green-fg} {gray-fg}${beadId}{/gray-fg}`;
|
|
200
|
+
const items = [
|
|
201
|
+
'{bold}── Claude skills ──{/bold}',
|
|
202
|
+
...skills.map(cmd),
|
|
203
|
+
'{bold}── OpenCode skills ──{/bold}',
|
|
204
|
+
...skills.map((skill) => `${cmd(skill)} {red-fg}opencode{/red-fg}`),
|
|
205
|
+
];
|
|
206
|
+
const headers = new Set([0, skills.length + 1]);
|
|
207
|
+
return listPicker(screen, 'Copy workflow command', items, 'magenta', 66, 1, headers);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function parentPicker(screen, epics, { selfId, currentParentIds = [] } = {}) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const exclude = new Set([selfId, ...currentParentIds]);
|
|
213
|
+
const sorted = [...epics]
|
|
214
|
+
.filter((e) => !exclude.has(e.id))
|
|
215
|
+
.sort((a, b) => {
|
|
216
|
+
const ta = a.updated_at || '', tb = b.updated_at || '';
|
|
217
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
218
|
+
return b.id.localeCompare(a.id);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const rows = ['{yellow-fg}Standalone bead{/}'];
|
|
222
|
+
const ids = [null];
|
|
223
|
+
|
|
224
|
+
for (const e of sorted) {
|
|
225
|
+
const id = e.id.padEnd(12);
|
|
226
|
+
const title = (e.title || '').slice(0, 60);
|
|
227
|
+
rows.push(`{gray-fg}${id}{/} ${title}`);
|
|
228
|
+
ids.push(e.id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const width = 72;
|
|
232
|
+
const height = Math.min(rows.length + 4, Math.max(8, screen.height - 4));
|
|
233
|
+
|
|
234
|
+
const picker = blessed.list({
|
|
235
|
+
parent: screen,
|
|
236
|
+
label: ' Change Parent ',
|
|
237
|
+
border: { type: 'line' },
|
|
238
|
+
top: 'center',
|
|
239
|
+
left: 'center',
|
|
240
|
+
width,
|
|
241
|
+
height,
|
|
242
|
+
items: rows,
|
|
243
|
+
keys: true,
|
|
244
|
+
vi: true,
|
|
245
|
+
tags: true,
|
|
246
|
+
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'magenta' } },
|
|
247
|
+
style: {
|
|
248
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
249
|
+
border: { fg: 'magenta' },
|
|
250
|
+
label: { fg: 'magenta', bold: true },
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
picker.focus();
|
|
255
|
+
screen.render();
|
|
256
|
+
|
|
257
|
+
picker.once('select', (_item, index) => {
|
|
258
|
+
picker.destroy();
|
|
259
|
+
screen.render();
|
|
260
|
+
resolve(ids[index]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
picker.key(['escape', 'q'], () => {
|
|
264
|
+
picker.destroy();
|
|
265
|
+
screen.render();
|
|
266
|
+
reject(new Error('cancelled'));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|