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/app.js
CHANGED
|
@@ -1,586 +1,617 @@
|
|
|
1
|
-
import blessed from 'blessed';
|
|
2
|
-
import { execFile } from 'child_process';
|
|
3
|
-
import { state, loadList, loadDetail, applyMutation, applyFilters, isPaged, ensureSelectedVisible } from './state.js';
|
|
4
|
-
import { createList, renderList } from './views/list.js';
|
|
5
|
-
import { createDetail, renderDetail } from './views/detail.js';
|
|
6
|
-
import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
|
|
7
|
-
import { showHelp } from './keys.js';
|
|
8
|
-
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListDown, bdEpics } from './bd.js';
|
|
9
|
-
|
|
10
|
-
const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
|
-
const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
|
|
12
|
-
|
|
13
|
-
// Neutralize blessed tag markup in free text before it lands in a tags:true
|
|
14
|
-
// box (mirrors esc() in views/detail.js). The title search is user-typed, so a
|
|
15
|
-
// query like "{bold}" would otherwise corrupt the status line.
|
|
16
|
-
// Single pass: a two-step .replace(/\{/).replace(/\}/) would re-process the "}"
|
|
17
|
-
// it just inserted into "{open}", corrupting any text that contains a literal
|
|
18
|
-
// "}" (e.g. a bd JSON error blob) into a stray "{open}" token.
|
|
19
|
-
const escTags = (s) => String(s ?? '').replace(/[{}]/g, (c) => (c === '{' ? '{open}' : '{close}'));
|
|
20
|
-
|
|
21
|
-
export async function run(cwd) {
|
|
22
|
-
state.cwd = cwd;
|
|
23
|
-
|
|
24
|
-
const screen = blessed.screen({ smartCSR: true, title: 'bdtui', fullUnicode: true });
|
|
25
|
-
|
|
26
|
-
const tabBar = blessed.box({
|
|
27
|
-
parent: screen,
|
|
28
|
-
top: 0, left: 0,
|
|
29
|
-
width: '100%', height: 1,
|
|
30
|
-
content: '', tags: true,
|
|
31
|
-
style: { bg: 'black', fg: 'white' },
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const statusBar = blessed.box({
|
|
35
|
-
parent: screen,
|
|
36
|
-
bottom: 0, left: 0,
|
|
37
|
-
width: '100%', height: 1,
|
|
38
|
-
content: ' Loading… | {gray-fg}? help{/}',
|
|
39
|
-
tags: true,
|
|
40
|
-
style: { bg: 'black', fg: 'white' },
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const list = createList(screen);
|
|
44
|
-
const detail = createDetail(screen);
|
|
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
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
function
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
key(['
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
key(['
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
key(['
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
list.select(
|
|
395
|
-
onNav();
|
|
396
|
-
screen.render();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
key(['
|
|
400
|
-
if (screen.focused !== list) return;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
list.focus();
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
setStatus(`
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { state, loadList, loadDetail, applyMutation, applyFilters, isPaged, ensureSelectedVisible } from './state.js';
|
|
4
|
+
import { createList, renderList } from './views/list.js';
|
|
5
|
+
import { createDetail, renderDetail } from './views/detail.js';
|
|
6
|
+
import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
|
|
7
|
+
import { showHelp } from './keys.js';
|
|
8
|
+
import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListDown, bdEpics } from './bd.js';
|
|
9
|
+
|
|
10
|
+
const FILTERS = ['deferred', 'blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
|
+
const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
|
|
12
|
+
|
|
13
|
+
// Neutralize blessed tag markup in free text before it lands in a tags:true
|
|
14
|
+
// box (mirrors esc() in views/detail.js). The title search is user-typed, so a
|
|
15
|
+
// query like "{bold}" would otherwise corrupt the status line.
|
|
16
|
+
// Single pass: a two-step .replace(/\{/).replace(/\}/) would re-process the "}"
|
|
17
|
+
// it just inserted into "{open}", corrupting any text that contains a literal
|
|
18
|
+
// "}" (e.g. a bd JSON error blob) into a stray "{open}" token.
|
|
19
|
+
const escTags = (s) => String(s ?? '').replace(/[{}]/g, (c) => (c === '{' ? '{open}' : '{close}'));
|
|
20
|
+
|
|
21
|
+
export async function run(cwd) {
|
|
22
|
+
state.cwd = cwd;
|
|
23
|
+
|
|
24
|
+
const screen = blessed.screen({ smartCSR: true, title: 'bdtui', fullUnicode: true });
|
|
25
|
+
|
|
26
|
+
const tabBar = blessed.box({
|
|
27
|
+
parent: screen,
|
|
28
|
+
top: 0, left: 0,
|
|
29
|
+
width: '100%', height: 1,
|
|
30
|
+
content: '', tags: true,
|
|
31
|
+
style: { bg: 'black', fg: 'white' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const statusBar = blessed.box({
|
|
35
|
+
parent: screen,
|
|
36
|
+
bottom: 0, left: 0,
|
|
37
|
+
width: '100%', height: 1,
|
|
38
|
+
content: ' Loading… | {gray-fg}? help{/}',
|
|
39
|
+
tags: true,
|
|
40
|
+
style: { bg: 'black', fg: 'white' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const list = createList(screen);
|
|
44
|
+
const detail = createDetail(screen);
|
|
45
|
+
|
|
46
|
+
// blessed's enableMouse() only asks xterm-like terminals for X10 + UTF-8
|
|
47
|
+
// (?1005) mouse reports. X10 can't encode columns past 223 and xterm.js (the
|
|
48
|
+
// VS Code terminal) ignores ?1005, so on a wide terminal every wheel/click on
|
|
49
|
+
// the right part of the Detail pane is silently dropped. Also request SGR
|
|
50
|
+
// (?1006): blessed parses it, and it has no coordinate limit.
|
|
51
|
+
function enableMouse() {
|
|
52
|
+
screen.program.enableMouse();
|
|
53
|
+
screen.program.setMouse({ sgrMouse: true }, true);
|
|
54
|
+
}
|
|
55
|
+
enableMouse();
|
|
56
|
+
|
|
57
|
+
let debounceTimer = null;
|
|
58
|
+
|
|
59
|
+
let statusTimer = null;
|
|
60
|
+
// Describe every active narrowing filter (title search, epic scope, type) in
|
|
61
|
+
// one line with the result count and reset hint. Returns null when none are
|
|
62
|
+
// active so the caller can fall back to a plain "Ready".
|
|
63
|
+
function filterSummary() {
|
|
64
|
+
const parts = [];
|
|
65
|
+
// Plain text only — setStatus() escapes the whole summary, so pre-escaping
|
|
66
|
+
// textFilter here would double-escape any braces in the search query.
|
|
67
|
+
if (state.textFilter) parts.push(`"${state.textFilter}"`);
|
|
68
|
+
if (state.epicFilter) parts.push(`epic: ${state.epicFilter}`);
|
|
69
|
+
if (state.typeFilter !== 'all') parts.push(`type: ${state.typeFilter}`);
|
|
70
|
+
if (parts.length === 0) return null;
|
|
71
|
+
return `${parts.join(' · ')} — ${state.listOrder.length} results | Shift+r to reset`;
|
|
72
|
+
}
|
|
73
|
+
function defaultStatus() {
|
|
74
|
+
return filterSummary() || 'Ready';
|
|
75
|
+
}
|
|
76
|
+
function setStatus(msg, isError = false, transient = false) {
|
|
77
|
+
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
78
|
+
// The status bar is a single tags:true row. Neutralize blessed tag markup
|
|
79
|
+
// and collapse newlines so dynamic text — bd error messages especially,
|
|
80
|
+
// which can be multi-line and contain "{" — renders as plain text instead
|
|
81
|
+
// of corrupting the markup or overflowing the one-line height.
|
|
82
|
+
const safe = escTags(String(msg ?? '').replace(/\s*\n\s*/g, ' ').trim());
|
|
83
|
+
const icon = isError ? '{red-fg}✗{/}' : '{green-fg}●{/}';
|
|
84
|
+
const text = isError ? `{red-fg}${safe}{/}` : safe;
|
|
85
|
+
statusBar.setContent(` ${icon} ${text} {gray-fg}? help · q quit{/}`);
|
|
86
|
+
screen.render();
|
|
87
|
+
if (transient) {
|
|
88
|
+
statusTimer = setTimeout(() => {
|
|
89
|
+
statusTimer = null;
|
|
90
|
+
setStatus(defaultStatus());
|
|
91
|
+
}, 3000);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderTabBar() {
|
|
96
|
+
const tabs = FILTERS.map((f) => {
|
|
97
|
+
return f === state.filter
|
|
98
|
+
? `{blue-bg}{white-fg}{bold} ${f} {/bold}{/}`
|
|
99
|
+
: `{gray-fg} ${f} {/}`;
|
|
100
|
+
});
|
|
101
|
+
const count = state.listOrder.length;
|
|
102
|
+
const typeLabel = state.typeFilter === 'all' ? 'all' : `${state.typeFilter} only`;
|
|
103
|
+
const typeInfo = ` {gray-fg}│{/} {yellow-fg}type:{/} ${typeLabel}`;
|
|
104
|
+
const epicInfo = state.epicFilter
|
|
105
|
+
? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
|
|
106
|
+
: '';
|
|
107
|
+
const textInfo = state.textFilter
|
|
108
|
+
? ` {gray-fg}│{/} {green-fg}search:{/} "${escTags(state.textFilter)}"`
|
|
109
|
+
: '';
|
|
110
|
+
const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
|
|
111
|
+
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + textInfo + info);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setFocusBorder(focused) {
|
|
115
|
+
if (focused === 'list') {
|
|
116
|
+
list.style.border.fg = 'cyan';
|
|
117
|
+
detail.style.border.fg = 'gray';
|
|
118
|
+
} else {
|
|
119
|
+
list.style.border.fg = 'gray';
|
|
120
|
+
detail.style.border.fg = 'cyan';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function render() {
|
|
125
|
+
renderTabBar();
|
|
126
|
+
renderList(list);
|
|
127
|
+
renderDetail(detail);
|
|
128
|
+
screen.render();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Lazy "load more": the paged tabs (all / closed) load every bead but render
|
|
132
|
+
// them pageSize at a time; reveal the next batch once selection reaches the
|
|
133
|
+
// last visible row (infinite-scroll style).
|
|
134
|
+
function maybeLoadMore() {
|
|
135
|
+
if (!isPaged()) return;
|
|
136
|
+
if (state.visibleCount >= state.listOrder.length) return;
|
|
137
|
+
if (list.selected < state.visibleCount - 1) return;
|
|
138
|
+
state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.listOrder.length);
|
|
139
|
+
renderList(list);
|
|
140
|
+
setStatus(`Showing ${state.visibleCount} of ${state.listOrder.length}`, false, true);
|
|
141
|
+
screen.render();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// When list selection changes, update selectedId and debounce detail fetch
|
|
145
|
+
function onNav() {
|
|
146
|
+
setImmediate(() => {
|
|
147
|
+
maybeLoadMore();
|
|
148
|
+
const idx = list.selected;
|
|
149
|
+
const id = state.listOrder[idx];
|
|
150
|
+
if (!id || id === state.selectedId) return;
|
|
151
|
+
state.selectedId = id;
|
|
152
|
+
renderDetail(detail);
|
|
153
|
+
screen.render();
|
|
154
|
+
|
|
155
|
+
clearTimeout(debounceTimer);
|
|
156
|
+
debounceTimer = setTimeout(async () => {
|
|
157
|
+
try {
|
|
158
|
+
await loadDetail(id);
|
|
159
|
+
renderDetail(detail);
|
|
160
|
+
screen.render();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
setStatus(err.message, true, true);
|
|
163
|
+
}
|
|
164
|
+
}, 80);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Follow every selection change on the list — keyboard nav, a mouse click on
|
|
169
|
+
// a row, or a wheel scroll — not just keypresses, so clicks update Detail too.
|
|
170
|
+
list.on('select item', onNav);
|
|
171
|
+
|
|
172
|
+
// Focus border highlighting
|
|
173
|
+
list.on('focus', () => { setFocusBorder('list'); screen.render(); });
|
|
174
|
+
detail.on('focus', () => { setFocusBorder('detail'); screen.render(); });
|
|
175
|
+
|
|
176
|
+
async function refresh() {
|
|
177
|
+
setStatus('Refreshing…');
|
|
178
|
+
try {
|
|
179
|
+
await loadList();
|
|
180
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
181
|
+
state.selectedId = state.listOrder[0] || null;
|
|
182
|
+
}
|
|
183
|
+
ensureSelectedVisible();
|
|
184
|
+
if (state.selectedId) await loadDetail(state.selectedId);
|
|
185
|
+
render();
|
|
186
|
+
setStatus(defaultStatus());
|
|
187
|
+
} catch (err) {
|
|
188
|
+
setStatus(err.message, true, true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function cycleFilter(dir = 1) {
|
|
193
|
+
const idx = FILTERS.indexOf(state.filter);
|
|
194
|
+
const next = (idx + dir + FILTERS.length) % FILTERS.length;
|
|
195
|
+
state.filter = FILTERS[next];
|
|
196
|
+
setStatus(`Filter: ${state.filter} — loading…`);
|
|
197
|
+
try {
|
|
198
|
+
await loadList();
|
|
199
|
+
state.selectedId = state.listOrder[0] || null;
|
|
200
|
+
if (state.selectedId) await loadDetail(state.selectedId);
|
|
201
|
+
render();
|
|
202
|
+
setStatus(defaultStatus());
|
|
203
|
+
} catch (err) {
|
|
204
|
+
setStatus(err.message, true, true);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function cycleTypeFilter() {
|
|
209
|
+
const idx = TYPE_FILTERS.indexOf(state.typeFilter);
|
|
210
|
+
state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
|
|
211
|
+
applyFilters();
|
|
212
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
213
|
+
state.selectedId = state.listOrder[0] || null;
|
|
214
|
+
} else if (!state.selectedId) {
|
|
215
|
+
state.selectedId = state.listOrder[0] || null;
|
|
216
|
+
}
|
|
217
|
+
ensureSelectedVisible();
|
|
218
|
+
render();
|
|
219
|
+
if (state.selectedId) {
|
|
220
|
+
try {
|
|
221
|
+
await loadDetail(state.selectedId);
|
|
222
|
+
renderDetail(detail);
|
|
223
|
+
screen.render();
|
|
224
|
+
} catch (err) {
|
|
225
|
+
setStatus(err.message, true, true);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
setStatus(defaultStatus());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Global keys ────────────────────────────────────────────────────────────
|
|
233
|
+
// Modal open? swallow global keys so modals own their own keybindings
|
|
234
|
+
// (e.g. Esc/q close the help dialog instead of quitting the app).
|
|
235
|
+
let modalOpen = false;
|
|
236
|
+
function key(keys, handler) {
|
|
237
|
+
screen.key(keys, (...args) => {
|
|
238
|
+
if (modalOpen) return;
|
|
239
|
+
handler(...args);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
244
|
+
|
|
245
|
+
// r reloads data while preserving every active filter; Shift+r clears all
|
|
246
|
+
// narrowing filters (title search, epic scope, type) and reloads fresh.
|
|
247
|
+
// blessed reports a shifted letter as `S-<lower>` (never bare uppercase), so
|
|
248
|
+
// the binding must be 'S-r' — 'R' would be dead. Same for S-c / S-g below.
|
|
249
|
+
key(['r'], async () => {
|
|
250
|
+
await refresh();
|
|
251
|
+
});
|
|
252
|
+
key(['S-r'], async () => {
|
|
253
|
+
const had = !!(state.textFilter || state.epicFilter || state.typeFilter !== 'all');
|
|
254
|
+
state.textFilter = null;
|
|
255
|
+
state.epicFilter = null;
|
|
256
|
+
state.typeFilter = 'all';
|
|
257
|
+
await refresh();
|
|
258
|
+
if (had) setStatus('Filters reset', false, true);
|
|
259
|
+
});
|
|
260
|
+
key(['tab'], () => cycleFilter(1));
|
|
261
|
+
key(['S-tab'], () => cycleFilter(-1));
|
|
262
|
+
key(['t'], cycleTypeFilter);
|
|
263
|
+
|
|
264
|
+
key(['e'], async () => {
|
|
265
|
+
if (screen.focused !== list) return;
|
|
266
|
+
setStatus('Loading epics…');
|
|
267
|
+
let epics;
|
|
268
|
+
try {
|
|
269
|
+
epics = await bdEpics(state.cwd);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
setStatus(err.message, true, true);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
setStatus(defaultStatus());
|
|
275
|
+
let picked;
|
|
276
|
+
try {
|
|
277
|
+
picked = await epicPicker(screen, epics, state.epicFilter);
|
|
278
|
+
} catch {
|
|
279
|
+
list.focus();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
list.focus();
|
|
283
|
+
state.epicFilter = picked;
|
|
284
|
+
if (picked) {
|
|
285
|
+
state.filter = 'all';
|
|
286
|
+
setStatus(`Epic: ${picked} — loading…`);
|
|
287
|
+
try {
|
|
288
|
+
await loadList();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
setStatus(err.message, true, true);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
applyFilters();
|
|
295
|
+
}
|
|
296
|
+
state.selectedId = state.listOrder[0] || null;
|
|
297
|
+
if (state.selectedId) {
|
|
298
|
+
try { await loadDetail(state.selectedId); } catch (err) {
|
|
299
|
+
setStatus(err.message, true, true);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
render();
|
|
304
|
+
setStatus(defaultStatus());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// '?' toggles the help modal. Registered directly (NOT through key(), which
|
|
308
|
+
// swallows every global while a modal is open) so the same key that opens the
|
|
309
|
+
// help can also close it. Driving open AND close from this one handler — kept
|
|
310
|
+
// out of showHelp's own close keys — avoids re-entrancy: a separate '?' close
|
|
311
|
+
// binding would fire on the very keypress that registered it and slam the
|
|
312
|
+
// modal shut again. closeHelp is non-null exactly while the help is open.
|
|
313
|
+
let closeHelp = null;
|
|
314
|
+
screen.key(['?'], () => {
|
|
315
|
+
if (closeHelp) { closeHelp(); return; }
|
|
316
|
+
if (modalOpen) return; // another modal (a picker) owns the screen
|
|
317
|
+
modalOpen = true;
|
|
318
|
+
closeHelp = showHelp(screen, () => {
|
|
319
|
+
closeHelp = null;
|
|
320
|
+
modalOpen = false;
|
|
321
|
+
list.focus();
|
|
322
|
+
screen.render();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
key(['enter', 'l'], () => {
|
|
327
|
+
if (screen.focused === list) { setFocusBorder('detail'); detail.focus(); screen.render(); }
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
key(['escape'], () => {
|
|
331
|
+
if (screen.focused !== list) { setFocusBorder('list'); list.focus(); screen.render(); }
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
key(['h'], async () => {
|
|
335
|
+
if (screen.focused === detail) { setFocusBorder('list'); list.focus(); screen.render(); return; }
|
|
336
|
+
if (screen.focused !== list) return;
|
|
337
|
+
if (!state.selectedId) return;
|
|
338
|
+
const id = state.selectedId;
|
|
339
|
+
const bead = state.beadsById.get(id);
|
|
340
|
+
if (bead?.issue_type === 'epic') {
|
|
341
|
+
setStatus(`${id} is an epic — epics are top-level`, true, true);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
setStatus('Loading parents…');
|
|
345
|
+
let epics, downDeps;
|
|
346
|
+
try {
|
|
347
|
+
[epics, downDeps] = await Promise.all([bdEpics(state.cwd), bdDepListDown(id, state.cwd)]);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
setStatus(err.message, true, true);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// A bead should have at most one parent, but a prior bug could leave it with
|
|
353
|
+
// several parent-child deps — collect them all so we can clear every one.
|
|
354
|
+
const currentParentIds = downDeps
|
|
355
|
+
.filter((d) => d.dependency_type === 'parent-child')
|
|
356
|
+
.map((d) => d.id)
|
|
357
|
+
.filter(Boolean);
|
|
358
|
+
let newParent;
|
|
359
|
+
try {
|
|
360
|
+
newParent = await parentPicker(screen, epics, { selfId: id, currentParentIds });
|
|
361
|
+
} catch (err) {
|
|
362
|
+
list.focus();
|
|
363
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
list.focus();
|
|
367
|
+
setStatus(`Reparenting ${id}…`);
|
|
368
|
+
try {
|
|
369
|
+
await applyMutation(id, async () => {
|
|
370
|
+
// Detach every existing parent first so the bead is never left with two
|
|
371
|
+
// parents, then attach the chosen one (skip if it's already a parent).
|
|
372
|
+
for (const pid of currentParentIds) {
|
|
373
|
+
if (pid !== newParent) await bdDepRemove(id, pid, state.cwd);
|
|
374
|
+
}
|
|
375
|
+
if (newParent && !currentParentIds.includes(newParent)) {
|
|
376
|
+
await bdDepAdd(id, newParent, 'parent-child', state.cwd);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
await loadList();
|
|
380
|
+
ensureSelectedVisible();
|
|
381
|
+
render();
|
|
382
|
+
setStatus(
|
|
383
|
+
newParent ? `Reparented ${id} → ${newParent}` : `${id} is now standalone`,
|
|
384
|
+
false,
|
|
385
|
+
true,
|
|
386
|
+
);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
setStatus(err.message, true, true);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
key(['g'], () => {
|
|
393
|
+
if (screen.focused !== list) return;
|
|
394
|
+
list.select(0);
|
|
395
|
+
onNav();
|
|
396
|
+
screen.render();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
key(['S-g'], () => {
|
|
400
|
+
if (screen.focused !== list) return;
|
|
401
|
+
// Jump to the true bottom — in paged mode reveal every remaining batch first.
|
|
402
|
+
if (isPaged() && state.visibleCount < state.listOrder.length) {
|
|
403
|
+
state.visibleCount = state.listOrder.length;
|
|
404
|
+
renderList(list);
|
|
405
|
+
}
|
|
406
|
+
list.select(state.listOrder.length - 1);
|
|
407
|
+
onNav();
|
|
408
|
+
screen.render();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
key(['/'], async () => {
|
|
412
|
+
if (screen.focused !== list) return;
|
|
413
|
+
try {
|
|
414
|
+
// Pre-fill with the active search so it can be edited; an empty submit
|
|
415
|
+
// clears the title filter (other filters stay put).
|
|
416
|
+
const query = await textPrompt(screen, 'Filter by title', state.textFilter || '');
|
|
417
|
+
list.focus();
|
|
418
|
+
state.textFilter = query || null;
|
|
419
|
+
applyFilters();
|
|
420
|
+
if (!state.selectedId || !state.listOrder.includes(state.selectedId)) {
|
|
421
|
+
state.selectedId = state.listOrder[0] || null;
|
|
422
|
+
}
|
|
423
|
+
ensureSelectedVisible();
|
|
424
|
+
renderList(list);
|
|
425
|
+
renderDetail(detail);
|
|
426
|
+
screen.render();
|
|
427
|
+
setStatus(defaultStatus());
|
|
428
|
+
} catch {
|
|
429
|
+
list.focus();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ── Mutation keys (list focus only) ────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
key(['s'], async () => {
|
|
436
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
437
|
+
const id = state.selectedId;
|
|
438
|
+
try {
|
|
439
|
+
const newStatus = await statusPicker(screen);
|
|
440
|
+
list.focus();
|
|
441
|
+
const oldStatus = state.beadsById.get(id)?.status;
|
|
442
|
+
setStatus(`${id}: ${oldStatus} → ${newStatus}…`);
|
|
443
|
+
await applyMutation(id, () => bdUpdate(id, { status: newStatus }, state.cwd));
|
|
444
|
+
renderList(list);
|
|
445
|
+
renderDetail(detail);
|
|
446
|
+
screen.render();
|
|
447
|
+
setStatus(`${id}: ${oldStatus} → ${newStatus}`, false, true);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
list.focus();
|
|
450
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
key(['c'], async () => {
|
|
455
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
456
|
+
const id = state.selectedId;
|
|
457
|
+
try {
|
|
458
|
+
const reason = await textPrompt(screen, 'Close reason');
|
|
459
|
+
list.focus();
|
|
460
|
+
setStatus(`Closing ${id}…`);
|
|
461
|
+
await applyMutation(id, () => bdClose(id, reason, state.cwd));
|
|
462
|
+
await loadList();
|
|
463
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
464
|
+
state.selectedId = state.listOrder[0] || null;
|
|
465
|
+
}
|
|
466
|
+
ensureSelectedVisible();
|
|
467
|
+
render();
|
|
468
|
+
setStatus(`Closed ${id}`, false, true);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
list.focus();
|
|
471
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
key(['S-c'], async () => {
|
|
476
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
477
|
+
const id = state.selectedId;
|
|
478
|
+
setStatus(`Claiming ${id}…`);
|
|
479
|
+
try {
|
|
480
|
+
await applyMutation(id, () => bdClaim(id, state.cwd));
|
|
481
|
+
renderList(list);
|
|
482
|
+
renderDetail(detail);
|
|
483
|
+
screen.render();
|
|
484
|
+
setStatus(`Claimed ${id}`, false, true);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
setStatus(err.message, true, true);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
key(['o'], async () => {
|
|
491
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
492
|
+
const id = state.selectedId;
|
|
493
|
+
setStatus(`Reopening ${id}…`);
|
|
494
|
+
try {
|
|
495
|
+
await applyMutation(id, () => bdReopen(id, state.cwd));
|
|
496
|
+
await loadList();
|
|
497
|
+
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
498
|
+
state.selectedId = state.listOrder[0] || null;
|
|
499
|
+
}
|
|
500
|
+
ensureSelectedVisible();
|
|
501
|
+
render();
|
|
502
|
+
setStatus(`Reopened ${id}`, false, true);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
setStatus(err.message, true, true);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
key(['p'], async () => {
|
|
509
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
510
|
+
const id = state.selectedId;
|
|
511
|
+
try {
|
|
512
|
+
const priority = await priorityPicker(screen);
|
|
513
|
+
list.focus();
|
|
514
|
+
setStatus(`Setting priority ${priority} on ${id}…`);
|
|
515
|
+
await applyMutation(id, () => bdUpdate(id, { priority }, state.cwd));
|
|
516
|
+
renderList(list);
|
|
517
|
+
renderDetail(detail);
|
|
518
|
+
screen.render();
|
|
519
|
+
setStatus(`${id}: priority → ${priority}`, false, true);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
list.focus();
|
|
522
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
async function copyToClipboard(text, { okMsg, busyMsg } = {}) {
|
|
527
|
+
setStatus(busyMsg || `Copying…`);
|
|
528
|
+
const write = (cmd, args) => new Promise((resolve, reject) => {
|
|
529
|
+
let settled = false;
|
|
530
|
+
const finish = (err) => { if (!settled) { settled = true; err ? reject(err) : resolve(); } };
|
|
531
|
+
const p = execFile(cmd, args, (err) => finish(err || null));
|
|
532
|
+
p.on('error', finish);
|
|
533
|
+
if (p.stdin) {
|
|
534
|
+
p.stdin.once('finish', () => setTimeout(() => finish(null), 100));
|
|
535
|
+
p.stdin.end(text);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
const candidates = process.platform === 'win32'
|
|
539
|
+
? [['clip', []]]
|
|
540
|
+
: [
|
|
541
|
+
['wl-copy', []],
|
|
542
|
+
['xclip', ['-selection', 'clipboard']],
|
|
543
|
+
['xsel', ['--clipboard', '--input']],
|
|
544
|
+
['pbcopy', []],
|
|
545
|
+
];
|
|
546
|
+
for (const [cmd, args] of candidates) {
|
|
547
|
+
try {
|
|
548
|
+
await write(cmd, args);
|
|
549
|
+
setStatus(okMsg || `Copied`, false, true);
|
|
550
|
+
return;
|
|
551
|
+
} catch {}
|
|
552
|
+
}
|
|
553
|
+
const hint = process.platform === 'win32'
|
|
554
|
+
? 'clip.exe missing'
|
|
555
|
+
: 'install wl-clipboard, xclip, or xsel';
|
|
556
|
+
setStatus(`clipboard unavailable — ${hint}`, true, true);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
key(['y'], () => {
|
|
560
|
+
const id = state.selectedId;
|
|
561
|
+
if (!id) return;
|
|
562
|
+
copyToClipboard(id, { busyMsg: `Copying ${id}…`, okMsg: `Copied ${id}` });
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
key(['w'], async () => {
|
|
566
|
+
if (screen.focused !== list || !state.selectedId) return;
|
|
567
|
+
const id = state.selectedId;
|
|
568
|
+
const bead = state.beadsById.get(id);
|
|
569
|
+
try {
|
|
570
|
+
const line = await skillPicker(screen, id, bead?.issue_type);
|
|
571
|
+
list.focus();
|
|
572
|
+
await copyToClipboard(line, {
|
|
573
|
+
busyMsg: `Copying ${line}…`,
|
|
574
|
+
okMsg: `Copied: ${line}`,
|
|
575
|
+
});
|
|
576
|
+
} catch (err) {
|
|
577
|
+
list.focus();
|
|
578
|
+
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Release blessed's mouse capture so the terminal can do native click-drag
|
|
583
|
+
// text selection (then copy with the terminal's own copy). blessed grabs the
|
|
584
|
+
// mouse process-wide the moment any element sets mouse:true, which blocks
|
|
585
|
+
// native selection; toggling it off hands the mouse back to the terminal.
|
|
586
|
+
// Re-enable via program.enableMouse() directly (inside enableMouse()) —
|
|
587
|
+
// screen.enableMouse() is a no-op here because blessed's _listenedMouse latch
|
|
588
|
+
// is already set.
|
|
589
|
+
let mouseCaptured = true;
|
|
590
|
+
key(['m'], () => {
|
|
591
|
+
mouseCaptured = !mouseCaptured;
|
|
592
|
+
if (mouseCaptured) {
|
|
593
|
+
enableMouse();
|
|
594
|
+
setStatus('Mouse restored — wheel scroll & click active', false, true);
|
|
595
|
+
} else {
|
|
596
|
+
screen.program.disableMouse();
|
|
597
|
+
setStatus('Mouse released — drag to select text, copy with your terminal · press m to restore');
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ── Boot ───────────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
setFocusBorder('list');
|
|
604
|
+
list.focus();
|
|
605
|
+
setStatus('Loading…');
|
|
606
|
+
try {
|
|
607
|
+
await loadList();
|
|
608
|
+
state.selectedId = state.listOrder[0] || null;
|
|
609
|
+
if (state.selectedId) await loadDetail(state.selectedId);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
setStatus(err.message, true);
|
|
612
|
+
}
|
|
613
|
+
render();
|
|
614
|
+
// setStatus already appends "? help · q quit"; defaultStatus() yields "Ready"
|
|
615
|
+
// (or the active-filter summary) without duplicating the help hint.
|
|
616
|
+
setStatus(defaultStatus());
|
|
617
|
+
}
|