dsh-code 0.9.1 → 1.0.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/README.en.md +278 -249
- package/README.md +131 -102
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +36 -1
- package/lib/index.mjs +3055 -819
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +84 -16
- package/lib/types/attachments.d.ts +20 -0
- package/lib/types/authorization-panel.d.ts +22 -0
- package/lib/types/authorization.d.ts +36 -0
- package/lib/types/editor.d.ts +6 -0
- package/lib/types/fork.d.ts +8 -0
- package/lib/types/git-workflow.d.ts +23 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/kernel-panels.d.ts +39 -0
- package/lib/types/keyboard.d.ts +41 -0
- package/lib/types/mentions.d.ts +30 -38
- package/lib/types/models.d.ts +3 -1
- package/lib/types/permissions.d.ts +4 -14
- package/lib/types/presets.d.ts +5 -20
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +10 -39
- package/lib/types/render/editor.d.ts +137 -0
- package/lib/types/render/export.d.ts +1 -1
- package/lib/types/render/lines.d.ts +6 -2
- package/lib/types/render/markdown.d.ts +3 -1
- package/lib/types/render/projection.d.ts +29 -3
- package/lib/types/render/status.d.ts +6 -13
- package/lib/types/session-directory.d.ts +1 -3
- package/lib/types/startup.d.ts +14 -11
- package/lib/types/store.d.ts +11 -9
- package/lib/types/subagents.d.ts +3 -3
- package/lib/types/theme.d.ts +14 -1
- package/lib/types/version.d.ts +15 -2
- package/package.json +159 -141
- package/src/app.ts +1490 -663
- package/src/attachments.ts +128 -0
- package/src/authorization-panel.ts +285 -0
- package/src/authorization.ts +147 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1523 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +126 -0
- package/src/mentions.ts +78 -117
- package/src/models.ts +20 -14
- package/src/permissions.ts +5 -13
- package/src/presets.ts +6 -22
- package/src/provider-settings.ts +95 -1
- package/src/render/animations.ts +420 -450
- package/src/render/editor.ts +398 -0
- package/src/render/export.ts +79 -79
- package/src/render/lines.ts +342 -236
- package/src/render/markdown.ts +99 -26
- package/src/render/projection.ts +106 -19
- package/src/render/status.ts +713 -650
- package/src/render/text.ts +150 -150
- package/src/render/tool-detail.ts +3 -1
- package/src/session-directory.ts +4 -4
- package/src/startup.ts +136 -119
- package/src/store.ts +23 -11
- package/src/subagents.ts +13 -5
- package/src/theme.ts +214 -206
- package/src/version.ts +58 -1
package/src/kernel-panels.ts
CHANGED
|
@@ -1,798 +1,914 @@
|
|
|
1
|
-
/** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
|
|
2
|
-
|
|
3
|
-
import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
4
|
-
import { Box, Text, useInput, useStdout } from 'ink'
|
|
5
|
-
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
6
|
-
import type { SubagentRow } from './subagents.ts'
|
|
7
|
-
import type { PermissionRow } from './permissions.ts'
|
|
8
|
-
import type { PresetRow } from './presets.ts'
|
|
9
|
-
import type { PluginRow } from './plugin-inventory.ts'
|
|
10
|
-
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
11
|
-
import { formatRelativeTime } from './session-directory.ts'
|
|
12
|
-
import { panelViewport, revealRow } from './render/inspector.ts'
|
|
13
|
-
import { textLines } from './render/lines.ts'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
readonly
|
|
22
|
-
readonly
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return `search: ${query === '' ? 'type to filter' : query}`
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
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
|
-
const
|
|
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
|
-
rows: rows.
|
|
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
|
-
const
|
|
214
|
-
|
|
215
|
-
const
|
|
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
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
useInput((input, key) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
if (key.
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
return
|
|
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
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
)
|
|
502
|
-
|
|
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
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (key.
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (key.return
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
if (
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (key.upArrow)
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const [
|
|
732
|
-
const [
|
|
733
|
-
const [
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
})
|
|
798
|
-
|
|
1
|
+
/** Bounded, composer-safe panels for preset, session, and plugin kernel views. */
|
|
2
|
+
|
|
3
|
+
import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
|
4
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
5
|
+
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
6
|
+
import type { SubagentRow } from './subagents.ts'
|
|
7
|
+
import type { PermissionRow } from './permissions.ts'
|
|
8
|
+
import type { PresetRow } from './presets.ts'
|
|
9
|
+
import type { PluginRow } from './plugin-inventory.ts'
|
|
10
|
+
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
11
|
+
import { formatRelativeTime } from './session-directory.ts'
|
|
12
|
+
import { panelViewport, revealRow } from './render/inspector.ts'
|
|
13
|
+
import { markdownLines, textLines, type LineStyle, type StyledLine } from './render/lines.ts'
|
|
14
|
+
import { deleteLastGrapheme } from './render/editor.ts'
|
|
15
|
+
import { stripPasteMarkers } from './keyboard.ts'
|
|
16
|
+
import { DEFAULT_STATUSLINE_ITEMS, STATUS_ITEMS, type StatusItemId } from './render/status.ts'
|
|
17
|
+
import { singleLineText, truncateColumns } from './render/text.ts'
|
|
18
|
+
import { getPalette, inkColor } from './theme.ts'
|
|
19
|
+
|
|
20
|
+
interface ListFrameProps {
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly rows: readonly { readonly key: string; readonly text: string; readonly disabled?: boolean }[]
|
|
23
|
+
readonly cursor: number
|
|
24
|
+
readonly loading: boolean
|
|
25
|
+
readonly error?: string
|
|
26
|
+
readonly query: string
|
|
27
|
+
/** Ctrl+F-gated search focus for this panel: typing edits the query only
|
|
28
|
+
* while true. `undefined` keeps the plain "type to filter" prompt (the
|
|
29
|
+
* panel filters by typing directly). */
|
|
30
|
+
readonly searching?: boolean
|
|
31
|
+
readonly footer: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True for the Ctrl+F search-focus toggle. */
|
|
35
|
+
function isSearchToggle(input: string, key: { ctrl?: boolean }): boolean {
|
|
36
|
+
return key.ctrl === true && input === 'f'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The search-state line: gated panels show only an ACTIVE filter (the ctrl+f
|
|
40
|
+
* toggle lives in the footer), direct-typing panels keep the plain prompt. */
|
|
41
|
+
function searchLine(searching: boolean | undefined, query: string): string {
|
|
42
|
+
if (searching === true) return `search: ${query === '' ? 'type to filter · esc stops' : query}`
|
|
43
|
+
if (searching === false) return query === '' ? '' : `search: ${query}`
|
|
44
|
+
return `search: ${query === '' ? 'type to filter' : query}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ListFrame(props: ListFrameProps): ReactElement {
|
|
48
|
+
const stdout = useStdout().stdout
|
|
49
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
50
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
51
|
+
if (viewport.compact) {
|
|
52
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
|
|
53
|
+
}
|
|
54
|
+
const stateRows = props.loading
|
|
55
|
+
? [{ key: 'loading', text: ' loading…' }]
|
|
56
|
+
: props.error !== undefined
|
|
57
|
+
? [{ key: 'error', text: ` ${singleLineText(props.error)}` }]
|
|
58
|
+
: props.rows.length === 0
|
|
59
|
+
? [{ key: 'empty', text: ' no matching entries' }]
|
|
60
|
+
: props.rows
|
|
61
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
62
|
+
const offset = revealRow(0, props.cursor, stateRows.length, bodyRows)
|
|
63
|
+
const visible = stateRows.slice(offset, offset + bodyRows)
|
|
64
|
+
return createElement(
|
|
65
|
+
Box,
|
|
66
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
67
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(props.title), viewport.contentColumns)),
|
|
68
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(searchLine(props.searching, props.query)), viewport.contentColumns)),
|
|
69
|
+
...visible.map((row, index) => {
|
|
70
|
+
const absolute = offset + index
|
|
71
|
+
const selected = !props.loading && props.error === undefined && props.rows.length > 0 && absolute === props.cursor
|
|
72
|
+
return createElement(Text, {
|
|
73
|
+
key: row.key,
|
|
74
|
+
color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : undefined,
|
|
75
|
+
dimColor: row.disabled,
|
|
76
|
+
wrap: 'truncate-end',
|
|
77
|
+
}, truncateColumns(`${selected ? '› ' : ' '}${singleLineText(row.text)}`, viewport.contentColumns))
|
|
78
|
+
}),
|
|
79
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply one keystroke to a panel search query. IME commits arrive as one
|
|
85
|
+
* multi-character chunk, so the whole printable run is appended; paste
|
|
86
|
+
* markers are stripped and control-laden chunks are ignored.
|
|
87
|
+
*/
|
|
88
|
+
export function editQuery(query: string, input: string, key: { backspace?: boolean; delete?: boolean }): string | undefined {
|
|
89
|
+
if (key.backspace || key.delete) return deleteLastGrapheme(query)
|
|
90
|
+
const text = stripPasteMarkers(input)
|
|
91
|
+
if (text !== '' && !/[\u0000-\u001f\u007f]/u.test(text)) return query + text
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ModePanel({ current, load, select, close }: {
|
|
96
|
+
current: string
|
|
97
|
+
load(): Promise<readonly PresetRow[]>
|
|
98
|
+
select(id: string): void
|
|
99
|
+
close(): void
|
|
100
|
+
}): ReactElement {
|
|
101
|
+
const [rows, setRows] = useState<readonly PresetRow[]>([])
|
|
102
|
+
const [query, setQuery] = useState('')
|
|
103
|
+
const [cursor, setCursor] = useState(0)
|
|
104
|
+
const [loading, setLoading] = useState(true)
|
|
105
|
+
const [error, setError] = useState<string>()
|
|
106
|
+
const refresh = (): void => {
|
|
107
|
+
setLoading(true); setError(undefined)
|
|
108
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
109
|
+
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
useEffect(refresh, [])
|
|
113
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
114
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
115
|
+
useInput((input, key) => {
|
|
116
|
+
if (key.escape || input === 'q') return close()
|
|
117
|
+
if (input === 'r' && query === '') return refresh()
|
|
118
|
+
if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
|
|
119
|
+
if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
|
|
120
|
+
if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
|
|
121
|
+
const next = editQuery(query, input, key)
|
|
122
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
123
|
+
})
|
|
124
|
+
return createElement(ListFrame, {
|
|
125
|
+
title: `/mode · current ${current}`,
|
|
126
|
+
rows: visible.map(row => ({ key: row.id, disabled: row.broken !== undefined, text: `${row.id === current ? '●' : '○'} ${row.name ?? row.id} · ${row.description ?? row.trust}${row.broken === undefined ? '' : ` · broken: ${row.broken}`}` })),
|
|
127
|
+
cursor, loading, error, query, footer: '↑↓ choose · enter switch · r refresh · esc close',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function PermissionPanel({ current, load, select, close }: {
|
|
132
|
+
current: string
|
|
133
|
+
load(): Promise<readonly PermissionRow[]>
|
|
134
|
+
select(id: string): void
|
|
135
|
+
close(): void
|
|
136
|
+
}): ReactElement {
|
|
137
|
+
const [rows, setRows] = useState<readonly PermissionRow[]>([])
|
|
138
|
+
const [query, setQuery] = useState('')
|
|
139
|
+
const [cursor, setCursor] = useState(0)
|
|
140
|
+
const [loading, setLoading] = useState(true)
|
|
141
|
+
const [error, setError] = useState<string>()
|
|
142
|
+
const refresh = (): void => {
|
|
143
|
+
setLoading(true); setError(undefined)
|
|
144
|
+
Promise.resolve().then(load).then(value => { setRows(value); setLoading(false) }, reason => {
|
|
145
|
+
setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
useEffect(refresh, [])
|
|
149
|
+
const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
|
|
150
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
|
|
151
|
+
useInput((input, key) => {
|
|
152
|
+
if (key.escape || input === 'q') return close()
|
|
153
|
+
if (input === 'r' && query === '') return refresh()
|
|
154
|
+
if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
|
|
155
|
+
if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
|
|
156
|
+
if (key.return && visible[cursor] !== undefined) return select(visible[cursor]!.id)
|
|
157
|
+
const next = editQuery(query, input, key)
|
|
158
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
159
|
+
})
|
|
160
|
+
return createElement(ListFrame, {
|
|
161
|
+
title: `/permission · current ${current}`,
|
|
162
|
+
rows: visible.map(row => ({ key: row.id, text: `${row.id === current ? '●' : '○'} ${row.id}${row.description === undefined ? '' : ` · ${row.description}`}` })),
|
|
163
|
+
cursor, loading, error, query, footer: '↑↓ choose · enter select · r refresh · esc close',
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function PluginPanel({ load, close, initialQuery = '' }: { load(): readonly PluginRow[]; close(): void; initialQuery?: string }): ReactElement {
|
|
168
|
+
const [epoch, setEpoch] = useState(0)
|
|
169
|
+
const [query, setQuery] = useState(initialQuery)
|
|
170
|
+
const [cursor, setCursor] = useState(0)
|
|
171
|
+
const [expanded, setExpanded] = useState(false)
|
|
172
|
+
const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
|
|
173
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
174
|
+
useInput((input, key) => {
|
|
175
|
+
if (key.escape || input === 'q') return close()
|
|
176
|
+
if (input === 'r' && query === '') return setEpoch(value => value + 1)
|
|
177
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
|
|
178
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
|
|
179
|
+
if (key.return) return setExpanded(value => !value)
|
|
180
|
+
const next = editQuery(query, input, key)
|
|
181
|
+
if (next !== undefined) { setQuery(next); setCursor(0) }
|
|
182
|
+
})
|
|
183
|
+
return createElement(ListFrame, {
|
|
184
|
+
title: '/plugin · loader inspector',
|
|
185
|
+
rows: rows.map((row, index) => ({
|
|
186
|
+
key: row.entryId,
|
|
187
|
+
disabled: !row.enabled,
|
|
188
|
+
text: `${row.enabled ? '●' : '○'} ${row.entryId} · ${row.phase ?? 'not mounted'}${expanded && index === cursor ? ` · ${row.moduleName}` : ''}`,
|
|
189
|
+
})), cursor, loading: false, query, footer: '↑↓ inspect · enter details · r refresh · esc close',
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One background job snapshot for the /jobs panel (the registry's read-only view). */
|
|
194
|
+
export interface JobRow {
|
|
195
|
+
/** The registry-issued id (`<kind>-N`). */
|
|
196
|
+
readonly id: string
|
|
197
|
+
/** Producer kind (bash, subagent, …). */
|
|
198
|
+
readonly kind: string
|
|
199
|
+
/** One-line model-facing label (the command; the delegation description). */
|
|
200
|
+
readonly label: string
|
|
201
|
+
/** Lifecycle state. */
|
|
202
|
+
readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
|
203
|
+
/** Kind-specific status detail once the producer supplied one. */
|
|
204
|
+
readonly detail?: string
|
|
205
|
+
/** Epoch ms when the job was registered. */
|
|
206
|
+
readonly startedAt: number
|
|
207
|
+
/** Epoch ms when the job settled; absent while running/stopping. */
|
|
208
|
+
readonly finishedAt?: number
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
|
|
212
|
+
export function runClock(ms: number): string {
|
|
213
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
214
|
+
const minutes = Math.floor(total / 60)
|
|
215
|
+
const seconds = total % 60
|
|
216
|
+
return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Status glyph per job lifecycle state. */
|
|
220
|
+
const JOB_MARK: Record<JobRow['status'], string> = {
|
|
221
|
+
running: '●',
|
|
222
|
+
stopping: '⏸',
|
|
223
|
+
completed: '✓',
|
|
224
|
+
killed: '⊘',
|
|
225
|
+
failed: '✗',
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The read-only background-job panel: caller-owned and unowned jobs from the
|
|
230
|
+
* host `jobs` registry in registration order, with a local second-hand while
|
|
231
|
+
* the panel is open (elapsed clocks advance and the snapshot re-reads; the
|
|
232
|
+
* interval dies with the panel). Cancel stays upstream-only; an absent
|
|
233
|
+
* registry renders as the plain empty state (a harmless missing service).
|
|
234
|
+
*/
|
|
235
|
+
export function JobsPanel({ load, close }: { load(): readonly JobRow[]; close(): void }): ReactElement {
|
|
236
|
+
const [, setRefresh] = useState(0)
|
|
237
|
+
const [cursor, setCursor] = useState(0)
|
|
238
|
+
const [, setTick] = useState(0)
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const id = setInterval(() => setTick(value => value + 1), 1_000)
|
|
241
|
+
return () => clearInterval(id)
|
|
242
|
+
}, [])
|
|
243
|
+
const rows = load()
|
|
244
|
+
useInput((input, key) => {
|
|
245
|
+
if (key.escape || input === 'q') return close()
|
|
246
|
+
if (input === 'r') return setRefresh(value => value + 1)
|
|
247
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
|
|
248
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
|
|
249
|
+
})
|
|
250
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
251
|
+
return createElement(ListFrame, {
|
|
252
|
+
title: `/jobs · background tasks · ${rows.length}`,
|
|
253
|
+
rows: rows.map(row => ({
|
|
254
|
+
key: row.id,
|
|
255
|
+
text: `${JOB_MARK[row.status]} ${row.id} · ${singleLineText(row.label)} · ${runClock((row.finishedAt ?? Date.now()) - row.startedAt)}${row.detail === undefined ? '' : ` · ${singleLineText(row.detail)}`}`,
|
|
256
|
+
})),
|
|
257
|
+
cursor, loading: false, query: '', searching: false, footer: '↑↓ inspect · r refresh · esc close',
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function ResumePanel({ currentCwd, load, readTranscript, select, requestDelete, deleteConfirmId, reloadToken = 0, deleteMode = false, close }: {
|
|
262
|
+
currentCwd: string
|
|
263
|
+
load(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
264
|
+
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
265
|
+
select(row: SessionRow): void
|
|
266
|
+
/** Arm the composer-based delete confirm for one row (App owns the keys). */
|
|
267
|
+
requestDelete?(row: SessionRow): void
|
|
268
|
+
/** The row id awaiting y/n in the composer, when any (App-owned). */
|
|
269
|
+
deleteConfirmId?: string
|
|
270
|
+
/** Bump to reload the listing (e.g. after a deletion). */
|
|
271
|
+
reloadToken?: number
|
|
272
|
+
/** Opened via /delete: hint-first delete mode. */
|
|
273
|
+
deleteMode?: boolean
|
|
274
|
+
close(): void
|
|
275
|
+
}): ReactElement {
|
|
276
|
+
// Codex resume-picker default: the CURRENT directory's root sessions; the
|
|
277
|
+
// cwd filter widens to all only on request (the old default leaked every
|
|
278
|
+
// directory's sessions into what read as a current-directory view).
|
|
279
|
+
const [options, setOptions] = useState<SessionDirectoryOptions>({ sessions: 'roots', cwd: 'current', sort: 'newest', currentCwd, query: '' })
|
|
280
|
+
const [focus, setFocus] = useState(0)
|
|
281
|
+
const [density, setDensity] = useState<'comfortable' | 'dense'>('comfortable')
|
|
282
|
+
const [rows, setRows] = useState<readonly SessionRow[]>([])
|
|
283
|
+
const [cursor, setCursor] = useState(0)
|
|
284
|
+
const [loading, setLoading] = useState(true)
|
|
285
|
+
const [error, setError] = useState<string>()
|
|
286
|
+
const [expanded, setExpanded] = useState<string>()
|
|
287
|
+
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
288
|
+
/** Ctrl+F-gated search: typing filters only while searching (codex). */
|
|
289
|
+
const [searching, setSearching] = useState(false)
|
|
290
|
+
/** Reference clock pinned per row render, so relative times never drift mid-list. */
|
|
291
|
+
const now = useMemo(() => Date.now(), [rows, options])
|
|
292
|
+
const transcriptLoad = useRef<AbortController>()
|
|
293
|
+
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const controller = new AbortController()
|
|
296
|
+
setLoading(true); setError(undefined)
|
|
297
|
+
Promise.resolve().then(() => load(options, controller.signal)).then(value => {
|
|
298
|
+
if (!controller.signal.aborted) { setRows(value); setLoading(false) }
|
|
299
|
+
}, reason => {
|
|
300
|
+
if (!controller.signal.aborted) { setError(reason instanceof Error ? reason.message : String(reason)); setLoading(false) }
|
|
301
|
+
})
|
|
302
|
+
return () => controller.abort()
|
|
303
|
+
}, [options, reloadToken])
|
|
304
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
305
|
+
const cycle = (): void => {
|
|
306
|
+
if (focus === 3) {
|
|
307
|
+
setDensity(current => current === 'comfortable' ? 'dense' : 'comfortable')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
setOptions(value => {
|
|
311
|
+
if (focus === 0) return { ...value, sessions: value.sessions === 'roots' ? 'all' : 'roots' }
|
|
312
|
+
if (focus === 1) return { ...value, cwd: value.cwd === 'all' ? 'current' : 'all' }
|
|
313
|
+
return { ...value, sort: value.sort === 'newest' ? 'oldest' : 'newest' }
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
useInput((input, key) => {
|
|
317
|
+
// While a deletion awaits y/n, the COMPOSER owns every key (App routes
|
|
318
|
+
// them); the panel yields so y/n cannot be handled twice.
|
|
319
|
+
if (deleteConfirmId !== undefined) return
|
|
320
|
+
if (key.escape) {
|
|
321
|
+
if (searching) { setSearching(false); return }
|
|
322
|
+
return close()
|
|
323
|
+
}
|
|
324
|
+
if (isSearchToggle(input, key)) { setSearching(current => !current); return }
|
|
325
|
+
if (searching) {
|
|
326
|
+
if (key.return) { setSearching(false); return }
|
|
327
|
+
const next = editQuery(options.query, input, key)
|
|
328
|
+
if (next !== undefined) { setOptions(value => ({ ...value, query: next })); setCursor(0) }
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (input === 'q') return close()
|
|
332
|
+
if (key.tab) return setFocus(value => (value + (key.shift ? 3 : 1)) % 4)
|
|
333
|
+
if (key.leftArrow) return cycle()
|
|
334
|
+
if (key.rightArrow) return cycle()
|
|
335
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : Math.max(0, value - 1))
|
|
336
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : Math.min(rows.length - 1, value + 1))
|
|
337
|
+
if (key.pageUp) return setCursor(value => Math.max(0, value - 8))
|
|
338
|
+
if (key.pageDown) return setCursor(value => Math.min(rows.length - 1, value + 8))
|
|
339
|
+
if (input === 'g') return setCursor(0)
|
|
340
|
+
if (input === 'G') return setCursor(Math.max(0, rows.length - 1))
|
|
341
|
+
if (input === 'd' && rows[cursor] !== undefined && requestDelete !== undefined) return requestDelete(rows[cursor]!)
|
|
342
|
+
if (input === 'e' && rows[cursor] !== undefined) {
|
|
343
|
+
return setExpanded(value => value === rows[cursor]!.id ? undefined : rows[cursor]!.id)
|
|
344
|
+
}
|
|
345
|
+
if (input === 't' && rows[cursor] !== undefined) {
|
|
346
|
+
const row = rows[cursor]!
|
|
347
|
+
transcriptLoad.current?.abort()
|
|
348
|
+
setTranscript({ id: row.id })
|
|
349
|
+
const controller = new AbortController()
|
|
350
|
+
transcriptLoad.current = controller
|
|
351
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
|
|
352
|
+
text => { if (!controller.signal.aborted) setTranscript({ id: row.id, text }) },
|
|
353
|
+
reason => { if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) }) },
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (key.return && rows[cursor]?.resumable === true) return select(rows[cursor]!)
|
|
358
|
+
}, { isActive: transcript === undefined })
|
|
359
|
+
if (transcript !== undefined) {
|
|
360
|
+
return createElement(DocumentPanel, {
|
|
361
|
+
title: `transcript · ${transcript.id}`,
|
|
362
|
+
text: transcript.text,
|
|
363
|
+
error: transcript.error,
|
|
364
|
+
close: () => { transcriptLoad.current?.abort(); setTranscript(undefined) },
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
const pendingRow = deleteConfirmId === undefined ? undefined : rows.find(row => row.id === deleteConfirmId)
|
|
368
|
+
const toolbar = `[${focus === 0 ? '>' : ''}${options.sessions}] [${focus === 1 ? '>' : ''}${options.cwd} cwd] [${focus === 2 ? '>' : ''}${options.sort}] [${focus === 3 ? '>' : ''}${density}]`
|
|
369
|
+
return createElement(ListFrame, {
|
|
370
|
+
title: deleteConfirmId === undefined
|
|
371
|
+
? `/resume${deleteMode ? ' — delete mode' : ''}${searching ? ' — searching' : ''} · ${toolbar}`
|
|
372
|
+
: `permanently delete ${pendingRow === undefined ? deleteConfirmId.slice(-12) : pendingRow.title ?? pendingRow.id}? this cannot be undone · subagent threads go too`,
|
|
373
|
+
rows: rows.map(row => ({
|
|
374
|
+
key: row.id,
|
|
375
|
+
disabled: !row.resumable,
|
|
376
|
+
text: `${row.subagent ? '↳' : '○'} ${row.title ?? row.id.slice(-12)}${density === 'comfortable' ? ` · ${formatRelativeTime(row.updatedAt ?? row.createdAt, now)} · ${row.workspace} · ${row.preset}` : ''}${row.live ? ' · live' : ''}${expanded === row.id ? ` · ${row.id} · ${row.cwd}${row.parent === undefined ? '' : ` · parent ${row.parent}`}` : ''}`,
|
|
377
|
+
})), cursor, loading, error, query: options.query, searching,
|
|
378
|
+
footer: 'tab/←→ filters · ↑↓/pg navigate · ctrl+f search · e details · t transcript · d delete · enter resume',
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function documentStyleProps(style: LineStyle): { color?: string; bold?: boolean; italic?: boolean; strikethrough?: boolean } {
|
|
383
|
+
switch (style) {
|
|
384
|
+
case 'accent':
|
|
385
|
+
return { color: inkColor(getPalette().brandBright) }
|
|
386
|
+
case 'accentBold':
|
|
387
|
+
return { color: inkColor(getPalette().brandBright), bold: true }
|
|
388
|
+
case 'code':
|
|
389
|
+
return { color: inkColor(getPalette().code) }
|
|
390
|
+
case 'dim':
|
|
391
|
+
return { color: inkColor(getPalette().dim) }
|
|
392
|
+
case 'bold':
|
|
393
|
+
return { bold: true }
|
|
394
|
+
case 'italic':
|
|
395
|
+
return { italic: true }
|
|
396
|
+
case 'boldItalic':
|
|
397
|
+
return { bold: true, italic: true }
|
|
398
|
+
case 'strike':
|
|
399
|
+
return { color: inkColor(getPalette().dim), strikethrough: true }
|
|
400
|
+
default:
|
|
401
|
+
return {}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function DocumentRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
|
|
406
|
+
return createElement(
|
|
407
|
+
Box,
|
|
408
|
+
{ flexDirection: 'column' },
|
|
409
|
+
...lines.map((line, index) => createElement(
|
|
410
|
+
Text,
|
|
411
|
+
{ key: index, wrap: 'truncate-end' },
|
|
412
|
+
line.segments.length === 0
|
|
413
|
+
? ' '
|
|
414
|
+
: line.segments.map((segment, at) => createElement(Text, { key: at, ...documentStyleProps(segment.style) }, segment.text)),
|
|
415
|
+
)),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function DocumentPanel({ title, text, error, close }: {
|
|
420
|
+
title: string
|
|
421
|
+
text?: string
|
|
422
|
+
error?: string
|
|
423
|
+
close(): void
|
|
424
|
+
}): ReactElement {
|
|
425
|
+
const stdout = useStdout().stdout
|
|
426
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
427
|
+
const [scroll, setScroll] = useState(0)
|
|
428
|
+
const lines = useMemo(() => text === undefined ? [] : markdownLines(text, viewport.contentColumns), [text, viewport.contentColumns])
|
|
429
|
+
useInput((input, key) => {
|
|
430
|
+
if (key.escape || input === 'q' || input === 't') return close()
|
|
431
|
+
if (key.upArrow) return setScroll(value => Math.max(0, value - 1))
|
|
432
|
+
if (key.downArrow) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + 1))
|
|
433
|
+
if (key.pageUp) return setScroll(value => Math.max(0, value - Math.max(1, viewport.bodyRows - 1)))
|
|
434
|
+
if (key.pageDown) return setScroll(value => Math.min(Math.max(0, lines.length - viewport.bodyRows), value + Math.max(1, viewport.bodyRows - 1)))
|
|
435
|
+
if (input === 'g') return setScroll(0)
|
|
436
|
+
if (input === 'G') return setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
437
|
+
})
|
|
438
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('transcript · esc close', viewport.contentColumns))
|
|
439
|
+
const body: readonly StyledLine[] = error !== undefined
|
|
440
|
+
? textLines(`error: ${singleLineText(error)}`, viewport.contentColumns, 'error')
|
|
441
|
+
: text === undefined
|
|
442
|
+
? textLines('loading transcript…', viewport.contentColumns, 'dim')
|
|
443
|
+
: lines.slice(scroll, scroll + viewport.bodyRows)
|
|
444
|
+
return createElement(
|
|
445
|
+
Box,
|
|
446
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
447
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(singleLineText(title), viewport.contentColumns)),
|
|
448
|
+
createElement(DocumentRows, { lines: body }),
|
|
449
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`lines ${lines.length === 0 ? 0 : scroll + 1}-${Math.min(lines.length, scroll + viewport.bodyRows)}/${lines.length} · ↑↓/pg/g/G · t/esc close`, viewport.contentColumns)),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The /history recall panel (Codex composer-history search, bounded): one
|
|
455
|
+
* query line over the newest-first recall space, filtered by substring, with
|
|
456
|
+
* arrow selection and enter to fill the composer. Editing the query restarts
|
|
457
|
+
* from the newest match; Esc closes without touching the draft.
|
|
458
|
+
*/
|
|
459
|
+
export function HistoryPanel({ entries, fill, close }: {
|
|
460
|
+
/** Newest-first recall entries (persistent + in-session, deduped). */
|
|
461
|
+
entries: readonly string[]
|
|
462
|
+
/** Accept one entry: its text plus its recall-space index (browsing resumes there). */
|
|
463
|
+
fill(text: string, index: number): void
|
|
464
|
+
close(): void
|
|
465
|
+
}): ReactElement {
|
|
466
|
+
const [query, setQuery] = useState('')
|
|
467
|
+
const [cursor, setCursor] = useState(0)
|
|
468
|
+
const matches = query === ''
|
|
469
|
+
? entries
|
|
470
|
+
: entries.filter(entry => entry.toLowerCase().includes(query.toLowerCase()))
|
|
471
|
+
useInput((input, key) => {
|
|
472
|
+
if (key.escape) return close()
|
|
473
|
+
if (key.return) {
|
|
474
|
+
const entry = matches[cursor]
|
|
475
|
+
if (entry !== undefined) fill(entry, entries.indexOf(entry))
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
479
|
+
if (key.downArrow) return setCursor(value => Math.min(matches.length - 1, value + 1))
|
|
480
|
+
if (input === 'g') return setCursor(0)
|
|
481
|
+
if (input === 'G') return setCursor(matches.length - 1)
|
|
482
|
+
if (key.backspace) {
|
|
483
|
+
setQuery(current => deleteLastGrapheme(current))
|
|
484
|
+
setCursor(0)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (input !== '' && !key.ctrl && !key.meta && !key.shift) {
|
|
488
|
+
const text = stripPasteMarkers(input)
|
|
489
|
+
if (text !== '') {
|
|
490
|
+
setQuery(current => (current + text).slice(0, 120))
|
|
491
|
+
setCursor(0)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
const stdout = useStdout().stdout
|
|
496
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
497
|
+
if (viewport.compact) {
|
|
498
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
|
|
499
|
+
}
|
|
500
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
501
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
502
|
+
const offset = revealRow(0, cursor, matches.length, bodyRows)
|
|
503
|
+
const visible = matches.slice(offset, offset + bodyRows)
|
|
504
|
+
const header = query === ''
|
|
505
|
+
? `/history · ${entries.length} prompts · type to filter`
|
|
506
|
+
: `/history · ${matches.length} of ${entries.length} match '${truncateColumns(singleLineText(query), viewport.contentColumns - 30)}'`
|
|
507
|
+
return createElement(
|
|
508
|
+
Box,
|
|
509
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
510
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(header, viewport.contentColumns)),
|
|
511
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(` filter ${query === '' ? '· type to search prompts' : '· ' + singleLineText(query)}, enter fills the composer`, viewport.contentColumns)),
|
|
512
|
+
...(visible.length === 0
|
|
513
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no matching prompts', viewport.contentColumns))]
|
|
514
|
+
: visible.map((entry, index) => {
|
|
515
|
+
const absolute = offset + index
|
|
516
|
+
const selected = absolute === cursor
|
|
517
|
+
return createElement(
|
|
518
|
+
Text,
|
|
519
|
+
{
|
|
520
|
+
key: `history-${absolute}`,
|
|
521
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
522
|
+
wrap: 'truncate-end',
|
|
523
|
+
},
|
|
524
|
+
truncateColumns((selected ? '› ' : ' ') + singleLineText(entry), viewport.contentColumns),
|
|
525
|
+
)
|
|
526
|
+
})),
|
|
527
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · g/G ends · enter fill · esc close', viewport.contentColumns)),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The /statusline picker (the Codex setup-view contract): one bounded list
|
|
533
|
+
* of every status item with its enabled mark, arrow reordering, and a
|
|
534
|
+
* live preview — the real status line under the composer updates as you
|
|
535
|
+
* edit, so the panel itself carries no duplicate preview row.
|
|
536
|
+
*/
|
|
537
|
+
export function StatuslinePanel({ enabled, change, close }: {
|
|
538
|
+
enabled: readonly StatusItemId[]
|
|
539
|
+
change(items: readonly StatusItemId[]): void
|
|
540
|
+
close(): void
|
|
541
|
+
}): ReactElement {
|
|
542
|
+
// Working state: the full catalog in display order (enabled entries in
|
|
543
|
+
// their configured positions, disabled ones trailing canonically) plus
|
|
544
|
+
// the enabled set. Persisted shape is the enabled subsequence only.
|
|
545
|
+
const [order, setOrder] = useState<readonly StatusItemId[]>(() => {
|
|
546
|
+
const seen = new Set(enabled)
|
|
547
|
+
return [...enabled, ...DEFAULT_STATUSLINE_ITEMS.filter(id => !seen.has(id))]
|
|
548
|
+
})
|
|
549
|
+
const [on, setOn] = useState<ReadonlySet<StatusItemId>>(() => new Set(enabled))
|
|
550
|
+
const [cursor, setCursor] = useState(0)
|
|
551
|
+
const commit = (nextOrder: readonly StatusItemId[], nextOn: ReadonlySet<StatusItemId>): void => {
|
|
552
|
+
setOrder(nextOrder)
|
|
553
|
+
setOn(nextOn)
|
|
554
|
+
change(nextOrder.filter(id => nextOn.has(id)))
|
|
555
|
+
}
|
|
556
|
+
const move = (offset: number): void => {
|
|
557
|
+
const target = cursor + offset
|
|
558
|
+
if (target < 0 || target >= order.length) return
|
|
559
|
+
const next = [...order]
|
|
560
|
+
const [item] = next.splice(cursor, 1)
|
|
561
|
+
next.splice(target, 0, item!)
|
|
562
|
+
commit(next, on)
|
|
563
|
+
setCursor(target)
|
|
564
|
+
}
|
|
565
|
+
useInput((input, key) => {
|
|
566
|
+
if (key.escape || input === 'q' || key.return) return close()
|
|
567
|
+
if (key.upArrow) return setCursor(value => Math.max(0, value - 1))
|
|
568
|
+
if (key.downArrow) return setCursor(value => Math.min(order.length - 1, value + 1))
|
|
569
|
+
if (key.leftArrow) return move(-1)
|
|
570
|
+
if (key.rightArrow) return move(1)
|
|
571
|
+
if (input === 'g') return setCursor(0)
|
|
572
|
+
if (input === 'G') return setCursor(order.length - 1)
|
|
573
|
+
if (input === 'd') {
|
|
574
|
+
commit([...DEFAULT_STATUSLINE_ITEMS], new Set(DEFAULT_STATUSLINE_ITEMS))
|
|
575
|
+
setCursor(0)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
if (input === ' ') {
|
|
579
|
+
const item = order[cursor]
|
|
580
|
+
if (item === undefined) return
|
|
581
|
+
const next = new Set(on)
|
|
582
|
+
if (next.has(item)) next.delete(item)
|
|
583
|
+
else next.add(item)
|
|
584
|
+
commit(order, next)
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
const stdout = useStdout().stdout
|
|
588
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
589
|
+
if (viewport.compact) {
|
|
590
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
|
|
591
|
+
}
|
|
592
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
593
|
+
const bodyRows = Math.max(1, viewport.bodyRows - 1)
|
|
594
|
+
const offset = revealRow(0, cursor, order.length, bodyRows)
|
|
595
|
+
const visible = order.slice(offset, offset + bodyRows)
|
|
596
|
+
const meta = new Map(STATUS_ITEMS.map(item => [item.id, item]))
|
|
597
|
+
return createElement(
|
|
598
|
+
Box,
|
|
599
|
+
{ width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
|
|
600
|
+
createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns('/statusline · items apply to the live status line below', viewport.contentColumns)),
|
|
601
|
+
...visible.map((id, index) => {
|
|
602
|
+
const absolute = offset + index
|
|
603
|
+
const selected = absolute === cursor
|
|
604
|
+
const info = meta.get(id)
|
|
605
|
+
return createElement(
|
|
606
|
+
Text,
|
|
607
|
+
{
|
|
608
|
+
key: id,
|
|
609
|
+
color: selected ? inkColor(getPalette().brandBright) : undefined,
|
|
610
|
+
dimColor: !on.has(id) || undefined,
|
|
611
|
+
wrap: 'truncate-end',
|
|
612
|
+
},
|
|
613
|
+
truncateColumns((selected ? '› ' : ' ') + (on.has(id) ? '● ' : '○ ') + (info?.label ?? id) + (info === undefined ? '' : ' · ' + info.description + ' · ' + info.side), viewport.contentColumns),
|
|
614
|
+
)
|
|
615
|
+
}),
|
|
616
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns('↑↓ move · space toggle · ←→ reorder · d default · esc close', viewport.contentColumns)),
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The `/model` reasoning-effort stage (the Codex model → reasoning popup
|
|
622
|
+
* contract): one bounded list over the selected model's adapter-advertised
|
|
623
|
+
* effort levels — in the adapter's own display order, ids verbatim (the
|
|
624
|
+
* kernel treats them as opaque and rejects anything else) — with the
|
|
625
|
+
* effective effort and the model default marked. A model WITHOUT an
|
|
626
|
+
* adapter-declared default leads with a "Default" (provider-default) row —
|
|
627
|
+
* the web effort pane's first entry — so the user can clear a picked level
|
|
628
|
+
* back to provider behavior. A model advertising no levels opens the same
|
|
629
|
+
* stage with an explicit empty state (the web pane's "no levels" copy)
|
|
630
|
+
* instead of a bare failure notice. Enter applies one level; Esc returns to
|
|
631
|
+
* the model list without applying.
|
|
632
|
+
*/
|
|
633
|
+
export function EffortPanel({ row, current, select, back }: {
|
|
634
|
+
/** The model row whose advertised levels this stage lists. */
|
|
635
|
+
row: ModelRow
|
|
636
|
+
/** Effective effort currently in force ('' when none), for the ● mark. */
|
|
637
|
+
current: string | undefined
|
|
638
|
+
/** Accept one advertised effort id, or '' for the provider default. */
|
|
639
|
+
select(effortId: string): void
|
|
640
|
+
/** Return to the model list without applying. */
|
|
641
|
+
back(): void
|
|
642
|
+
}): ReactElement {
|
|
643
|
+
const advertised = row.reasoning?.efforts ?? []
|
|
644
|
+
const empty = row.reasoning === undefined || advertised.length === 0
|
|
645
|
+
// The provider-default row only exists when the adapter declares no default
|
|
646
|
+
// effort: with one, the default is an advertised level already in the list.
|
|
647
|
+
const hasDefaultRow = row.reasoning !== undefined && row.reasoning.defaultEffort === undefined
|
|
648
|
+
const rows = empty
|
|
649
|
+
? [{ id: '', name: '' }]
|
|
650
|
+
: hasDefaultRow
|
|
651
|
+
? [{ id: '', name: 'Default' }, ...advertised]
|
|
652
|
+
: advertised
|
|
653
|
+
// An absent or cleared effort is the Default row's current state.
|
|
654
|
+
const effective = current === undefined || current === '' ? '' : current
|
|
655
|
+
// The list opens ON the effective level (or the model's default row), so a
|
|
656
|
+
// quick re-pick never restarts the cursor from the top.
|
|
657
|
+
const wanted = effective === '' ? row.reasoning?.defaultEffort ?? '' : effective
|
|
658
|
+
const initialCursor = Math.max(0, rows.findIndex(effort => effort.id === wanted))
|
|
659
|
+
const [cursor, setCursor] = useState(initialCursor)
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
if (rows.length === 0) {
|
|
662
|
+
if (cursor !== 0) setCursor(0)
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
if (cursor >= rows.length) setCursor(rows.length - 1)
|
|
666
|
+
}, [rows.length, cursor])
|
|
667
|
+
useInput((input, key) => {
|
|
668
|
+
if (key.escape || input === 'q') return back()
|
|
669
|
+
if (empty) return
|
|
670
|
+
if (input === 'g') {
|
|
671
|
+
setCursor(0)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
if (input === 'G') {
|
|
675
|
+
setCursor(rows.length - 1)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
if (key.upArrow) {
|
|
679
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
if (key.downArrow) {
|
|
683
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
687
|
+
select(rows[cursor]!.id)
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
return createElement(ListFrame, {
|
|
691
|
+
title: `/model — effort for ${row.providerName} · ${row.modelName}`,
|
|
692
|
+
rows: empty
|
|
693
|
+
? [{ key: 'empty', disabled: true, text: 'this model advertises no reasoning effort levels — the provider default applies' }]
|
|
694
|
+
: rows.map(effort => ({
|
|
695
|
+
key: effort.id,
|
|
696
|
+
text: `${effort.id === effective ? '●' : '○'} ${effort.name}${effort.id === row.reasoning?.defaultEffort ? ' · default' : ''}${effort.description === undefined ? '' : ` · ${effort.description}`}`,
|
|
697
|
+
})),
|
|
698
|
+
cursor,
|
|
699
|
+
loading: false,
|
|
700
|
+
query: '',
|
|
701
|
+
footer: empty ? 'esc back' : '↑↓ choose · enter apply · esc/q back',
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** One merged /agents row: live feed state or a persisted child session. */
|
|
706
|
+
interface AgentsEntry {
|
|
707
|
+
readonly id: string
|
|
708
|
+
readonly label: string
|
|
709
|
+
readonly activity: string
|
|
710
|
+
readonly running: boolean
|
|
711
|
+
readonly done: boolean
|
|
712
|
+
readonly live: boolean
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The /agents panel (the Codex agent-picker contract, read-only): this
|
|
717
|
+
* conversation's subagent conversations — live rows from the activity feed
|
|
718
|
+
* first, persisted children the feed has not seen this process after — with
|
|
719
|
+
* Enter/t opening the child's full transcript in the shared read-only
|
|
720
|
+
* document view (the same projection the exporter uses).
|
|
721
|
+
*/
|
|
722
|
+
export function AgentsPanel({ live, load, readTranscript, close }: {
|
|
723
|
+
/** Live feed rows (child sessions observed this process). */
|
|
724
|
+
live: readonly SubagentRow[]
|
|
725
|
+
/** Load this session's persisted child sessions by lineage. */
|
|
726
|
+
load(): Promise<readonly SessionRow[]>
|
|
727
|
+
/** Read one child session's full transcript as markdown. */
|
|
728
|
+
readTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
729
|
+
close(): void
|
|
730
|
+
}): ReactElement {
|
|
731
|
+
const [dirRows, setDirRows] = useState<readonly SessionRow[] | undefined>(undefined)
|
|
732
|
+
const [error, setError] = useState<string>()
|
|
733
|
+
const [loading, setLoading] = useState(true)
|
|
734
|
+
const [cursor, setCursor] = useState(0)
|
|
735
|
+
const [transcript, setTranscript] = useState<{ id: string; text?: string; error?: string }>()
|
|
736
|
+
const transcriptLoad = useRef<AbortController>()
|
|
737
|
+
useEffect(() => () => transcriptLoad.current?.abort(), [])
|
|
738
|
+
const refresh = (): void => {
|
|
739
|
+
setLoading(true)
|
|
740
|
+
setError(undefined)
|
|
741
|
+
Promise.resolve().then(load).then(value => {
|
|
742
|
+
setDirRows(value)
|
|
743
|
+
setLoading(false)
|
|
744
|
+
}, reason => {
|
|
745
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
746
|
+
setLoading(false)
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
useEffect(refresh, [])
|
|
750
|
+
// Live feed rows first (they carry the running state), then persisted
|
|
751
|
+
// children only the directory knows — settled subagents from earlier turns.
|
|
752
|
+
const rows = useMemo<readonly AgentsEntry[]>(() => {
|
|
753
|
+
const seen = new Set(live.map(row => row.id))
|
|
754
|
+
const feedRows: AgentsEntry[] = live.map(row => ({
|
|
755
|
+
id: row.id,
|
|
756
|
+
label: row.label,
|
|
757
|
+
activity: row.activity,
|
|
758
|
+
running: row.state === 'running',
|
|
759
|
+
done: row.state === 'done',
|
|
760
|
+
live: true,
|
|
761
|
+
}))
|
|
762
|
+
const persisted: AgentsEntry[] = (dirRows ?? [])
|
|
763
|
+
.filter(row => !seen.has(row.id))
|
|
764
|
+
.map(row => ({
|
|
765
|
+
id: row.id,
|
|
766
|
+
label: row.title ?? row.id.slice(-12),
|
|
767
|
+
activity: row.workspace,
|
|
768
|
+
running: false,
|
|
769
|
+
done: !row.live,
|
|
770
|
+
live: row.live,
|
|
771
|
+
}))
|
|
772
|
+
return [...feedRows, ...persisted]
|
|
773
|
+
}, [live, dirRows])
|
|
774
|
+
useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
|
|
775
|
+
const openTranscript = (): void => {
|
|
776
|
+
const row = rows[cursor]
|
|
777
|
+
if (row === undefined) return
|
|
778
|
+
transcriptLoad.current?.abort()
|
|
779
|
+
setTranscript({ id: row.id })
|
|
780
|
+
const controller = new AbortController()
|
|
781
|
+
transcriptLoad.current = controller
|
|
782
|
+
Promise.resolve().then(() => readTranscript(row.id, controller.signal)).then(
|
|
783
|
+
text => {
|
|
784
|
+
if (!controller.signal.aborted) setTranscript({ id: row.id, text })
|
|
785
|
+
},
|
|
786
|
+
reason => {
|
|
787
|
+
if (!controller.signal.aborted) setTranscript({ id: row.id, error: reason instanceof Error ? reason.message : String(reason) })
|
|
788
|
+
},
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
useInput((input, key) => {
|
|
792
|
+
if (key.escape || input === 'q') return close()
|
|
793
|
+
if (input === 'r') return refresh()
|
|
794
|
+
if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
|
|
795
|
+
if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
|
|
796
|
+
if ((key.return || input === 't') && rows[cursor] !== undefined) return openTranscript()
|
|
797
|
+
}, { isActive: transcript === undefined })
|
|
798
|
+
if (transcript !== undefined) {
|
|
799
|
+
return createElement(DocumentPanel, {
|
|
800
|
+
title: `subagent · ${transcript.id.slice(-12)}`,
|
|
801
|
+
text: transcript.text,
|
|
802
|
+
error: transcript.error,
|
|
803
|
+
close: () => {
|
|
804
|
+
transcriptLoad.current?.abort()
|
|
805
|
+
setTranscript(undefined)
|
|
806
|
+
},
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
return createElement(ListFrame, {
|
|
810
|
+
title: `/agents · ${live.length} live · ${rows.length} total`,
|
|
811
|
+
rows: rows.map(row => ({
|
|
812
|
+
key: row.id,
|
|
813
|
+
text: `${row.running ? '●' : row.done ? '✓' : row.live ? '⏸' : '○'} ${row.label} · ${row.activity}${row.live ? ' · live' : ''}`,
|
|
814
|
+
})),
|
|
815
|
+
cursor,
|
|
816
|
+
loading,
|
|
817
|
+
...error === undefined ? {} : { error },
|
|
818
|
+
query: '',
|
|
819
|
+
footer: '↑↓ choose · enter/t transcript · r refresh · esc close',
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* The /subagent model panel: which model configuration delegated subagents
|
|
825
|
+
* run on. The kernel seeds child agents from the parent's CREATE-TIME
|
|
826
|
+
* AgentOptions, so a mid-session /model switch would otherwise leave them on
|
|
827
|
+
* the launch-time route; the TUI mirrors the selection onto subagent-origin
|
|
828
|
+
* requests (or an explicit override picked here) via an agent/request
|
|
829
|
+
* listener. The leading "inherit" row restores follow-the-current-model
|
|
830
|
+
* behavior; picking a model with several advertised efforts opens the same
|
|
831
|
+
* effort stage /model uses. Effort overrides are not offered separately —
|
|
832
|
+
* the kernel's AgentOptions has no effort channel for children, so the level
|
|
833
|
+
* rides the selected model exactly as /model applies it.
|
|
834
|
+
*/
|
|
835
|
+
export function SubagentPanel({ current, load, pick, inherit, close }: {
|
|
836
|
+
/** Display label of the override in force, '' when following the current model. */
|
|
837
|
+
current: string
|
|
838
|
+
load(): Promise<ModelDirectory>
|
|
839
|
+
/** Apply one model (with an advertised effort, when picked) as the override. */
|
|
840
|
+
pick(row: ModelRow, effortId?: string): void
|
|
841
|
+
/** Drop the override: subagents follow the current model again. */
|
|
842
|
+
inherit(): void
|
|
843
|
+
close(): void
|
|
844
|
+
}): ReactElement {
|
|
845
|
+
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
846
|
+
const [error, setError] = useState<string>()
|
|
847
|
+
const [loading, setLoading] = useState(true)
|
|
848
|
+
const [cursor, setCursor] = useState(0)
|
|
849
|
+
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
850
|
+
const refresh = (): void => {
|
|
851
|
+
setLoading(true)
|
|
852
|
+
setError(undefined)
|
|
853
|
+
Promise.resolve().then(load).then(value => {
|
|
854
|
+
setDirectory(value)
|
|
855
|
+
setLoading(false)
|
|
856
|
+
}, reason => {
|
|
857
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
858
|
+
setLoading(false)
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
useEffect(refresh, [])
|
|
862
|
+
const rows = useMemo(() => directory?.rows ?? [], [directory])
|
|
863
|
+
// The list opens on the override's own row (index 0 is the inherit row).
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (current === '' || rows.length === 0) return
|
|
866
|
+
const index = rows.findIndex(row => current.startsWith(`${row.provider}/${row.model}`))
|
|
867
|
+
if (index >= 0) setCursor(index + 1)
|
|
868
|
+
}, [rows, current])
|
|
869
|
+
useEffect(() => setCursor(value => Math.min(value, rows.length)), [rows.length])
|
|
870
|
+
// Hooks stay unconditional: the effort stage below swaps the rendered
|
|
871
|
+
// subtree but must never skip the input hook (an early return here would
|
|
872
|
+
// change the hook count when the stage opens and closes).
|
|
873
|
+
useInput((input, key) => {
|
|
874
|
+
if (effortFor !== undefined) return
|
|
875
|
+
if (key.escape || input === 'q') return close()
|
|
876
|
+
if (input === 'r' && !loading) return refresh()
|
|
877
|
+
if (key.upArrow) return setCursor(value => (value + rows.length) % (rows.length + 1))
|
|
878
|
+
if (key.downArrow) return setCursor(value => (value + 1) % (rows.length + 1))
|
|
879
|
+
if (key.return) {
|
|
880
|
+
if (cursor === 0) return inherit()
|
|
881
|
+
const row = rows[cursor - 1]
|
|
882
|
+
if (row === undefined) return
|
|
883
|
+
if (row.reasoning !== undefined && row.reasoning.efforts.length > 1) {
|
|
884
|
+
setEffortFor(row)
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0]!.id : undefined
|
|
888
|
+
pick(row, effortId)
|
|
889
|
+
}
|
|
890
|
+
})
|
|
891
|
+
if (effortFor !== undefined) {
|
|
892
|
+
return createElement(EffortPanel, {
|
|
893
|
+
row: effortFor,
|
|
894
|
+
current: current === '' ? undefined : current.split('@')[1],
|
|
895
|
+
select: effortId => pick(effortFor, effortId),
|
|
896
|
+
back: () => setEffortFor(undefined),
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
return createElement(ListFrame, {
|
|
900
|
+
title: `/subagent — model for delegated agents${current === '' ? '' : ` · override ${current}`}`,
|
|
901
|
+
rows: [
|
|
902
|
+
{ key: '__inherit__', text: `${current === '' ? '●' : '○'} inherit — follow the current model (/model switches apply)` },
|
|
903
|
+
...rows.map(row => ({
|
|
904
|
+
key: `${row.provider}/${row.model}`,
|
|
905
|
+
text: `${current.startsWith(`${row.provider}/${row.model}`) ? '●' : '○'} ${row.providerName} · ${row.modelName}`,
|
|
906
|
+
})),
|
|
907
|
+
],
|
|
908
|
+
cursor,
|
|
909
|
+
loading,
|
|
910
|
+
...error === undefined ? {} : { error },
|
|
911
|
+
query: '',
|
|
912
|
+
footer: '↑↓ choose · enter apply · r refresh · esc close',
|
|
913
|
+
})
|
|
914
|
+
}
|