dsh-code 0.5.0 → 0.6.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 +219 -0
- package/README.md +148 -137
- package/bin/deepseek.mjs +38 -3
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +26440 -499
- package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
- package/lib/types/app.d.ts +10 -0
- package/lib/types/history.d.ts +79 -0
- package/lib/types/kernel-panels.d.ts +25 -0
- package/lib/types/render/animations.d.ts +10 -1
- package/lib/types/render/inspector.d.ts +2 -0
- package/lib/types/render/projection.d.ts +31 -8
- package/lib/types/render/status.d.ts +129 -14
- package/package.json +117 -117
- package/src/app.ts +2543 -2205
- package/src/history.ts +136 -0
- package/src/index.ts +253 -52
- package/src/kernel-panels.ts +168 -3
- package/src/render/animations.ts +14 -1
- package/src/render/export.ts +4 -0
- package/src/render/inspector.ts +12 -3
- package/src/render/lines.ts +21 -10
- package/src/render/projection.ts +82 -15
- package/src/render/status.ts +520 -66
- package/src/whale-glyph.ts +23 -23
- package/README.zh.md +0 -204
package/src/app.ts
CHANGED
|
@@ -1,2205 +1,2543 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
3
|
-
* transcript, the todo panel, the streaming line, the approval bar, the model
|
|
4
|
-
* panel, local notices, and the input box with history and slash-command
|
|
5
|
-
* completion. All state arrives through the transcript store (derived from
|
|
6
|
-
* the durable session log) plus local input state; the app owns no session
|
|
7
|
-
* mutation of its own.
|
|
8
|
-
*
|
|
9
|
-
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
10
|
-
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
11
|
-
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
12
|
-
* would demand a React global.
|
|
13
|
-
*
|
|
14
|
-
* @module @deepseek-ai/dsh-code/app
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
|
-
} from 'react'
|
|
20
|
-
import { Box, Static, Text, useInput, useStdout } from 'ink'
|
|
21
|
-
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
22
|
-
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
23
|
-
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
|
24
|
-
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
25
|
-
import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
|
|
26
|
-
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
27
|
-
import type { TranscriptStore } from './store.ts'
|
|
28
|
-
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
29
|
-
import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
30
|
-
import type { ToolDetail } from './render/tool-detail.ts'
|
|
31
|
-
import { caretVisible, pulseFrame } from './render/animations.ts'
|
|
32
|
-
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
33
|
-
import type { CommandsView } from './commands.ts'
|
|
34
|
-
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
35
|
-
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
36
|
-
import type { SkillsView, SkillRow } from './skills.ts'
|
|
37
|
-
import type { MentionCandidate } from './mentions.ts'
|
|
38
|
-
import { ModePanel, PluginPanel, ResumePanel } from './kernel-panels.ts'
|
|
39
|
-
import type { PresetRow } from './presets.ts'
|
|
40
|
-
import type { PluginRow } from './plugin-inventory.ts'
|
|
41
|
-
import
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
type
|
|
67
|
-
|
|
68
|
-
} from './render/
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
function
|
|
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
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
return createElement(
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
)
|
|
479
|
-
case '
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
: TUI_RGB.brandBright
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
const
|
|
623
|
-
const
|
|
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
|
-
return
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
return
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
return
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
if (
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
},
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
:
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
return
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
Box,
|
|
1269
|
-
{
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
},
|
|
1275
|
-
createElement(
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
const
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
const
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
if (
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
)
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
:
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
3
|
+
* transcript, the todo panel, the streaming line, the approval bar, the model
|
|
4
|
+
* panel, local notices, and the input box with history and slash-command
|
|
5
|
+
* completion. All state arrives through the transcript store (derived from
|
|
6
|
+
* the durable session log) plus local input state; the app owns no session
|
|
7
|
+
* mutation of its own.
|
|
8
|
+
*
|
|
9
|
+
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
10
|
+
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
11
|
+
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
12
|
+
* would demand a React global.
|
|
13
|
+
*
|
|
14
|
+
* @module @deepseek-ai/dsh-code/app
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
|
+
} from 'react'
|
|
20
|
+
import { Box, Static, Text, useInput, useStdout, type Key } from 'ink'
|
|
21
|
+
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
23
|
+
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
|
24
|
+
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
25
|
+
import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
|
|
26
|
+
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
27
|
+
import type { TranscriptStore } from './store.ts'
|
|
28
|
+
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
29
|
+
import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
30
|
+
import type { ToolDetail } from './render/tool-detail.ts'
|
|
31
|
+
import { busyChaseFrame, caretVisible, pulseFrame } from './render/animations.ts'
|
|
32
|
+
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
33
|
+
import type { CommandsView } from './commands.ts'
|
|
34
|
+
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
35
|
+
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
36
|
+
import type { SkillsView, SkillRow } from './skills.ts'
|
|
37
|
+
import type { MentionCandidate } from './mentions.ts'
|
|
38
|
+
import { ModePanel, HistoryPanel, PluginPanel, ResumePanel, StatuslinePanel } from './kernel-panels.ts'
|
|
39
|
+
import type { PresetRow } from './presets.ts'
|
|
40
|
+
import type { PluginRow } from './plugin-inventory.ts'
|
|
41
|
+
import {
|
|
42
|
+
recallEntries,
|
|
43
|
+
recallNewer,
|
|
44
|
+
recallOlder,
|
|
45
|
+
recordLocalEntry,
|
|
46
|
+
type RecallState,
|
|
47
|
+
} from './history.ts'
|
|
48
|
+
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
49
|
+
|
|
50
|
+
/** Match Codex's settled-resize window before rebuilding terminal scrollback. */
|
|
51
|
+
const RESIZE_REFLOW_DELAY_MS = 75
|
|
52
|
+
|
|
53
|
+
/** Reset region/style, clear the visible screen and scrollback, then home. */
|
|
54
|
+
const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
|
|
55
|
+
import {
|
|
56
|
+
formatTokens,
|
|
57
|
+
layoutStatusBar,
|
|
58
|
+
parseStatuslineItems,
|
|
59
|
+
STATUS_CYCLE_HINT,
|
|
60
|
+
STATUS_GROUP_SEPARATOR,
|
|
61
|
+
STATUS_ITEM_SEPARATOR,
|
|
62
|
+
type StatusFacts,
|
|
63
|
+
type StatusGroup,
|
|
64
|
+
type StatusItemId,
|
|
65
|
+
type StatusSpan,
|
|
66
|
+
type StatusTone,
|
|
67
|
+
} from './render/status.ts'
|
|
68
|
+
import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
69
|
+
import {
|
|
70
|
+
clampScroll,
|
|
71
|
+
followInspectorCursor,
|
|
72
|
+
inspectorViewport,
|
|
73
|
+
layoutGutterRows,
|
|
74
|
+
moveScroll,
|
|
75
|
+
panelViewport,
|
|
76
|
+
revealRow,
|
|
77
|
+
selectionWindow,
|
|
78
|
+
} from './render/inspector.ts'
|
|
79
|
+
import {
|
|
80
|
+
lineSegment,
|
|
81
|
+
markdownLines,
|
|
82
|
+
styledLines,
|
|
83
|
+
textLines,
|
|
84
|
+
transcriptEntryLines,
|
|
85
|
+
type LineStyle,
|
|
86
|
+
type StyledLine,
|
|
87
|
+
} from './render/lines.ts'
|
|
88
|
+
|
|
89
|
+
/** Visual priority for one bounded local notice. */
|
|
90
|
+
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
91
|
+
|
|
92
|
+
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
93
|
+
export interface AppProps {
|
|
94
|
+
/** Event-fed transcript store for the live session. */
|
|
95
|
+
store: TranscriptStore
|
|
96
|
+
/** Approval-question store fed by the answerer listener. */
|
|
97
|
+
approval: ApprovalStore
|
|
98
|
+
/** ask_user_question store fed by the single UI provider. */
|
|
99
|
+
questions: QuestionStore
|
|
100
|
+
/** Live slash-command descriptor list (completion candidates). */
|
|
101
|
+
commands: CommandsView
|
|
102
|
+
/** Live user-invocable skill catalog (completion candidates). */
|
|
103
|
+
skills: SkillsView
|
|
104
|
+
/** `provider/model` selection serving this session (updated on /model). */
|
|
105
|
+
model: string
|
|
106
|
+
/** Working-directory basename the session serves. */
|
|
107
|
+
cwd: string
|
|
108
|
+
/** Absolute working directory used by session filters and references. */
|
|
109
|
+
workspaceRoot: string
|
|
110
|
+
/** Git branch name, empty outside a repository. */
|
|
111
|
+
branch: string
|
|
112
|
+
/** Short session identifier. */
|
|
113
|
+
sessionId: string
|
|
114
|
+
/** Whether this session was resumed from persistence. */
|
|
115
|
+
resumed: boolean
|
|
116
|
+
/** Agent preset currently composing the session. */
|
|
117
|
+
mode: string
|
|
118
|
+
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
119
|
+
dispatch(text: string): void
|
|
120
|
+
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
121
|
+
steer(text: string): void
|
|
122
|
+
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
123
|
+
interrupt(): boolean
|
|
124
|
+
/** Quit: unmount, flush, and request process exit. */
|
|
125
|
+
quit(): void
|
|
126
|
+
/** Load the selectable model directory (called when /model opens). */
|
|
127
|
+
loadModels(): Promise<ModelDirectory>
|
|
128
|
+
/** Load @mention candidates for the typed query (files + sessions). */
|
|
129
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
130
|
+
/** Apply one /model selection; returns the display label. */
|
|
131
|
+
selectModel(row: ModelRow): string
|
|
132
|
+
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
133
|
+
cyclePermission(): string
|
|
134
|
+
/** Export the transcript to a markdown file (/export [path]); reports via notices. */
|
|
135
|
+
exportTranscript(argument: string): Promise<void>
|
|
136
|
+
/** Rename the session (/title <text>); returns the outcome line for the notice. */
|
|
137
|
+
renameTitle(argument: string): string
|
|
138
|
+
/** Preset/session/plugin kernel operations. */
|
|
139
|
+
loadPresets(): Promise<readonly PresetRow[]>
|
|
140
|
+
switchMode(id: string): Promise<string>
|
|
141
|
+
createSession(mode?: string): void
|
|
142
|
+
loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
143
|
+
loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
144
|
+
switchSession(row: SessionRow): void
|
|
145
|
+
cancelSessionSwitch(): boolean
|
|
146
|
+
loadPlugins(): readonly PluginRow[]
|
|
147
|
+
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
148
|
+
onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
|
|
149
|
+
/** Ordered enabled status items (/statusline config); the runner owns persistence. */
|
|
150
|
+
statusline: readonly string[]
|
|
151
|
+
/** Persist a new statusline item set; the runner surfaces IO failures as notices. */
|
|
152
|
+
saveStatusline(items: readonly string[]): void
|
|
153
|
+
/** Persistent cross-session input history (oldest first); the runner owns the file. */
|
|
154
|
+
history: readonly string[]
|
|
155
|
+
/** Persist one submitted prompt to the global history file. */
|
|
156
|
+
recordHistory(text: string): void
|
|
157
|
+
/** Cancel one queued inbox message by identity (Delete on the empty composer). */
|
|
158
|
+
cancelQueued(messageId: string): void
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Ink `color` string for one palette triple. */
|
|
162
|
+
function inkColor(triple: readonly [number, number, number]): string {
|
|
163
|
+
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Pad text with spaces to a visible-column target (menu name column). */
|
|
167
|
+
function padColumns(text: string, width: number): string {
|
|
168
|
+
const clipped = truncateColumns(singleLineText(text), width)
|
|
169
|
+
return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Interval-driven frame counter for one self-contained animated leaf. */
|
|
173
|
+
function useFrames(intervalMs: number): number {
|
|
174
|
+
const [tick, setTick] = useState(0)
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
const id = setInterval(() => setTick(current => current + 1), intervalMs)
|
|
177
|
+
return () => {
|
|
178
|
+
clearInterval(id)
|
|
179
|
+
}
|
|
180
|
+
}, [intervalMs])
|
|
181
|
+
return tick
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Ink re-subscribes its input effect whenever the handler identity changes.
|
|
186
|
+
* Keep terminal input ownership stable while a local surface updates cursor,
|
|
187
|
+
* scroll, or draft state; otherwise every key toggles raw mode and can make
|
|
188
|
+
* Ink repeatedly repaint the live region.
|
|
189
|
+
*/
|
|
190
|
+
function useStableInput(handler: (input: string, key: Key) => void, active: boolean): void {
|
|
191
|
+
const handlerRef = useRef(handler)
|
|
192
|
+
handlerRef.current = handler
|
|
193
|
+
const stableHandler = useCallback((input: string, key: Key): void => {
|
|
194
|
+
handlerRef.current(input, key)
|
|
195
|
+
}, [])
|
|
196
|
+
useInput(stableHandler, { isActive: active })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
|
|
200
|
+
function Pulse(): ReactElement {
|
|
201
|
+
const tick = useFrames(125)
|
|
202
|
+
return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
|
|
207
|
+
* ring trail clockwise around the eight outer positions (8 frames × 125ms =
|
|
208
|
+
* the web's 1s cycle). Replaces the plain busy ellipsis as the composer's
|
|
209
|
+
* prompt marker and leads the Deep-diving line.
|
|
210
|
+
*/
|
|
211
|
+
function BusyChase(): ReactElement {
|
|
212
|
+
const tick = useFrames(125)
|
|
213
|
+
return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, busyChaseFrame(tick) + ' ')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Blinking block caret appended to streaming text. */
|
|
217
|
+
function Caret(): ReactElement {
|
|
218
|
+
const tick = useFrames(530)
|
|
219
|
+
return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Blinking input cursor: inverse block while the caret phase is on. */
|
|
223
|
+
function CursorBlock({ char }: { char: string }): ReactElement {
|
|
224
|
+
const tick = useFrames(530)
|
|
225
|
+
return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
|
|
229
|
+
function runClock(ms: number): string {
|
|
230
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
231
|
+
const minutes = Math.floor(total / 60)
|
|
232
|
+
const seconds = total % 60
|
|
233
|
+
return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The busy line, web TurnStatus contract: the StateDot chase leads the plain
|
|
238
|
+
* `Deep diving...` label, with the elapsed clock appended only once the turn
|
|
239
|
+
* has clearly been running (15s) — anchored to `turn/start` so a resumed
|
|
240
|
+
* mid-turn keeps the real time.
|
|
241
|
+
*/
|
|
242
|
+
function DeepDivingLine({ since }: { since: number }): ReactElement {
|
|
243
|
+
useFrames(1000)
|
|
244
|
+
const elapsed = since === 0 ? 0 : Date.now() - since
|
|
245
|
+
return createElement(
|
|
246
|
+
Box,
|
|
247
|
+
{ flexDirection: 'row' },
|
|
248
|
+
createElement(BusyChase),
|
|
249
|
+
createElement(
|
|
250
|
+
Text,
|
|
251
|
+
{ dimColor: true },
|
|
252
|
+
elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The streaming buffer rendered with a hard size cap: the live region must
|
|
259
|
+
* ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
|
|
260
|
+
* than the screen freezes (cursor-up past the top, garbage, no scroll). The
|
|
261
|
+
* cap counts explicit newlines and terminal wrapping, slicing from the END so
|
|
262
|
+
* the freshest tokens stay visible while a long reply streams; the complete
|
|
263
|
+
* text lands in the flushed scrollback once the turn assembles it.
|
|
264
|
+
*/
|
|
265
|
+
function StreamTail({ text, dim, maxRows, prefix, children }: {
|
|
266
|
+
text: string
|
|
267
|
+
dim: boolean
|
|
268
|
+
maxRows: number
|
|
269
|
+
prefix?: string
|
|
270
|
+
children?: ReactElement
|
|
271
|
+
}): ReactElement {
|
|
272
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
273
|
+
const safeRows = Math.max(1, maxRows)
|
|
274
|
+
// App padding consumes two columns; the final extra column keeps a caret
|
|
275
|
+
// from wrapping onto an unbudgeted row.
|
|
276
|
+
const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ''))
|
|
277
|
+
const initial = displayTail(text, contentColumns, safeRows)
|
|
278
|
+
// Reserve one row for the omission marker only when a marker is needed.
|
|
279
|
+
const tail = initial.truncated && safeRows > 1
|
|
280
|
+
? displayTail(text, contentColumns, safeRows - 1)
|
|
281
|
+
: initial
|
|
282
|
+
return createElement(
|
|
283
|
+
Box,
|
|
284
|
+
{ flexDirection: 'column' },
|
|
285
|
+
tail.truncated && safeRows > 1
|
|
286
|
+
? createElement(Text, { dimColor: true }, ' …')
|
|
287
|
+
: undefined,
|
|
288
|
+
createElement(Text, { dimColor: dim || undefined }, prefix, tail.text, children),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Ink props for one markdown style class. */
|
|
293
|
+
function segmentProps(style: MdSegment['style']): {
|
|
294
|
+
color: string | undefined
|
|
295
|
+
bold: boolean | undefined
|
|
296
|
+
italic: boolean | undefined
|
|
297
|
+
strikethrough: boolean | undefined
|
|
298
|
+
} {
|
|
299
|
+
switch (style) {
|
|
300
|
+
case 'accent':
|
|
301
|
+
return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
302
|
+
case 'code':
|
|
303
|
+
return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
304
|
+
case 'dim':
|
|
305
|
+
return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
|
|
306
|
+
case 'bold':
|
|
307
|
+
return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
|
|
308
|
+
case 'italic':
|
|
309
|
+
return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
|
|
310
|
+
case 'boldItalic':
|
|
311
|
+
return { color: undefined, bold: true, italic: true, strikethrough: undefined }
|
|
312
|
+
case 'strike':
|
|
313
|
+
return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
|
|
314
|
+
default:
|
|
315
|
+
return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Ink props for the richer line model used by bounded scrolling panels. */
|
|
320
|
+
function lineStyleProps(style: LineStyle): {
|
|
321
|
+
color: string | undefined
|
|
322
|
+
bold: boolean | undefined
|
|
323
|
+
italic: boolean | undefined
|
|
324
|
+
strikethrough: boolean | undefined
|
|
325
|
+
dimColor: boolean | undefined
|
|
326
|
+
} {
|
|
327
|
+
switch (style) {
|
|
328
|
+
case 'brand':
|
|
329
|
+
return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
|
|
330
|
+
case 'success':
|
|
331
|
+
return { color: inkColor(TUI_RGB.success), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
|
|
332
|
+
case 'error':
|
|
333
|
+
return { color: inkColor(TUI_RGB.error), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
|
|
334
|
+
case 'warn':
|
|
335
|
+
return { color: inkColor(TUI_RGB.warn), bold: undefined, italic: undefined, strikethrough: undefined, dimColor: undefined }
|
|
336
|
+
case 'dimItalic':
|
|
337
|
+
return { color: undefined, bold: undefined, italic: true, strikethrough: undefined, dimColor: true }
|
|
338
|
+
default:
|
|
339
|
+
return { ...segmentProps(style), dimColor: undefined }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Render width-safe rows; every child is exactly one terminal row. */
|
|
344
|
+
function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
|
|
345
|
+
return createElement(
|
|
346
|
+
Box,
|
|
347
|
+
{ flexDirection: 'column' },
|
|
348
|
+
...lines.map((line, index) => createElement(
|
|
349
|
+
Text,
|
|
350
|
+
{ key: index, wrap: 'truncate-end' },
|
|
351
|
+
line.segments.length === 0
|
|
352
|
+
? ' '
|
|
353
|
+
: line.segments.map((segment, at) => createElement(
|
|
354
|
+
Text,
|
|
355
|
+
{ key: at, ...lineStyleProps(segment.style) },
|
|
356
|
+
segment.text,
|
|
357
|
+
)),
|
|
358
|
+
)),
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Codex-style panel rhythm that still participates in the row budget. */
|
|
363
|
+
function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
|
|
364
|
+
return visible ? createElement(Text, null, ' ') : undefined
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** One settled markdown document rendered as styled lines at the terminal width. */
|
|
368
|
+
function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }): ReactElement {
|
|
369
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
370
|
+
// Cached by (text, width): settled replies re-layout only when either moves.
|
|
371
|
+
// The indent participates in the wrap budget so padded replies never
|
|
372
|
+
// double-wrap inside the padded box.
|
|
373
|
+
const lines = useMemo(
|
|
374
|
+
() => renderMarkdown(displayText(text), Math.max(20, columns - 2 - indent)),
|
|
375
|
+
[text, columns, indent],
|
|
376
|
+
)
|
|
377
|
+
return createElement(
|
|
378
|
+
Box,
|
|
379
|
+
{ flexDirection: 'column', paddingLeft: indent },
|
|
380
|
+
...lines.map((line, index) => createElement(
|
|
381
|
+
Text,
|
|
382
|
+
{ key: index },
|
|
383
|
+
line.segments.length === 0
|
|
384
|
+
? ' '
|
|
385
|
+
: line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
|
|
386
|
+
)),
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* One expanded tool-card body for the verbose transcript (Ctrl+O): the
|
|
392
|
+
* presentation contract's structured cards — inline diffs, read windows,
|
|
393
|
+
* web sources — rendered as plain terminal rows, degradation-safe against
|
|
394
|
+
* replayed metadata.
|
|
395
|
+
*/
|
|
396
|
+
function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
|
|
397
|
+
switch (detail.kind) {
|
|
398
|
+
case 'diff':
|
|
399
|
+
return createElement(
|
|
400
|
+
Box,
|
|
401
|
+
{ flexDirection: 'column' },
|
|
402
|
+
...detail.diffs.map((diff, index) => createElement(
|
|
403
|
+
Box,
|
|
404
|
+
{ key: index, flexDirection: 'column' },
|
|
405
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
|
|
406
|
+
...diff.lines.map((line, at) => createElement(
|
|
407
|
+
Text,
|
|
408
|
+
{
|
|
409
|
+
key: at,
|
|
410
|
+
color: line.mark === '+' ? inkColor(TUI_RGB.success) : line.mark === '-' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
|
|
411
|
+
wrap: 'truncate-end',
|
|
412
|
+
},
|
|
413
|
+
` ${line.mark}${displayText(line.text)}`,
|
|
414
|
+
)),
|
|
415
|
+
)),
|
|
416
|
+
)
|
|
417
|
+
case 'read':
|
|
418
|
+
return createElement(
|
|
419
|
+
Box,
|
|
420
|
+
{ flexDirection: 'column' },
|
|
421
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
|
|
422
|
+
...detail.lines.map((line, at) => createElement(
|
|
423
|
+
Text,
|
|
424
|
+
{ key: at, dimColor: true, wrap: 'truncate-end' },
|
|
425
|
+
` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
|
|
426
|
+
)),
|
|
427
|
+
)
|
|
428
|
+
case 'web-search':
|
|
429
|
+
return createElement(
|
|
430
|
+
Box,
|
|
431
|
+
{ flexDirection: 'column' },
|
|
432
|
+
...detail.sources.map((source, at) => createElement(
|
|
433
|
+
Text,
|
|
434
|
+
{ key: at, wrap: 'truncate-end' },
|
|
435
|
+
brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
|
|
436
|
+
createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
|
|
437
|
+
)),
|
|
438
|
+
createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
|
|
439
|
+
)
|
|
440
|
+
case 'web-fetch':
|
|
441
|
+
return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
|
|
442
|
+
case 'raw':
|
|
443
|
+
return createElement(
|
|
444
|
+
Box,
|
|
445
|
+
{ flexDirection: 'column' },
|
|
446
|
+
...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
|
|
447
|
+
createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
|
|
448
|
+
)
|
|
449
|
+
default:
|
|
450
|
+
return assertNever(detail, 'tool detail kind')
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** One settled transcript row. */
|
|
454
|
+
function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
|
|
455
|
+
switch (entry.kind) {
|
|
456
|
+
case 'user':
|
|
457
|
+
// Collapsed injected context reads as a dim ↳ row; only direct human
|
|
458
|
+
// prompts get the brand ❯ (they are different surfaces, not the same).
|
|
459
|
+
return entry.notice
|
|
460
|
+
? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
|
|
461
|
+
: createElement(Text, null, brand('❯ '), displayText(entry.text))
|
|
462
|
+
case 'assistant':
|
|
463
|
+
// Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
|
|
464
|
+
// text dim-italic expanded (Ctrl+R toggles globally). The collapsed
|
|
465
|
+
// row is static — an animated counter inside the text would jitter the
|
|
466
|
+
// line width every frame. The reply body carries the same two-column
|
|
467
|
+
// gutter as the composer, so reply text aligns with the input cursor
|
|
468
|
+
// (Codex LIVE_PREFIX alignment).
|
|
469
|
+
return createElement(
|
|
470
|
+
Box,
|
|
471
|
+
{ flexDirection: 'column' },
|
|
472
|
+
entry.reasoning === ''
|
|
473
|
+
? undefined
|
|
474
|
+
: showReasoning
|
|
475
|
+
? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
|
|
476
|
+
: createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
|
|
477
|
+
createElement(MarkdownBody, { text: entry.text, indent: 2 }),
|
|
478
|
+
)
|
|
479
|
+
case 'tool': {
|
|
480
|
+
// Claude-Code-style tool card: the invocation row plus a nested ⎿
|
|
481
|
+
// result line, so the summary reads under its call instead of inline.
|
|
482
|
+
const mark = entry.state === 'running'
|
|
483
|
+
? createElement(Pulse)
|
|
484
|
+
: entry.state === 'error'
|
|
485
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
486
|
+
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
487
|
+
return createElement(
|
|
488
|
+
Box,
|
|
489
|
+
{ flexDirection: 'column' },
|
|
490
|
+
createElement(
|
|
491
|
+
Text,
|
|
492
|
+
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
493
|
+
mark,
|
|
494
|
+
' ',
|
|
495
|
+
brand(entry.name),
|
|
496
|
+
entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
|
|
497
|
+
),
|
|
498
|
+
entry.summary === ''
|
|
499
|
+
? undefined
|
|
500
|
+
: createElement(
|
|
501
|
+
Text,
|
|
502
|
+
{ color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
|
|
503
|
+
` ⎿ ${displayText(entry.summary)}`,
|
|
504
|
+
),
|
|
505
|
+
verbose && entry.detail !== undefined
|
|
506
|
+
? createElement(ToolDetailBody, { detail: entry.detail })
|
|
507
|
+
: undefined,
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
case 'command': {
|
|
511
|
+
const mark = entry.state === 'running'
|
|
512
|
+
? createElement(Pulse)
|
|
513
|
+
: entry.state === 'error'
|
|
514
|
+
? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
|
|
515
|
+
: createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
|
|
516
|
+
return createElement(
|
|
517
|
+
Box,
|
|
518
|
+
{ flexDirection: 'column' },
|
|
519
|
+
createElement(
|
|
520
|
+
Text,
|
|
521
|
+
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
522
|
+
mark,
|
|
523
|
+
' ',
|
|
524
|
+
brand(`/${entry.name}`),
|
|
525
|
+
entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
|
|
526
|
+
),
|
|
527
|
+
entry.summary === ''
|
|
528
|
+
? undefined
|
|
529
|
+
: createElement(Text, { color: inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
case 'turn-marker':
|
|
533
|
+
// Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
|
|
534
|
+
return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
|
|
535
|
+
case 'compaction':
|
|
536
|
+
// Completed compaction lifecycle: what it reclaimed, or why it failed.
|
|
537
|
+
return createElement(
|
|
538
|
+
Text,
|
|
539
|
+
{ dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
|
|
540
|
+
entry.ok
|
|
541
|
+
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
542
|
+
: ` ⧉ compaction failed: ${displayText(entry.error)}`,
|
|
543
|
+
)
|
|
544
|
+
case 'retry':
|
|
545
|
+
// Provider-routed retry: amber while the backoff waits, dim once the
|
|
546
|
+
// next attempt is underway.
|
|
547
|
+
return createElement(
|
|
548
|
+
Text,
|
|
549
|
+
{ color: entry.state === 'running' ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim), wrap: verbose ? 'truncate-end' : undefined },
|
|
550
|
+
` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
551
|
+
)
|
|
552
|
+
case 'files': {
|
|
553
|
+
// Turn-tail deliverables: the turn's mutated files (web turnTail chips).
|
|
554
|
+
const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
|
|
555
|
+
const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
|
|
556
|
+
return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
|
|
557
|
+
}
|
|
558
|
+
case 'pending':
|
|
559
|
+
// Codex PendingSteer: queued prompts render as ordinary user rows; the
|
|
560
|
+
// durable user/message retires them seamlessly.
|
|
561
|
+
return createElement(
|
|
562
|
+
Text,
|
|
563
|
+
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
564
|
+
brand('❯ '),
|
|
565
|
+
displayText(entry.text),
|
|
566
|
+
)
|
|
567
|
+
case 'error':
|
|
568
|
+
return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
|
|
569
|
+
default:
|
|
570
|
+
return assertNever(entry, 'transcript entry kind')
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The whale wordmark header in DeepSeek blue, hugging its content width.
|
|
576
|
+
* The 8-row half-block glyph pairs adjacent lines, so on a terminal too
|
|
577
|
+
* short to show it whole (or mid-resize) the clipped pairs garble the
|
|
578
|
+
* screen — below the height floor the header collapses to a single-line
|
|
579
|
+
* wordmark that stays correct at any size.
|
|
580
|
+
*/
|
|
581
|
+
function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
582
|
+
const rows = useStdout().stdout?.rows ?? 40
|
|
583
|
+
const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
|
|
584
|
+
if (rows < 20) {
|
|
585
|
+
return createElement(
|
|
586
|
+
Box,
|
|
587
|
+
{ flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
|
|
588
|
+
createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
|
|
589
|
+
createElement(Text, { dimColor: true }, hint),
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
return createElement(
|
|
593
|
+
Box,
|
|
594
|
+
// alignSelf shrinks the border to the whale-plus-wordmark content instead
|
|
595
|
+
// of stretching across the terminal and stranding empty space on the right
|
|
596
|
+
// (the compact-banner treatment the Claude Code welcome uses).
|
|
597
|
+
{ flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
|
|
598
|
+
createElement(
|
|
599
|
+
Box,
|
|
600
|
+
{ flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
|
|
601
|
+
...WHALE_GLYPH.map((row, index) => createElement(Text, { key: index, color: inkColor(TUI_RGB.brand) }, row)),
|
|
602
|
+
),
|
|
603
|
+
createElement(
|
|
604
|
+
Box,
|
|
605
|
+
{ flexDirection: 'column', justifyContent: 'center' },
|
|
606
|
+
createElement(Text, { color: inkColor(TUI_RGB.brandBright), bold: true }, 'DeepSeek Harness'),
|
|
607
|
+
createElement(Text, { dimColor: true }, hint),
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Todo status glyph: web TodoPanel's three-state marker. */
|
|
613
|
+
function todoMark(status: TodoItem['status']): string {
|
|
614
|
+
return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** One-row todo summary: task count cannot grow the live Ink tree. */
|
|
618
|
+
function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
|
|
619
|
+
if (todos.length === 0) return undefined
|
|
620
|
+
const completed = todos.filter(todo => todo.status === 'completed').length
|
|
621
|
+
const inProgress = todos.filter(todo => todo.status === 'in_progress').length
|
|
622
|
+
const pending = todos.length - completed - inProgress
|
|
623
|
+
const current = todos.find(todo => todo.status === 'in_progress')
|
|
624
|
+
return createElement(
|
|
625
|
+
Box,
|
|
626
|
+
{ paddingX: 1 },
|
|
627
|
+
createElement(
|
|
628
|
+
Text,
|
|
629
|
+
{ color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
|
|
630
|
+
`todos ${completed}/${todos.length}`,
|
|
631
|
+
createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
|
|
632
|
+
current === undefined ? '' : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
|
|
633
|
+
),
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Ink props for one status tone: the Codex status-line accent mapping over
|
|
639
|
+
* the DeepSeek palette, all blue by design — the status bar speaks only in
|
|
640
|
+
* degrees of blue (deep accent, primary figures, bright model identity, sky
|
|
641
|
+
* paths and done states), with amber/red reserved for warnings and errors.
|
|
642
|
+
*/
|
|
643
|
+
function statusToneProps(tone: StatusTone): {
|
|
644
|
+
color: string | undefined
|
|
645
|
+
bold: boolean | undefined
|
|
646
|
+
dimColor: boolean | undefined
|
|
647
|
+
} {
|
|
648
|
+
switch (tone) {
|
|
649
|
+
case 'model':
|
|
650
|
+
return { color: inkColor(TUI_RGB.brandBright), bold: true, dimColor: undefined }
|
|
651
|
+
case 'live':
|
|
652
|
+
return { color: inkColor(TUI_RGB.brandBright), bold: undefined, dimColor: undefined }
|
|
653
|
+
case 'path':
|
|
654
|
+
return { color: inkColor(TUI_RGB.code), bold: undefined, dimColor: undefined }
|
|
655
|
+
case 'branch':
|
|
656
|
+
return { color: inkColor(TUI_RGB.text), bold: undefined, dimColor: undefined }
|
|
657
|
+
case 'value':
|
|
658
|
+
return { color: inkColor(TUI_RGB.brand), bold: undefined, dimColor: undefined }
|
|
659
|
+
case 'label':
|
|
660
|
+
case 'meta':
|
|
661
|
+
return { color: undefined, bold: undefined, dimColor: true }
|
|
662
|
+
case 'accent':
|
|
663
|
+
return { color: inkColor(TUI_RGB.brandDeep), bold: undefined, dimColor: undefined }
|
|
664
|
+
case 'success':
|
|
665
|
+
return { color: inkColor(TUI_RGB.code), bold: true, dimColor: undefined }
|
|
666
|
+
case 'warn':
|
|
667
|
+
return { color: inkColor(TUI_RGB.warn), bold: true, dimColor: undefined }
|
|
668
|
+
case 'error':
|
|
669
|
+
return { color: inkColor(TUI_RGB.error), bold: true, dimColor: undefined }
|
|
670
|
+
default:
|
|
671
|
+
return { color: undefined, bold: undefined, dimColor: true }
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The footer status line: two stacked physical rows in every mode. Row 1
|
|
677
|
+
* carries Claude-Code-style identity facts and session figures from the left
|
|
678
|
+
* with the Codex-style permission badge — the autonomous-selection anchor
|
|
679
|
+
* with its shift+tab cycle hint — pinned to the right edge. Row 2 (mode,
|
|
680
|
+
* context progress bar, cache, duration figures) renders only while it has
|
|
681
|
+
* content, so the footer degrades to a single row on narrow terminals. Both
|
|
682
|
+
* layouts arrive pre-measured from the pure reducer, so Ink only paints;
|
|
683
|
+
* truncation degrades groups, it never wraps a row.
|
|
684
|
+
*/
|
|
685
|
+
function StatusLine({ facts, stats, busy, columns, items }: {
|
|
686
|
+
facts: StatusFacts
|
|
687
|
+
stats: Parameters<typeof layoutStatusBar>[1]
|
|
688
|
+
busy: boolean
|
|
689
|
+
columns: number
|
|
690
|
+
items: readonly string[]
|
|
691
|
+
}): ReactElement {
|
|
692
|
+
const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
|
|
693
|
+
const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string): ReactElement => {
|
|
694
|
+
const leftParts: ReactElement[] = []
|
|
695
|
+
row.left.forEach((group, groupIndex) => {
|
|
696
|
+
if (groupIndex > 0) {
|
|
697
|
+
leftParts.push(createElement(Text, { key: key + 'gs' + groupIndex, dimColor: true }, STATUS_GROUP_SEPARATOR))
|
|
698
|
+
}
|
|
699
|
+
group.spans.forEach((span, spanIndex) => {
|
|
700
|
+
leftParts.push(createElement(
|
|
701
|
+
Text,
|
|
702
|
+
{ key: key + 'g' + groupIndex + 's' + spanIndex, wrap: 'truncate-end', ...statusToneProps(span.tone) },
|
|
703
|
+
span.text,
|
|
704
|
+
))
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
const rightParts: ReactElement[] = []
|
|
708
|
+
row.right.forEach((span, index) => {
|
|
709
|
+
if (index > 0) {
|
|
710
|
+
rightParts.push(createElement(Text, { key: key + 'rs' + index, dimColor: true }, STATUS_ITEM_SEPARATOR))
|
|
711
|
+
}
|
|
712
|
+
rightParts.push(createElement(
|
|
713
|
+
Text,
|
|
714
|
+
{ key: key + 'r' + index, wrap: 'truncate-end', ...statusToneProps(span.tone) },
|
|
715
|
+
span.text,
|
|
716
|
+
))
|
|
717
|
+
})
|
|
718
|
+
if (row.hint) {
|
|
719
|
+
rightParts.push(createElement(Text, { key: key + 'hint', dimColor: true }, STATUS_CYCLE_HINT))
|
|
720
|
+
}
|
|
721
|
+
// Each row already fits the column budget; truncate-end stays as the
|
|
722
|
+
// terminal-measurement backstop so a drifting cell count clips instead
|
|
723
|
+
// of wrapping.
|
|
724
|
+
return createElement(
|
|
725
|
+
Box,
|
|
726
|
+
// Match the prompt text inside the bordered composer: one border column
|
|
727
|
+
// plus one padding column. Keeping these rows margin-free also makes
|
|
728
|
+
// the composer and status a fixed bottom unit in every interface.
|
|
729
|
+
{ paddingLeft: 2, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
|
|
730
|
+
createElement(Text, { wrap: 'truncate-end' }, ...leftParts),
|
|
731
|
+
rightParts.length > 0 ? createElement(Text, { wrap: 'truncate-end' }, ...rightParts) : undefined,
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
const row2Present = layout.row2.left.length > 0
|
|
735
|
+
return createElement(
|
|
736
|
+
Box,
|
|
737
|
+
{ flexDirection: 'column' },
|
|
738
|
+
renderRow(layout.row1, 's1'),
|
|
739
|
+
row2Present ? renderRow(layout.row2, 's2') : undefined,
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* One fixed-height local feedback row. Errors remain visible while a slash
|
|
745
|
+
* subpage is open, but arbitrary exception text can never add physical rows
|
|
746
|
+
* above the composer.
|
|
747
|
+
*/
|
|
748
|
+
function NoticeLine({ text, tone, columns }: {
|
|
749
|
+
text: string
|
|
750
|
+
tone: NoticeTone
|
|
751
|
+
columns: number
|
|
752
|
+
}): ReactElement {
|
|
753
|
+
const color = tone === 'error'
|
|
754
|
+
? TUI_RGB.error
|
|
755
|
+
: tone === 'warning'
|
|
756
|
+
? TUI_RGB.warn
|
|
757
|
+
: TUI_RGB.brandBright
|
|
758
|
+
const mark = tone === 'error' ? '⨯' : tone === 'warning' ? '!' : '•'
|
|
759
|
+
return createElement(
|
|
760
|
+
Box,
|
|
761
|
+
{ paddingLeft: 2 },
|
|
762
|
+
createElement(
|
|
763
|
+
Text,
|
|
764
|
+
{ color: inkColor(color), wrap: 'truncate-end' },
|
|
765
|
+
truncateColumns(`${mark} ${singleLineText(text)}`, Math.max(1, columns - 2)),
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
771
|
+
function ApprovalBar({ snapshot, locked }: { snapshot: ApprovalSnapshot; locked: boolean }): ReactElement | undefined {
|
|
772
|
+
const stdout = useStdout().stdout
|
|
773
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
774
|
+
const [scroll, setScroll] = useState(0)
|
|
775
|
+
const pending = snapshot.pending
|
|
776
|
+
const active = !locked && snapshot.pending !== undefined && !snapshot.answered
|
|
777
|
+
const content = useMemo<readonly StyledLine[]>(() => pending === undefined
|
|
778
|
+
? []
|
|
779
|
+
: [
|
|
780
|
+
...styledLines([lineSegment(pending.headline, 'warn')], viewport.contentColumns),
|
|
781
|
+
...(pending.command === '' ? [] : textLines(` ${pending.command}`, viewport.contentColumns, 'dim')),
|
|
782
|
+
], [pending, viewport.contentColumns])
|
|
783
|
+
const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
|
|
784
|
+
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
setScroll(0)
|
|
787
|
+
}, [pending])
|
|
788
|
+
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll)
|
|
791
|
+
}, [visibleScroll, scroll])
|
|
792
|
+
|
|
793
|
+
useInput((input, key) => {
|
|
794
|
+
if (snapshot.pending === undefined) return
|
|
795
|
+
if (key.upArrow) {
|
|
796
|
+
setScroll(current => moveScroll(current, -1, content.length, viewport.bodyRows))
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
if (key.downArrow) {
|
|
800
|
+
setScroll(current => moveScroll(current, 1, content.length, viewport.bodyRows))
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (key.pageUp) {
|
|
804
|
+
setScroll(current => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
if (key.pageDown) {
|
|
808
|
+
setScroll(current => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows))
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
if (snapshot.answered) return
|
|
812
|
+
if (input === 'y' || input === 'Y') {
|
|
813
|
+
snapshot.pending.answer('allowed-once')
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
if (input === 'n' || input === 'N') {
|
|
817
|
+
snapshot.pending.answer('rejected')
|
|
818
|
+
}
|
|
819
|
+
}, { isActive: active })
|
|
820
|
+
if (snapshot.pending === undefined) return undefined
|
|
821
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
822
|
+
if (viewport.compact) {
|
|
823
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('approval · y allow · n reject', viewport.contentColumns))
|
|
824
|
+
}
|
|
825
|
+
const { answered } = snapshot
|
|
826
|
+
return createElement(
|
|
827
|
+
Box,
|
|
828
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn) },
|
|
829
|
+
createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
|
|
830
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
831
|
+
createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
|
|
832
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
833
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(answered
|
|
834
|
+
? 'submitted…'
|
|
835
|
+
: '↑↓/pgup/pgdn scroll · y allow once · n reject', viewport.contentColumns))),
|
|
836
|
+
)
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The ask_user_question bar: walks one request question by question,
|
|
841
|
+
* renders the option menu (Claude-Code style: arrows move, space toggles a
|
|
842
|
+
* multi-select, enter submits, `c` opens the custom-answer box, Esc
|
|
843
|
+
* interrupts the question as aborted). Plan reviews arrive through the same
|
|
844
|
+
* service with a `plan-review` intent — the approve option gets a ✓ mark,
|
|
845
|
+
* the answer encoding stays identical.
|
|
846
|
+
*/
|
|
847
|
+
function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
|
|
848
|
+
const stdout = useStdout().stdout
|
|
849
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
850
|
+
const pending = snapshot.pending
|
|
851
|
+
const request = pending?.request
|
|
852
|
+
const [index, setIndex] = useState(0)
|
|
853
|
+
const [cursor, setCursor] = useState(0)
|
|
854
|
+
const [selected, setSelected] = useState<readonly number[]>([])
|
|
855
|
+
const [mode, setMode] = useState<'options' | 'custom'>('options')
|
|
856
|
+
const [custom, setCustom] = useState('')
|
|
857
|
+
const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
|
|
858
|
+
const [submitted, setSubmitted] = useState(false)
|
|
859
|
+
const [scroll, setScroll] = useState(0)
|
|
860
|
+
const [manualScroll, setManualScroll] = useState(false)
|
|
861
|
+
const [followCustomTail, setFollowCustomTail] = useState(false)
|
|
862
|
+
|
|
863
|
+
// A new request resets the walk; questions without options start in the
|
|
864
|
+
// custom-answer box (a free-form question). Depend on the request rather
|
|
865
|
+
// than its wrapper snapshot: external stores may refresh that wrapper while
|
|
866
|
+
// a question is still active, and a reset must never become a render loop.
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
const first = request?.questions[0]
|
|
869
|
+
const initialMode = first?.options === undefined || first.options.length === 0 ? 'custom' : 'options'
|
|
870
|
+
setIndex(current => current === 0 ? current : 0)
|
|
871
|
+
setCursor(current => current === 0 ? current : 0)
|
|
872
|
+
setSelected(current => current.length === 0 ? current : [])
|
|
873
|
+
setMode(current => current === initialMode ? current : initialMode)
|
|
874
|
+
setCustom(current => current === '' ? current : '')
|
|
875
|
+
setAnswers(current => current.length === 0 ? current : [])
|
|
876
|
+
setSubmitted(current => current ? false : current)
|
|
877
|
+
setScroll(current => current === 0 ? current : 0)
|
|
878
|
+
setManualScroll(current => current ? false : current)
|
|
879
|
+
setFollowCustomTail(current => current === (initialMode === 'custom') ? current : initialMode === 'custom')
|
|
880
|
+
}, [request])
|
|
881
|
+
|
|
882
|
+
const question = pending?.request.questions[index]
|
|
883
|
+
const options = question?.options ?? []
|
|
884
|
+
const isPlan = question?.intent?.kind === 'plan-review'
|
|
885
|
+
const isMulti = question?.multiSelect === true
|
|
886
|
+
const active = !locked && pending !== undefined && question !== undefined && !submitted
|
|
887
|
+
const rendered = useMemo(() => {
|
|
888
|
+
if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
|
|
889
|
+
const lines: StyledLine[] = []
|
|
890
|
+
const optionRows: number[] = []
|
|
891
|
+
if (question.header !== undefined) {
|
|
892
|
+
lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
|
|
893
|
+
}
|
|
894
|
+
lines.push(...textLines(question.question, viewport.contentColumns))
|
|
895
|
+
if (question.detail !== undefined) {
|
|
896
|
+
lines.push(...(isPlan
|
|
897
|
+
? markdownLines(question.detail, viewport.contentColumns)
|
|
898
|
+
: textLines(question.detail, viewport.contentColumns, 'dim')))
|
|
899
|
+
}
|
|
900
|
+
if (submitted) {
|
|
901
|
+
lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
|
|
902
|
+
} else if (mode === 'custom' || options.length === 0) {
|
|
903
|
+
lines.push(...styledLines([
|
|
904
|
+
lineSegment(' custom: ', 'brand'),
|
|
905
|
+
lineSegment(custom, 'plain'),
|
|
906
|
+
lineSegment('▌', 'brand'),
|
|
907
|
+
], viewport.contentColumns))
|
|
908
|
+
} else {
|
|
909
|
+
options.forEach((option, at) => {
|
|
910
|
+
optionRows.push(lines.length)
|
|
911
|
+
const chosen = isMulti && selected.includes(at)
|
|
912
|
+
const approve = isPlan && question.intent?.approve === option.label
|
|
913
|
+
const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
|
|
914
|
+
const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
|
|
915
|
+
lines.push(...styledLines([
|
|
916
|
+
lineSegment(mark, style),
|
|
917
|
+
lineSegment(option.label, style),
|
|
918
|
+
lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
|
|
919
|
+
], viewport.contentColumns))
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
return { lines, optionRows }
|
|
923
|
+
}, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
|
|
924
|
+
// Keeping a focused option visible is derived from the current render. It
|
|
925
|
+
// deliberately does not write state from an effect: keyboard selection
|
|
926
|
+
// then has one update path, rather than a cursor update repeatedly causing
|
|
927
|
+
// a post-render scroll update (and, under rapid input, an update-depth
|
|
928
|
+
// loop). Page scrolling explicitly takes ownership until focus moves again.
|
|
929
|
+
const focusedRow = rendered.optionRows[cursor] ?? 0
|
|
930
|
+
const automaticScroll = mode === 'options' && options.length > 0 && !manualScroll
|
|
931
|
+
? revealRow(scroll, focusedRow, rendered.lines.length, viewport.bodyRows)
|
|
932
|
+
: (mode === 'custom' || options.length === 0) && followCustomTail
|
|
933
|
+
? Math.max(0, rendered.lines.length - viewport.bodyRows)
|
|
934
|
+
: scroll
|
|
935
|
+
const visibleScroll = clampScroll(automaticScroll, rendered.lines.length, viewport.bodyRows)
|
|
936
|
+
|
|
937
|
+
const commit = (answer: AskUserQuestionAnswerItem): void => {
|
|
938
|
+
if (pending === undefined) return
|
|
939
|
+
const next = [...answers, answer]
|
|
940
|
+
const total = pending.request.questions.length
|
|
941
|
+
if (index + 1 >= total) {
|
|
942
|
+
setSubmitted(true)
|
|
943
|
+
store.submit(pending, { answers: next })
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
setAnswers(next)
|
|
947
|
+
const nextIndex = index + 1
|
|
948
|
+
const nextQuestion = pending.request.questions[nextIndex]
|
|
949
|
+
setIndex(nextIndex)
|
|
950
|
+
setCursor(0)
|
|
951
|
+
setSelected([])
|
|
952
|
+
setMode(nextQuestion?.options === undefined || nextQuestion.options.length === 0 ? 'custom' : 'options')
|
|
953
|
+
setCustom('')
|
|
954
|
+
setScroll(0)
|
|
955
|
+
setManualScroll(false)
|
|
956
|
+
setFollowCustomTail(nextQuestion?.options === undefined || nextQuestion.options.length === 0)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const commitOption = (): void => {
|
|
960
|
+
if (pending === undefined || question === undefined) return
|
|
961
|
+
if (isMulti) {
|
|
962
|
+
const labels = selected
|
|
963
|
+
.map(at => options[at]?.label)
|
|
964
|
+
.filter((label): label is string => label !== undefined)
|
|
965
|
+
const customText = custom.trim()
|
|
966
|
+
commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
const option = options[cursor]
|
|
970
|
+
if (option === undefined) return
|
|
971
|
+
commit({ id: question.id, selected: [option.label] })
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* A question with choices has two local focus surfaces, just like Codex:
|
|
976
|
+
* the choice list and the optional custom-answer editor. Returning to the
|
|
977
|
+
* list keeps the user's current choice (and multi-select state), but drops
|
|
978
|
+
* the transient custom draft so a second Escape can cancel the question.
|
|
979
|
+
*/
|
|
980
|
+
const returnToOptions = (): void => {
|
|
981
|
+
if (options.length === 0) return
|
|
982
|
+
setMode('options')
|
|
983
|
+
setCustom('')
|
|
984
|
+
setScroll(0)
|
|
985
|
+
setManualScroll(false)
|
|
986
|
+
setFollowCustomTail(false)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
useStableInput((input, key) => {
|
|
990
|
+
if (pending === undefined || question === undefined || submitted) return
|
|
991
|
+
if (key.escape) {
|
|
992
|
+
if (mode === 'custom' && options.length > 0) {
|
|
993
|
+
returnToOptions()
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
store.cancel(pending)
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (key.pageUp) {
|
|
1000
|
+
setManualScroll(true)
|
|
1001
|
+
setFollowCustomTail(false)
|
|
1002
|
+
setScroll(moveScroll(visibleScroll, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
if (key.pageDown) {
|
|
1006
|
+
setManualScroll(true)
|
|
1007
|
+
setFollowCustomTail(false)
|
|
1008
|
+
setScroll(moveScroll(visibleScroll, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows))
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
if (mode === 'custom' || options.length === 0) {
|
|
1012
|
+
if (key.tab && options.length > 0) {
|
|
1013
|
+
returnToOptions()
|
|
1014
|
+
return
|
|
1015
|
+
}
|
|
1016
|
+
if (key.upArrow) {
|
|
1017
|
+
setFollowCustomTail(false)
|
|
1018
|
+
setScroll(moveScroll(visibleScroll, -1, rendered.lines.length, viewport.bodyRows))
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
if (key.downArrow) {
|
|
1022
|
+
setFollowCustomTail(false)
|
|
1023
|
+
setScroll(moveScroll(visibleScroll, 1, rendered.lines.length, viewport.bodyRows))
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
if (key.return) {
|
|
1027
|
+
if (custom.trim() === '' && options.length > 0) {
|
|
1028
|
+
commitOption()
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
commit({
|
|
1032
|
+
id: question.id,
|
|
1033
|
+
selected: isMulti
|
|
1034
|
+
? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
|
|
1035
|
+
: [],
|
|
1036
|
+
...(custom.trim() === '' ? {} : { custom: custom.trim() }),
|
|
1037
|
+
})
|
|
1038
|
+
return
|
|
1039
|
+
}
|
|
1040
|
+
if (key.backspace || key.delete) {
|
|
1041
|
+
if (custom === '' && options.length > 0) {
|
|
1042
|
+
returnToOptions()
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
setCustom(current => current.slice(0, -1))
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
1049
|
+
setCustom(current => current + input)
|
|
1050
|
+
}
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
if (key.upArrow) {
|
|
1054
|
+
setManualScroll(false)
|
|
1055
|
+
setCursor(current => (current + options.length - 1) % options.length)
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
if (key.downArrow) {
|
|
1059
|
+
setManualScroll(false)
|
|
1060
|
+
setCursor(current => (current + 1) % options.length)
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
if (key.return) {
|
|
1064
|
+
commitOption()
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
if (key.tab || input === 'c' || input === 'C') {
|
|
1068
|
+
setMode('custom')
|
|
1069
|
+
setManualScroll(false)
|
|
1070
|
+
setFollowCustomTail(true)
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
if (input === ' ' && isMulti) {
|
|
1074
|
+
setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
|
|
1075
|
+
}
|
|
1076
|
+
}, active)
|
|
1077
|
+
|
|
1078
|
+
if (pending === undefined || question === undefined) return undefined
|
|
1079
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1080
|
+
if (viewport.compact) {
|
|
1081
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? 'plan review · esc cancel' : 'question · esc cancel', viewport.contentColumns))
|
|
1082
|
+
}
|
|
1083
|
+
const footer = submitted
|
|
1084
|
+
? 'submitted…'
|
|
1085
|
+
: mode === 'custom'
|
|
1086
|
+
? options.length === 0
|
|
1087
|
+
? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
|
|
1088
|
+
: '↑↓/pgup/pgdn scroll · type answer · enter submit · tab/esc or empty backspace: options'
|
|
1089
|
+
: options.length === 0
|
|
1090
|
+
? '↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt'
|
|
1091
|
+
: isMulti
|
|
1092
|
+
? '↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt'
|
|
1093
|
+
: '↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt'
|
|
1094
|
+
return createElement(
|
|
1095
|
+
Box,
|
|
1096
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep) },
|
|
1097
|
+
createElement(
|
|
1098
|
+
Text,
|
|
1099
|
+
{ color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true, wrap: 'truncate-end' },
|
|
1100
|
+
truncateColumns(`${isPlan ? '📋 plan review' : '❓ question'} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
|
|
1101
|
+
),
|
|
1102
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1103
|
+
createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
|
|
1104
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1105
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
|
|
1106
|
+
)
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
1110
|
+
function ModelPanel({ directory, error, onSelect, onRetry, onClose }: {
|
|
1111
|
+
directory: ModelDirectory | undefined
|
|
1112
|
+
error: string | undefined
|
|
1113
|
+
onSelect(row: ModelRow): void
|
|
1114
|
+
onRetry(): void
|
|
1115
|
+
onClose(): void
|
|
1116
|
+
}): ReactElement {
|
|
1117
|
+
const [cursor, setCursor] = useState(0)
|
|
1118
|
+
const stdout = useStdout().stdout
|
|
1119
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1120
|
+
const rows = directory?.rows ?? []
|
|
1121
|
+
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
if (rows.length === 0) {
|
|
1124
|
+
if (cursor !== 0) setCursor(0)
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
if (cursor >= rows.length) setCursor(rows.length - 1)
|
|
1128
|
+
}, [rows.length, cursor])
|
|
1129
|
+
|
|
1130
|
+
useInput((input, key) => {
|
|
1131
|
+
if (key.escape || input === 'q') {
|
|
1132
|
+
onClose()
|
|
1133
|
+
return
|
|
1134
|
+
}
|
|
1135
|
+
if (input === 'r') {
|
|
1136
|
+
onRetry()
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
if (rows.length === 0) return
|
|
1140
|
+
if (key.upArrow) {
|
|
1141
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
if (key.downArrow) {
|
|
1145
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
if (key.pageUp) {
|
|
1149
|
+
setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
if (key.pageDown) {
|
|
1153
|
+
setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
if (input === 'g') {
|
|
1157
|
+
setCursor(0)
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
if (input === 'G') {
|
|
1161
|
+
setCursor(rows.length - 1)
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
if (key.return && rows[cursor] !== undefined) {
|
|
1165
|
+
onSelect(rows[cursor])
|
|
1166
|
+
}
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1170
|
+
if (viewport.compact) {
|
|
1171
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/model · r retry · esc/q close', viewport.contentColumns))
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const stateRows: ReactElement[] = directory === undefined && error === undefined
|
|
1175
|
+
? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ' loading models…')]
|
|
1176
|
+
: error !== undefined
|
|
1177
|
+
? [createElement(
|
|
1178
|
+
Text,
|
|
1179
|
+
{ key: 'error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
1180
|
+
truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
|
|
1181
|
+
)]
|
|
1182
|
+
: [
|
|
1183
|
+
...(directory?.failures.length === 0
|
|
1184
|
+
? []
|
|
1185
|
+
: [createElement(
|
|
1186
|
+
Text,
|
|
1187
|
+
{ key: 'failures', color: inkColor(TUI_RGB.warn), wrap: 'truncate-end' },
|
|
1188
|
+
truncateColumns(` unavailable providers: ${directory?.failures.join(', ')}`, viewport.contentColumns),
|
|
1189
|
+
)]),
|
|
1190
|
+
...(rows.length === 0
|
|
1191
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
|
|
1192
|
+
: []),
|
|
1193
|
+
]
|
|
1194
|
+
// Measurement and rendering share the same physical-row budget: state
|
|
1195
|
+
// messages consume body rows before selectable entries, as in Codex's
|
|
1196
|
+
// list-selection views.
|
|
1197
|
+
const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length)
|
|
1198
|
+
const first = selectionWindow(cursor, rows.length, rowBudget)
|
|
1199
|
+
const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
|
|
1200
|
+
return createElement(
|
|
1201
|
+
Box,
|
|
1202
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
|
|
1203
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
1204
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1205
|
+
...stateRows,
|
|
1206
|
+
...visible.map((row) => {
|
|
1207
|
+
const index = rows.indexOf(row)
|
|
1208
|
+
const label = displayText(`${row.providerName} · ${row.modelName}`)
|
|
1209
|
+
return createElement(
|
|
1210
|
+
Text,
|
|
1211
|
+
{
|
|
1212
|
+
key: `${row.provider}/${row.model}`,
|
|
1213
|
+
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
1214
|
+
wrap: 'truncate-end',
|
|
1215
|
+
},
|
|
1216
|
+
truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
|
|
1217
|
+
)
|
|
1218
|
+
}),
|
|
1219
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1220
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close', viewport.contentColumns))),
|
|
1221
|
+
)
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* The /help overlay: one scrolling card with the keyboard map, the TUI-local
|
|
1226
|
+
* commands, the live registry commands, and the user-invocable skills — the
|
|
1227
|
+
* real command surface, replacing the one-line notice.
|
|
1228
|
+
*/
|
|
1229
|
+
function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
1230
|
+
descriptors: readonly CommandDescriptor[]
|
|
1231
|
+
skills: readonly SkillRow[]
|
|
1232
|
+
commandError: string | undefined
|
|
1233
|
+
skillError: string | undefined
|
|
1234
|
+
onClose(): void
|
|
1235
|
+
}): ReactElement {
|
|
1236
|
+
const stdout = useStdout().stdout
|
|
1237
|
+
const columns = stdout?.columns ?? 80
|
|
1238
|
+
const viewport = panelViewport(columns, stdout?.rows ?? 30)
|
|
1239
|
+
const [scroll, setScroll] = useState(0)
|
|
1240
|
+
const nameWidth = Math.min(18, Math.max(1, viewport.contentColumns - 2))
|
|
1241
|
+
const descBudget = Math.max(0, viewport.contentColumns - nameWidth - 2)
|
|
1242
|
+
const row = (label: string, description: string): ReactElement => createElement(
|
|
1243
|
+
Text,
|
|
1244
|
+
{ dimColor: true, wrap: 'truncate-end' },
|
|
1245
|
+
` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`,
|
|
1246
|
+
)
|
|
1247
|
+
const content: ReactElement[] = [
|
|
1248
|
+
createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
|
|
1249
|
+
createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
|
|
1250
|
+
createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
|
|
1251
|
+
createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
|
|
1252
|
+
createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
|
|
1253
|
+
createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
|
|
1254
|
+
createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
|
|
1255
|
+
createElement(Text, { key: 'commands-gap' }, ' '),
|
|
1256
|
+
createElement(Text, { key: 'commands-title', bold: true, wrap: 'truncate-end' }, ' commands'),
|
|
1257
|
+
...(commandError === undefined
|
|
1258
|
+
? []
|
|
1259
|
+
: [createElement(
|
|
1260
|
+
Text,
|
|
1261
|
+
{ key: 'commands-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
1262
|
+
truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
|
|
1263
|
+
)]),
|
|
1264
|
+
createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
|
|
1265
|
+
createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
|
|
1266
|
+
createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
|
|
1267
|
+
createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
|
|
1268
|
+
createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
|
|
1269
|
+
createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
|
|
1270
|
+
createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
|
|
1271
|
+
createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
|
|
1272
|
+
createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
|
|
1273
|
+
createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
|
|
1274
|
+
createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
|
|
1275
|
+
createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
|
|
1276
|
+
...descriptors.map(descriptor => createElement(
|
|
1277
|
+
Text,
|
|
1278
|
+
{ key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
1279
|
+
` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
|
|
1280
|
+
)),
|
|
1281
|
+
...(skills.length === 0 && skillError === undefined
|
|
1282
|
+
? []
|
|
1283
|
+
: [
|
|
1284
|
+
createElement(Text, { key: 'skills-gap' }, ' '),
|
|
1285
|
+
createElement(Text, { key: 'skills-title', bold: true, wrap: 'truncate-end' }, ' skills'),
|
|
1286
|
+
]),
|
|
1287
|
+
...(skillError === undefined
|
|
1288
|
+
? []
|
|
1289
|
+
: [createElement(
|
|
1290
|
+
Text,
|
|
1291
|
+
{ key: 'skills-error', color: inkColor(TUI_RGB.error), wrap: 'truncate-end' },
|
|
1292
|
+
truncateColumns(` skill catalog unavailable: ${singleLineText(skillError)}`, viewport.contentColumns),
|
|
1293
|
+
)]),
|
|
1294
|
+
...skills.map(skill => createElement(
|
|
1295
|
+
Text,
|
|
1296
|
+
{ key: `skill-${skill.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
1297
|
+
` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`,
|
|
1298
|
+
)),
|
|
1299
|
+
]
|
|
1300
|
+
const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows)
|
|
1301
|
+
const scrollBy = (delta: number): void => {
|
|
1302
|
+
setScroll(current => moveScroll(current, delta, content.length, viewport.bodyRows))
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
useEffect(() => {
|
|
1306
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll)
|
|
1307
|
+
}, [visibleScroll, scroll])
|
|
1308
|
+
|
|
1309
|
+
useInput((input, key) => {
|
|
1310
|
+
if (key.escape || input === 'q') {
|
|
1311
|
+
onClose()
|
|
1312
|
+
return
|
|
1313
|
+
}
|
|
1314
|
+
if (key.upArrow) scrollBy(-1)
|
|
1315
|
+
else if (key.downArrow) scrollBy(1)
|
|
1316
|
+
else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
|
|
1317
|
+
else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
|
|
1318
|
+
else if (input === 'g') setScroll(0)
|
|
1319
|
+
else if (input === 'G') setScroll(Math.max(0, content.length - viewport.bodyRows))
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1323
|
+
if (viewport.compact) {
|
|
1324
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/help · esc/q close', viewport.contentColumns))
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return createElement(
|
|
1328
|
+
Box,
|
|
1329
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand) },
|
|
1330
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)),
|
|
1331
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1332
|
+
...content.slice(visibleScroll, visibleScroll + viewport.bodyRows),
|
|
1333
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1334
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Collapse arbitrary metadata to one terminal row before verbose rendering. */
|
|
1339
|
+
function verboseLine(text: string, columns: number): string {
|
|
1340
|
+
return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** One-row editor window keeping the logical cursor visible in long drafts. */
|
|
1344
|
+
function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
|
|
1345
|
+
const width = Math.max(1, columns)
|
|
1346
|
+
const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
|
|
1347
|
+
const caretSource = value.slice(cursor, cursor + 1)
|
|
1348
|
+
const caret = caretSource === '' ? ' ' : normalize(caretSource)
|
|
1349
|
+
const remaining = Math.max(0, width - visibleColumns(caret))
|
|
1350
|
+
const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
|
|
1351
|
+
const beforeBudget = Math.max(0, remaining - afterBudget)
|
|
1352
|
+
const before = beforeBudget === 0
|
|
1353
|
+
? ''
|
|
1354
|
+
: displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
|
|
1355
|
+
const after = afterBudget === 0
|
|
1356
|
+
? ''
|
|
1357
|
+
: truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
|
|
1358
|
+
return { before, caret, after }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* The Ctrl+O transcript inspector: one selected durable entry at a time,
|
|
1363
|
+
* with independent history selection and content scrolling. The complete
|
|
1364
|
+
* retained entry is converted to physical rows, but only one viewport slice
|
|
1365
|
+
* reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
|
|
1366
|
+
*/
|
|
1367
|
+
function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[]; onClose(): void }): ReactElement {
|
|
1368
|
+
const stdout = useStdout().stdout
|
|
1369
|
+
const columns = stdout?.columns ?? 80
|
|
1370
|
+
const rows = stdout?.rows ?? 30
|
|
1371
|
+
const viewport = inspectorViewport(columns, rows)
|
|
1372
|
+
const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
|
|
1373
|
+
const [scroll, setScroll] = useState(0)
|
|
1374
|
+
const savedScroll = useRef(new Map<number, number>())
|
|
1375
|
+
const cursorRef = useRef(cursor)
|
|
1376
|
+
const previousLength = useRef(entries.length)
|
|
1377
|
+
const entry = entries[cursor]
|
|
1378
|
+
const allLines = useMemo(
|
|
1379
|
+
() => entry === undefined ? [] : transcriptEntryLines(entry, viewport.contentColumns),
|
|
1380
|
+
[entry, viewport.contentColumns],
|
|
1381
|
+
)
|
|
1382
|
+
const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
|
|
1383
|
+
|
|
1384
|
+
useEffect(() => {
|
|
1385
|
+
cursorRef.current = cursor
|
|
1386
|
+
}, [cursor])
|
|
1387
|
+
|
|
1388
|
+
useEffect(() => {
|
|
1389
|
+
const current = cursorRef.current
|
|
1390
|
+
const next = followInspectorCursor(current, previousLength.current, entries.length)
|
|
1391
|
+
if (next !== current) {
|
|
1392
|
+
savedScroll.current.set(current, visibleScroll)
|
|
1393
|
+
setCursor(next)
|
|
1394
|
+
setScroll(savedScroll.current.get(next) ?? 0)
|
|
1395
|
+
}
|
|
1396
|
+
previousLength.current = entries.length
|
|
1397
|
+
}, [entries.length])
|
|
1398
|
+
|
|
1399
|
+
useEffect(() => {
|
|
1400
|
+
const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows)
|
|
1401
|
+
if (clamped !== scroll) setScroll(clamped)
|
|
1402
|
+
savedScroll.current.set(cursor, clamped)
|
|
1403
|
+
}, [cursor, scroll, allLines.length, viewport.bodyRows])
|
|
1404
|
+
|
|
1405
|
+
const selectEntry = (next: number): void => {
|
|
1406
|
+
if (entries.length === 0) return
|
|
1407
|
+
const selected = Math.max(0, Math.min(entries.length - 1, next))
|
|
1408
|
+
if (selected === cursor) return
|
|
1409
|
+
savedScroll.current.set(cursor, visibleScroll)
|
|
1410
|
+
setCursor(selected)
|
|
1411
|
+
setScroll(savedScroll.current.get(selected) ?? 0)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const scrollBy = (delta: number): void => {
|
|
1415
|
+
setScroll(current => moveScroll(current, delta, allLines.length, viewport.bodyRows))
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
useInput((input, key) => {
|
|
1419
|
+
if (key.escape || input === 'q' || (key.ctrl && input === 'o')) {
|
|
1420
|
+
onClose()
|
|
1421
|
+
return
|
|
1422
|
+
}
|
|
1423
|
+
if (entries.length === 0) return
|
|
1424
|
+
if (key.leftArrow) {
|
|
1425
|
+
selectEntry(cursor - 1)
|
|
1426
|
+
return
|
|
1427
|
+
}
|
|
1428
|
+
if (key.rightArrow) {
|
|
1429
|
+
selectEntry(cursor + 1)
|
|
1430
|
+
return
|
|
1431
|
+
}
|
|
1432
|
+
if (key.upArrow) {
|
|
1433
|
+
scrollBy(-1)
|
|
1434
|
+
return
|
|
1435
|
+
}
|
|
1436
|
+
if (key.downArrow) {
|
|
1437
|
+
scrollBy(1)
|
|
1438
|
+
return
|
|
1439
|
+
}
|
|
1440
|
+
if (key.pageUp) {
|
|
1441
|
+
scrollBy(-Math.max(1, viewport.bodyRows - 1))
|
|
1442
|
+
return
|
|
1443
|
+
}
|
|
1444
|
+
if (key.pageDown) {
|
|
1445
|
+
scrollBy(Math.max(1, viewport.bodyRows - 1))
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1448
|
+
if (input === 'g') {
|
|
1449
|
+
setScroll(0)
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1452
|
+
if (input === 'G') {
|
|
1453
|
+
setScroll(Math.max(0, allLines.length - viewport.bodyRows))
|
|
1454
|
+
}
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1458
|
+
if (viewport.compact) {
|
|
1459
|
+
return createElement(
|
|
1460
|
+
Text,
|
|
1461
|
+
{ wrap: 'truncate-end' },
|
|
1462
|
+
truncateColumns('history details · ctrl+o / esc / q close', viewport.contentColumns),
|
|
1463
|
+
)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const title = entries.length === 0
|
|
1467
|
+
? 'history details · empty'
|
|
1468
|
+
: `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`
|
|
1469
|
+
const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows)
|
|
1470
|
+
return createElement(
|
|
1471
|
+
Box,
|
|
1472
|
+
{
|
|
1473
|
+
flexDirection: 'column',
|
|
1474
|
+
width: viewport.outerColumns,
|
|
1475
|
+
paddingX: 1,
|
|
1476
|
+
borderStyle: 'round',
|
|
1477
|
+
borderColor: inkColor(TUI_RGB.brand),
|
|
1478
|
+
},
|
|
1479
|
+
createElement(
|
|
1480
|
+
Text,
|
|
1481
|
+
{ color: inkColor(TUI_RGB.brand), bold: true, wrap: 'truncate-end' },
|
|
1482
|
+
truncateColumns(title, viewport.contentColumns),
|
|
1483
|
+
),
|
|
1484
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1485
|
+
createElement(
|
|
1486
|
+
Box,
|
|
1487
|
+
{ flexDirection: 'column' },
|
|
1488
|
+
entry === undefined
|
|
1489
|
+
? createElement(Text, { dimColor: true }, ' no durable entries yet')
|
|
1490
|
+
: createElement(StyledRows, { lines: visible }),
|
|
1491
|
+
),
|
|
1492
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1493
|
+
createElement(
|
|
1494
|
+
Text,
|
|
1495
|
+
{ dimColor: true, wrap: 'truncate-end' },
|
|
1496
|
+
dim(truncateColumns('←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close', viewport.contentColumns)),
|
|
1497
|
+
),
|
|
1498
|
+
)
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
|
|
1502
|
+
const MemoVerbosePanel = memo(VerbosePanel)
|
|
1503
|
+
|
|
1504
|
+
/** Stable append-only boundary: modal updates must never revisit Static rows. */
|
|
1505
|
+
function staticRow(item: unknown): ReactElement {
|
|
1506
|
+
return item as ReactElement
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function StaticTranscript({ items }: { items: ReactElement[] }): ReactElement {
|
|
1510
|
+
return createElement(Static, { items, children: staticRow })
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const MemoStaticTranscript = memo(StaticTranscript)
|
|
1514
|
+
|
|
1515
|
+
/** One completion candidate row. */
|
|
1516
|
+
interface CompletionCandidate {
|
|
1517
|
+
/** Insertion text for the command name (with leading slash). */
|
|
1518
|
+
label: string
|
|
1519
|
+
/** Human-readable description shown beside the label. */
|
|
1520
|
+
description: string
|
|
1521
|
+
/** Candidate origin; skills land the same literal text but route through the prompt. */
|
|
1522
|
+
origin: 'command' | 'skill' | 'mention' | 'path'
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Resolve completion candidates for the current input: TUI-local commands,
|
|
1527
|
+
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
1528
|
+
* typed prefix. Command names win collisions (the dispatch tries the
|
|
1529
|
+
* registry first and only then falls through to the skill gesture).
|
|
1530
|
+
*/
|
|
1531
|
+
function completionCandidates(
|
|
1532
|
+
value: string,
|
|
1533
|
+
descriptors: readonly CommandDescriptor[],
|
|
1534
|
+
skills: readonly SkillRow[],
|
|
1535
|
+
): readonly CompletionCandidate[] {
|
|
1536
|
+
if (!value.startsWith('/')) return []
|
|
1537
|
+
const prefix = value.slice(1).split(' ')[0] ?? ''
|
|
1538
|
+
const local: CompletionCandidate[] = [
|
|
1539
|
+
{ label: '/help', description: 'show commands', origin: 'command' },
|
|
1540
|
+
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
1541
|
+
{ label: '/mode', description: 'select the agent preset', origin: 'command' },
|
|
1542
|
+
{ label: '/new', description: 'start a fresh session', origin: 'command' },
|
|
1543
|
+
{ label: '/resume', description: 'browse or switch sessions', origin: 'command' },
|
|
1544
|
+
{ label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
|
|
1545
|
+
{ label: '/statusline', description: 'customize the status line', origin: 'command' },
|
|
1546
|
+
{ label: '/history', description: 'search and recall past prompts', origin: 'command' },
|
|
1547
|
+
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
1548
|
+
{ label: '/export', description: 'export the transcript to markdown', origin: 'command' },
|
|
1549
|
+
{ label: '/title', description: 'rename this session', origin: 'command' },
|
|
1550
|
+
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
1551
|
+
]
|
|
1552
|
+
// Local commands shadow registry names (e.g. the plugin-registered
|
|
1553
|
+
// /permission is served by the registry itself, never duplicated here),
|
|
1554
|
+
// so collisions cannot render two rows with the same key.
|
|
1555
|
+
const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
|
|
1556
|
+
const registry = descriptors
|
|
1557
|
+
.filter(descriptor => !localNames.has(descriptor.name))
|
|
1558
|
+
.map((descriptor): CompletionCandidate => ({
|
|
1559
|
+
label: `/${descriptor.name}`,
|
|
1560
|
+
description: descriptor.description,
|
|
1561
|
+
origin: 'command',
|
|
1562
|
+
}))
|
|
1563
|
+
const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
|
|
1564
|
+
const skillRows = skills
|
|
1565
|
+
.filter(skill => !taken.has(skill.name))
|
|
1566
|
+
.map((skill): CompletionCandidate => ({
|
|
1567
|
+
label: `/${skill.name}`,
|
|
1568
|
+
description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
|
|
1569
|
+
origin: 'skill',
|
|
1570
|
+
}))
|
|
1571
|
+
const all = [...local, ...registry, ...skillRows]
|
|
1572
|
+
// The menu itself caps its visible rows behind a scroll window, so the
|
|
1573
|
+
// candidate cap only bounds how many entries cycling can reach; 11 keeps
|
|
1574
|
+
// every TUI-local command reachable with an empty prefix.
|
|
1575
|
+
if (prefix === '') return all.slice(0, 11)
|
|
1576
|
+
return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 11)
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* The completion menu, rendered inside the composer's subtree directly above
|
|
1581
|
+
* the framed box — attached the way Claude-Code anchors its dropdown. Opening
|
|
1582
|
+
* it grows the stack downward: the composer stays the last element on screen
|
|
1583
|
+
* and everything above (the flushed static transcript, the status line) never
|
|
1584
|
+
* moves. Props-only (no lifted state): the menu is a pure view of the input
|
|
1585
|
+
* editor's live completion state, so no cross-component effect ever resyncs
|
|
1586
|
+
* it (a state lift here previously deadlocked the menu after a resize).
|
|
1587
|
+
*/
|
|
1588
|
+
function CompletionMenu({ active, mention, index, rows }: {
|
|
1589
|
+
active: boolean
|
|
1590
|
+
mention: boolean
|
|
1591
|
+
index: number
|
|
1592
|
+
rows: readonly CompletionCandidate[]
|
|
1593
|
+
}): ReactElement | undefined {
|
|
1594
|
+
// Hook order is unconditional: `active` toggling must not change the hook
|
|
1595
|
+
// count (the early return used to sit above useStdout).
|
|
1596
|
+
const stdout = useStdout().stdout
|
|
1597
|
+
const columns = stdout?.columns ?? 80
|
|
1598
|
+
const terminalRows = stdout?.rows ?? 30
|
|
1599
|
+
if (!active) return undefined
|
|
1600
|
+
const contentColumns = Math.max(1, columns - 4)
|
|
1601
|
+
const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
|
|
1602
|
+
const descBudget = Math.max(0, contentColumns - nameWidth - 2)
|
|
1603
|
+
const showFooter = terminalRows >= 12
|
|
1604
|
+
const spacious = terminalRows >= 14
|
|
1605
|
+
const verticalPadding = spacious ? 1 : 0
|
|
1606
|
+
const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10) - verticalPadding * 2))
|
|
1607
|
+
const selected = rows.length === 0 ? 0 : index % rows.length
|
|
1608
|
+
const first = selectionWindow(selected, rows.length, limit)
|
|
1609
|
+
const visible = rows.slice(first, first + limit)
|
|
1610
|
+
return createElement(
|
|
1611
|
+
Box,
|
|
1612
|
+
{ flexDirection: 'column', marginLeft: 2, paddingY: verticalPadding },
|
|
1613
|
+
...(rows.length === 0
|
|
1614
|
+
? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
|
|
1615
|
+
: visible.map((candidate, at) => {
|
|
1616
|
+
const absolute = first + at
|
|
1617
|
+
return createElement(
|
|
1618
|
+
Text,
|
|
1619
|
+
{
|
|
1620
|
+
key: candidate.label,
|
|
1621
|
+
color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
1622
|
+
wrap: 'truncate-end',
|
|
1623
|
+
},
|
|
1624
|
+
`${absolute === selected ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
|
|
1625
|
+
)
|
|
1626
|
+
})),
|
|
1627
|
+
showFooter ? createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')) : undefined,
|
|
1628
|
+
)
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
1633
|
+
* dispatched; input editing keeps a cursor with history and completion.
|
|
1634
|
+
* While a modal (approval / question / model panel) owns the keys, the
|
|
1635
|
+
* box passes every key through untouched.
|
|
1636
|
+
*/
|
|
1637
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, openMode, openResume, openPlugin, openStatusline, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed }: {
|
|
1638
|
+
active: boolean
|
|
1639
|
+
frozen: boolean
|
|
1640
|
+
busy: boolean
|
|
1641
|
+
descriptors: readonly CommandDescriptor[]
|
|
1642
|
+
skills: readonly SkillRow[]
|
|
1643
|
+
dispatch(text: string): void
|
|
1644
|
+
steer(text: string): void
|
|
1645
|
+
interrupt(): boolean
|
|
1646
|
+
quit(): void
|
|
1647
|
+
openModel(): void
|
|
1648
|
+
openHelp(): void
|
|
1649
|
+
openMode(): void
|
|
1650
|
+
openResume(): void
|
|
1651
|
+
openPlugin(query?: string): void
|
|
1652
|
+
openStatusline(): void
|
|
1653
|
+
openHistory(): void
|
|
1654
|
+
createSession(mode?: string): void
|
|
1655
|
+
cancelSessionSwitch(): boolean
|
|
1656
|
+
notify(text: string, tone?: NoticeTone): void
|
|
1657
|
+
hasNotice: boolean
|
|
1658
|
+
dismissNotice(): void
|
|
1659
|
+
toggleReasoning(): void
|
|
1660
|
+
openVerbose(): void
|
|
1661
|
+
clearView(): void
|
|
1662
|
+
refresh(): void
|
|
1663
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
1664
|
+
cyclePermission(): string
|
|
1665
|
+
exportTranscript(argument: string): Promise<void>
|
|
1666
|
+
renameTitle(argument: string): string
|
|
1667
|
+
/** Newest-first recall space (persistent + in-session, deduped). */
|
|
1668
|
+
recallSpace: readonly string[]
|
|
1669
|
+
/** Record one in-session submission (deduped, local only). */
|
|
1670
|
+
recordLocal(text: string): void
|
|
1671
|
+
/** Persist one submission to the global history file. */
|
|
1672
|
+
recordHistory(text: string): void
|
|
1673
|
+
/** Live queued inbox rows; Delete on the empty composer cancels the newest. */
|
|
1674
|
+
queued: readonly { messageId: string; target: 'next-turn' | 'next-step'; text: string }[]
|
|
1675
|
+
/** Cancel one queued inbox message by identity. */
|
|
1676
|
+
cancelQueued(messageId: string): void
|
|
1677
|
+
/** Accepted /history entry waiting to be placed into the composer. */
|
|
1678
|
+
historyFill: { text: string; index: number } | undefined
|
|
1679
|
+
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
1680
|
+
historyConsumed(): void
|
|
1681
|
+
}): ReactElement {
|
|
1682
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
1683
|
+
const [value, setValue] = useState('')
|
|
1684
|
+
const [cursor, setCursor] = useState(0)
|
|
1685
|
+
// Codex shell-style recall: the navigation cursor, the saved draft restored
|
|
1686
|
+
// on Down past the newest entry, and the boundary-gate anchor.
|
|
1687
|
+
const recall = useRef<RecallState>({ entries: [], index: null, savedDraft: '', lastRecalled: null })
|
|
1688
|
+
|
|
1689
|
+
// A /history panel acceptance lands as a fill: place the text at the end of
|
|
1690
|
+
// the composer and resume recall from that entry.
|
|
1691
|
+
useEffect(() => {
|
|
1692
|
+
if (historyFill === undefined) return
|
|
1693
|
+
setValue(historyFill.text)
|
|
1694
|
+
setCursor(historyFill.text.length)
|
|
1695
|
+
setDismissedMenuValue(undefined)
|
|
1696
|
+
recall.current = {
|
|
1697
|
+
entries: recallSpace,
|
|
1698
|
+
index: historyFill.index,
|
|
1699
|
+
savedDraft: historyFill.text,
|
|
1700
|
+
lastRecalled: historyFill.text,
|
|
1701
|
+
}
|
|
1702
|
+
historyConsumed()
|
|
1703
|
+
}, [historyFill, recallSpace, historyConsumed])
|
|
1704
|
+
|
|
1705
|
+
// Keep the navigation's recall space fresh while browsing state survives
|
|
1706
|
+
// (new local submissions extend the space; the index stays valid unless
|
|
1707
|
+
// the space shrank, in which case browsing ends at the current position).
|
|
1708
|
+
if (recall.current.entries !== recallSpace) {
|
|
1709
|
+
const index = recall.current.index === null || recall.current.index < recallSpace.length
|
|
1710
|
+
? recall.current.index
|
|
1711
|
+
: null
|
|
1712
|
+
recall.current = { ...recall.current, entries: recallSpace, index }
|
|
1713
|
+
}
|
|
1714
|
+
const [completionIndex, setCompletionIndex] = useState(0)
|
|
1715
|
+
const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
|
|
1716
|
+
const candidates = completionCandidates(value, descriptors, skills)
|
|
1717
|
+
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
|
|
1718
|
+
|
|
1719
|
+
// @mention token: the last `@word` on the cursor's line before the cursor.
|
|
1720
|
+
const beforeCursor = value.slice(0, cursor)
|
|
1721
|
+
const lastLine = beforeCursor.split('\n').at(-1) ?? ''
|
|
1722
|
+
const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
|
|
1723
|
+
const mentionToken = tokenMatch === null
|
|
1724
|
+
? undefined
|
|
1725
|
+
: { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
|
|
1726
|
+
const mentionActive = mentionToken !== undefined
|
|
1727
|
+
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
1728
|
+
|
|
1729
|
+
// Bare path token: the last whitespace-delimited run on the cursor's line
|
|
1730
|
+
// when it already looks like a path (Claude-Code bare Tab completion). A
|
|
1731
|
+
// LEADING '/' is the command namespace, never a path — without this guard
|
|
1732
|
+
// typing the bare '/' hijacked the menu into the workspace file scan and
|
|
1733
|
+
// the slash-command candidates never appeared.
|
|
1734
|
+
const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
|
|
1735
|
+
const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
|
|
1736
|
+
const pathActive = !mentionActive
|
|
1737
|
+
&& !bareToken.startsWith('/')
|
|
1738
|
+
&& (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
|
|
1739
|
+
const pathTokenStart = beforeCursor.length - bareToken.length
|
|
1740
|
+
const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
|
|
1741
|
+
|
|
1742
|
+
useEffect(() => {
|
|
1743
|
+
if (!active || !pathActive) {
|
|
1744
|
+
setPathRows([])
|
|
1745
|
+
return
|
|
1746
|
+
}
|
|
1747
|
+
const controller = new AbortController()
|
|
1748
|
+
setPathRows([])
|
|
1749
|
+
loadMentions(bareToken, controller.signal).then(
|
|
1750
|
+
rows => setPathRows(rows.filter(row => row.kind !== 'session')),
|
|
1751
|
+
() => {},
|
|
1752
|
+
)
|
|
1753
|
+
return () => {
|
|
1754
|
+
controller.abort()
|
|
1755
|
+
}
|
|
1756
|
+
}, [active, pathActive, bareToken])
|
|
1757
|
+
|
|
1758
|
+
useEffect(() => {
|
|
1759
|
+
if (!active || !mentionActive) {
|
|
1760
|
+
setMentionRows([])
|
|
1761
|
+
return
|
|
1762
|
+
}
|
|
1763
|
+
const controller = new AbortController()
|
|
1764
|
+
setMentionRows([])
|
|
1765
|
+
loadMentions(mentionToken.query, controller.signal).then(
|
|
1766
|
+
rows => setMentionRows(rows),
|
|
1767
|
+
() => {},
|
|
1768
|
+
)
|
|
1769
|
+
return () => {
|
|
1770
|
+
controller.abort()
|
|
1771
|
+
}
|
|
1772
|
+
}, [active, mentionActive, mentionToken?.query])
|
|
1773
|
+
|
|
1774
|
+
// Codex routes keys to the topmost surface first. Completion therefore
|
|
1775
|
+
// remains available while a turn runs, and Esc dismisses it before the
|
|
1776
|
+
// same key is allowed to interrupt the turn.
|
|
1777
|
+
const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value
|
|
1778
|
+
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
1779
|
+
? mentionRows.map(row => ({
|
|
1780
|
+
label: row.label.startsWith('@')
|
|
1781
|
+
? row.label
|
|
1782
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
1783
|
+
description: row.description,
|
|
1784
|
+
origin: 'mention',
|
|
1785
|
+
}))
|
|
1786
|
+
: pathActive
|
|
1787
|
+
? pathRows.map(row => ({
|
|
1788
|
+
label: row.label,
|
|
1789
|
+
description: row.description,
|
|
1790
|
+
origin: 'path',
|
|
1791
|
+
}))
|
|
1792
|
+
: candidates
|
|
1793
|
+
|
|
1794
|
+
useInput((input, key) => {
|
|
1795
|
+
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
1796
|
+
if (!active) return
|
|
1797
|
+
// Shift+Tab cycles the permission preset (Claude-Code convention).
|
|
1798
|
+
if (key.tab && key.shift) {
|
|
1799
|
+
try {
|
|
1800
|
+
const next = cyclePermission()
|
|
1801
|
+
if (next !== '') notify(`permission → ${next}`)
|
|
1802
|
+
} catch (error: unknown) {
|
|
1803
|
+
notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
1804
|
+
}
|
|
1805
|
+
return
|
|
1806
|
+
}
|
|
1807
|
+
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
1808
|
+
if (key.ctrl && input === 'r') {
|
|
1809
|
+
toggleReasoning()
|
|
1810
|
+
return
|
|
1811
|
+
}
|
|
1812
|
+
// Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
|
|
1813
|
+
// adapted to append-only static rows): one history entry at a time with
|
|
1814
|
+
// tool cards and reasoning expanded, Esc returns.
|
|
1815
|
+
if (key.ctrl && input === 'o') {
|
|
1816
|
+
openVerbose()
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
// Ctrl+C is three-state (community-TUI convention): a running turn is
|
|
1820
|
+
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
1821
|
+
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
1822
|
+
if (key.ctrl && input === 'c') {
|
|
1823
|
+
if (busy) {
|
|
1824
|
+
interrupt()
|
|
1825
|
+
} else if (value !== '') {
|
|
1826
|
+
setValue('')
|
|
1827
|
+
setCursor(0)
|
|
1828
|
+
setCompletionIndex(0)
|
|
1829
|
+
setDismissedMenuValue(undefined)
|
|
1830
|
+
} else {
|
|
1831
|
+
quit()
|
|
1832
|
+
}
|
|
1833
|
+
return
|
|
1834
|
+
}
|
|
1835
|
+
if (key.ctrl && input === 'd') {
|
|
1836
|
+
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
|
|
1837
|
+
else quit()
|
|
1838
|
+
return
|
|
1839
|
+
}
|
|
1840
|
+
if (key.escape) {
|
|
1841
|
+
if (menuActive) {
|
|
1842
|
+
setDismissedMenuValue(value)
|
|
1843
|
+
return
|
|
1844
|
+
}
|
|
1845
|
+
if (hasNotice) {
|
|
1846
|
+
dismissNotice()
|
|
1847
|
+
return
|
|
1848
|
+
}
|
|
1849
|
+
if (busy) interrupt()
|
|
1850
|
+
return
|
|
1851
|
+
}
|
|
1852
|
+
// Delete on the empty composer cancels the newest queued message (the
|
|
1853
|
+
// web queue-mirror contract: the durable splice drops the pending row).
|
|
1854
|
+
if (key.delete && value === '' && queued.length > 0) {
|
|
1855
|
+
cancelQueued(queued[queued.length - 1]!.messageId)
|
|
1856
|
+
return
|
|
1857
|
+
}
|
|
1858
|
+
if (key.return) {
|
|
1859
|
+
// Multi-line editing: most terminals send the same byte for
|
|
1860
|
+
// shift+enter as enter, so newline insertion rides alt/meta+enter
|
|
1861
|
+
// and ctrl+j (the two distinguishable bindings); a bare return submits.
|
|
1862
|
+
if (key.meta || (key.ctrl && input === 'j')) {
|
|
1863
|
+
setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
|
|
1864
|
+
setCursor(cursor + 1)
|
|
1865
|
+
setDismissedMenuValue(undefined)
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
const text = value.trim()
|
|
1869
|
+
setValue('')
|
|
1870
|
+
setCursor(0)
|
|
1871
|
+
setCompletionIndex(0)
|
|
1872
|
+
setDismissedMenuValue(undefined)
|
|
1873
|
+
if (text === '') return
|
|
1874
|
+
dismissNotice()
|
|
1875
|
+
// Global recall records non-slash submissions only (slash lines are
|
|
1876
|
+
// commands, not prompts) — Codex record_local_submission semantics;
|
|
1877
|
+
// the submission resets any active recall browsing.
|
|
1878
|
+
if (!text.startsWith('/')) {
|
|
1879
|
+
recordLocal(text)
|
|
1880
|
+
recordHistory(text)
|
|
1881
|
+
}
|
|
1882
|
+
recall.current = { entries: recallSpace, index: null, savedDraft: '', lastRecalled: null }
|
|
1883
|
+
if (text === '/quit') {
|
|
1884
|
+
quit()
|
|
1885
|
+
return
|
|
1886
|
+
}
|
|
1887
|
+
if (text === '/help') {
|
|
1888
|
+
openHelp()
|
|
1889
|
+
return
|
|
1890
|
+
}
|
|
1891
|
+
if (text === '/clear') {
|
|
1892
|
+
// Clear the screen AND drop the folded view: the raw ANSI clear + a
|
|
1893
|
+
// Static remount (refresh) so the ledger stays in sync, then the
|
|
1894
|
+
// store resets so the rebuilt transcript starts empty.
|
|
1895
|
+
refresh()
|
|
1896
|
+
clearView()
|
|
1897
|
+
dismissNotice()
|
|
1898
|
+
return
|
|
1899
|
+
}
|
|
1900
|
+
if (text === '/export' || text.startsWith('/export ')) {
|
|
1901
|
+
void exportTranscript(text.slice(8))
|
|
1902
|
+
return
|
|
1903
|
+
}
|
|
1904
|
+
if (text === '/title' || text.startsWith('/title ')) {
|
|
1905
|
+
const outcome = renameTitle(text.slice(7))
|
|
1906
|
+
const tone: NoticeTone = outcome.startsWith('rename failed:')
|
|
1907
|
+
? 'error'
|
|
1908
|
+
: outcome.startsWith('usage:') || outcome.includes('unavailable')
|
|
1909
|
+
? 'warning'
|
|
1910
|
+
: 'info'
|
|
1911
|
+
notify(outcome, tone)
|
|
1912
|
+
return
|
|
1913
|
+
}
|
|
1914
|
+
if (text === '/model' || text.startsWith('/model ')) {
|
|
1915
|
+
openModel()
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
if (text === '/mode' || text.startsWith('/mode ')) {
|
|
1919
|
+
const mode = text.slice(5).trim()
|
|
1920
|
+
if (mode === '') openMode()
|
|
1921
|
+
else dispatch(text)
|
|
1922
|
+
return
|
|
1923
|
+
}
|
|
1924
|
+
if (text === '/resume cancel') {
|
|
1925
|
+
notify(cancelSessionSwitch() ? 'pending session switch cancelled' : 'no pending session switch', 'info')
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
if (text === '/resume' || text.startsWith('/resume ')) {
|
|
1929
|
+
const id = text.slice(7).trim()
|
|
1930
|
+
if (id === '') openResume()
|
|
1931
|
+
else dispatch(text)
|
|
1932
|
+
return
|
|
1933
|
+
}
|
|
1934
|
+
if (text === '/new' || text.startsWith('/new ')) {
|
|
1935
|
+
createSession(text.slice(4).trim() || undefined)
|
|
1936
|
+
return
|
|
1937
|
+
}
|
|
1938
|
+
if (text === '/plugin' || text.startsWith('/plugin ')) {
|
|
1939
|
+
openPlugin(text.slice(7).trim())
|
|
1940
|
+
return
|
|
1941
|
+
}
|
|
1942
|
+
if (text === '/statusline') {
|
|
1943
|
+
openStatusline()
|
|
1944
|
+
return
|
|
1945
|
+
}
|
|
1946
|
+
if (text === '/history') {
|
|
1947
|
+
openHistory()
|
|
1948
|
+
return
|
|
1949
|
+
}
|
|
1950
|
+
if (busy && !text.startsWith('/')) {
|
|
1951
|
+
// A running turn is steered, not blocked: the inbox delivers this
|
|
1952
|
+
// text at the next step boundary (Esc/Ctrl+C still cancels outright).
|
|
1953
|
+
// Slash lines keep the registry path — commands run out of band.
|
|
1954
|
+
steer(text)
|
|
1955
|
+
return
|
|
1956
|
+
}
|
|
1957
|
+
dispatch(text)
|
|
1958
|
+
return
|
|
1959
|
+
}
|
|
1960
|
+
if (menuActive && key.upArrow) {
|
|
1961
|
+
setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
|
|
1962
|
+
return
|
|
1963
|
+
}
|
|
1964
|
+
if (menuActive && key.downArrow) {
|
|
1965
|
+
setCompletionIndex(index => (index + 1) % menuRows.length)
|
|
1966
|
+
return
|
|
1967
|
+
}
|
|
1968
|
+
if (key.upArrow) {
|
|
1969
|
+
// Claude-Code shell recall: Up always walks the global history (the
|
|
1970
|
+
// current draft is saved for Down-past-newest restore); the boundary
|
|
1971
|
+
// gate from Codex only blocks interior multiline movement, which the
|
|
1972
|
+
// user experience here deliberately skips.
|
|
1973
|
+
if (recall.current.entries.length === 0) return
|
|
1974
|
+
const step = recallOlder(recall.current, value)
|
|
1975
|
+
recall.current = step.state
|
|
1976
|
+
if (step.entry !== undefined) {
|
|
1977
|
+
setValue(step.entry)
|
|
1978
|
+
setCursor(step.entry.length)
|
|
1979
|
+
setDismissedMenuValue(undefined)
|
|
1980
|
+
}
|
|
1981
|
+
return
|
|
1982
|
+
}
|
|
1983
|
+
if (key.downArrow) {
|
|
1984
|
+
if (recall.current.entries.length === 0) return
|
|
1985
|
+
const step = recallNewer(recall.current)
|
|
1986
|
+
recall.current = step.state
|
|
1987
|
+
if (step.entry !== undefined) {
|
|
1988
|
+
setValue(step.entry)
|
|
1989
|
+
setCursor(step.entry.length)
|
|
1990
|
+
setDismissedMenuValue(undefined)
|
|
1991
|
+
}
|
|
1992
|
+
return
|
|
1993
|
+
}
|
|
1994
|
+
if (key.tab && menuActive) {
|
|
1995
|
+
if (mentionActive && mentionToken !== undefined) {
|
|
1996
|
+
const row = mentionRows[completionIndex % mentionRows.length]
|
|
1997
|
+
if (row !== undefined) {
|
|
1998
|
+
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
1999
|
+
// file rows insert `@path` (directories keep their trailing slash).
|
|
2000
|
+
const insertion = row.label.startsWith('@')
|
|
2001
|
+
? row.label
|
|
2002
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
2003
|
+
setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
|
|
2004
|
+
setCursor(mentionToken.start + insertion.length)
|
|
2005
|
+
}
|
|
2006
|
+
} else if (pathActive) {
|
|
2007
|
+
const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
|
|
2008
|
+
if (row !== undefined) {
|
|
2009
|
+
// Bare path completion replaces the typed token with the chosen
|
|
2010
|
+
// workspace path (directories keep their trailing slash).
|
|
2011
|
+
const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
|
|
2012
|
+
setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
|
|
2013
|
+
setCursor(pathTokenStart + insertion.length)
|
|
2014
|
+
}
|
|
2015
|
+
} else {
|
|
2016
|
+
const candidate = candidates[completionIndex % candidates.length]
|
|
2017
|
+
if (candidate !== undefined) {
|
|
2018
|
+
setValue(`${candidate.label} `)
|
|
2019
|
+
setCursor(candidate.label.length + 1)
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
setCompletionIndex(0)
|
|
2023
|
+
setDismissedMenuValue(undefined)
|
|
2024
|
+
return
|
|
2025
|
+
}
|
|
2026
|
+
if (key.backspace || key.delete) {
|
|
2027
|
+
if (cursor > 0) {
|
|
2028
|
+
setValue(value.slice(0, cursor - 1) + value.slice(cursor))
|
|
2029
|
+
setCursor(cursor - 1)
|
|
2030
|
+
setCompletionIndex(0)
|
|
2031
|
+
setDismissedMenuValue(undefined)
|
|
2032
|
+
}
|
|
2033
|
+
return
|
|
2034
|
+
}
|
|
2035
|
+
if (key.leftArrow) {
|
|
2036
|
+
setCursor(Math.max(0, cursor - 1))
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2039
|
+
if (key.rightArrow) {
|
|
2040
|
+
setCursor(Math.min(value.length, cursor + 1))
|
|
2041
|
+
return
|
|
2042
|
+
}
|
|
2043
|
+
if (key.ctrl && input === 'u') {
|
|
2044
|
+
setValue('')
|
|
2045
|
+
setCursor(0)
|
|
2046
|
+
setDismissedMenuValue(undefined)
|
|
2047
|
+
return
|
|
2048
|
+
}
|
|
2049
|
+
// Readline parity: Ctrl+K cuts from the cursor to the end of the line.
|
|
2050
|
+
if (key.ctrl && input === 'k') {
|
|
2051
|
+
setValue(value.slice(0, cursor))
|
|
2052
|
+
setDismissedMenuValue(undefined)
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
// Ctrl+L refreshes the screen (readline convention): raw ANSI clear
|
|
2056
|
+
// plus a Static remount so the flushed transcript re-emits (a bare
|
|
2057
|
+
// console.clear() would desync Ink's ledger against the static rows).
|
|
2058
|
+
if (key.ctrl && input === 'l') {
|
|
2059
|
+
refresh()
|
|
2060
|
+
return
|
|
2061
|
+
}
|
|
2062
|
+
if (key.ctrl && input === 'a') {
|
|
2063
|
+
setCursor(0)
|
|
2064
|
+
return
|
|
2065
|
+
}
|
|
2066
|
+
if (key.ctrl && input === 'e') {
|
|
2067
|
+
setCursor(value.length)
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
2070
|
+
if (input !== '' && !key.ctrl && !key.meta) {
|
|
2071
|
+
setValue(value.slice(0, cursor) + input + value.slice(cursor))
|
|
2072
|
+
setCursor(cursor + input.length)
|
|
2073
|
+
setCompletionIndex(0)
|
|
2074
|
+
setDismissedMenuValue(undefined)
|
|
2075
|
+
}
|
|
2076
|
+
})
|
|
2077
|
+
|
|
2078
|
+
// Every exclusive panel keeps the composer as a stable visual anchor, but
|
|
2079
|
+
// freezes it to one row: no menu, multiline wrap, or animation.
|
|
2080
|
+
if (frozen) {
|
|
2081
|
+
const frozen = value === ''
|
|
2082
|
+
? 'type a message'
|
|
2083
|
+
: verboseLine(value, Math.max(1, columns - 6))
|
|
2084
|
+
return createElement(
|
|
2085
|
+
Box,
|
|
2086
|
+
{ width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
|
|
2087
|
+
createElement(
|
|
2088
|
+
Text,
|
|
2089
|
+
{ wrap: 'truncate-end' },
|
|
2090
|
+
createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
|
|
2091
|
+
frozen,
|
|
2092
|
+
),
|
|
2093
|
+
)
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
|
|
2097
|
+
|
|
2098
|
+
return createElement(
|
|
2099
|
+
Box,
|
|
2100
|
+
{ flexDirection: 'column' },
|
|
2101
|
+
// The completion dropdown rides directly above the box (Claude-Code
|
|
2102
|
+
// anchor): rendered from the editor's own live state, never lifted.
|
|
2103
|
+
createElement(CompletionMenu, {
|
|
2104
|
+
active: menuActive,
|
|
2105
|
+
mention: mentionActive,
|
|
2106
|
+
index: completionIndex,
|
|
2107
|
+
rows: menuRows,
|
|
2108
|
+
}),
|
|
2109
|
+
// The framed input box: a visible boundary so the prompt never blends
|
|
2110
|
+
// into the transcript above it; the cursor block sits immediately after
|
|
2111
|
+
// the prompt marker (leftmost), with the dim placeholder trailing it —
|
|
2112
|
+
// no extra space, so the empty state reads `❯ ▮type a message…`.
|
|
2113
|
+
createElement(
|
|
2114
|
+
Box,
|
|
2115
|
+
{ width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
|
|
2116
|
+
createElement(
|
|
2117
|
+
Text,
|
|
2118
|
+
{ wrap: 'truncate-end' },
|
|
2119
|
+
busy
|
|
2120
|
+
? createElement(BusyChase)
|
|
2121
|
+
: createElement(Text, { color: inkColor(TUI_RGB.brand) }, '❯ '),
|
|
2122
|
+
value === '' ? undefined : editor.before,
|
|
2123
|
+
createElement(CursorBlock, { char: editor.caret }),
|
|
2124
|
+
value === '' && !busy
|
|
2125
|
+
? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
|
|
2126
|
+
: editor.after,
|
|
2127
|
+
),
|
|
2128
|
+
),
|
|
2129
|
+
)
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
2133
|
+
export function App(props: AppProps): ReactElement {
|
|
2134
|
+
const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
|
|
2135
|
+
const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
|
|
2136
|
+
const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
|
|
2137
|
+
const [modelLabel, setModelLabel] = useState(props.model)
|
|
2138
|
+
const [modelOpen, setModelOpen] = useState(false)
|
|
2139
|
+
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
2140
|
+
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
2141
|
+
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
2142
|
+
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
2143
|
+
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
2144
|
+
setNotice({ text, tone })
|
|
2145
|
+
}, [])
|
|
2146
|
+
|
|
2147
|
+
useEffect(() => {
|
|
2148
|
+
props.onBridgeReady({ notify })
|
|
2149
|
+
}, [])
|
|
2150
|
+
useEffect(() => {
|
|
2151
|
+
if (!modelOpen) return
|
|
2152
|
+
let cancelled = false
|
|
2153
|
+
setDirectory(undefined)
|
|
2154
|
+
setModelError(undefined)
|
|
2155
|
+
// Enter the promise chain before invoking the loader so a provider that
|
|
2156
|
+
// throws synchronously becomes an in-panel error instead of escaping the
|
|
2157
|
+
// React effect and tearing down Ink.
|
|
2158
|
+
Promise.resolve().then(() => props.loadModels()).then((loaded) => {
|
|
2159
|
+
if (!cancelled) setDirectory(loaded)
|
|
2160
|
+
}, (error: unknown) => {
|
|
2161
|
+
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
|
|
2162
|
+
})
|
|
2163
|
+
return () => {
|
|
2164
|
+
cancelled = true
|
|
2165
|
+
}
|
|
2166
|
+
}, [modelOpen, modelLoadEpoch, props.loadModels])
|
|
2167
|
+
|
|
2168
|
+
const busy = view.busy
|
|
2169
|
+
const [showReasoning, setShowReasoning] = useState(false)
|
|
2170
|
+
const [verboseOpen, setVerboseOpen] = useState(false)
|
|
2171
|
+
const [helpOpen, setHelpOpen] = useState(false)
|
|
2172
|
+
const [modeOpen, setModeOpen] = useState(false)
|
|
2173
|
+
const [resumeOpen, setResumeOpen] = useState(false)
|
|
2174
|
+
const [pluginOpen, setPluginOpen] = useState(false)
|
|
2175
|
+
const [pluginQuery, setPluginQuery] = useState('')
|
|
2176
|
+
const [statuslineOpen, setStatuslineOpen] = useState(false)
|
|
2177
|
+
const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
|
|
2178
|
+
const [historyOpen, setHistoryOpen] = useState(false)
|
|
2179
|
+
/** The /history panel's accepted entry: text plus its recall-space index. */
|
|
2180
|
+
const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
|
|
2181
|
+
/** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
|
|
2182
|
+
const [localHistory, setLocalHistory] = useState<readonly string[]>([])
|
|
2183
|
+
const recordLocal = useCallback((text: string): void => {
|
|
2184
|
+
setLocalHistory(current => recordLocalEntry(current, text))
|
|
2185
|
+
}, [])
|
|
2186
|
+
/** Newest-first recall space shared by the composer and the /history panel. */
|
|
2187
|
+
const recallSpace = useMemo(
|
|
2188
|
+
() => recallEntries(props.history, localHistory),
|
|
2189
|
+
[props.history, localHistory],
|
|
2190
|
+
)
|
|
2191
|
+
const historyConsumed = useCallback((): void => {
|
|
2192
|
+
setHistoryFill(undefined)
|
|
2193
|
+
}, [])
|
|
2194
|
+
/** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). */
|
|
2195
|
+
const queuedRows = useMemo(
|
|
2196
|
+
() => view.entries.filter((entry): entry is Extract<TranscriptEntry, { kind: 'pending' }> => entry.kind === 'pending'),
|
|
2197
|
+
[view.entries],
|
|
2198
|
+
)
|
|
2199
|
+
const [refreshEpoch, setRefreshEpoch] = useState(0)
|
|
2200
|
+
const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
|
|
2201
|
+
const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
|
|
2202
|
+
const approvalPending = approvalSnapshot.pending !== undefined
|
|
2203
|
+
const questionPending = questionSnapshot.pending !== undefined
|
|
2204
|
+
// While any modal owns the keys, the prompt box passes everything through.
|
|
2205
|
+
const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
2206
|
+
|
|
2207
|
+
// Human questions outrank local inspectors. Close the lower modal instead
|
|
2208
|
+
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
2209
|
+
useEffect(() => {
|
|
2210
|
+
if (!approvalPending && !questionPending) return
|
|
2211
|
+
setModelOpen(false)
|
|
2212
|
+
setHelpOpen(false)
|
|
2213
|
+
setModeOpen(false)
|
|
2214
|
+
setResumeOpen(false)
|
|
2215
|
+
setPluginOpen(false)
|
|
2216
|
+
setStatuslineOpen(false)
|
|
2217
|
+
setHistoryOpen(false)
|
|
2218
|
+
setVerboseOpen(false)
|
|
2219
|
+
}, [approvalPending, questionPending])
|
|
2220
|
+
|
|
2221
|
+
// Append-only transcript: everything up to the first still-mutable entry
|
|
2222
|
+
// (a running tool/retry) flushes through Ink's `<Static>` into native
|
|
2223
|
+
// scrollback and is normally never rewritten — the Claude-Code stability
|
|
2224
|
+
// contract
|
|
2225
|
+
// that lets arbitrarily long conversations scroll instead of freezing when
|
|
2226
|
+
// the live tree exceeds the terminal height. The dynamic region below stays
|
|
2227
|
+
// small: the streaming tail, modals, composer, and its status footer.
|
|
2228
|
+
// `assistant/chunk` preserves `entries` identity. Memoizing on that identity
|
|
2229
|
+
// keeps long settled histories out of the per-token render path.
|
|
2230
|
+
const settled = useMemo(() => settledEntryCount(view.entries), [view.entries])
|
|
2231
|
+
// Claude-Code spacing: one blank row before each user prompt (except the
|
|
2232
|
+
// first) separates replies from the next turn. Settled rows flush once with
|
|
2233
|
+
// the reasoning toggle as it is NOW (Ctrl+R affects subsequent flushes);
|
|
2234
|
+
// Ctrl+O browses the frozen history through a bounded selected-entry view.
|
|
2235
|
+
const settledRows = useMemo(() => {
|
|
2236
|
+
const rows: ReactElement[] = [createElement(Header, { key: 'header', resumed: props.resumed })]
|
|
2237
|
+
view.entries.slice(0, settled).forEach((entry, index) => {
|
|
2238
|
+
const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
|
|
2239
|
+
const roomyPrompt = entry.kind === 'user' && !entry.notice
|
|
2240
|
+
if (roomyPrompt) {
|
|
2241
|
+
rows.push(createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
|
|
2242
|
+
}
|
|
2243
|
+
rows.push(createElement(Box, { key: index, paddingX: 1 }, row))
|
|
2244
|
+
if (roomyPrompt) {
|
|
2245
|
+
rows.push(createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' ')))
|
|
2246
|
+
}
|
|
2247
|
+
})
|
|
2248
|
+
return rows
|
|
2249
|
+
}, [view.entries, settled, showReasoning, props.resumed])
|
|
2250
|
+
|
|
2251
|
+
// Hook order is unconditional. Its dimensions drive every live-region
|
|
2252
|
+
// budget before any dynamic rows are constructed.
|
|
2253
|
+
const appStdout = useStdout().stdout
|
|
2254
|
+
const [terminalSize, setTerminalSize] = useState(() => ({
|
|
2255
|
+
columns: appStdout?.columns ?? 80,
|
|
2256
|
+
rows: appStdout?.rows ?? 30,
|
|
2257
|
+
}))
|
|
2258
|
+
const terminalSizeRef = useRef(terminalSize)
|
|
2259
|
+
useEffect(() => {
|
|
2260
|
+
if (appStdout === undefined) return
|
|
2261
|
+
let replayTimer: ReturnType<typeof setTimeout> | undefined
|
|
2262
|
+
const handleResize = (): void => {
|
|
2263
|
+
const next = {
|
|
2264
|
+
columns: appStdout.columns ?? 80,
|
|
2265
|
+
rows: appStdout.rows ?? 30,
|
|
2266
|
+
}
|
|
2267
|
+
if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return
|
|
2268
|
+
terminalSizeRef.current = next
|
|
2269
|
+
|
|
2270
|
+
// Ink 5 erases by the old logical line count. Once the terminal reflows
|
|
2271
|
+
// a full-width border at a new width, that count is no longer enough and
|
|
2272
|
+
// stale frames remain visible. Follow Codex's source-backed reflow
|
|
2273
|
+
// policy: update live geometry immediately, but wait for the resize
|
|
2274
|
+
// burst to settle before one hard reset and one transcript replay at the
|
|
2275
|
+
// final width. Replaying Static on every event appends duplicate history.
|
|
2276
|
+
setTerminalSize(next)
|
|
2277
|
+
if (replayTimer !== undefined) clearTimeout(replayTimer)
|
|
2278
|
+
replayTimer = setTimeout(() => {
|
|
2279
|
+
appStdout.write(RESIZE_REFLOW_CLEAR)
|
|
2280
|
+
setRefreshEpoch(epoch => epoch + 1)
|
|
2281
|
+
}, RESIZE_REFLOW_DELAY_MS)
|
|
2282
|
+
}
|
|
2283
|
+
appStdout.on('resize', handleResize)
|
|
2284
|
+
return () => {
|
|
2285
|
+
appStdout.off('resize', handleResize)
|
|
2286
|
+
if (replayTimer !== undefined) clearTimeout(replayTimer)
|
|
2287
|
+
}
|
|
2288
|
+
}, [appStdout])
|
|
2289
|
+
const terminalRows = terminalSize.rows
|
|
2290
|
+
const terminalColumns = terminalSize.columns
|
|
2291
|
+
const composerGutterRows = layoutGutterRows(terminalRows)
|
|
2292
|
+
// Bottom chrome is now composer (3) + status (up to 2 rows); the budget
|
|
2293
|
+
// keeps the live/streaming area strictly below the terminal height.
|
|
2294
|
+
const dynamicRows = Math.max(1, terminalRows - 13 - composerGutterRows)
|
|
2295
|
+
const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
|
|
2296
|
+
const deepDivingVisible = busy && !streamingActive
|
|
2297
|
+
const allLiveLines = useMemo(
|
|
2298
|
+
() => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
|
|
2299
|
+
[view.entries, settled, terminalColumns],
|
|
2300
|
+
)
|
|
2301
|
+
const liveBudget = streamingActive
|
|
2302
|
+
? Math.max(1, Math.floor(dynamicRows / 3))
|
|
2303
|
+
: Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
|
|
2304
|
+
const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
|
|
2305
|
+
|
|
2306
|
+
// The screen refresh used by /clear and Ctrl+L: a raw ANSI clear (wipe
|
|
2307
|
+
// screen AND scrollback, home the cursor) then a Static remount via the
|
|
2308
|
+
// key change, which re-flushes the current items from index 0. NEVER
|
|
2309
|
+
// console.clear() — it desyncs Ink's internal line ledger against the
|
|
2310
|
+
// flushed static rows and garbles every frame after.
|
|
2311
|
+
const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length)
|
|
2312
|
+
const reasoningRows = view.streamingReasoning === ''
|
|
2313
|
+
? 0
|
|
2314
|
+
: view.streaming === ''
|
|
2315
|
+
? streamRows
|
|
2316
|
+
: streamRows <= 1
|
|
2317
|
+
? 0
|
|
2318
|
+
: showReasoning
|
|
2319
|
+
? Math.max(1, Math.floor(streamRows / 3))
|
|
2320
|
+
: 1
|
|
2321
|
+
const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
|
|
2322
|
+
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending
|
|
2323
|
+
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
2324
|
+
const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || historyOpen || inspectorVisible || approvalPending || questionPending
|
|
2325
|
+
const closeInspector = useCallback((): void => {
|
|
2326
|
+
setVerboseOpen(false)
|
|
2327
|
+
}, [])
|
|
2328
|
+
const refreshScreen = (): void => {
|
|
2329
|
+
if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
2330
|
+
setRefreshEpoch(epoch => epoch + 1)
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
return createElement(
|
|
2334
|
+
Box,
|
|
2335
|
+
{ flexDirection: 'column' },
|
|
2336
|
+
createElement(MemoStaticTranscript, {
|
|
2337
|
+
key: refreshEpoch,
|
|
2338
|
+
items: settledRows,
|
|
2339
|
+
}),
|
|
2340
|
+
transcriptVisible
|
|
2341
|
+
? createElement(
|
|
2342
|
+
Box,
|
|
2343
|
+
// The two-column gutter matches the composer's border + padding, so
|
|
2344
|
+
// message text aligns with the input cursor (Codex LIVE_PREFIX).
|
|
2345
|
+
{ flexDirection: 'column', paddingX: 2 },
|
|
2346
|
+
visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
|
|
2347
|
+
view.streamingReasoning !== '' && reasoningRows > 0
|
|
2348
|
+
? createElement(StreamTail, {
|
|
2349
|
+
text: showReasoning ? view.streamingReasoning : 'Thinking…',
|
|
2350
|
+
prefix: ' ✻ ',
|
|
2351
|
+
dim: true,
|
|
2352
|
+
maxRows: reasoningRows,
|
|
2353
|
+
})
|
|
2354
|
+
: undefined,
|
|
2355
|
+
view.streaming !== '' && answerRows > 0
|
|
2356
|
+
? createElement(
|
|
2357
|
+
StreamTail,
|
|
2358
|
+
// The same two-column gutter as settled replies: streamed text
|
|
2359
|
+
// lands exactly where the assembled message will render.
|
|
2360
|
+
{ text: view.streaming, dim: false, maxRows: answerRows, prefix: ' ' },
|
|
2361
|
+
busy ? createElement(Caret) : undefined,
|
|
2362
|
+
)
|
|
2363
|
+
: undefined,
|
|
2364
|
+
deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
|
|
2365
|
+
)
|
|
2366
|
+
: undefined,
|
|
2367
|
+
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
2368
|
+
createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
|
|
2369
|
+
createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending }),
|
|
2370
|
+
modelOpen && !approvalPending && !questionPending
|
|
2371
|
+
? createElement(ModelPanel, {
|
|
2372
|
+
directory,
|
|
2373
|
+
error: modelError,
|
|
2374
|
+
onSelect: (row: ModelRow) => {
|
|
2375
|
+
try {
|
|
2376
|
+
setModelLabel(props.selectModel(row))
|
|
2377
|
+
notify(`model → next step uses ${row.provider}/${row.model}`)
|
|
2378
|
+
setModelOpen(false)
|
|
2379
|
+
} catch (error: unknown) {
|
|
2380
|
+
notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
onRetry: () => {
|
|
2384
|
+
setModelLoadEpoch(epoch => epoch + 1)
|
|
2385
|
+
},
|
|
2386
|
+
onClose: () => {
|
|
2387
|
+
setModelOpen(false)
|
|
2388
|
+
},
|
|
2389
|
+
})
|
|
2390
|
+
: undefined,
|
|
2391
|
+
helpOpen && !approvalPending && !questionPending
|
|
2392
|
+
? createElement(HelpPanel, {
|
|
2393
|
+
descriptors,
|
|
2394
|
+
skills,
|
|
2395
|
+
commandError: props.commands.error,
|
|
2396
|
+
skillError: props.skills.error,
|
|
2397
|
+
onClose: () => {
|
|
2398
|
+
setHelpOpen(false)
|
|
2399
|
+
},
|
|
2400
|
+
})
|
|
2401
|
+
: undefined,
|
|
2402
|
+
verboseOpen && !approvalPending && !questionPending
|
|
2403
|
+
? createElement(MemoVerbosePanel, {
|
|
2404
|
+
entries: view.entries,
|
|
2405
|
+
onClose: closeInspector,
|
|
2406
|
+
})
|
|
2407
|
+
: undefined,
|
|
2408
|
+
modeOpen && !approvalPending && !questionPending
|
|
2409
|
+
? createElement(ModePanel, {
|
|
2410
|
+
current: props.mode,
|
|
2411
|
+
load: props.loadPresets,
|
|
2412
|
+
select: (id: string) => {
|
|
2413
|
+
void props.switchMode(id).then(label => {
|
|
2414
|
+
notify(`mode → ${label}`)
|
|
2415
|
+
setModeOpen(false)
|
|
2416
|
+
}, (reason: unknown) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error'))
|
|
2417
|
+
},
|
|
2418
|
+
close: () => setModeOpen(false),
|
|
2419
|
+
})
|
|
2420
|
+
: undefined,
|
|
2421
|
+
resumeOpen && !approvalPending && !questionPending
|
|
2422
|
+
? createElement(ResumePanel, {
|
|
2423
|
+
currentCwd: props.workspaceRoot,
|
|
2424
|
+
load: props.loadSessions,
|
|
2425
|
+
readTranscript: props.loadSessionTranscript,
|
|
2426
|
+
select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
|
|
2427
|
+
close: () => setResumeOpen(false),
|
|
2428
|
+
})
|
|
2429
|
+
: undefined,
|
|
2430
|
+
pluginOpen && !approvalPending && !questionPending
|
|
2431
|
+
? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
|
|
2432
|
+
: undefined,
|
|
2433
|
+
statuslineOpen && !approvalPending && !questionPending
|
|
2434
|
+
? createElement(StatuslinePanel, {
|
|
2435
|
+
enabled: statuslineItems,
|
|
2436
|
+
change: items => {
|
|
2437
|
+
setStatuslineItems(items)
|
|
2438
|
+
props.saveStatusline(items)
|
|
2439
|
+
},
|
|
2440
|
+
close: () => setStatuslineOpen(false),
|
|
2441
|
+
})
|
|
2442
|
+
: undefined,
|
|
2443
|
+
historyOpen && !approvalPending && !questionPending
|
|
2444
|
+
? createElement(HistoryPanel, {
|
|
2445
|
+
entries: recallSpace,
|
|
2446
|
+
fill: (text: string, index: number) => {
|
|
2447
|
+
setHistoryFill({ text, index })
|
|
2448
|
+
setHistoryOpen(false)
|
|
2449
|
+
},
|
|
2450
|
+
close: () => setHistoryOpen(false),
|
|
2451
|
+
})
|
|
2452
|
+
: undefined,
|
|
2453
|
+
notice === undefined
|
|
2454
|
+
? undefined
|
|
2455
|
+
: createElement(NoticeLine, {
|
|
2456
|
+
text: notice.text,
|
|
2457
|
+
tone: notice.tone,
|
|
2458
|
+
columns: terminalColumns,
|
|
2459
|
+
}),
|
|
2460
|
+
// Persistent bottom chrome: every interface owns exactly the same
|
|
2461
|
+
// composer/status geometry. Panels may change above it, but can no longer
|
|
2462
|
+
// reorder the status or introduce mode-specific vertical margins.
|
|
2463
|
+
createElement(
|
|
2464
|
+
Box,
|
|
2465
|
+
{ flexDirection: 'column', marginTop: composerGutterRows },
|
|
2466
|
+
createElement(Input, {
|
|
2467
|
+
active: inputActive,
|
|
2468
|
+
frozen: modalVisible,
|
|
2469
|
+
busy,
|
|
2470
|
+
descriptors,
|
|
2471
|
+
skills,
|
|
2472
|
+
dispatch: props.dispatch,
|
|
2473
|
+
steer: props.steer,
|
|
2474
|
+
interrupt: props.interrupt,
|
|
2475
|
+
quit: props.quit,
|
|
2476
|
+
openModel: () => {
|
|
2477
|
+
setDirectory(undefined)
|
|
2478
|
+
setModelError(undefined)
|
|
2479
|
+
setModelOpen(true)
|
|
2480
|
+
},
|
|
2481
|
+
openHelp: () => {
|
|
2482
|
+
setHelpOpen(true)
|
|
2483
|
+
},
|
|
2484
|
+
openMode: () => setModeOpen(true),
|
|
2485
|
+
openResume: () => setResumeOpen(true),
|
|
2486
|
+
openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
|
|
2487
|
+
openStatusline: () => setStatuslineOpen(true),
|
|
2488
|
+
openHistory: () => setHistoryOpen(true),
|
|
2489
|
+
createSession: props.createSession,
|
|
2490
|
+
cancelSessionSwitch: props.cancelSessionSwitch,
|
|
2491
|
+
notify,
|
|
2492
|
+
hasNotice: notice !== undefined,
|
|
2493
|
+
dismissNotice: () => {
|
|
2494
|
+
setNotice(undefined)
|
|
2495
|
+
},
|
|
2496
|
+
openVerbose: () => {
|
|
2497
|
+
setVerboseOpen(true)
|
|
2498
|
+
},
|
|
2499
|
+
clearView: () => {
|
|
2500
|
+
props.store.reset()
|
|
2501
|
+
},
|
|
2502
|
+
refresh: refreshScreen,
|
|
2503
|
+
// Ctrl+R must re-render already-settled history too: settled rows
|
|
2504
|
+
// flush through <Static> once, so the toggle rides the same
|
|
2505
|
+
// source-backed clear+replay the resize path uses — one clear, one
|
|
2506
|
+
// authoritative re-flush at the new visibility.
|
|
2507
|
+
toggleReasoning: () => {
|
|
2508
|
+
setShowReasoning(current => !current)
|
|
2509
|
+
refreshScreen()
|
|
2510
|
+
},
|
|
2511
|
+
loadMentions: props.loadMentions,
|
|
2512
|
+
cyclePermission: props.cyclePermission,
|
|
2513
|
+
exportTranscript: props.exportTranscript,
|
|
2514
|
+
renameTitle: props.renameTitle,
|
|
2515
|
+
recallSpace,
|
|
2516
|
+
recordLocal,
|
|
2517
|
+
recordHistory: props.recordHistory,
|
|
2518
|
+
queued: queuedRows,
|
|
2519
|
+
cancelQueued: props.cancelQueued,
|
|
2520
|
+
historyFill,
|
|
2521
|
+
historyConsumed,
|
|
2522
|
+
}),
|
|
2523
|
+
createElement(StatusLine, {
|
|
2524
|
+
facts: {
|
|
2525
|
+
model: modelLabel,
|
|
2526
|
+
mode: props.mode,
|
|
2527
|
+
cwd: props.cwd,
|
|
2528
|
+
branch: props.branch,
|
|
2529
|
+
sessionId: props.sessionId,
|
|
2530
|
+
title: view.title,
|
|
2531
|
+
plan: view.plan,
|
|
2532
|
+
permission: view.permission,
|
|
2533
|
+
sandbox: view.sandbox,
|
|
2534
|
+
goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
|
|
2535
|
+
},
|
|
2536
|
+
stats: view.stats,
|
|
2537
|
+
busy,
|
|
2538
|
+
columns: terminalColumns,
|
|
2539
|
+
items: statuslineItems,
|
|
2540
|
+
}),
|
|
2541
|
+
),
|
|
2542
|
+
)
|
|
2543
|
+
}
|