bdtui 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.0] - 2026-05-11
6
+
7
+ ### Fixed
8
+ - 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
9
+
10
+ ### Changed
11
+ - `blocked` tab now uses `bd blocked` (dependency-blocked issues) instead of `bd list --status blocked`
12
+ - Workflow skill picker renamed to "Copy workflow command" and positioned on the left side of the screen
13
+ - Detail pane field rows use a `│` column separator with consistent alignment across all rows
14
+
15
+ ### Added
16
+ - Screenshot in README
17
+
18
+ ## [0.1.0] - 2026-04-28
19
+
20
+ ### Added
21
+ - Initial MVP: blessed two-pane TUI (list + detail) for `bd`
22
+ - Tree view grouping tasks under their parent epic
23
+ - Status and type filter tabs with keyboard cycling
24
+ - Rich color scheme for status, priority, and dependency lines
25
+ - Inline markdown rendering in description, acceptance, and notes fields
26
+ - Mutation keys: change status, close, claim, reopen, change priority, manage deps
27
+ - `y` to yank bead ID to clipboard, `w` to copy a workflow skill command
28
+ - In-memory title filter (`/`)
29
+ - 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
+ ![bdtui screenshot](docs/images/bdtui.png)
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.0",
3
+ "version": "0.2.0",
4
4
  "description": "Terminal UI for the bd beads issue tracker",
5
5
  "type": "module",
6
- "bin": { "bdtui": "bin/bdtui.js" },
7
- "files": ["bin", "src", "README.md"],
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": { "node": ">=20" },
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 = ['ready', 'open', 'in_progress', 'all'];
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
- function setStatus(msg, isError = false) {
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
- state.filter = FILTERS[(idx + 1) % FILTERS.length];
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
- screen.key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
183
+ key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
137
184
 
138
- screen.key(['r'], refresh);
139
- screen.key(['f'], cycleFilter);
185
+ key(['r'], refresh);
186
+ key(['tab'], () => cycleFilter(1));
187
+ key(['S-tab'], () => cycleFilter(-1));
188
+ key(['t'], cycleTypeFilter);
140
189
 
141
- screen.key(['?'], () => showHelp(screen, () => { list.focus(); screen.render(); }));
190
+ key(['?'], () => {
191
+ modalOpen = true;
192
+ showHelp(screen, () => {
193
+ modalOpen = false;
194
+ list.focus();
195
+ screen.render();
196
+ });
197
+ });
142
198
 
143
- screen.key(['enter', 'l'], () => {
199
+ key(['enter', 'l'], () => {
144
200
  if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
145
201
  });
146
202
 
147
- screen.key(['h', 'escape'], () => {
203
+ key(['h', 'escape'], () => {
148
204
  if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
149
205
  });
150
206
 
151
- screen.key(['g'], () => {
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
- screen.key(['G'], () => {
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
- screen.key(['/'], async () => {
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
- screen.key(['s'], async () => {
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
- screen.key(['c'], async () => {
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
- screen.key(['C'], async () => {
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
- screen.key(['o'], async () => {
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
- screen.key(['p'], async () => {
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
- screen.key(['D'], async () => {
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
- screen.key(['y'], () => {
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
- const write = (cmd, args) => new Promise((res) => {
298
- const p = execFile(cmd, args, res);
299
- p.stdin?.end(id);
300
- });
301
- write('wl-copy', [])
302
- .catch(() => write('xclip', ['-selection', 'clipboard']))
303
- .catch(() => write('xsel', ['--clipboard', '--input']))
304
- .catch(() => write('pbcopy', []))
305
- .then((err) => {
306
- setStatus(err ? `${id} (clipboard unavailable)` : `Copied ${id}`);
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
- ' f Cycle filter (ready/open/in_progress/all)',
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
 
@@ -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
- for (const l of text.split('\n')) {
32
- if (l.startsWith('## ')) lines.push(` ${bold(t('cyan', l.slice(3)))}`);
33
- else if (l.startsWith('# ')) lines.push(` ${bold(t('white', l.slice(2)))}`);
34
- else if (l.match(/^[-*] /)) lines.push(` ${t('gray', '•')} ${l.slice(2)}`);
35
- else lines.push(` ${l}`);
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
- field('Status', `{${statColor}-fg}{bold}${b.status || '—'}{/bold}{/}`) +
88
- ' ' +
89
- field('Priority', `{${prioColor}-fg}{bold}P${prio} ${prioLabel}{/bold}{/}`)
90
- );
91
- lines.push(
92
- field('Type', t('white', b.issue_type || '—')) +
93
- ' ' +
94
- field('Owner', t('white', b.owner || '—'))
95
- );
96
- lines.push(
97
- field('Created', t('gray', fmtDate(b.created_at))) +
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 ───────────────────────────────────────────────────────────
@@ -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: 'center',
12
- width: 36,
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',