jaitus 0.3.3 → 0.3.5
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.md +34 -38
- package/bin/jaitus.js +14 -1
- package/package.json +2 -2
- package/src/agent/loop.js +234 -35
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/commands/update.js +78 -12
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +571 -119
- package/src/mcp.js +209 -72
- package/src/pendingUpdate.js +41 -0
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +161 -0
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui/widgets.js +6 -2
- package/src/tui-ink.js +2509 -1998
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/src/tui-ink.js
CHANGED
|
@@ -1,1998 +1,2509 @@
|
|
|
1
|
-
// ─── The Jaitus app view ────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// One conversation, full bleed, with exactly four pieces of permanent chrome: a header
|
|
4
|
-
// line, the transcript, the composer, and a status line. There is no dashboard, no
|
|
5
|
-
// sidebar and no second column, because the thing being read is prose and code and
|
|
6
|
-
// both of those want the full width of the window.
|
|
7
|
-
//
|
|
8
|
-
// Three decisions are worth knowing before changing anything here:
|
|
9
|
-
//
|
|
10
|
-
// • **Entries keep their source, not their pixels.** `entries.js` renders rows on
|
|
11
|
-
// demand for the current width and caches them against it. Resizing re-lays every
|
|
12
|
-
// message correctly, and scrolling is exact because the row count is already known
|
|
13
|
-
// — the previous renderer estimated it and drifted.
|
|
14
|
-
// • **Colour survives.** Everything the rest of the CLI prints already carries ANSI.
|
|
15
|
-
// It is parsed back into styled spans rather than stripped, so a red ✗ printed by
|
|
16
|
-
// a command written two years ago is still red here.
|
|
17
|
-
// • **Motion is one timer.** Ink 7 consolidates every `useAnimation` into a single
|
|
18
|
-
// render cycle, which is the only reason a spinner, a shimmering label, a drifting
|
|
19
|
-
// hairline and a blinking caret can all run without the frame rate suffering.
|
|
20
|
-
//
|
|
21
|
-
// Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
|
|
22
|
-
// every animated widget has a still frame that reads correctly on its own.
|
|
23
|
-
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
|
|
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
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
h(Text, { color:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
h(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
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
|
-
const
|
|
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
|
-
state.transcriptOffset
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// The
|
|
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
|
-
if (
|
|
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
|
-
if (!
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
function
|
|
492
|
-
const p = palette();
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
h(
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
const
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
if (
|
|
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
|
-
// border
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
state
|
|
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
|
-
const
|
|
1108
|
-
state
|
|
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
|
-
if (
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
const
|
|
1247
|
-
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
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
|
-
function
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
const
|
|
1406
|
-
|
|
1407
|
-
if (
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
if (
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
return
|
|
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
|
-
const
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
if (
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
if (
|
|
1832
|
-
|
|
1833
|
-
if (
|
|
1834
|
-
if (
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
if (
|
|
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
|
-
state.
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
state.
|
|
1934
|
-
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
if (
|
|
1948
|
-
|
|
1949
|
-
state.
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1
|
+
// ─── The Jaitus app view ────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// One conversation, full bleed, with exactly four pieces of permanent chrome: a header
|
|
4
|
+
// line, the transcript, the composer, and a status line. There is no dashboard, no
|
|
5
|
+
// sidebar and no second column, because the thing being read is prose and code and
|
|
6
|
+
// both of those want the full width of the window.
|
|
7
|
+
//
|
|
8
|
+
// Three decisions are worth knowing before changing anything here:
|
|
9
|
+
//
|
|
10
|
+
// • **Entries keep their source, not their pixels.** `entries.js` renders rows on
|
|
11
|
+
// demand for the current width and caches them against it. Resizing re-lays every
|
|
12
|
+
// message correctly, and scrolling is exact because the row count is already known
|
|
13
|
+
// — the previous renderer estimated it and drifted.
|
|
14
|
+
// • **Colour survives.** Everything the rest of the CLI prints already carries ANSI.
|
|
15
|
+
// It is parsed back into styled spans rather than stripped, so a red ✗ printed by
|
|
16
|
+
// a command written two years ago is still red here.
|
|
17
|
+
// • **Motion is one timer.** Ink 7 consolidates every `useAnimation` into a single
|
|
18
|
+
// render cycle, which is the only reason a spinner, a shimmering label, a drifting
|
|
19
|
+
// hairline and a blinking caret can all run without the frame rate suffering.
|
|
20
|
+
//
|
|
21
|
+
// Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
|
|
22
|
+
// every animated widget has a still frame that reads correctly on its own.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import React from 'react';
|
|
27
|
+
import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
|
|
28
|
+
import { palette, resetPalette, mix, ramp } from './tui/theme.js';
|
|
29
|
+
import { makeEntry, rowsFor, invalidate } from './tui/entries.js';
|
|
30
|
+
import { plain, stringWidth, truncateRow, wrapRow, rowText, inverseRange } from './tui/ansi.js';
|
|
31
|
+
import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, Caret, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
|
|
32
|
+
import { wordRows, wordSize } from './tui/wordmark.js';
|
|
33
|
+
import { enableMouse, disableMouse, parseMouse, isMouse, isMouseFragment, createHitMap } from './tui/mouse.js';
|
|
34
|
+
import { openUrl } from './open.js';
|
|
35
|
+
import { readClipboardText, readClipboardImage, shrinkImageFile } from './tui/clipboard.js';
|
|
36
|
+
import { onSettingsChange } from './config.js';
|
|
37
|
+
import { MASCOT_ROWS, startupTip } from './brand.js';
|
|
38
|
+
|
|
39
|
+
const h = React.createElement;
|
|
40
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
41
|
+
const ascii = () => process.env.JAITUS_ASCII === '1';
|
|
42
|
+
const g = (uni, plainText) => (ascii() ? plainText : uni);
|
|
43
|
+
|
|
44
|
+
// How many transcript rows the chrome costs. Kept as one table so a change to the
|
|
45
|
+
// layout cannot silently desynchronise the scroll maths from what is on screen.
|
|
46
|
+
// The header is one row plus its hairline. Kept here as one table so the layout and the
|
|
47
|
+
// hit map cannot disagree about where anything is — the controls sit on row 1, which is
|
|
48
|
+
// what registerHeaderHits assumes.
|
|
49
|
+
const CHROME = { header: 2, status: 1, composerFrame: 3, gapAboveComposer: 1 };
|
|
50
|
+
|
|
51
|
+
const MODE_LABEL = {
|
|
52
|
+
default: 'ask',
|
|
53
|
+
acceptEdits: 'auto-edit',
|
|
54
|
+
plan: 'plan',
|
|
55
|
+
bypassPermissions: 'bypass',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ─── Fuzzy matching for the composer menu ───────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Subsequence rather than prefix, so `/dr` finds `/deep-research` and `@idx` finds
|
|
61
|
+
// `src/index.js`. Consecutive and word-boundary hits score higher, which is what stops
|
|
62
|
+
// a long path from outranking the file you obviously meant.
|
|
63
|
+
function fuzzy(needle, haystack) {
|
|
64
|
+
const n = needle.toLowerCase();
|
|
65
|
+
const stack = haystack.toLowerCase();
|
|
66
|
+
if (!n) return { score: 1, hits: [] };
|
|
67
|
+
const hits = [];
|
|
68
|
+
let score = 0;
|
|
69
|
+
let at = 0;
|
|
70
|
+
let streak = 0;
|
|
71
|
+
for (const ch of n) {
|
|
72
|
+
const found = stack.indexOf(ch, at);
|
|
73
|
+
if (found === -1) return null;
|
|
74
|
+
const boundary = found === 0 || /[^a-z0-9]/.test(stack[found - 1]);
|
|
75
|
+
streak = found === at ? streak + 1 : 0;
|
|
76
|
+
score += 1 + streak * 2 + (boundary ? 3 : 0);
|
|
77
|
+
hits.push(found);
|
|
78
|
+
at = found + 1;
|
|
79
|
+
}
|
|
80
|
+
// Shorter candidates win ties: `/ask` should beat `/architect` for `ask`.
|
|
81
|
+
return { score: score - haystack.length * 0.05, hits };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function highlightMatch(text, hits, base, accent) {
|
|
85
|
+
const set = new Set(hits);
|
|
86
|
+
const spans = [];
|
|
87
|
+
let run = '';
|
|
88
|
+
let runHit = false;
|
|
89
|
+
[...String(text)].forEach((ch, index) => {
|
|
90
|
+
const hit = set.has(index);
|
|
91
|
+
if (hit !== runHit && run) { spans.push({ t: run, color: runHit ? accent : base, bold: runHit }); run = ''; }
|
|
92
|
+
runHit = hit;
|
|
93
|
+
run += ch;
|
|
94
|
+
});
|
|
95
|
+
if (run) spans.push({ t: run, color: runHit ? accent : base, bold: runHit });
|
|
96
|
+
return spans;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rank one command against what has been typed.
|
|
101
|
+
*
|
|
102
|
+
* The name is matched first and scores highest, but the summary is searched too, because
|
|
103
|
+
* the name of a command is only obvious once you already know it — typing `context`
|
|
104
|
+
* finds `/compact`, and `diff` finds `/code-review`. A summary hit always scores below
|
|
105
|
+
* every name hit, so typing a real command's letters never buries it under something
|
|
106
|
+
* that merely mentions those letters in its description.
|
|
107
|
+
*/
|
|
108
|
+
function scoreCommand(fragment, name, summary) {
|
|
109
|
+
const bare = name.replace(/^\//, '');
|
|
110
|
+
const byName = fuzzy(fragment, bare);
|
|
111
|
+
if (byName) return { score: byName.score + 100, hits: byName.hits.map((i) => i + 1), where: 'name' };
|
|
112
|
+
if (!summary || fragment.length < 2) return null;
|
|
113
|
+
// Word-prefix rather than subsequence on the summary: a loose subsequence over a
|
|
114
|
+
// sentence matches nearly everything, and a menu that matches everything is a menu
|
|
115
|
+
// that helps with nothing.
|
|
116
|
+
const words = summary.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
117
|
+
const needle = fragment.toLowerCase();
|
|
118
|
+
const at = words.findIndex((w) => w.startsWith(needle));
|
|
119
|
+
if (at === -1) return null;
|
|
120
|
+
return { score: 20 - at - (words[at].length - needle.length) * 0.1, hits: [], where: 'summary' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function menuItemsFor(input, commands, fileProvider, cursor) {
|
|
124
|
+
const upto = input.slice(0, cursor);
|
|
125
|
+
const slash = /(?:^|\s)\/([^\s]*)$/.exec(upto);
|
|
126
|
+
if (slash) {
|
|
127
|
+
const fragment = slash[1];
|
|
128
|
+
return commands
|
|
129
|
+
.map((command) => {
|
|
130
|
+
const name = typeof command === 'string' ? command : command.name;
|
|
131
|
+
const summary = typeof command === 'string' ? '' : command.summary || '';
|
|
132
|
+
const match = scoreCommand(fragment, name, summary);
|
|
133
|
+
return match ? { kind: 'command', value: name, label: name, summary, score: match.score, hits: match.hits, where: match.where } : null;
|
|
134
|
+
})
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.sort((a, b) => b.score - a.score)
|
|
137
|
+
.slice(0, 9);
|
|
138
|
+
}
|
|
139
|
+
const at = upto.lastIndexOf('@');
|
|
140
|
+
if (at >= 0 && !/\s/.test(upto.slice(at + 1))) {
|
|
141
|
+
const fragment = upto.slice(at + 1);
|
|
142
|
+
const files = fileProvider?.(fragment) || [];
|
|
143
|
+
return files
|
|
144
|
+
.map((file) => {
|
|
145
|
+
const match = fuzzy(fragment.split('/').pop() || '', String(file).split('/').pop() || String(file));
|
|
146
|
+
return { kind: 'file', value: file, label: String(file), summary: '', score: match ? match.score : 0, hits: [] };
|
|
147
|
+
})
|
|
148
|
+
.sort((a, b) => b.score - a.score)
|
|
149
|
+
.slice(0, 9);
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Chrome ─────────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function Header({ state, width }) {
|
|
157
|
+
const p = palette();
|
|
158
|
+
const m = state.meta;
|
|
159
|
+
const project = String(m.cwd || '.').split(/[\\/]/).filter(Boolean).pop() || '.';
|
|
160
|
+
const mode = MODE_LABEL[m.mode] || 'ask';
|
|
161
|
+
const modeColor = m.mode === 'bypassPermissions' ? p.red : m.mode === 'plan' ? p.yellow : m.mode === 'acceptEdits' ? p.teal : p.muted;
|
|
162
|
+
const narrow = width < 78;
|
|
163
|
+
|
|
164
|
+
// ── no mark in the header, and this is why ──
|
|
165
|
+
//
|
|
166
|
+
// The Jaitus J is a SQUARE mark. A half-block cell holds two square pixels, so a
|
|
167
|
+
// faithful square mark needs its height in pixel rows and half that in terminal rows:
|
|
168
|
+
// the real glyph, sampled from logo.png, degrades below about 6×6 and is properly
|
|
169
|
+
// itself at 8×8 — which is 8 cells by 4 rows, or 6 by 3 at a push. The header has one
|
|
170
|
+
// row for its title.
|
|
171
|
+
//
|
|
172
|
+
// Three marks were tried at one and two rows. Each was an abstraction of the logo
|
|
173
|
+
// rather than the logo, and each read as some other product's badge sitting next to
|
|
174
|
+
// this one's — correctly rejected. Half a square mark is not a small version of it.
|
|
175
|
+
//
|
|
176
|
+
// So the header carries the BRAND TOKEN instead: the coral chip with the J reversed
|
|
177
|
+
// out of it, which is the same mark the web header and the classic renderer use, and
|
|
178
|
+
// which is the one form of the logo that is honest at a single row — it is a colour
|
|
179
|
+
// field with a letter in it, not a squashed drawing of a square mark. Beside it the
|
|
180
|
+
// name in caps. Plain mixed-case `Jaitus` in the corner read as a window title; a
|
|
181
|
+
// coral field does not read as anything but a logo.
|
|
182
|
+
//
|
|
183
|
+
// The chip is also the only piece that survives every degradation — it is a space, a
|
|
184
|
+
// letter and a space, so it is not tofu on a raster console and not a box on a
|
|
185
|
+
// monochrome one. The real mark still lives where it has room: the welcome, and now
|
|
186
|
+
// the banner entry at the top of the transcript.
|
|
187
|
+
const ruleWidth = Math.max(4, width - 2);
|
|
188
|
+
// Below this the name is dropped and the chip carries it alone: the header's right
|
|
189
|
+
// side is controls, and squeezing six more characters in ahead of them is what makes
|
|
190
|
+
// Ink wrap the row and turn a two-row header into three.
|
|
191
|
+
const showName = width >= 62;
|
|
192
|
+
|
|
193
|
+
const left = h(Box, { flexDirection: 'row' },
|
|
194
|
+
h(Text, { color: '#ffffff', backgroundColor: p.brand, bold: true }, ' J '),
|
|
195
|
+
showName ? h(GradientText, { from: p.brandSoft, to: p.teal, bold: true }, ' JAITUS') : null,
|
|
196
|
+
h(Text, { color: p.faint }, ` ${g('▸', '/')} `),
|
|
197
|
+
h(Text, { color: p.muted }, project),
|
|
198
|
+
m.branch && width >= 78 ? h(Text, { color: p.faint }, ` ${g('⎇', 'b:')} ${m.branch}`) : null,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const right = h(Box, { flexDirection: 'row' },
|
|
202
|
+
h(Text, { color: modeColor, bold: true }, mode.toUpperCase()),
|
|
203
|
+
h(Text, { color: p.faint }, ' · '),
|
|
204
|
+
h(Text, { color: p.muted }, narrow ? shortModel(m.model) : String(m.model || 'auto')),
|
|
205
|
+
narrow ? null : h(Text, { color: p.faint }, ' · '),
|
|
206
|
+
narrow ? null : h(Meter, { value: m.ctxUsed || 0, max: m.ctxMax || 1, width: 8, danger: true }),
|
|
207
|
+
narrow ? null : h(Text, { color: p.dim }, ` ${m.ctx || '0 tok'}`),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return h(Box, { flexDirection: 'column', width },
|
|
211
|
+
h(Box, { flexDirection: 'row', paddingX: 1 }, left, h(Spacer), right),
|
|
212
|
+
// Idle, the hairline is not flat grey any more: it starts brand-tinted under the
|
|
213
|
+
// chip and fades into the line colour across the window. It is the cheapest way to
|
|
214
|
+
// make the header band belong to this product rather than to any terminal app —
|
|
215
|
+
// one row that was already being drawn, now carrying the identity.
|
|
216
|
+
h(Box, { paddingX: 1 },
|
|
217
|
+
h(FlowingRule, {
|
|
218
|
+
width: ruleWidth,
|
|
219
|
+
active: state.busy,
|
|
220
|
+
from: state.busy ? p.brand : mix(p.line, p.brand, 0.5),
|
|
221
|
+
to: state.busy ? p.teal : p.line,
|
|
222
|
+
})),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const shortModel = (id) => String(id || 'auto').split(/[/:]/).pop().slice(0, 18);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The fresh-session moment.
|
|
230
|
+
*
|
|
231
|
+
* Deliberately not a splash screen with a feature list: the wordmark, one line saying
|
|
232
|
+
* what this is, and three things you can type. Everything else the user needs is one
|
|
233
|
+
* `/` away and the menu will show it to them.
|
|
234
|
+
*/
|
|
235
|
+
function Hero({ state, width, height }) {
|
|
236
|
+
const p = palette();
|
|
237
|
+
|
|
238
|
+
// ── the intro ──
|
|
239
|
+
//
|
|
240
|
+
// One pass, under a second, then it settles and stays settled. The pieces arrive in
|
|
241
|
+
// the order you should read them — the lockup, then what this is, then the three
|
|
242
|
+
// things you can type. Under reduced motion everything is simply already there.
|
|
243
|
+
const motion = motionOn();
|
|
244
|
+
const { time } = useAnimation({ interval: 40, isActive: motion && !state.introDone });
|
|
245
|
+
if (motion && time >= 900 && !state.introDone) state.introDone = true;
|
|
246
|
+
const elapsed = state.introDone ? 900 : time;
|
|
247
|
+
const phase = (from, to) => (motion && !state.introDone
|
|
248
|
+
? Math.max(0, Math.min(1, (elapsed - from) / (to - from)))
|
|
249
|
+
: 1);
|
|
250
|
+
|
|
251
|
+
const markIn = phase(0, 420);
|
|
252
|
+
const lineIn = phase(280, 640);
|
|
253
|
+
const tipsIn = phase(420, 900);
|
|
254
|
+
|
|
255
|
+
// ── the lockup ──
|
|
256
|
+
//
|
|
257
|
+
// Mark and logotype on one baseline, left-aligned at exactly the indent transcript
|
|
258
|
+
// entries use, so the first answer appears directly beneath the branding with no
|
|
259
|
+
// jump. Both are four terminal rows: the mark is the DESIGNED mascot rather than the
|
|
260
|
+
// photographic badge downsampled, and the letterforms are drawn on a pixel grid and
|
|
261
|
+
// rendered with half-blocks — one cell per pixel is what made the old wordmark read
|
|
262
|
+
// as ASCII art, because a terminal cell is twice as tall as it is wide.
|
|
263
|
+
const mascot = MASCOT_ROWS();
|
|
264
|
+
const markInk = ramp(mix(p.bg, p.brandDeep, markIn), mix(p.bg, p.brand, markIn), mascot.length);
|
|
265
|
+
const mark = mascot.map((row, index) => [{ t: row, color: markInk[index] }]);
|
|
266
|
+
const markWidth = Math.max(...mascot.map((row) => row.length));
|
|
267
|
+
|
|
268
|
+
const word = wordRows('JAITUS', (column, total) =>
|
|
269
|
+
mix(p.bg, mix(p.brand, p.teal, total > 1 ? column / (total - 1) : 0), markIn));
|
|
270
|
+
const wordWidth = wordSize('JAITUS').width;
|
|
271
|
+
|
|
272
|
+
// Everything below is measured against what will fit, and dropped from the bottom up
|
|
273
|
+
// — the tips first, then the tagline — so a short window shows less rather than
|
|
274
|
+
// overflowing into the composer.
|
|
275
|
+
const showMark = width >= wordWidth + markWidth + 8;
|
|
276
|
+
const showWord = width >= wordWidth + 6;
|
|
277
|
+
const lockupRows = showWord ? word.length : 1;
|
|
278
|
+
const room = (rows) => height >= lockupRows + rows;
|
|
279
|
+
|
|
280
|
+
const tips = [
|
|
281
|
+
[`${g('›', '>')} `, 'describe a change', 'it reads the code before touching it'],
|
|
282
|
+
['/ ', 'browse commands', 'research, review, commit, rewind'],
|
|
283
|
+
['@ ', 'pull in a file', 'fuzzy-matched from this directory'],
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
return h(Box, { flexDirection: 'column', width, height, paddingX: 1, justifyContent: 'flex-end' },
|
|
287
|
+
h(Box, { flexDirection: 'row' },
|
|
288
|
+
showMark ? h(Box, { marginRight: 2, flexDirection: 'column' }, h(Rows, { rows: mark })) : null,
|
|
289
|
+
showWord
|
|
290
|
+
? h(Box, { flexDirection: 'column' }, h(Rows, { rows: word }))
|
|
291
|
+
: h(GradientText, { from: p.brand, to: p.teal, bold: true }, 'JAITUS'),
|
|
292
|
+
),
|
|
293
|
+
room(3) ? h(Box, { marginTop: 1 },
|
|
294
|
+
h(Text, { color: mix(p.bg, p.dim, lineIn) },
|
|
295
|
+
`a coding agent in your terminal ${g('·', '-')} v${state.meta.version || ''}`)) : null,
|
|
296
|
+
room(8) ? h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
297
|
+
tips.map(([marker, title, note], index) => {
|
|
298
|
+
const on = Math.max(0, Math.min(1, tipsIn * 3 - index));
|
|
299
|
+
return h(Box, { key: index, flexDirection: 'row' },
|
|
300
|
+
h(Text, { color: mix(p.bg, p.brand, on), bold: true }, marker),
|
|
301
|
+
h(Text, { color: mix(p.bg, p.text, on) }, title.padEnd(20)),
|
|
302
|
+
width >= 70 ? h(Text, { color: mix(p.bg, p.faint, on) }, note) : null,
|
|
303
|
+
);
|
|
304
|
+
})) : null,
|
|
305
|
+
room(10) ? h(Box, { marginTop: 1 },
|
|
306
|
+
h(Text, { color: mix(p.bg, p.faint, tipsIn), italic: true }, `tip: ${startupTip()}`)) : null,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Lay the transcript out.
|
|
312
|
+
*
|
|
313
|
+
* Separate from the component that draws it, and called by `App` BEFORE any of its
|
|
314
|
+
* children render. Everything derived from the layout — the scroll ceiling, the screen
|
|
315
|
+
* map the drag handler reads, the click regions, the sticky prompt — has to be known
|
|
316
|
+
* before the chrome that displays it is rendered. Computing them inside the transcript
|
|
317
|
+
* component meant the header above it was drawn from the PREVIOUS frame's numbers, so
|
|
318
|
+
* the pinned prompt was always one scroll behind.
|
|
319
|
+
*/
|
|
320
|
+
function layoutTranscript(state, width, height, top) {
|
|
321
|
+
const inner = Math.max(24, width - 2);
|
|
322
|
+
const rows = [];
|
|
323
|
+
// Where each prompt starts, so the sticky header can name the one whose answer is
|
|
324
|
+
// currently on screen rather than always the newest.
|
|
325
|
+
const prompts = [];
|
|
326
|
+
for (const entry of state.transcript) {
|
|
327
|
+
const built = rowsFor(entry, inner);
|
|
328
|
+
if (!built.length) continue;
|
|
329
|
+
if (rows.length) rows.push([]);
|
|
330
|
+
if (entry.kind === 'user') prompts.push({ at: rows.length, text: String(entry.data.text || '') });
|
|
331
|
+
rows.push(...built);
|
|
332
|
+
}
|
|
333
|
+
if (state.live && state.live.text) {
|
|
334
|
+
const built = rowsFor(state.live.entry, inner);
|
|
335
|
+
if (built.length) {
|
|
336
|
+
if (rows.length) rows.push([]);
|
|
337
|
+
rows.push(...built);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The scroll ceiling. It was previously applied only where the rows were drawn, while
|
|
342
|
+
// the controller let the offset grow without bound — so scrolling up past the top kept
|
|
343
|
+
// incrementing a number nothing could see, and scrolling back down did nothing at all
|
|
344
|
+
// until it had counted its way home. That is the "scroll gets stuck" report: it was
|
|
345
|
+
// not stuck, it was somewhere off the top of a list that had already ended.
|
|
346
|
+
const maxOffset = Math.max(0, rows.length - height);
|
|
347
|
+
state.maxScroll = maxOffset;
|
|
348
|
+
const offset = state.viewingTranscript ? clamp(state.transcriptOffset, 0, maxOffset) : 0;
|
|
349
|
+
// Snap the stored offset to what will actually be drawn: this is the only place that
|
|
350
|
+
// knows how many rows exist at this width, so it is the authority, and writing the
|
|
351
|
+
// clamped value back is what keeps the controller's bound self-correcting.
|
|
352
|
+
state.transcriptOffset = offset;
|
|
353
|
+
if (!offset) state.viewingTranscript = false;
|
|
354
|
+
|
|
355
|
+
const end = rows.length - offset;
|
|
356
|
+
const first = Math.max(0, end - height);
|
|
357
|
+
const visible = rows.slice(first, end);
|
|
358
|
+
// Bottom-anchored: a short conversation sits at the composer, not floating at the top
|
|
359
|
+
// of an empty screen with the input a dozen rows below it.
|
|
360
|
+
const pad = Math.max(0, height - visible.length);
|
|
361
|
+
const screenTop = top + pad + 1;
|
|
362
|
+
|
|
363
|
+
// The drag handler needs the text under the pointer, and it must be the text actually
|
|
364
|
+
// on screen rather than the transcript's idea of it.
|
|
365
|
+
state.screen = { top: screenTop, left: 2, rows: visible };
|
|
366
|
+
|
|
367
|
+
// The sticky header names the prompt whose answer you are looking at — the last one
|
|
368
|
+
// that has scrolled off the top. Scroll far enough that the prompt itself is back on
|
|
369
|
+
// screen and there is nothing to pin; scroll past it and it becomes the one before.
|
|
370
|
+
const owning = prompts.filter((entry) => entry.at < first).at(-1);
|
|
371
|
+
state.stickyPrompt = owning ? owning.text : '';
|
|
372
|
+
|
|
373
|
+
return { visible, pad, screenTop };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* How to select text, in words that are true for THIS terminal.
|
|
378
|
+
*
|
|
379
|
+
* Dragging selects inside the app everywhere and copies on release. What differs is the
|
|
380
|
+
* way back to the terminal's own selection while the app has the mouse: Shift in most
|
|
381
|
+
* terminals, Option in iTerm2 — and nothing at all in macOS Terminal, which has no
|
|
382
|
+
* bypass key. "shift+drag selects" was shown to everyone, and on a Mac it is simply
|
|
383
|
+
* wrong.
|
|
384
|
+
*/
|
|
385
|
+
export function selectHint(env = process.env, platform = process.platform) {
|
|
386
|
+
const term = env.TERM_PROGRAM || '';
|
|
387
|
+
if (term === 'iTerm.app') return 'drag copies · ⌥ drag: native';
|
|
388
|
+
if (term === 'Apple_Terminal') return 'drag or double-click copies';
|
|
389
|
+
if (platform === 'darwin' && !term) return 'drag copies · ⌥ drag: native';
|
|
390
|
+
return 'drag copies · shift drag: native';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Register every URL on screen as a click target.
|
|
395
|
+
*
|
|
396
|
+
* A URL longer than the line is hard-cut across rows by wrapRow, so the match on the
|
|
397
|
+
* first row is only a prefix of it — clicking it opened a truncated address. wrapRow
|
|
398
|
+
* marks the rows it cut mid-word (`cut` on the last span); when a match runs to the end
|
|
399
|
+
* of such a row, the next row's leading token (after the hanging indent) is appended, and
|
|
400
|
+
* that piece is registered with the whole URL too.
|
|
401
|
+
*/
|
|
402
|
+
export function linkHitsFor(rows) {
|
|
403
|
+
const texts = rows.map((row) => rowText(row));
|
|
404
|
+
const cut = rows.map((row) => Boolean(row.length && row[row.length - 1].cut));
|
|
405
|
+
const out = [];
|
|
406
|
+
const claimed = new Set(); // "row:col" of continuation pieces, so they are not matched again
|
|
407
|
+
texts.forEach((text, index) => {
|
|
408
|
+
for (const match of text.matchAll(URL_IN_TEXT)) {
|
|
409
|
+
if (claimed.has(`${index}:${match.index}`)) continue;
|
|
410
|
+
let url = match[0];
|
|
411
|
+
const pieces = [{ row: index, x: match.index, len: match[0].length }];
|
|
412
|
+
let row = index;
|
|
413
|
+
let end = match.index + match[0].length;
|
|
414
|
+
while (cut[row] && end === texts[row].length && row + 1 < texts.length) {
|
|
415
|
+
const lead = /^(\s*)([^\s<>"'`]+)/.exec(texts[row + 1]);
|
|
416
|
+
if (!lead) break;
|
|
417
|
+
url += lead[2];
|
|
418
|
+
row += 1;
|
|
419
|
+
pieces.push({ row, x: lead[1].length, len: lead[2].length });
|
|
420
|
+
claimed.add(`${row}:${lead[1].length}`);
|
|
421
|
+
end = lead[0].length;
|
|
422
|
+
}
|
|
423
|
+
url = url.replace(/[.,;:)\]]+$/, '');
|
|
424
|
+
for (const piece of pieces) out.push({ ...piece, url });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function registerLinkHits(visible, screenTop, hits) {
|
|
431
|
+
if (!hits) return;
|
|
432
|
+
for (const piece of linkHitsFor(visible)) {
|
|
433
|
+
hits.add({
|
|
434
|
+
x1: 2 + piece.x,
|
|
435
|
+
x2: 2 + piece.x + piece.len - 1,
|
|
436
|
+
y1: screenTop + piece.row,
|
|
437
|
+
y2: screenTop + piece.row,
|
|
438
|
+
action: { type: 'link', url: piece.url },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function Transcript({ state, width, height, layout }) {
|
|
444
|
+
const painted = paintSelection(layout.visible, state.selection, layout.screenTop, 2);
|
|
445
|
+
// Anchored to the bottom: if this box ever gets less room than it was laid out for,
|
|
446
|
+
// the OLDEST line is clipped off the top rather than the newest off the bottom.
|
|
447
|
+
return h(Box, { flexDirection: 'column', width, height, paddingX: 1, overflow: 'hidden', justifyContent: 'flex-end' },
|
|
448
|
+
layout.pad ? h(Box, { height: layout.pad, flexShrink: 0 }) : null,
|
|
449
|
+
h(Rows, { rows: painted }),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Trailing punctuation is stripped by the caller: "see https://x.com/a." ends a sentence
|
|
454
|
+
// far more often than it names a path called `.`.
|
|
455
|
+
const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/g;
|
|
456
|
+
|
|
457
|
+
// Replies to the startup probe (terminal.js), with or without the ESC Ink strips: OSC 11
|
|
458
|
+
// background colour, then CSI ? … c device attributes — alone or run together.
|
|
459
|
+
export const TERMINAL_REPLY = /^(?=[\x1b\][])(?:\x1b?\]11;rgb:[0-9a-f]{1,4}\/[0-9a-f]{1,4}\/[0-9a-f]{1,4}(?:\x07|\x1b\\)?)?(?:\x1b?\[\?[0-9;]*c)?$/i;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Draw the drag selection.
|
|
463
|
+
*
|
|
464
|
+
* Mouse reporting takes the terminal's own highlight away, so if the app does not draw
|
|
465
|
+
* one there is no feedback at all while dragging — which is indistinguishable from
|
|
466
|
+
* selection being broken, because from the user's side it is.
|
|
467
|
+
*/
|
|
468
|
+
function paintSelection(rows, selection, screenTop, left) {
|
|
469
|
+
if (!selection || !selection.anchor || !selection.focus) return rows;
|
|
470
|
+
const a = selection.anchor;
|
|
471
|
+
const b = selection.focus;
|
|
472
|
+
const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
|
|
473
|
+
if (from.y === to.y && from.x === to.x) return rows;
|
|
474
|
+
|
|
475
|
+
return rows.map((row, index) => {
|
|
476
|
+
const y = screenTop + index;
|
|
477
|
+
if (y < from.y || y > to.y) return row;
|
|
478
|
+
const startCol = y === from.y ? Math.max(0, from.x - left) : 0;
|
|
479
|
+
const endCol = y === to.y ? Math.max(0, to.x - left + 1) : rowText(row).length;
|
|
480
|
+
return inverseRange(row, startCol, endCol);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The line that proves the thing is alive.
|
|
486
|
+
*
|
|
487
|
+
* It carries four facts the user actually asks for while waiting: that work is
|
|
488
|
+
* happening, what it is, how long it has been, and how to stop it. The previous
|
|
489
|
+
* version carried only the first.
|
|
490
|
+
*/
|
|
491
|
+
function WorkBar({ state, width }) {
|
|
492
|
+
const p = palette();
|
|
493
|
+
if (!state.busy && !state.status) return null;
|
|
494
|
+
const label = plain(state.status) || 'Working';
|
|
495
|
+
const narrow = width < 70;
|
|
496
|
+
return h(Box, { flexDirection: 'row', paddingX: 1 },
|
|
497
|
+
h(Spinner, { kind: ascii() ? 'ascii' : 'braille' }),
|
|
498
|
+
h(Text, null, ' '),
|
|
499
|
+
h(Shimmer, { from: p.brand, to: p.brandSoft }, truncatePlain(label, Math.max(12, width - (narrow ? 22 : 46)))),
|
|
500
|
+
h(Spacer),
|
|
501
|
+
state.turnStartedAt ? h(Elapsed, { since: state.turnStartedAt }) : null,
|
|
502
|
+
state.turnTokens ? h(Text, { color: p.faint }, ` ${state.turnTokens.toLocaleString()} tok`) : null,
|
|
503
|
+
narrow ? null : h(Text, { color: p.faint }, ` esc ${g('↩', '')}stop`),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const truncatePlain = (text, width) => (stringWidth(text) <= width ? text : `${String(text).slice(0, Math.max(1, width - 1))}…`);
|
|
508
|
+
|
|
509
|
+
function Menu({ state, width }) {
|
|
510
|
+
const p = palette();
|
|
511
|
+
if (!state.menu.length) return null;
|
|
512
|
+
const labelWidth = Math.min(28, Math.max(...state.menu.map((item) => stringWidth(item.label))) + 2);
|
|
513
|
+
return h(Box, { flexDirection: 'column', marginBottom: 1 },
|
|
514
|
+
state.menu.map((item, index) => {
|
|
515
|
+
const selected = index === state.menuIndex;
|
|
516
|
+
const spans = [
|
|
517
|
+
{ t: selected ? `${g('▸', '>')} ` : ' ', color: p.brand, bold: true },
|
|
518
|
+
...highlightMatch(item.label, item.hits || [], selected ? p.text : p.muted, p.brand),
|
|
519
|
+
];
|
|
520
|
+
const used = stringWidth(item.label) + 2;
|
|
521
|
+
if (item.summary && width > 60) {
|
|
522
|
+
spans.push({ t: ' '.repeat(Math.max(1, labelWidth - used + 2)) });
|
|
523
|
+
spans.push({ t: item.summary, color: p.faint });
|
|
524
|
+
}
|
|
525
|
+
return h(Box, {
|
|
526
|
+
key: `${item.value}-${index}`,
|
|
527
|
+
backgroundColor: selected ? p.raised : undefined,
|
|
528
|
+
}, h(Row, { spans: truncateRow(spans, width - 2) }));
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function Composer({ state, width }) {
|
|
534
|
+
const p = palette();
|
|
535
|
+
const inner = Math.max(16, width - 4);
|
|
536
|
+
const bang = state.input.startsWith('!');
|
|
537
|
+
const promptMark = bang ? '! ' : `${g('›', '>')} `;
|
|
538
|
+
const promptColor = bang ? p.yellow : p.brand;
|
|
539
|
+
|
|
540
|
+
// A paste placeholder is a chip, not prose: it stands for content that is really
|
|
541
|
+
// there and will be sent, so it reads as an object rather than as something you typed
|
|
542
|
+
// and might have to delete.
|
|
543
|
+
const composed = [];
|
|
544
|
+
let at = 0;
|
|
545
|
+
for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]|\[Image #\d+\]/g)) {
|
|
546
|
+
if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.body });
|
|
547
|
+
composed.push({ t: match[0], color: p.purple, backgroundColor: p.raised });
|
|
548
|
+
at = match.index + match[0].length;
|
|
549
|
+
}
|
|
550
|
+
if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.body });
|
|
551
|
+
|
|
552
|
+
// The same lines the App measured the composer's height from (composerLayout): the
|
|
553
|
+
// frame height is fixed, so a composer one row taller than it was counted as pushes
|
|
554
|
+
// the whole screen up — that was the header vanishing after a multi-line paste.
|
|
555
|
+
const { lines, caretLine, caretCol } = composerLayout(state, inner - 2, composed);
|
|
556
|
+
const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
|
|
557
|
+
|
|
558
|
+
const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
|
|
559
|
+
|
|
560
|
+
return h(Box, {
|
|
561
|
+
flexDirection: 'column',
|
|
562
|
+
borderStyle: ascii() ? 'classic' : 'round',
|
|
563
|
+
borderColor,
|
|
564
|
+
paddingX: 1,
|
|
565
|
+
width,
|
|
566
|
+
},
|
|
567
|
+
h(Menu, { state, width: inner }),
|
|
568
|
+
h(Box, { flexDirection: 'row' },
|
|
569
|
+
h(Text, { color: promptColor, bold: true }, promptMark),
|
|
570
|
+
h(Box, { flexDirection: 'column', width: inner - 2 },
|
|
571
|
+
lines.map((row, index) => {
|
|
572
|
+
// The caret sits AT the cursor. It used to be drawn after the whole line, so
|
|
573
|
+
// moving left with the arrow keys showed no movement at all while typing went
|
|
574
|
+
// in somewhere in the middle.
|
|
575
|
+
const [left, right] = index === caretLine ? splitSpansAt(row, caretCol) : [row, []];
|
|
576
|
+
return h(Box, { key: index, flexDirection: 'row' },
|
|
577
|
+
h(Row, { spans: left, wrap: 'wrap' }),
|
|
578
|
+
// The caret is always present, including at an empty prompt. Hiding it there
|
|
579
|
+
// was meant to keep it off the placeholder, but the result is a prompt with
|
|
580
|
+
// no cursor at all — there is nothing on screen saying the app is waiting for
|
|
581
|
+
// typing, which is the one thing a prompt has to say. The placeholder sits
|
|
582
|
+
// AFTER the caret instead, so the caret is at the insertion point either way.
|
|
583
|
+
index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
|
|
584
|
+
right.length ? h(Row, { spans: right, wrap: 'wrap' }) : null,
|
|
585
|
+
!state.input && index === 0
|
|
586
|
+
? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
|
|
587
|
+
: null,
|
|
588
|
+
);
|
|
589
|
+
}),
|
|
590
|
+
),
|
|
591
|
+
),
|
|
592
|
+
state.queued.length
|
|
593
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, `${g('⏎', '>')} ${state.queued.length} message${state.queued.length === 1 ? '' : 's'} queued — they send when this turn finishes`))
|
|
594
|
+
: null,
|
|
595
|
+
liveAttachments(state).length
|
|
596
|
+
? h(Box, { marginTop: 1 }, h(Text, { color: p.purple, wrap: 'truncate-end' },
|
|
597
|
+
`${liveAttachments(state).map((a) => `${g('⧉', '[img]')} #${a.id} ${a.name || 'image'}${a.bytes ? ` ${Math.max(1, Math.round(a.bytes / 1024))} KB` : ''}`).join(' ')}`,
|
|
598
|
+
h(Text, { color: p.faint }, ' · backspace over a chip removes it')))
|
|
599
|
+
: null,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Attachments whose chip is still in the input — the ones that will be sent. */
|
|
604
|
+
function liveAttachments(state) {
|
|
605
|
+
// Current attachments, and earlier ones a line recalled from history still names.
|
|
606
|
+
const all = new Map();
|
|
607
|
+
for (const a of [...(state.imageStore ? state.imageStore.values() : []), ...(state.attachments || [])]) if (a?.id) all.set(a.id, a);
|
|
608
|
+
return [...all.values()].filter((a) => state.input.includes(`[Image #${a.id}]`));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const COMPOSER_MAX_LINES = 8;
|
|
612
|
+
|
|
613
|
+
/** Spans split at every newline: one array of spans per line the user typed. */
|
|
614
|
+
function splitSpansAtNewlines(spans) {
|
|
615
|
+
const out = [[]];
|
|
616
|
+
for (const span of spans) {
|
|
617
|
+
const parts = String(span.t).split('\n');
|
|
618
|
+
parts.forEach((part, i) => {
|
|
619
|
+
if (i > 0) out.push([]);
|
|
620
|
+
if (part) out[out.length - 1].push({ ...span, t: part });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The composer's lines as drawn: newlines are real line breaks (a pasted three-line
|
|
628
|
+
* snippet is three lines), each line wraps at `width`, and at most COMPOSER_MAX_LINES
|
|
629
|
+
* are shown — a window that follows the cursor. Returns the visible lines, the caret's
|
|
630
|
+
* line within them, and how many there are in total.
|
|
631
|
+
*
|
|
632
|
+
* Both the App (to size the frame) and the Composer (to draw) call this. They used to
|
|
633
|
+
* measure separately: the App's count treated a newline as a space and capped at 8, the
|
|
634
|
+
* Composer drew every line — so any newline or long input made the frame taller than
|
|
635
|
+
* the terminal, and the terminal scrolled.
|
|
636
|
+
*/
|
|
637
|
+
function composerLayout(state, width, spans = null) {
|
|
638
|
+
const input = state.input || '';
|
|
639
|
+
if (!input) return { lines: [[]], caretLine: 0, total: 1 };
|
|
640
|
+
const paragraphs = splitSpansAtNewlines(spans || [{ t: input }]);
|
|
641
|
+
const all = [];
|
|
642
|
+
let caret = 0;
|
|
643
|
+
let caretCol = 0;
|
|
644
|
+
let start = 0; // offset of this paragraph in `input`
|
|
645
|
+
const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
|
|
646
|
+
for (const para of paragraphs) {
|
|
647
|
+
const text = para.map((s) => s.t).join('');
|
|
648
|
+
const rows = para.length ? wrapRow(para, width) : [[]];
|
|
649
|
+
if (cursor >= start && cursor <= start + text.length) {
|
|
650
|
+
let rowStart = start;
|
|
651
|
+
let line = rows.length - 1;
|
|
652
|
+
let col = 0;
|
|
653
|
+
for (let i = 0; i < rows.length; i++) {
|
|
654
|
+
const len = rows[i].map((s) => s.t).join('').length;
|
|
655
|
+
if (cursor <= rowStart + len || i === rows.length - 1) { line = i; col = Math.max(0, Math.min(len, cursor - rowStart)); break; }
|
|
656
|
+
rowStart += len + 1; // + the space the wrap consumed
|
|
657
|
+
}
|
|
658
|
+
caret = all.length + line;
|
|
659
|
+
caretCol = col;
|
|
660
|
+
}
|
|
661
|
+
all.push(...rows);
|
|
662
|
+
start += text.length + 1; // + the newline
|
|
663
|
+
}
|
|
664
|
+
if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, caretCol, total: all.length };
|
|
665
|
+
const first = Math.max(0, Math.min(caret - COMPOSER_MAX_LINES + 1, all.length - COMPOSER_MAX_LINES));
|
|
666
|
+
return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, caretCol, total: all.length };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** A row of spans cut at character `n`: [before, after]. */
|
|
670
|
+
function splitSpansAt(row, n) {
|
|
671
|
+
const before = [];
|
|
672
|
+
const after = [];
|
|
673
|
+
let seen = 0;
|
|
674
|
+
for (const span of row || []) {
|
|
675
|
+
const t = String(span.t);
|
|
676
|
+
if (seen + t.length <= n) before.push(span);
|
|
677
|
+
else if (seen >= n) after.push(span);
|
|
678
|
+
else { before.push({ ...span, t: t.slice(0, n - seen) }); after.push({ ...span, t: t.slice(n - seen) }); }
|
|
679
|
+
seen += t.length;
|
|
680
|
+
}
|
|
681
|
+
return [before, after];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function StatusBar({ state, width }) {
|
|
685
|
+
const p = palette();
|
|
686
|
+
if (state.flash) {
|
|
687
|
+
const tone = { ok: p.teal, warn: p.yellow, info: p.muted }[state.flash.tone] || p.muted;
|
|
688
|
+
const mark = { ok: g('✓', '+'), warn: '!', info: g('›', '>') }[state.flash.tone] || g('›', '>');
|
|
689
|
+
return h(Box, { paddingX: 1 },
|
|
690
|
+
h(Text, { color: tone, bold: true }, `${mark} `),
|
|
691
|
+
h(Text, { color: tone, wrap: 'truncate-end' }, plain(state.flash.text)));
|
|
692
|
+
}
|
|
693
|
+
if (state.statusLine) {
|
|
694
|
+
return h(Box, { paddingX: 1 }, h(Text, { color: p.muted, wrap: 'truncate-end' }, plain(state.statusLine)));
|
|
695
|
+
}
|
|
696
|
+
const bits = state.viewingTranscript
|
|
697
|
+
? [
|
|
698
|
+
[`${g('▲', '^')} history`, p.yellow],
|
|
699
|
+
[state.newMessages ? `${state.newMessages} new below` : 'scrolled back', p.muted],
|
|
700
|
+
['ctrl+end to live', p.faint],
|
|
701
|
+
]
|
|
702
|
+
: width < 70
|
|
703
|
+
? [['ctrl+p palette', p.faint], ['shift+tab mode', p.faint]]
|
|
704
|
+
: [
|
|
705
|
+
['ctrl+p palette', p.faint],
|
|
706
|
+
['shift+tab mode', p.faint],
|
|
707
|
+
['ctrl+l clear', p.faint],
|
|
708
|
+
['esc esc rewind', p.faint],
|
|
709
|
+
// Mouse reporting takes the terminal's own text selection away, so the way to
|
|
710
|
+
// get it back has to be on screen — otherwise the first thing a new user
|
|
711
|
+
// notices is that they can no longer copy anything.
|
|
712
|
+
state.mouse ? [selectHint(), p.faint] : ['ctrl+d debug', p.faint],
|
|
713
|
+
];
|
|
714
|
+
const spans = [];
|
|
715
|
+
bits.forEach(([text, color], index) => {
|
|
716
|
+
if (index) spans.push({ t: ` ${g('·', '|')} `, color: p.faint });
|
|
717
|
+
spans.push({ t: text, color });
|
|
718
|
+
});
|
|
719
|
+
const right = [];
|
|
720
|
+
if (state.spent?.turns) {
|
|
721
|
+
right.push({ t: `${state.spent.turns} turn${state.spent.turns === 1 ? '' : 's'}`, color: p.faint });
|
|
722
|
+
right.push({ t: ` ${g('·', '|')} `, color: p.faint });
|
|
723
|
+
right.push({ t: `${((state.spent.input || 0) + (state.spent.cached || 0) + (state.spent.output || 0)).toLocaleString()} tok`, color: p.faint });
|
|
724
|
+
}
|
|
725
|
+
// The counter on the right keeps its width; the hints get what is left, with a gap.
|
|
726
|
+
// Both used to shrink together, so the hints were cut off flush against the counter
|
|
727
|
+
// ("drag copi1 turn").
|
|
728
|
+
const rightWidth = right.reduce((n, s) => n + stringWidth(s.t), 0);
|
|
729
|
+
const leftRoom = Math.max(0, width - 2 - rightWidth - (rightWidth ? 3 : 0));
|
|
730
|
+
return h(Box, { flexDirection: 'row', paddingX: 1, width },
|
|
731
|
+
h(Row, { spans: truncateRow(spans, leftRoom) }),
|
|
732
|
+
h(Spacer),
|
|
733
|
+
rightWidth ? h(Box, { flexShrink: 0 }, h(Row, { spans: right })) : null,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── Overlays ───────────────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Exact height, so the box can be exactly centred.
|
|
741
|
+
*
|
|
742
|
+
* Ink will not tell you how tall a Box is until after it has laid it out, and an
|
|
743
|
+
* absolutely-positioned element has to be given its `top` before that happens. So the
|
|
744
|
+
* height is computed the same way Yoga will: borders, padding, and every row and margin
|
|
745
|
+
* in order. Guessing it — which is what a constant like `6 + options` was — puts the
|
|
746
|
+
* overlay a few rows off centre and the error grows with the content.
|
|
747
|
+
*/
|
|
748
|
+
function dialogHeight(questionRows, previewRows, optionCount) {
|
|
749
|
+
return 2 // border top + bottom
|
|
750
|
+
+ 2 // paddingY
|
|
751
|
+
+ 1 // title
|
|
752
|
+
+ 1 + questionRows
|
|
753
|
+
+ (previewRows ? 1 + previewRows : 0)
|
|
754
|
+
+ 1 + optionCount
|
|
755
|
+
+ 1 + 1; // gap + hint
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function Dialog({ state, width, height, hits }) {
|
|
759
|
+
const p = palette();
|
|
760
|
+
const dialog = state.dialog;
|
|
761
|
+
if (!dialog) return null;
|
|
762
|
+
const boxWidth = Math.min(Math.max(48, Math.floor(width * 0.78)), width - 4);
|
|
763
|
+
const questionRows = wrapRow([{ t: plain(dialog.question) }], boxWidth - 6).length;
|
|
764
|
+
// What is being approved is WRAPPED, never cut at the box edge: a long command's tail
|
|
765
|
+
// (`… && curl x | sh`) was simply not shown, so it could be approved unseen. When it
|
|
766
|
+
// is taller than the screen allows, the start and the END are shown with the count of
|
|
767
|
+
// what is hidden between them.
|
|
768
|
+
const wrappedPreview = (dialog.preview || []).flatMap((line) => wrapRow(previewSpans(line, p), boxWidth - 6));
|
|
769
|
+
const roomForPreview = Math.max(4, height - (questionRows + dialog.options.length + 12));
|
|
770
|
+
let previewRows = wrappedPreview;
|
|
771
|
+
if (wrappedPreview.length > roomForPreview) {
|
|
772
|
+
const head = Math.floor((roomForPreview - 1) / 2);
|
|
773
|
+
const tail = roomForPreview - 1 - head;
|
|
774
|
+
const hidden = wrappedPreview.length - head - tail;
|
|
775
|
+
previewRows = [...wrappedPreview.slice(0, head), [{ t: `… ${hidden} more line${hidden === 1 ? '' : 's'} …`, color: p.yellow }], ...wrappedPreview.slice(-tail)];
|
|
776
|
+
}
|
|
777
|
+
// More options than fit: a window that follows the selection (a picker of 20 sessions
|
|
778
|
+
// or models no longer has to be cut to the 9 that have a number key).
|
|
779
|
+
const roomForOptions = Math.max(3, height - questionRows - previewRows.length - 12);
|
|
780
|
+
const optionCount = Math.min(dialog.options.length, roomForOptions);
|
|
781
|
+
const firstOption = Math.max(0, Math.min(dialog.index - Math.floor(optionCount / 2), dialog.options.length - optionCount));
|
|
782
|
+
const visibleOptions = dialog.options.slice(firstOption, firstOption + optionCount);
|
|
783
|
+
const rows = dialogHeight(questionRows, previewRows.length, optionCount);
|
|
784
|
+
const accent = dialog.tone === 'danger' ? p.red : p.brand;
|
|
785
|
+
|
|
786
|
+
const top = Math.max(0, Math.round((height - rows) / 2));
|
|
787
|
+
const left = Math.max(0, Math.round((width - boxWidth) / 2));
|
|
788
|
+
|
|
789
|
+
// The whole box swallows clicks first, so nothing lands on the header behind it; the
|
|
790
|
+
// option rows are registered after and therefore win inside their own row. The
|
|
791
|
+
// arithmetic mirrors dialogHeight() exactly — terminal rows are 1-based, hence the +1.
|
|
792
|
+
hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
|
|
793
|
+
const optionsTop = top + 2 + 1 + 1 + questionRows + (previewRows.length ? 1 + previewRows.length : 0) + 1;
|
|
794
|
+
visibleOptions.forEach((option, index) => {
|
|
795
|
+
hits?.add({
|
|
796
|
+
x1: left + 1, x2: left + boxWidth, y1: optionsTop + index + 1, y2: optionsTop + index + 1,
|
|
797
|
+
action: { type: 'dialogOption', index: firstOption + index },
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
return h(Box, { position: 'absolute', top, left, width: boxWidth },
|
|
802
|
+
h(Reveal, { rows },
|
|
803
|
+
h(Box, {
|
|
804
|
+
flexDirection: 'column',
|
|
805
|
+
width: boxWidth,
|
|
806
|
+
borderStyle: ascii() ? 'classic' : 'round',
|
|
807
|
+
borderColor: accent,
|
|
808
|
+
backgroundColor: p.surface,
|
|
809
|
+
paddingX: 2,
|
|
810
|
+
paddingY: 1,
|
|
811
|
+
},
|
|
812
|
+
h(Box, { flexDirection: 'row' },
|
|
813
|
+
h(Text, { color: accent, bold: true }, `${g('◆', '#')} ${dialog.title || 'Permission required'}`),
|
|
814
|
+
h(Spacer),
|
|
815
|
+
h(Text, { color: p.faint }, `${dialog.options.length} option${dialog.options.length === 1 ? '' : 's'}`),
|
|
816
|
+
),
|
|
817
|
+
h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
818
|
+
wrapRow([{ t: plain(dialog.question), color: p.text }], boxWidth - 6)
|
|
819
|
+
.map((row, index) => h(Row, { key: index, spans: row }))),
|
|
820
|
+
previewRows.length
|
|
821
|
+
? h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
822
|
+
previewRows.map((row, index) => h(Row, { key: index, spans: row })))
|
|
823
|
+
: null,
|
|
824
|
+
h(Box, { marginTop: 1, flexDirection: 'column' },
|
|
825
|
+
visibleOptions.map((option, i) => {
|
|
826
|
+
const index = firstOption + i;
|
|
827
|
+
const selected = index === dialog.index;
|
|
828
|
+
const more = (i === 0 && firstOption > 0) ? ` ${g('↑', '^')}${firstOption} more`
|
|
829
|
+
: (i === visibleOptions.length - 1 && firstOption + optionCount < dialog.options.length) ? ` ${g('↓', 'v')}${dialog.options.length - firstOption - optionCount} more` : '';
|
|
830
|
+
return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
|
|
831
|
+
h(Text, { color: selected ? accent : p.muted, bold: selected, wrap: 'truncate-end' },
|
|
832
|
+
`${selected ? g(' ▸ ', ' > ') : ' '}${index < 9 ? `${index + 1}.` : ' '} ${plain(option.label)}`),
|
|
833
|
+
more ? h(Text, { color: p.faint }, more) : null);
|
|
834
|
+
})),
|
|
835
|
+
h(Box, { marginTop: 1 },
|
|
836
|
+
h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
|
|
837
|
+
),
|
|
838
|
+
),
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function previewSpans(line, p) {
|
|
843
|
+
const text = plain(line);
|
|
844
|
+
if (text.startsWith('+')) return [{ t: text, color: p.addFg, backgroundColor: p.addBg }];
|
|
845
|
+
if (text.startsWith('-')) return [{ t: text, color: p.delFg, backgroundColor: p.delBg }];
|
|
846
|
+
if (text.startsWith('@@')) return [{ t: text, color: p.hunkFg }];
|
|
847
|
+
return [{ t: text, color: p.muted }];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function Panel({ state, width, height, hits }) {
|
|
851
|
+
const p = palette();
|
|
852
|
+
const panel = state.panel;
|
|
853
|
+
if (!panel) return null;
|
|
854
|
+
const boxWidth = Math.min(Math.max(52, Math.floor(width * 0.84)), width - 4);
|
|
855
|
+
// Leave room for the box's own chrome (8 rows) plus a margin, and never grow past
|
|
856
|
+
// what the content needs — a palette with four matches should be four rows tall, not
|
|
857
|
+
// a half-screen box with an empty lower half.
|
|
858
|
+
const maxRows = Math.max(4, Math.min(height - 10, 18));
|
|
859
|
+
|
|
860
|
+
let body;
|
|
861
|
+
let bodyRows;
|
|
862
|
+
let firstVisible = 0;
|
|
863
|
+
if (panel.palette) {
|
|
864
|
+
// Keep the selection in view without letting the window run past either end.
|
|
865
|
+
const visible = Math.min(maxRows, panel.options.length);
|
|
866
|
+
firstVisible = Math.max(0, Math.min(panel.index - Math.floor(visible / 2), panel.options.length - visible));
|
|
867
|
+
const view = panel.options.slice(firstVisible, firstVisible + visible);
|
|
868
|
+
bodyRows = Math.max(1, view.length);
|
|
869
|
+
body = view.map((option, index) => {
|
|
870
|
+
const globalIndex = firstVisible + index;
|
|
871
|
+
const selected = globalIndex === panel.index;
|
|
872
|
+
const name = typeof option === 'string' ? option : option.name;
|
|
873
|
+
const summary = typeof option === 'string' ? '' : option.summary || '';
|
|
874
|
+
return h(Box, { key: `${name}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
|
|
875
|
+
h(Text, { color: selected ? p.brand : p.muted, bold: selected, wrap: 'truncate-end' }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
|
|
876
|
+
summary ? h(Text, { color: p.faint, wrap: 'truncate-end' }, summary) : null,
|
|
877
|
+
);
|
|
878
|
+
});
|
|
879
|
+
if (!view.length) {
|
|
880
|
+
body = [h(Text, { key: 'none', color: p.faint, italic: true }, `nothing matches “${panel.filter || ''}”`)];
|
|
881
|
+
bodyRows = 1;
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
// Scrollable. Only the last maxRows used to be drawn, with no way up — the top of a
|
|
885
|
+
// long /diff or /tasks was unreachable. `follow` (a live panel at its bottom) keeps
|
|
886
|
+
// showing the newest lines as they arrive.
|
|
887
|
+
const all = panel.rows?.length ? panel.rows : (panel.lines || []);
|
|
888
|
+
const total = all.length;
|
|
889
|
+
const maxStart = Math.max(0, total - maxRows);
|
|
890
|
+
panel._maxRows = maxRows;
|
|
891
|
+
panel._maxStart = maxStart;
|
|
892
|
+
const start = panel.follow ? maxStart : Math.max(0, Math.min(panel.scroll || 0, maxStart));
|
|
893
|
+
panel.scroll = start;
|
|
894
|
+
panel._range = total > maxRows ? `${start + 1}–${Math.min(total, start + maxRows)} of ${total}` : '';
|
|
895
|
+
const view = all.slice(start, start + maxRows);
|
|
896
|
+
bodyRows = Math.max(1, view.length);
|
|
897
|
+
body = panel.rows?.length
|
|
898
|
+
? [h(Rows, { key: 'rows', rows: view })]
|
|
899
|
+
: view.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// border 2 + padY 2 + title 1 + gap 1 + body + gap 1 + hint 1
|
|
903
|
+
const rows = 8 + bodyRows;
|
|
904
|
+
const top = Math.max(0, Math.round((height - rows) / 2));
|
|
905
|
+
const left = Math.max(0, Math.round((width - boxWidth) / 2));
|
|
906
|
+
|
|
907
|
+
hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
|
|
908
|
+
if (panel.palette) {
|
|
909
|
+
const bodyTop = top + 2 + 1 + 1;
|
|
910
|
+
for (let i = 0; i < bodyRows; i++) {
|
|
911
|
+
hits?.add({
|
|
912
|
+
x1: left + 1, x2: left + boxWidth, y1: bodyTop + i + 1, y2: bodyTop + i + 1,
|
|
913
|
+
action: { type: 'paletteRow', index: firstVisible + i },
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return h(Box, { position: 'absolute', top, left, width: boxWidth },
|
|
919
|
+
h(Reveal, { rows },
|
|
920
|
+
h(Box, {
|
|
921
|
+
flexDirection: 'column',
|
|
922
|
+
width: boxWidth,
|
|
923
|
+
borderStyle: ascii() ? 'classic' : 'round',
|
|
924
|
+
borderColor: p.teal,
|
|
925
|
+
backgroundColor: p.surface,
|
|
926
|
+
paddingX: 2,
|
|
927
|
+
paddingY: 1,
|
|
928
|
+
},
|
|
929
|
+
h(Box, { flexDirection: 'row' },
|
|
930
|
+
h(Text, { color: p.teal, bold: true }, plain(panel.title)),
|
|
931
|
+
h(Spacer),
|
|
932
|
+
panel.palette ? h(Text, { color: p.faint }, `${panel.options.length}`) : null,
|
|
933
|
+
),
|
|
934
|
+
h(Box, { marginTop: 1, flexDirection: 'column' }, body),
|
|
935
|
+
h(Box, { marginTop: 1 }, h(Text, { color: p.faint }, panel.palette
|
|
936
|
+
? `type to filter · ${g('↑↓', 'up/dn')} choose · ${panel.onPick ? 'enter pick' : 'enter run'} · esc close`
|
|
937
|
+
: `${panel._range ? `${panel._range} · ${g('↑↓', 'up/dn')} pgup/pgdn scroll · ` : ''}esc close`)),
|
|
938
|
+
),
|
|
939
|
+
),
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ─── The app ────────────────────────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
function App({ controller }) {
|
|
946
|
+
// Ink's hook answers in `{columns, rows}`, not `{width, height}`. Destructuring the
|
|
947
|
+
// wrong names yields two `undefined`s that propagate into every wrap and clamp as
|
|
948
|
+
// NaN — comparisons against NaN are all false, so the layout does not throw, it just
|
|
949
|
+
// silently breaks one word per line. Worth naming, because nothing in the output
|
|
950
|
+
// points at the hook.
|
|
951
|
+
const size = useWindowSize();
|
|
952
|
+
const width = Math.max(30, size?.columns || process.stdout.columns || 80);
|
|
953
|
+
// One row short of the terminal, on purpose. A frame that fills the terminal exactly
|
|
954
|
+
// puts Ink on its Windows path of clearing the whole screen before EVERY frame (ink.js
|
|
955
|
+
// shouldClearTerminalForFrame), and ConPTY — which Windows Terminal also sits on —
|
|
956
|
+
// commits that clear as its own synchronized frame: measured in a real ConPTY, the
|
|
957
|
+
// screen went blank and was redrawn ~9 times a second while a reply streamed, and on
|
|
958
|
+
// every keystroke. One row shorter, Ink redraws in place and only what changed.
|
|
959
|
+
const height = Math.max(10, (size?.rows || process.stdout.rows || 24) - 1);
|
|
960
|
+
const { exit } = useApp();
|
|
961
|
+
const [, setTick] = React.useState(0);
|
|
962
|
+
const p = palette();
|
|
963
|
+
|
|
964
|
+
React.useEffect(() => {
|
|
965
|
+
controller.attach(setTick);
|
|
966
|
+
return () => controller.attach(() => {});
|
|
967
|
+
}, [controller]);
|
|
968
|
+
|
|
969
|
+
useCursor({ visible: false });
|
|
970
|
+
useInput((input, key) => controller.handleKey(input, key, exit));
|
|
971
|
+
// Bracketed paste. Without it a pasted block arrives as ordinary input, and the first
|
|
972
|
+
// newline in it SUBMITS — so pasting a stack trace sent its first line and left the
|
|
973
|
+
// rest in the box. Ink keeps paste off the useInput channel entirely while this is
|
|
974
|
+
// mounted, so the two cannot race.
|
|
975
|
+
usePaste((text) => controller.handlePaste(text));
|
|
976
|
+
|
|
977
|
+
// A resize invalidates anything expressed in screen coordinates. The transcript
|
|
978
|
+
// re-wraps at the new width, so a selection made at the old one now highlights
|
|
979
|
+
// unrelated text, and the hit map is rebuilt from scratch on the next frame anyway.
|
|
980
|
+
React.useEffect(() => { controller.onResize(width, height); }, [controller, width, height]);
|
|
981
|
+
|
|
982
|
+
const state = controller.state;
|
|
983
|
+
// One empty frame after a resize — see onResize.
|
|
984
|
+
if (state.blankFrame) return h(Box, { height: 1 });
|
|
985
|
+
const composerRows = CHROME.composerFrame
|
|
986
|
+
+ (state.menu.length ? state.menu.length + 1 : 0)
|
|
987
|
+
+ Math.max(0, composerTextRows(state, width) - 1)
|
|
988
|
+
+ (state.queued.length ? 2 : 0)
|
|
989
|
+
+ (liveAttachments(state).length ? 2 : 0);
|
|
990
|
+
const workRows = state.busy || state.status ? 1 : 0;
|
|
991
|
+
// A single pinned line showing what was last asked, while the transcript is scrolled
|
|
992
|
+
// away from it. Scrolling back through a long answer and losing track of the question
|
|
993
|
+
// it was answering is the specific thing this fixes.
|
|
994
|
+
const stickyRows = state.viewingTranscript ? 1 : 0;
|
|
995
|
+
const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
|
|
996
|
+
state._transcriptHeight = transcriptHeight;
|
|
997
|
+
state._width = width;
|
|
998
|
+
|
|
999
|
+
// The welcome stands until there is real conversation. Anything chrome-ish — a
|
|
1000
|
+
// notice, a stray pre-formatted print from an older code path — must not suppress it:
|
|
1001
|
+
// one such line was enough to make the whole welcome screen, logo included, invisible,
|
|
1002
|
+
// and nothing about the result suggested why.
|
|
1003
|
+
const CONVERSATION = new Set(['user', 'assistant', 'tool', 'diff', 'todo', 'error']);
|
|
1004
|
+
const showHero = state.home && !state.live && !state.transcript.some((e) => CONVERSATION.has(e.kind));
|
|
1005
|
+
|
|
1006
|
+
// Immediate-mode hit testing: the regions are rebuilt from the same numbers that
|
|
1007
|
+
// produce the layout, every frame. Anything measured after the fact would be a second
|
|
1008
|
+
// source of truth for where things are, and the two would drift.
|
|
1009
|
+
const hits = controller.hits;
|
|
1010
|
+
hits.reset();
|
|
1011
|
+
const transcriptTop = CHROME.header + stickyRows; // 0-based row above the transcript
|
|
1012
|
+
// Laid out here, before anything renders, so the header above the transcript is drawn
|
|
1013
|
+
// from THIS frame's numbers rather than the previous one's.
|
|
1014
|
+
const layout = showHero
|
|
1015
|
+
? { visible: [], pad: 0, screenTop: transcriptTop + 1 }
|
|
1016
|
+
: layoutTranscript(state, width, transcriptHeight, transcriptTop);
|
|
1017
|
+
hits.add({ x1: 1, x2: width, y1: transcriptTop + 1, y2: transcriptTop + transcriptHeight, action: { type: 'transcript' } });
|
|
1018
|
+
registerLinkHits(layout.visible, layout.screenTop, hits);
|
|
1019
|
+
registerHeaderHits(state, width, hits);
|
|
1020
|
+
hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
|
|
1021
|
+
if (state.menu.length) {
|
|
1022
|
+
const menuTop = height - CHROME.status - composerRows + 1; // inside the composer border
|
|
1023
|
+
state.menu.forEach((_item, index) => {
|
|
1024
|
+
hits.add({ x1: 2, x2: width - 1, y1: menuTop + index + 1, y2: menuTop + index + 1, action: { type: 'menuRow', index } });
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Everything but the transcript keeps its size (flexShrink 0); the transcript is the
|
|
1029
|
+
// one part allowed to give way if the chrome ever measures taller than counted.
|
|
1030
|
+
const fixed = (child) => h(Box, { flexShrink: 0, flexDirection: 'column' }, child);
|
|
1031
|
+
return h(Box, { flexDirection: 'column', width: '100%', height },
|
|
1032
|
+
fixed(h(Header, { state, width })),
|
|
1033
|
+
stickyRows ? fixed(h(StickyPrompt, { state, width })) : null,
|
|
1034
|
+
showHero
|
|
1035
|
+
? h(Hero, { state, width, height: transcriptHeight })
|
|
1036
|
+
: h(Transcript, { state, width, height: transcriptHeight, layout }),
|
|
1037
|
+
h(Box, { height: CHROME.gapAboveComposer, flexShrink: 0 }),
|
|
1038
|
+
fixed(h(WorkBar, { state, width })),
|
|
1039
|
+
h(Box, { paddingX: 1, flexShrink: 0 }, h(Composer, { state, width: width - 2 })),
|
|
1040
|
+
fixed(h(StatusBar, { state, width })),
|
|
1041
|
+
h(Panel, { state, width, height, hits }),
|
|
1042
|
+
h(Dialog, { state, width, height, hits }),
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function composerTextRows(state, width) {
|
|
1047
|
+
// The Composer is given width - 2 and draws text in (that - 4) - 2: width - 8.
|
|
1048
|
+
return composerLayout(state, Math.max(16, width - 6) - 2).lines.length;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The prompt currently being answered on screen.
|
|
1053
|
+
*
|
|
1054
|
+
* Published by the transcript each frame rather than read from the end of the list: the
|
|
1055
|
+
* useful line while scrolling back through a long session is the question THIS part of
|
|
1056
|
+
* the conversation was answering, not whatever was asked most recently. Scroll far
|
|
1057
|
+
* enough up and it becomes the previous exchange's question; scroll to where the prompt
|
|
1058
|
+
* itself is visible and there is nothing to pin.
|
|
1059
|
+
*/
|
|
1060
|
+
function StickyPrompt({ state, width }) {
|
|
1061
|
+
const p = palette();
|
|
1062
|
+
const text = String(state.stickyPrompt || '').replace(/\s+/g, ' ').trim();
|
|
1063
|
+
return h(Box, { flexDirection: 'row', paddingX: 1, width, backgroundColor: p.raised },
|
|
1064
|
+
h(Text, { color: p.brand, bold: true }, `${g('▌', '|')} `),
|
|
1065
|
+
h(Text, { color: text ? p.muted : p.faint, wrap: 'truncate-end' },
|
|
1066
|
+
text || 'the start of this conversation'),
|
|
1067
|
+
h(Spacer),
|
|
1068
|
+
h(Text, { color: p.faint }, ` ${g('↑', '^')} scrolled`),
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* The header's right-hand side is a row of controls, not a readout — the mode, the
|
|
1074
|
+
* model and the context meter all do something when clicked.
|
|
1075
|
+
*
|
|
1076
|
+
* The columns are computed from the same strings the header renders, walking right to
|
|
1077
|
+
* left from the edge, so a click lands on the thing it looks like it is on. Anything
|
|
1078
|
+
* here that stops matching Header() shows up immediately as a click that misses — which
|
|
1079
|
+
* is why tests/tui-render.js slices the rendered row at these exact columns.
|
|
1080
|
+
*/
|
|
1081
|
+
function registerHeaderHits(state, width, hits) {
|
|
1082
|
+
const m = state.meta;
|
|
1083
|
+
const narrow = width < 78;
|
|
1084
|
+
const mode = MODE_LABEL[m.mode] || 'ask';
|
|
1085
|
+
const model = narrow ? shortModel(m.model) : String(m.model || 'auto');
|
|
1086
|
+
|
|
1087
|
+
let right = width - 1; // 1-based column of the last cell (paddingX 1)
|
|
1088
|
+
if (!narrow) {
|
|
1089
|
+
const meter = 8 + 1 + stringWidth(m.ctx || '0 tok');
|
|
1090
|
+
hits.add({ x1: right - meter + 1, x2: right, y1: 1, y2: 1, action: { type: 'context' } });
|
|
1091
|
+
right -= meter + 5; // the ' · ' separator
|
|
1092
|
+
}
|
|
1093
|
+
hits.add({ x1: right - stringWidth(model) + 1, x2: right, y1: 1, y2: 1, action: { type: 'model' } });
|
|
1094
|
+
right -= stringWidth(model) + 5;
|
|
1095
|
+
hits.add({ x1: right - mode.length + 1, x2: right, y1: 1, y2: 1, action: { type: 'mode' } });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ─── Controller ─────────────────────────────────────────────────────────────────
|
|
1099
|
+
|
|
1100
|
+
export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
|
|
1101
|
+
publishSettings(settings);
|
|
1102
|
+
// Settings changed during the session apply now, not at the next start.
|
|
1103
|
+
onSettingsChange((next) => {
|
|
1104
|
+
const mouseBefore = settings?.mouse !== false;
|
|
1105
|
+
settings = next || {};
|
|
1106
|
+
publishSettings(settings);
|
|
1107
|
+
const mouseNow = settings.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE;
|
|
1108
|
+
if (state?.active && mouseBefore !== mouseNow) {
|
|
1109
|
+
if (mouseNow) state.mouse = enableMouse(process.stdout);
|
|
1110
|
+
else { disableMouse(process.stdout); state.mouse = false; }
|
|
1111
|
+
}
|
|
1112
|
+
if (state?.active) notifyNow();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
const commands = slashCommands.map((command) => (typeof command === 'string' ? { name: command, summary: '' } : command));
|
|
1116
|
+
|
|
1117
|
+
const state = {
|
|
1118
|
+
active: false, busy: false, home: true,
|
|
1119
|
+
input: '', cursor: 0, placeholder: '',
|
|
1120
|
+
menu: [], menuIndex: -1,
|
|
1121
|
+
history: [], historyIndex: -1, draft: null,
|
|
1122
|
+
queued: [], attachments: [],
|
|
1123
|
+
status: '', statusLine: '',
|
|
1124
|
+
// A short notice — "Copied 40 characters", "Opened <link>" — shown on the status
|
|
1125
|
+
// line for a moment. Deliberately NOT `status`: that is the live working status the
|
|
1126
|
+
// work bar draws with a spinner, so a notice put there looked like a task running,
|
|
1127
|
+
// and clearing it could wipe the real status of a turn in flight.
|
|
1128
|
+
flash: null,
|
|
1129
|
+
transcript: [], live: null, activity: [],
|
|
1130
|
+
transcriptOffset: 0, newMessages: 0, viewingTranscript: false,
|
|
1131
|
+
dialog: null, panel: null, mouse: false, selection: null, screen: null, maxScroll: 0, pastes: [],
|
|
1132
|
+
onLine: null, onEscape: null, onRewind: null, onCommand: null, onCopy: null, onModeCycle,
|
|
1133
|
+
debug: !!process.env.JAITUS_DEBUG, introDone: false,
|
|
1134
|
+
turnStartedAt: null, turnTokens: 0,
|
|
1135
|
+
spent: { turns: 0, input: 0, cached: 0, output: 0 },
|
|
1136
|
+
meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
let inkInstance = null;
|
|
1140
|
+
let update = () => {};
|
|
1141
|
+
let pending = null;
|
|
1142
|
+
let exited = false;
|
|
1143
|
+
const hits = createHitMap();
|
|
1144
|
+
const scrollStep = () => {
|
|
1145
|
+
const n = Number(settings?.scrollSpeed);
|
|
1146
|
+
return Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 1) * 3));
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Coalesce notifications to one per animation frame.
|
|
1151
|
+
*
|
|
1152
|
+
* A streaming response calls print thousands of times a second. Re-rendering per
|
|
1153
|
+
* chunk pins a core and makes typing lag behind the keyboard; batching at 16ms costs
|
|
1154
|
+
* nothing visually and keeps input responsive under a full-speed stream.
|
|
1155
|
+
*/
|
|
1156
|
+
function notify() {
|
|
1157
|
+
if (pending) return;
|
|
1158
|
+
pending = setTimeout(() => { pending = null; update((value) => value + 1); }, 16);
|
|
1159
|
+
if (pending.unref) pending.unref();
|
|
1160
|
+
}
|
|
1161
|
+
function notifyNow() {
|
|
1162
|
+
if (pending) { clearTimeout(pending); pending = null; }
|
|
1163
|
+
update((value) => value + 1);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* The brand lockup, as transcript row zero.
|
|
1168
|
+
*
|
|
1169
|
+
* Deliberately an ENTRY rather than chrome. It scrolls like anything else, so it is
|
|
1170
|
+
* never sticky and never costs a row of the working area — and because it is the
|
|
1171
|
+
* first thing in the scrollback it is still there at the top of a thousand-turn
|
|
1172
|
+
* session. The welcome screen owns the same lockup for the fresh-session moment; this
|
|
1173
|
+
* is what keeps it on screen once real conversation replaces that.
|
|
1174
|
+
*/
|
|
1175
|
+
function seedBanner() {
|
|
1176
|
+
if (process.env.JAITUS_NO_BANNER) return;
|
|
1177
|
+
if (state.transcript[0]?.kind === 'banner') return;
|
|
1178
|
+
state.transcript.unshift(makeEntry('banner', { version: state.meta.version }));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
let flashTimer = null;
|
|
1182
|
+
/** Show a notice on the status line for `ms`, then take it down — with a redraw. */
|
|
1183
|
+
function flash(text, tone = 'ok', ms = 1600) {
|
|
1184
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
1185
|
+
state.flash = { text: String(text), tone };
|
|
1186
|
+
notifyNow();
|
|
1187
|
+
flashTimer = setTimeout(() => {
|
|
1188
|
+
flashTimer = null;
|
|
1189
|
+
state.flash = null;
|
|
1190
|
+
// notifyNow, not a bare assignment: the old clear changed state without asking
|
|
1191
|
+
// for a redraw, so the notice stayed on screen until something else repainted.
|
|
1192
|
+
notifyNow();
|
|
1193
|
+
}, ms);
|
|
1194
|
+
if (flashTimer.unref) flashTimer.unref();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function push(kind, data) {
|
|
1198
|
+
const entry = makeEntry(kind, data);
|
|
1199
|
+
state.transcript.push(entry);
|
|
1200
|
+
// A transcript is not a log: 600 entries is far more than anyone scrolls back
|
|
1201
|
+
// through, and every one held is rows cached against the current width. The banner
|
|
1202
|
+
// is exempt — trimming from the front would quietly delete the brand off the top of
|
|
1203
|
+
// any long session, which is the exact failure this entry exists to fix.
|
|
1204
|
+
if (state.transcript.length > 600) {
|
|
1205
|
+
const keep = state.transcript[0]?.kind === 'banner' ? 1 : 0;
|
|
1206
|
+
state.transcript.splice(keep, state.transcript.length - 600);
|
|
1207
|
+
}
|
|
1208
|
+
if (state.viewingTranscript) state.newMessages += 1;
|
|
1209
|
+
notify();
|
|
1210
|
+
return entry;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function refreshMenu() {
|
|
1214
|
+
// Dismissed with Esc (or a recalled history line): stays closed until the text changes.
|
|
1215
|
+
if (state.menuDismissedFor !== undefined && state.menuDismissedFor === state.input) { state.menu = []; state.menuIndex = -1; return; }
|
|
1216
|
+
state.menuDismissedFor = undefined;
|
|
1217
|
+
state.menu = state.dialog || state.panel ? [] : menuItemsFor(state.input, commands, fileProvider, state.cursor);
|
|
1218
|
+
state.menuIndex = state.menu.length ? clamp(state.menuIndex < 0 ? 0 : state.menuIndex, 0, state.menu.length - 1) : -1;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function insert(text) {
|
|
1222
|
+
state.input = state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor);
|
|
1223
|
+
state.cursor += text.length;
|
|
1224
|
+
refreshMenu();
|
|
1225
|
+
notifyNow();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function clearInput() {
|
|
1229
|
+
state.input = '';
|
|
1230
|
+
state.cursor = 0;
|
|
1231
|
+
state.menu = [];
|
|
1232
|
+
state.menuIndex = -1;
|
|
1233
|
+
notifyNow();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Take the highlighted suggestion.
|
|
1238
|
+
*
|
|
1239
|
+
* `run` submits it instead of completing it, which is what Enter does on a command:
|
|
1240
|
+
* typing `/re`, pressing Enter and having the composer fill in `/review ` so you can
|
|
1241
|
+
* press Enter a second time is a completion, not a shortcut. Tab still completes, for
|
|
1242
|
+
* when the command needs an argument. A file suggestion is never run — `@` is always
|
|
1243
|
+
* building up a message.
|
|
1244
|
+
*/
|
|
1245
|
+
function chooseMenu({ run = false } = {}) {
|
|
1246
|
+
const item = state.menu[state.menuIndex];
|
|
1247
|
+
if (!item) return false;
|
|
1248
|
+
const trigger = item.kind === 'command' ? '/' : '@';
|
|
1249
|
+
const at = state.input.lastIndexOf(trigger, Math.max(0, state.cursor - 1));
|
|
1250
|
+
const prefix = at >= 0 ? state.input.slice(0, at) : '';
|
|
1251
|
+
const suffix = state.input.slice(state.cursor);
|
|
1252
|
+
|
|
1253
|
+
if (run && item.kind === 'command' && !prefix.trim() && !suffix.trim()) {
|
|
1254
|
+
state.input = String(item.value);
|
|
1255
|
+
state.cursor = state.input.length;
|
|
1256
|
+
state.menu = [];
|
|
1257
|
+
state.menuIndex = -1;
|
|
1258
|
+
submit();
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// A file keeps its `@`: without it the mention is plain text, so choosing a file
|
|
1263
|
+
// from the menu used to attach nothing at all. (Command names carry their own `/`.)
|
|
1264
|
+
const value = item.kind === 'file' ? `@${item.value}` : String(item.value);
|
|
1265
|
+
state.input = `${prefix}${value} ${suffix}`;
|
|
1266
|
+
state.cursor = prefix.length + value.length + 1;
|
|
1267
|
+
refreshMenu();
|
|
1268
|
+
notifyNow();
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* A paste.
|
|
1274
|
+
*
|
|
1275
|
+
* Short pastes go in as text — that is what you meant, and a chip for eleven
|
|
1276
|
+
* characters is a chip in the way. Anything long becomes a placeholder holding its
|
|
1277
|
+
* place in the sentence, with the real content kept aside and substituted back when
|
|
1278
|
+
* the message is sent. The alternative is a composer that has grown to forty rows and
|
|
1279
|
+
* hidden the conversation you were pasting INTO.
|
|
1280
|
+
*/
|
|
1281
|
+
function handlePaste(text) {
|
|
1282
|
+
const value = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
1283
|
+
if (!value) return;
|
|
1284
|
+
// Dragging image files onto the terminal pastes their paths: attach them instead.
|
|
1285
|
+
const images = imagePathsIn(value);
|
|
1286
|
+
if (images) { void attachImageFiles(images); return; }
|
|
1287
|
+
const lines = value.split('\n').length;
|
|
1288
|
+
|
|
1289
|
+
if (lines <= 3 && value.length <= 240) {
|
|
1290
|
+
insert(value);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Numbered for the whole session. `pastes.length + 1` reused a number after a chip was
|
|
1295
|
+
// deleted, so two chips could share it and both expand to the same content.
|
|
1296
|
+
const id = ++pasteSeq;
|
|
1297
|
+
state.pastes.push({ id, text: value });
|
|
1298
|
+
remember(pasteStore, id, { id, text: value });
|
|
1299
|
+
// The token has to survive being typed around, so it is bracketed and carries its
|
|
1300
|
+
// own index; expansion matches on that rather than on position.
|
|
1301
|
+
insert(`[#${id} pasted ${lines} line${lines === 1 ? '' : 's'}, ${value.length} chars]`);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Paste from the system clipboard — right-click and Ctrl+V. The app owns the mouse, so
|
|
1306
|
+
* the terminal's own right-click paste never runs; without this, right-click did
|
|
1307
|
+
* nothing at all. Text goes through handlePaste (so a long paste becomes a chip); with
|
|
1308
|
+
* no text, an image on the clipboard becomes an attachment.
|
|
1309
|
+
*/
|
|
1310
|
+
let pasting = false;
|
|
1311
|
+
let returnToLiveTimer = null;
|
|
1312
|
+
async function pasteFromClipboard({ imageOnly = false } = {}) {
|
|
1313
|
+
if (pasting) return;
|
|
1314
|
+
pasting = true;
|
|
1315
|
+
try {
|
|
1316
|
+
const text = imageOnly ? null : await (api._readClipboardText || readClipboardText)();
|
|
1317
|
+
if (text) { handlePaste(text); notifyNow(); return; }
|
|
1318
|
+
const img = await (api._readClipboardImage || readClipboardImage)();
|
|
1319
|
+
if (img?.bytes?.length) {
|
|
1320
|
+
const os = await import('node:os');
|
|
1321
|
+
const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}${img.ext || '.png'}`);
|
|
1322
|
+
fs.writeFileSync(file, img.bytes);
|
|
1323
|
+
await attachImageFiles([file], { name: 'clipboard image' });
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
flash(imageOnly ? 'No image on the clipboard' : 'The clipboard is empty', 'info', 1600);
|
|
1327
|
+
} catch {
|
|
1328
|
+
flash('Could not read the clipboard', 'warn', 2000);
|
|
1329
|
+
} finally {
|
|
1330
|
+
pasting = false;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ── a paste chip is one thing ──
|
|
1335
|
+
//
|
|
1336
|
+
// It looks like a single object, so it has to behave like one. Backspacing a character
|
|
1337
|
+
// off the end leaves `[#1 pasted 120 lines, 969 char` — a token that no longer matches,
|
|
1338
|
+
// so the content it stands for is silently orphaned and the literal text is what gets
|
|
1339
|
+
// sent. Deleting it whole is both what you meant and the only way the placeholder and
|
|
1340
|
+
// the thing it holds cannot come apart.
|
|
1341
|
+
// An attached image is a chip too — `[Image #2]` — so it is removed the same way: one
|
|
1342
|
+
// Backspace over it. There was no way at all to take an attachment back before.
|
|
1343
|
+
const CHIP = /\[#(\d+) pasted [^\]]*\]|\[Image #(\d+)\]/;
|
|
1344
|
+
const chipBefore = (text, cursor) => new RegExp(`(?:${CHIP.source})$`).exec(text.slice(0, cursor));
|
|
1345
|
+
const chipAfter = (text, cursor) => new RegExp(`^(?:${CHIP.source})`).exec(text.slice(cursor));
|
|
1346
|
+
|
|
1347
|
+
/** Forget the content a chip stood for, once the chip is gone. */
|
|
1348
|
+
function dropPaste(match) {
|
|
1349
|
+
if (match?.[1]) state.pastes = state.pastes.filter((entry) => entry.id !== Number(match[1]));
|
|
1350
|
+
if (match?.[2]) {
|
|
1351
|
+
const before = state.attachments.length;
|
|
1352
|
+
state.attachments = state.attachments.filter((a) => a.id !== Number(match[2]));
|
|
1353
|
+
if (state.attachments.length < before) flash(`Image #${match[2]} removed`, 'info', 1400);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ── images ──
|
|
1358
|
+
let imageSeq = 0;
|
|
1359
|
+
let pasteSeq = 0;
|
|
1360
|
+
// What earlier chips stood for, so a message recalled from history is sent whole.
|
|
1361
|
+
// Bounded: images are the big part.
|
|
1362
|
+
const pasteStore = new Map();
|
|
1363
|
+
const imageStore = new Map();
|
|
1364
|
+
state.imageStore = imageStore;
|
|
1365
|
+
const remember = (store, id, value, max = store === imageStore ? 12 : 60) => {
|
|
1366
|
+
store.set(id, value);
|
|
1367
|
+
while (store.size > max) store.delete(store.keys().next().value);
|
|
1368
|
+
};
|
|
1369
|
+
/** Add an image: a chip at the cursor, the pixels kept aside until the message is sent. */
|
|
1370
|
+
function attachImage({ name, block }) {
|
|
1371
|
+
const id = ++imageSeq;
|
|
1372
|
+
const bytes = Math.round((String(block?.source?.data || '').length * 3) / 4);
|
|
1373
|
+
state.attachments.push({ id, name: name || `image ${id}`, block, bytes });
|
|
1374
|
+
remember(imageStore, id, { id, name: name || `image ${id}`, block, bytes });
|
|
1375
|
+
const before = state.input.slice(0, state.cursor);
|
|
1376
|
+
insert(`${before && !/\s$/.test(before) ? ' ' : ''}[Image #${id}] `);
|
|
1377
|
+
flash(`Image #${id} attached${bytes ? ` (${Math.max(1, Math.round(bytes / 1024))} KB)` : ''} — Backspace over it removes it`, 'info', 2400);
|
|
1378
|
+
notifyNow();
|
|
1379
|
+
return id;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* A paste that is nothing but paths to image files — what dragging a screenshot onto a
|
|
1384
|
+
* terminal window produces — attaches them instead of typing the paths. Quoted paths
|
|
1385
|
+
* (Windows, with spaces) and backslash-escaped spaces (macOS) are both understood.
|
|
1386
|
+
*/
|
|
1387
|
+
function imagePathsIn(text) {
|
|
1388
|
+
const raw = String(text || '').trim();
|
|
1389
|
+
if (!raw || raw.length > 4000) return null;
|
|
1390
|
+
const parts = [];
|
|
1391
|
+
const re = /"([^"]+)"|'([^']+)'|((?:\\ |[^\s"'])+)/g;
|
|
1392
|
+
for (const m of raw.matchAll(re)) parts.push((m[1] ?? m[2] ?? m[3]).replace(/\\ /g, ' '));
|
|
1393
|
+
if (!parts.length) return null;
|
|
1394
|
+
const files = parts.map((p) => p.replace(/^file:\/\/\/?/, '')).map((p) => {
|
|
1395
|
+
try { return /\.(png|jpe?g|gif|webp)$/i.test(p) && fs.statSync(p).isFile() ? p : null; } catch { return null; }
|
|
1396
|
+
});
|
|
1397
|
+
return files.every(Boolean) ? files : null;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
async function attachImageFiles(files, { name = null } = {}) {
|
|
1401
|
+
const { imageBlock } = await import('./agent/tools.js');
|
|
1402
|
+
for (const file of files) {
|
|
1403
|
+
const label = name || path.basename(file);
|
|
1404
|
+
// Too big as it is (a 4K screenshot): a smaller copy is made first.
|
|
1405
|
+
const ready = await shrinkImageFile(file).catch(() => null);
|
|
1406
|
+
const block = ready ? await imageBlock(ready).catch(() => null) : null;
|
|
1407
|
+
if (block) attachImage({ name: label, block });
|
|
1408
|
+
else flash(`${label} could not be attached — it must be png, jpg, gif or webp, and it could not be made smaller than 3.5 MB`, 'warn', 3200);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/** Put the real content back in place of every placeholder. */
|
|
1413
|
+
function expandPastes(line) {
|
|
1414
|
+
return line.replace(/\[#(\d+) pasted [^\]]*\]/g, (whole, id) => {
|
|
1415
|
+
// The current pastes, then earlier ones: a line recalled from history with Up still
|
|
1416
|
+
// has its content — it used to send the literal placeholder text.
|
|
1417
|
+
const blob = state.pastes.find((entry) => entry.id === Number(id)) || pasteStore.get(Number(id));
|
|
1418
|
+
return blob ? blob.text : whole;
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function submit() {
|
|
1423
|
+
const typed = state.input;
|
|
1424
|
+
const line = expandPastes(typed);
|
|
1425
|
+
state.pastes = [];
|
|
1426
|
+
clearInput();
|
|
1427
|
+
if (!line.trim()) return;
|
|
1428
|
+
// History keeps the TOKEN form. Pressing Up after sending a thousand-line paste
|
|
1429
|
+
// should bring back the sentence you wrote, not the thousand lines.
|
|
1430
|
+
if (state.history.at(-1) !== typed) state.history.push(typed);
|
|
1431
|
+
state.lastTyped = typed;
|
|
1432
|
+
state.historyIndex = state.history.length;
|
|
1433
|
+
state.viewingTranscript = false;
|
|
1434
|
+
state.transcriptOffset = 0;
|
|
1435
|
+
state.newMessages = 0;
|
|
1436
|
+
if (state.busy) state.queued.push(line);
|
|
1437
|
+
else state.onLine?.(line);
|
|
1438
|
+
notifyNow();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function historyMove(direction) {
|
|
1442
|
+
if (!state.history.length) return;
|
|
1443
|
+
if (state.historyIndex === state.history.length) state.draft = state.input;
|
|
1444
|
+
state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
|
|
1445
|
+
state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
|
|
1446
|
+
state.cursor = state.input.length;
|
|
1447
|
+
// A recalled `/review` must not open the command menu: Up then moved inside the menu
|
|
1448
|
+
// instead of on to the older prompt, and Enter ran whichever row was highlighted.
|
|
1449
|
+
state.menuDismissedFor = state.input;
|
|
1450
|
+
refreshMenu();
|
|
1451
|
+
notifyNow();
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function wordLeft() {
|
|
1455
|
+
const upto = state.input.slice(0, state.cursor);
|
|
1456
|
+
const match = /\S+\s*$/.exec(upto);
|
|
1457
|
+
return match ? state.cursor - match[0].length : 0;
|
|
1458
|
+
}
|
|
1459
|
+
function wordRight() {
|
|
1460
|
+
const rest = state.input.slice(state.cursor);
|
|
1461
|
+
const match = /^\s*\S+/.exec(rest);
|
|
1462
|
+
return match ? state.cursor + match[0].length : state.input.length;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function resolveDialog(value) {
|
|
1466
|
+
const dialog = state.dialog;
|
|
1467
|
+
if (!dialog) return;
|
|
1468
|
+
state.dialog = null;
|
|
1469
|
+
refreshMenu();
|
|
1470
|
+
dialog.resolve(value);
|
|
1471
|
+
notifyNow();
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function resolvePanel() {
|
|
1475
|
+
const panel = state.panel;
|
|
1476
|
+
if (!panel) return;
|
|
1477
|
+
state.panel = null;
|
|
1478
|
+
refreshMenu();
|
|
1479
|
+
panel.resolve?.();
|
|
1480
|
+
notifyNow();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
let lastEscapeAt = 0;
|
|
1484
|
+
// Drag state. A press is ambiguous until release: it may be a click on a control or
|
|
1485
|
+
// the start of a text selection, and treating it as either one too early breaks the
|
|
1486
|
+
// other.
|
|
1487
|
+
let pressedAt = null;
|
|
1488
|
+
let dragged = false;
|
|
1489
|
+
// Counted so /debug can distinguish "mouse reporting was requested" from "the
|
|
1490
|
+
// terminal is actually sending reports". Those are different facts and only the
|
|
1491
|
+
// second one means the wheel works.
|
|
1492
|
+
let mouseReports = 0;
|
|
1493
|
+
let lastMouseAt = 0;
|
|
1494
|
+
// Half a mouse report, waiting for the rest (see isMouseFragment).
|
|
1495
|
+
let mouseFragment = null;
|
|
1496
|
+
// Double- and triple-click: select a word, then a line — what every terminal does,
|
|
1497
|
+
// and the first thing people try when they want one word out of the output.
|
|
1498
|
+
let lastClick = null;
|
|
1499
|
+
let clickCount = 0;
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Scroll the transcript. Positive is back into history.
|
|
1503
|
+
*
|
|
1504
|
+
* Clamped at BOTH ends against the real row count, which the transcript publishes
|
|
1505
|
+
* each frame. Previously only the renderer clamped it: the offset itself grew without
|
|
1506
|
+
* limit, so scrolling up past the top kept counting into a void and scrolling back
|
|
1507
|
+
* down did nothing visible until it had counted all the way back. It read as the
|
|
1508
|
+
* wheel sticking, at random, for a random length of time.
|
|
1509
|
+
*/
|
|
1510
|
+
function scroll(rows) {
|
|
1511
|
+
// The ceiling comes from the last frame. On the very first scroll after new content
|
|
1512
|
+
// there has not been one yet, so a stale 0 would swallow the gesture — allow the
|
|
1513
|
+
// move and let the renderer snap it back. It is authoritative; this is a hint.
|
|
1514
|
+
const known = Math.max(0, state.maxScroll || 0);
|
|
1515
|
+
const ceiling = known || (state.transcript.length ? Number.MAX_SAFE_INTEGER : 0);
|
|
1516
|
+
const next = clamp(state.transcriptOffset + rows, 0, ceiling);
|
|
1517
|
+
if (next === state.transcriptOffset) {
|
|
1518
|
+
if (!next && state.viewingTranscript) { state.viewingTranscript = false; state.newMessages = 0; notifyNow(); }
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
state.transcriptOffset = next;
|
|
1522
|
+
state.viewingTranscript = next > 0;
|
|
1523
|
+
if (!next) state.newMessages = 0;
|
|
1524
|
+
notifyNow();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** Every screen cell between two points, as text. */
|
|
1528
|
+
function selectedText() {
|
|
1529
|
+
const sel = state.selection;
|
|
1530
|
+
const screen = state.screen;
|
|
1531
|
+
if (!sel?.anchor || !sel?.focus || !screen) return '';
|
|
1532
|
+
const a = sel.anchor;
|
|
1533
|
+
const b = sel.focus;
|
|
1534
|
+
const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
|
|
1535
|
+
const out = [];
|
|
1536
|
+
screen.rows.forEach((row, index) => {
|
|
1537
|
+
const y = screen.top + index;
|
|
1538
|
+
if (y < from.y || y > to.y) return;
|
|
1539
|
+
const text = rowText(row);
|
|
1540
|
+
const start = y === from.y ? Math.max(0, from.x - screen.left) : 0;
|
|
1541
|
+
const end = y === to.y ? Math.max(0, to.x - screen.left + 1) : text.length;
|
|
1542
|
+
out.push(text.slice(start, end).replace(/\s+$/, ''));
|
|
1543
|
+
});
|
|
1544
|
+
return out.join('\n').replace(/\n+$/, '');
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Select the word or the whole line under a point, the way a double- or triple-click
|
|
1549
|
+
* does in a terminal. Returns false when there is no text there.
|
|
1550
|
+
*/
|
|
1551
|
+
function selectAt(point, unit) {
|
|
1552
|
+
const screen = state.screen;
|
|
1553
|
+
if (!screen) return false;
|
|
1554
|
+
const row = screen.rows[point.y - screen.top];
|
|
1555
|
+
if (!row) return false;
|
|
1556
|
+
const text = rowText(row);
|
|
1557
|
+
const col = point.x - screen.left;
|
|
1558
|
+
let start, end;
|
|
1559
|
+
if (unit === 'line') {
|
|
1560
|
+
// Past the gutter: the message bullet and quote bars are decoration, not text.
|
|
1561
|
+
start = text.length - text.replace(/^[\s●○◆▎│┃⎿]+/, '').length;
|
|
1562
|
+
end = text.replace(/\s+$/, '').length - 1;
|
|
1563
|
+
} else {
|
|
1564
|
+
if (col < 0 || col >= text.length || /\s/.test(text[col])) return false;
|
|
1565
|
+
// A "word" is what you would want to paste: a path, a URL, an identifier.
|
|
1566
|
+
const inWord = (ch) => ch && !/[\s"'`()[\]{}<>,;]/.test(ch);
|
|
1567
|
+
start = col; end = col;
|
|
1568
|
+
while (inWord(text[start - 1])) start -= 1;
|
|
1569
|
+
while (inWord(text[end + 1])) end += 1;
|
|
1570
|
+
}
|
|
1571
|
+
if (end < start) return false;
|
|
1572
|
+
state.selection = { anchor: { x: screen.left + start, y: point.y }, focus: { x: screen.left + end, y: point.y } };
|
|
1573
|
+
notifyNow();
|
|
1574
|
+
void finishSelection();
|
|
1575
|
+
return true;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function clearSelection() {
|
|
1579
|
+
if (!state.selection) return;
|
|
1580
|
+
state.selection = null;
|
|
1581
|
+
notifyNow();
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Finish a drag: copy what was highlighted.
|
|
1586
|
+
*
|
|
1587
|
+
* Copy-on-select is the terminal behaviour people expect, and taking the mouse away
|
|
1588
|
+
* from the terminal took it with it. Doing it on release rather than on a menu keeps
|
|
1589
|
+
* the muscle memory intact; `copyOnSelect off` turns it back into a plain highlight.
|
|
1590
|
+
*/
|
|
1591
|
+
async function finishSelection() {
|
|
1592
|
+
const text = selectedText();
|
|
1593
|
+
if (!text.trim()) { clearSelection(); return; }
|
|
1594
|
+
if (settings?.copyOnSelect === false) {
|
|
1595
|
+
// Not "Ctrl+Shift+C": that is the TERMINAL's copy shortcut and it acts on the
|
|
1596
|
+
// terminal's own selection, which does not exist while the app owns the mouse.
|
|
1597
|
+
// Naming a shortcut that cannot work is worse than naming none.
|
|
1598
|
+
flash(`${text.length} characters selected · Ctrl+C copies them`, 'info', 4000);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
|
|
1602
|
+
if (ok) flash(`Copied ${text.length} character${text.length === 1 ? '' : 's'}`, 'ok', 1400);
|
|
1603
|
+
else flash('Nothing could copy it — no clipboard tool found', 'warn', 3000);
|
|
1604
|
+
// The highlight stays a moment so it is clear WHAT was copied, then goes.
|
|
1605
|
+
const copied = state.selection;
|
|
1606
|
+
setTimeout(() => { if (state.selection === copied) clearSelection(); }, 700);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* A click landed on something.
|
|
1611
|
+
*
|
|
1612
|
+
* Everything reachable on screen does what it looks like it does: the mode pill opens
|
|
1613
|
+
* the mode picker, the model name opens the model picker, the context meter offers to
|
|
1614
|
+
* compact. That is the whole point of drawing them as distinct objects — an indicator
|
|
1615
|
+
* you cannot act on is a thing you have to go and find a command for.
|
|
1616
|
+
*/
|
|
1617
|
+
function dispatchClick(region, event) {
|
|
1618
|
+
if (!region) return;
|
|
1619
|
+
// While a dialog or panel is open, only its own controls respond. A click on the
|
|
1620
|
+
// header during a permission prompt opened the model picker OVER the prompt, which
|
|
1621
|
+
// was never answered — the session was stuck until the terminal was closed.
|
|
1622
|
+
const own = ['swallow', 'dialogOption', 'paletteRow', 'menuRow'];
|
|
1623
|
+
if ((state.dialog || state.panel) && !own.includes(region.action.type)) return;
|
|
1624
|
+
switch (region.action.type) {
|
|
1625
|
+
case 'swallow':
|
|
1626
|
+
return;
|
|
1627
|
+
case 'dialogOption': {
|
|
1628
|
+
const d = state.dialog;
|
|
1629
|
+
if (!d) return;
|
|
1630
|
+
d.index = clamp(region.action.index, 0, d.options.length - 1);
|
|
1631
|
+
return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
|
|
1632
|
+
}
|
|
1633
|
+
case 'paletteRow': {
|
|
1634
|
+
const panel = state.panel;
|
|
1635
|
+
if (!panel?.palette) return;
|
|
1636
|
+
panel.index = clamp(region.action.index, 0, Math.max(0, panel.options.length - 1));
|
|
1637
|
+
const chosen = panel.options[panel.index];
|
|
1638
|
+
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1639
|
+
state.panel = null;
|
|
1640
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1641
|
+
refreshMenu();
|
|
1642
|
+
if (!name) return notifyNow();
|
|
1643
|
+
state.input = name;
|
|
1644
|
+
state.cursor = name.length;
|
|
1645
|
+
return submit();
|
|
1646
|
+
}
|
|
1647
|
+
case 'menuRow': {
|
|
1648
|
+
state.menuIndex = clamp(region.action.index, 0, state.menu.length - 1);
|
|
1649
|
+
return chooseMenu({ run: true });
|
|
1650
|
+
}
|
|
1651
|
+
case 'mode':
|
|
1652
|
+
return void state.onModeCycle?.();
|
|
1653
|
+
case 'model':
|
|
1654
|
+
return void state.onCommand?.('/model');
|
|
1655
|
+
case 'context':
|
|
1656
|
+
return void state.onCommand?.('/context');
|
|
1657
|
+
case 'link':
|
|
1658
|
+
// Only http(s) reaches here — see openUrl. A terminal will happily render
|
|
1659
|
+
// `file:///` or `javascript:` in text that came from a model or a fetched page,
|
|
1660
|
+
// and handing either to the shell runs something nobody agreed to.
|
|
1661
|
+
if (openUrl(region.action.url)) {
|
|
1662
|
+
flash(`Opened ${region.action.url.slice(0, 60)}`, 'info', 1600);
|
|
1663
|
+
}
|
|
1664
|
+
return;
|
|
1665
|
+
case 'status':
|
|
1666
|
+
return openPalette();
|
|
1667
|
+
case 'transcript':
|
|
1668
|
+
// A click in the body is how you get back to the live end after scrolling.
|
|
1669
|
+
// Deferred, and cancelled by a second click: a double-click to select a word
|
|
1670
|
+
// while scrolled back used to jump to the live end on the first click, so the
|
|
1671
|
+
// second one selected whatever had moved under the pointer.
|
|
1672
|
+
if (state.viewingTranscript && event.button === 'left') {
|
|
1673
|
+
clearTimeout(returnToLiveTimer);
|
|
1674
|
+
returnToLiveTimer = setTimeout(() => {
|
|
1675
|
+
state.viewingTranscript = false;
|
|
1676
|
+
state.transcriptOffset = 0;
|
|
1677
|
+
state.newMessages = 0;
|
|
1678
|
+
notifyNow();
|
|
1679
|
+
}, 350);
|
|
1680
|
+
}
|
|
1681
|
+
return;
|
|
1682
|
+
default:
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** Scroll the open panel by `delta` rows; at the bottom it follows new output again. */
|
|
1687
|
+
function scrollPanel(delta) {
|
|
1688
|
+
const panel = state.panel;
|
|
1689
|
+
if (!panel || panel.palette) return;
|
|
1690
|
+
const max = panel._maxStart ?? 0;
|
|
1691
|
+
panel.scroll = Math.max(0, Math.min(max, (panel.follow ? max : panel.scroll || 0) + delta));
|
|
1692
|
+
panel.follow = panel.scroll >= max && !!panel.live;
|
|
1693
|
+
notifyNow();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function openPalette() {
|
|
1697
|
+
state.panel = {
|
|
1698
|
+
title: 'COMMAND PALETTE', palette: true, filter: '',
|
|
1699
|
+
all: commands, options: commands, index: 0, lines: [], resolve: () => {},
|
|
1700
|
+
};
|
|
1701
|
+
notifyNow();
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function handleKey(input, key, exit) {
|
|
1705
|
+
if (!state.active) return;
|
|
1706
|
+
|
|
1707
|
+
// ── the mouse arrives here too ──
|
|
1708
|
+
//
|
|
1709
|
+
// Ink does not recognise the reports, so it hands them through as ordinary input
|
|
1710
|
+
// with the escape stripped. Without this guard, scrolling the wheel types
|
|
1711
|
+
// `[<64;10;5M` into the composer.
|
|
1712
|
+
if (mouseFragment) {
|
|
1713
|
+
const fresh = Date.now() - mouseFragment.at < 300;
|
|
1714
|
+
const joined = mouseFragment.text + input;
|
|
1715
|
+
mouseFragment = null;
|
|
1716
|
+
if (fresh && (isMouse(joined) || isMouseFragment(joined))) input = joined;
|
|
1717
|
+
}
|
|
1718
|
+
if (isMouseFragment(input)) {
|
|
1719
|
+
mouseFragment = { text: input, at: Date.now() };
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
// A terminal that answers the startup probe late (over SSH, under a multiplexer)
|
|
1723
|
+
// delivers the background-colour or device-attributes reply after the app has
|
|
1724
|
+
// started; Ink passes it through as typed text.
|
|
1725
|
+
if (TERMINAL_REPLY.test(input)) return;
|
|
1726
|
+
if (isMouse(input)) {
|
|
1727
|
+
mouseReports += 1;
|
|
1728
|
+
lastMouseAt = Date.now();
|
|
1729
|
+
const event = parseMouse(input);
|
|
1730
|
+
if (!event) return;
|
|
1731
|
+
// The wheel scrolls what is in front: a panel, the palette's list, or nothing under
|
|
1732
|
+
// a dialog. It used to scroll the transcript hidden behind them.
|
|
1733
|
+
if (event.kind === 'wheelUp' || event.kind === 'wheelDown') {
|
|
1734
|
+
const up = event.kind === 'wheelUp';
|
|
1735
|
+
if (state.dialog) return undefined;
|
|
1736
|
+
if (state.panel?.palette) { state.panel.index = clamp(state.panel.index + (up ? -1 : 1), 0, Math.max(0, state.panel.options.length - 1)); return notifyNow(); }
|
|
1737
|
+
if (state.panel) return scrollPanel(up ? -3 : 3);
|
|
1738
|
+
return scroll(up ? scrollStep() : -scrollStep());
|
|
1739
|
+
}
|
|
1740
|
+
// Right-click pastes, as it does in a Windows console — the terminal cannot do it
|
|
1741
|
+
// itself while the app has the mouse.
|
|
1742
|
+
if (event.kind === 'down' && event.button === 'right') {
|
|
1743
|
+
if (!state.dialog && !state.panel) void pasteFromClipboard();
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (event.kind === 'down' && event.button === 'left') {
|
|
1748
|
+
clearTimeout(returnToLiveTimer); // a second click: this is a double-click, stay put
|
|
1749
|
+
// A press starts a possible drag AND a possible click. Which it was is only
|
|
1750
|
+
// known on release, so nothing is dispatched yet — dispatching here made every
|
|
1751
|
+
// attempt to select text also press whatever was under the cursor.
|
|
1752
|
+
pressedAt = { x: event.x, y: event.y };
|
|
1753
|
+
dragged = false;
|
|
1754
|
+
clearSelection();
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (event.kind === 'drag' && pressedAt) {
|
|
1758
|
+
if (event.x !== pressedAt.x || event.y !== pressedAt.y) dragged = true;
|
|
1759
|
+
state.selection = { anchor: pressedAt, focus: { x: event.x, y: event.y } };
|
|
1760
|
+
return notifyNow();
|
|
1761
|
+
}
|
|
1762
|
+
if (event.kind === 'up') {
|
|
1763
|
+
const start = pressedAt;
|
|
1764
|
+
pressedAt = null;
|
|
1765
|
+
if (!start) return;
|
|
1766
|
+
if (dragged) { dragged = false; void finishSelection(); return; }
|
|
1767
|
+
const now = Date.now();
|
|
1768
|
+
const again = lastClick && now - lastClick.at < 450 && lastClick.y === start.y && Math.abs(lastClick.x - start.x) <= 1;
|
|
1769
|
+
clickCount = again ? clickCount + 1 : 1;
|
|
1770
|
+
lastClick = { x: start.x, y: start.y, at: now };
|
|
1771
|
+
const region = hits.at(start.x, start.y);
|
|
1772
|
+
const onText = !region || region.action.type === 'transcript' || region.action.type === 'swallow';
|
|
1773
|
+
if (clickCount >= 2 && onText && selectAt(start, clickCount >= 3 ? 'line' : 'word')) return;
|
|
1774
|
+
return dispatchClick(region, event);
|
|
1775
|
+
}
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// ── dialog has the keyboard ──
|
|
1780
|
+
if (state.dialog) {
|
|
1781
|
+
const d = state.dialog;
|
|
1782
|
+
if (key.escape) return resolveDialog(undefined);
|
|
1783
|
+
if (key.upArrow || key.up) { d.index = clamp(d.index - 1, 0, d.options.length - 1); return notifyNow(); }
|
|
1784
|
+
if (key.downArrow || key.down) { d.index = clamp(d.index + 1, 0, d.options.length - 1); return notifyNow(); }
|
|
1785
|
+
if (key.return || key.enter) return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
|
|
1786
|
+
if (/^[1-9]$/.test(input)) {
|
|
1787
|
+
const index = clamp(Number(input) - 1, 0, d.options.length - 1);
|
|
1788
|
+
d.index = index;
|
|
1789
|
+
// A number is a decision, not a cursor move: waiting for Enter after it is the
|
|
1790
|
+
// most common reason a permission prompt feels unresponsive.
|
|
1791
|
+
return resolveDialog({ ...d.options[index], comment: d.comment || '' });
|
|
1792
|
+
}
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ── palette / panel has the keyboard ──
|
|
1797
|
+
if (state.panel) {
|
|
1798
|
+
const panel = state.panel;
|
|
1799
|
+
if (panel.palette) {
|
|
1800
|
+
if (key.upArrow || key.up) { panel.index = clamp(panel.index - 1, 0, panel.options.length - 1); return notifyNow(); }
|
|
1801
|
+
if (key.downArrow || key.down) { panel.index = clamp(panel.index + 1, 0, panel.options.length - 1); return notifyNow(); }
|
|
1802
|
+
if (key.return || key.enter) {
|
|
1803
|
+
const chosen = panel.options[panel.index];
|
|
1804
|
+
const name = typeof chosen === 'string' ? chosen : chosen?.name;
|
|
1805
|
+
state.panel = null;
|
|
1806
|
+
if (panel.onPick) { if (chosen) panel.onPick(chosen); return notifyNow(); }
|
|
1807
|
+
refreshMenu();
|
|
1808
|
+
if (!name) return notifyNow();
|
|
1809
|
+
// Run it. Picking a command out of a palette and then being handed it to
|
|
1810
|
+
// press Enter on again is a step that exists for no reason. Shift+Enter is
|
|
1811
|
+
// the escape hatch for a command you want to add an argument to.
|
|
1812
|
+
state.input = name;
|
|
1813
|
+
state.cursor = name.length;
|
|
1814
|
+
if (key.shift) { state.input = `${name} `; state.cursor = state.input.length; return notifyNow(); }
|
|
1815
|
+
submit();
|
|
1816
|
+
return notifyNow();
|
|
1817
|
+
}
|
|
1818
|
+
if (key.escape) { state.panel = null; panel.resolve?.(); return notifyNow(); }
|
|
1819
|
+
if (key.backspace || key.delete) {
|
|
1820
|
+
panel.filter = String(panel.filter || '').slice(0, -1);
|
|
1821
|
+
applyPaletteFilter(panel);
|
|
1822
|
+
return notifyNow();
|
|
1823
|
+
}
|
|
1824
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1825
|
+
panel.filter = `${panel.filter || ''}${input}`;
|
|
1826
|
+
applyPaletteFilter(panel);
|
|
1827
|
+
return notifyNow();
|
|
1828
|
+
}
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (key.escape || key.return || key.enter) resolvePanel();
|
|
1832
|
+
else if (key.upArrow || key.up) scrollPanel(-1);
|
|
1833
|
+
else if (key.downArrow || key.down) scrollPanel(1);
|
|
1834
|
+
else if (key.pageUp || key.pageup) scrollPanel(-(panel._maxRows || 10));
|
|
1835
|
+
else if (key.pageDown || key.pagedown) scrollPanel(panel._maxRows || 10);
|
|
1836
|
+
else if (key.home) scrollPanel(-1e9);
|
|
1837
|
+
else if (key.end) scrollPanel(1e9);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Fast PTYs on Windows deliver a typed line and its carriage return in one chunk.
|
|
1842
|
+
// Split here or the return becomes a literal character in the composer.
|
|
1843
|
+
const newlineAt = String(input || '').search(/[\r\n]/);
|
|
1844
|
+
if (newlineAt >= 0) {
|
|
1845
|
+
const text = String(input);
|
|
1846
|
+
const typed = text.slice(0, newlineAt);
|
|
1847
|
+
const sep = text[newlineAt];
|
|
1848
|
+
let rest = text.slice(newlineAt + 1);
|
|
1849
|
+
if (typed) insert(typed);
|
|
1850
|
+
// Enter (\r) sends; a line feed (\n — Ctrl+J), Alt/Shift+Enter, or a `\` right
|
|
1851
|
+
// before the cursor is a NEW LINE. This path used to send on every one of them, so
|
|
1852
|
+
// no key in the README could add a line: each line went out as its own message.
|
|
1853
|
+
// A \r\n pair is a line break inside pasted text, never a send.
|
|
1854
|
+
let newline = key.shift || key.meta || sep === '\n';
|
|
1855
|
+
if (sep === '\r' && rest.startsWith('\n')) { newline = true; rest = rest.slice(1); }
|
|
1856
|
+
if (!newline && state.input[state.cursor - 1] === '\\') {
|
|
1857
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1858
|
+
state.cursor -= 1;
|
|
1859
|
+
newline = true;
|
|
1860
|
+
}
|
|
1861
|
+
if (newline) insert('\n');
|
|
1862
|
+
// Whether Enter arrives on its own or glued to the typed text depends on how the
|
|
1863
|
+
// PTY batched the keystrokes — so both paths make the same decision, including
|
|
1864
|
+
// taking the highlighted menu item (`/re` + Enter runs `/review`).
|
|
1865
|
+
else if (state.menu.length) chooseMenu({ run: true });
|
|
1866
|
+
else submit();
|
|
1867
|
+
// The rest of the chunk goes through the same rules (it may hold more lines).
|
|
1868
|
+
if (rest) handleKey(rest, {}, exit);
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Ink reports Ctrl+<letter> as `key.ctrl` with the LETTER itself in `input`; its
|
|
1873
|
+
// Key type has no `name` field at all. Every `key.name === 'l'` style check in the
|
|
1874
|
+
// previous version of this file was therefore dead code — Ctrl+L, Ctrl+P, Ctrl+U,
|
|
1875
|
+
// Ctrl+K, Ctrl+A and Ctrl+E all did nothing, silently, because a condition that is
|
|
1876
|
+
// never true is indistinguishable from a shortcut nobody presses.
|
|
1877
|
+
//
|
|
1878
|
+
// `key.name` is still accepted so the controller tests (and the classic renderer's
|
|
1879
|
+
// readline keypress objects) can drive the same handler.
|
|
1880
|
+
const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
|
|
1881
|
+
|
|
1882
|
+
if (ctrlKey === 'c') {
|
|
1883
|
+
// With text selected, Ctrl+C copies it (as the README says) — including mid-turn,
|
|
1884
|
+
// where it used to stop the turn instead. With copyOnSelect off it was the only way.
|
|
1885
|
+
const selected = state.selection ? selectedText() : '';
|
|
1886
|
+
if (selected.trim()) {
|
|
1887
|
+
void (state.onCopy ? state.onCopy(selected) : Promise.resolve(false)).then((ok) => {
|
|
1888
|
+
flash(ok ? `Copied ${selected.length} character${selected.length === 1 ? '' : 's'}` : 'Nothing could copy it — no clipboard tool found', ok ? 'ok' : 'warn', 1600);
|
|
1889
|
+
clearSelection();
|
|
1890
|
+
});
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (state.busy) state.onEscape?.();
|
|
1894
|
+
else if (state.input) clearInput();
|
|
1895
|
+
else flash('Type /exit to leave', 'info', 1500);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (key.shift && key.tab) { void state.onModeCycle?.(); return; }
|
|
1899
|
+
if (ctrlKey === 'l') { api.clearTranscript(); return; }
|
|
1900
|
+
if (ctrlKey === 'd') { api.toggleDebug(); return; }
|
|
1901
|
+
if (ctrlKey === 'p') return openPalette();
|
|
1902
|
+
if (ctrlKey === 'r') return openHistorySearch();
|
|
1903
|
+
if (ctrlKey === 'o') return openTranscriptSearch();
|
|
1904
|
+
// Ctrl+V reaches the app only in terminals that do not paste on it themselves (those
|
|
1905
|
+
// send the text as a bracketed paste instead). Alt+V is for an image — a screenshot
|
|
1906
|
+
// cannot be typed in, so no terminal pastes one.
|
|
1907
|
+
if (ctrlKey === 'v') { void pasteFromClipboard(); return; }
|
|
1908
|
+
if (key.meta && String(input).toLowerCase() === 'v') { void pasteFromClipboard({ imageOnly: true }); return; }
|
|
1909
|
+
if (key.escape) {
|
|
1910
|
+
if (state.busy) return state.onEscape?.();
|
|
1911
|
+
// Esc closes the suggestion menu first, as the README says. It went straight to the
|
|
1912
|
+
// rewind countdown, so a message ending in `@word` or `/word` could not be sent —
|
|
1913
|
+
// Enter kept choosing a suggestion.
|
|
1914
|
+
if (state.menu.length) { state.menu = []; state.menuIndex = -1; state.menuDismissedFor = state.input; return notifyNow(); }
|
|
1915
|
+
if (state.viewingTranscript) { state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0; return notifyNow(); }
|
|
1916
|
+
const now = Date.now();
|
|
1917
|
+
if (lastEscapeAt && now - lastEscapeAt < 1200) {
|
|
1918
|
+
lastEscapeAt = 0;
|
|
1919
|
+
if (flashTimer) { clearTimeout(flashTimer); flashTimer = null; }
|
|
1920
|
+
state.flash = null;
|
|
1921
|
+
state.onRewind?.();
|
|
1922
|
+
} else {
|
|
1923
|
+
lastEscapeAt = now;
|
|
1924
|
+
flash('Press Esc again to rewind', 'info', 1200);
|
|
1925
|
+
}
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// ── scrollback ──
|
|
1930
|
+
if (key.pageUp || key.pageup) return scroll(10);
|
|
1931
|
+
if (key.pageDown || key.pagedown) return scroll(-10);
|
|
1932
|
+
if ((key.ctrl && (key.end || ctrlKey === 'g'))) {
|
|
1933
|
+
state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0;
|
|
1934
|
+
return notifyNow();
|
|
1935
|
+
}
|
|
1936
|
+
if ((key.ctrl && key.home)) return scroll(100000);
|
|
1937
|
+
|
|
1938
|
+
// ── menu ──
|
|
1939
|
+
if ((key.upArrow || key.up) && state.menu.length) { state.menuIndex = clamp(state.menuIndex - 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1940
|
+
if ((key.downArrow || key.down) && state.menu.length) { state.menuIndex = clamp(state.menuIndex + 1, 0, state.menu.length - 1); return notifyNow(); }
|
|
1941
|
+
if (key.tab && state.menu.length) return chooseMenu();
|
|
1942
|
+
if (key.return || key.enter || input === '\n') {
|
|
1943
|
+
// A new line, not a send: Ctrl+J (a line feed, where Enter is a carriage return),
|
|
1944
|
+
// Alt+Enter, Shift+Enter where the terminal reports it, or a `\` right before the
|
|
1945
|
+
// cursor. Only Shift was checked, which almost no terminal reports — so every way
|
|
1946
|
+
// the README advertised sent the half-written message instead.
|
|
1947
|
+
if (key.shift || key.meta || input === '\n') return insert('\n');
|
|
1948
|
+
if (state.input[state.cursor - 1] === '\\') {
|
|
1949
|
+
state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor);
|
|
1950
|
+
state.cursor -= 1;
|
|
1951
|
+
return insert('\n');
|
|
1952
|
+
}
|
|
1953
|
+
if (state.menu.length) return chooseMenu({ run: true });
|
|
1954
|
+
return submit();
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// ── line editing ──
|
|
1958
|
+
if (key.leftArrow || key.left) {
|
|
1959
|
+
if (key.ctrl || key.meta) state.cursor = wordLeft();
|
|
1960
|
+
else {
|
|
1961
|
+
// Step over a chip, not into it. A cursor sitting inside `[#1 pasted 120 …]`
|
|
1962
|
+
// can only do damage: anything typed there breaks the token.
|
|
1963
|
+
const chip = chipBefore(state.input, state.cursor);
|
|
1964
|
+
state.cursor = Math.max(0, state.cursor - (chip ? chip[0].length : 1));
|
|
1965
|
+
}
|
|
1966
|
+
return notifyNow();
|
|
1967
|
+
}
|
|
1968
|
+
if (key.rightArrow || key.right) {
|
|
1969
|
+
if (key.ctrl || key.meta) state.cursor = wordRight();
|
|
1970
|
+
else {
|
|
1971
|
+
const chip = chipAfter(state.input, state.cursor);
|
|
1972
|
+
state.cursor = Math.min(state.input.length, state.cursor + (chip ? chip[0].length : 1));
|
|
1973
|
+
}
|
|
1974
|
+
return notifyNow();
|
|
1975
|
+
}
|
|
1976
|
+
if (key.home || (ctrlKey === 'a')) { state.cursor = 0; return notifyNow(); }
|
|
1977
|
+
if (key.end || (ctrlKey === 'e')) { state.cursor = state.input.length; return notifyNow(); }
|
|
1978
|
+
if (ctrlKey === 'w') {
|
|
1979
|
+
// A chip is one word. Deleting "the last word" of `…, 969 chars]` would otherwise
|
|
1980
|
+
// leave a broken token behind.
|
|
1981
|
+
const chip = chipBefore(state.input, state.cursor);
|
|
1982
|
+
const at = chip ? state.cursor - chip[0].length : wordLeft();
|
|
1983
|
+
if (chip) dropPaste(chip);
|
|
1984
|
+
state.input = state.input.slice(0, at) + state.input.slice(state.cursor);
|
|
1985
|
+
state.cursor = at;
|
|
1986
|
+
refreshMenu();
|
|
1987
|
+
return notifyNow();
|
|
1988
|
+
}
|
|
1989
|
+
if (key.backspace) {
|
|
1990
|
+
if (state.cursor === 0) return;
|
|
1991
|
+
// A chip is inserted with a space after it; Backspace right after that space means
|
|
1992
|
+
// the chip, not the space — otherwise the first Backspace seemed to do nothing.
|
|
1993
|
+
const spaced = state.input[state.cursor - 1] === ' ' ? chipBefore(state.input, state.cursor - 1) : null;
|
|
1994
|
+
const chip = spaced || chipBefore(state.input, state.cursor);
|
|
1995
|
+
const eat = chip ? chip[0].length + (spaced ? 1 : 0) : 1;
|
|
1996
|
+
if (chip) dropPaste(chip);
|
|
1997
|
+
state.input = state.input.slice(0, state.cursor - eat) + state.input.slice(state.cursor);
|
|
1998
|
+
state.cursor -= eat;
|
|
1999
|
+
refreshMenu();
|
|
2000
|
+
return notifyNow();
|
|
2001
|
+
}
|
|
2002
|
+
if (key.delete) {
|
|
2003
|
+
const chip = chipAfter(state.input, state.cursor);
|
|
2004
|
+
const eat = chip ? chip[0].length : 1;
|
|
2005
|
+
if (chip) dropPaste(chip);
|
|
2006
|
+
state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + eat);
|
|
2007
|
+
refreshMenu();
|
|
2008
|
+
return notifyNow();
|
|
2009
|
+
}
|
|
2010
|
+
if (ctrlKey === 'u') { state.input = state.input.slice(state.cursor); state.cursor = 0; refreshMenu(); return notifyNow(); }
|
|
2011
|
+
if (ctrlKey === 'k') { state.input = state.input.slice(0, state.cursor); refreshMenu(); return notifyNow(); }
|
|
2012
|
+
if (key.upArrow || key.up) return historyMove(-1);
|
|
2013
|
+
if (key.downArrow || key.down) return historyMove(1);
|
|
2014
|
+
if (input && !key.ctrl && !key.meta) {
|
|
2015
|
+
// Control characters are keys, never text. Two pressed quickly arrive as one chunk
|
|
2016
|
+
// ("\x05\x15" — Ctrl+E, Ctrl+U) that Ink hands over as input; it used to be
|
|
2017
|
+
// inserted as invisible characters, so the line no longer started with `/` and a
|
|
2018
|
+
// command went to the model as a message. Ctrl+_ \ ] ^ (0x1c–0x1f) did the same.
|
|
2019
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(input)) {
|
|
2020
|
+
let text = '';
|
|
2021
|
+
const flushText = () => { if (text) { insert(text); text = ''; } };
|
|
2022
|
+
for (const ch of input) {
|
|
2023
|
+
const code = ch.charCodeAt(0);
|
|
2024
|
+
if (code >= 1 && code <= 26 && code !== 9) { flushText(); handleKey(String.fromCharCode(96 + code), { ctrl: true }, exit); }
|
|
2025
|
+
else if (code === 0x7f || code === 8) { flushText(); handleKey('', { backspace: true }, exit); }
|
|
2026
|
+
else if (code < 32 && code !== 9) { /* 0x1c–0x1f and NUL: nothing to type */ }
|
|
2027
|
+
else text += ch;
|
|
2028
|
+
}
|
|
2029
|
+
flushText();
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
insert(input);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* A searchable list whose choice goes to `onPick` — Ctrl+R (prompt history) and
|
|
2038
|
+
* Ctrl+O (the conversation). Neither existed in this view; the README listed both.
|
|
2039
|
+
* Matching is plain "contains" (case-insensitive): these are sentences, not names.
|
|
2040
|
+
*/
|
|
2041
|
+
function openSearchPanel({ title, options, onPick, empty }) {
|
|
2042
|
+
if (!options.length) { flash(empty, 'info', 1600); return; }
|
|
2043
|
+
state.panel = { title, palette: true, contains: true, filter: '', all: options, options, index: 0, lines: [], onPick, resolve: () => {} };
|
|
2044
|
+
state.menu = [];
|
|
2045
|
+
notifyNow();
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function openHistorySearch() {
|
|
2049
|
+
const seen = new Set();
|
|
2050
|
+
const options = [];
|
|
2051
|
+
for (const entry of [...state.history].reverse()) {
|
|
2052
|
+
if (!entry || seen.has(entry)) continue;
|
|
2053
|
+
seen.add(entry);
|
|
2054
|
+
options.push({ name: entry.replace(/\s+/g, ' ').slice(0, 110), value: entry });
|
|
2055
|
+
}
|
|
2056
|
+
openSearchPanel({
|
|
2057
|
+
title: 'SEARCH YOUR PROMPTS', options, empty: 'No prompt history yet',
|
|
2058
|
+
onPick: (o) => { state.input = o.value; state.cursor = o.value.length; state.menuDismissedFor = state.input; notifyNow(); },
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function openTranscriptSearch() {
|
|
2063
|
+
const inner = Math.max(24, (state._width || process.stdout.columns || 80) - 2);
|
|
2064
|
+
const rows = [];
|
|
2065
|
+
for (const entry of state.transcript) {
|
|
2066
|
+
const built = rowsFor(entry, inner);
|
|
2067
|
+
if (!built.length) continue;
|
|
2068
|
+
if (rows.length) rows.push([]);
|
|
2069
|
+
rows.push(...built);
|
|
2070
|
+
}
|
|
2071
|
+
const total = rows.length;
|
|
2072
|
+
const options = [];
|
|
2073
|
+
rows.forEach((row, i) => {
|
|
2074
|
+
const text = rowText(row).trim();
|
|
2075
|
+
if (text) options.push({ name: text.slice(0, 110), value: i });
|
|
2076
|
+
});
|
|
2077
|
+
openSearchPanel({
|
|
2078
|
+
title: 'SEARCH THE CONVERSATION', options: options.reverse(), empty: 'Nothing in the conversation yet',
|
|
2079
|
+
onPick: (o) => {
|
|
2080
|
+
// Scroll so the chosen line sits mid-screen (the offset counts rows up from the
|
|
2081
|
+
// bottom).
|
|
2082
|
+
const half = Math.floor((state._transcriptHeight || 20) / 2);
|
|
2083
|
+
const offset = Math.max(0, total - o.value - 1 - half);
|
|
2084
|
+
state.viewingTranscript = offset > 0;
|
|
2085
|
+
state.transcriptOffset = offset;
|
|
2086
|
+
flash(`Line ${o.value + 1} of ${total} — Ctrl+End returns to the end`, 'info', 2200);
|
|
2087
|
+
notifyNow();
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function applyPaletteFilter(panel) {
|
|
2093
|
+
const filter = String(panel.filter || '');
|
|
2094
|
+
if (!filter) { panel.options = panel.all; panel.index = 0; return; }
|
|
2095
|
+
if (panel.contains) {
|
|
2096
|
+
const needle = filter.toLowerCase();
|
|
2097
|
+
panel.options = panel.all.filter((o) => String(o.name).toLowerCase().includes(needle));
|
|
2098
|
+
panel.index = 0;
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
panel.options = panel.all
|
|
2102
|
+
.map((command) => {
|
|
2103
|
+
const name = typeof command === 'string' ? command : command.name;
|
|
2104
|
+
const match = fuzzy(filter, name.replace(/^\//, ''));
|
|
2105
|
+
return match ? { command, score: match.score } : null;
|
|
2106
|
+
})
|
|
2107
|
+
.filter(Boolean)
|
|
2108
|
+
.sort((a, b) => b.score - a.score)
|
|
2109
|
+
.map((entry) => entry.command);
|
|
2110
|
+
panel.index = 0;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
const api = {
|
|
2114
|
+
state,
|
|
2115
|
+
|
|
2116
|
+
attach(fn) { update = fn; },
|
|
2117
|
+
onEscape(fn) { state.onEscape = fn; },
|
|
2118
|
+
onRewind(fn) { state.onRewind = fn; },
|
|
2119
|
+
setMeta(next) { Object.assign(state.meta, next); notify(); },
|
|
2120
|
+
setSpent(next) { Object.assign(state.spent, next || {}); notify(); },
|
|
2121
|
+
|
|
2122
|
+
hits,
|
|
2123
|
+
onCommand(fn) { state.onCommand = fn; },
|
|
2124
|
+
onCopy(fn) { state.onCopy = fn; },
|
|
2125
|
+
handlePaste,
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* A resize is the one moment the screen has to be thrown away and redrawn.
|
|
2129
|
+
*
|
|
2130
|
+
* Ink clears only when the terminal gets NARROWER, on the reasoning that a wider
|
|
2131
|
+
* one cannot leave anything behind. That is true of output that flows, and false of
|
|
2132
|
+
* an app that draws a fixed number of rows: make the window shorter and the taller
|
|
2133
|
+
* frame's lower rows stay on screen underneath the new one. That is the duplicated
|
|
2134
|
+
* composer and the layout "breaking" on resize.
|
|
2135
|
+
*
|
|
2136
|
+
* Clearing on every size change costs one repaint during a drag-resize and removes
|
|
2137
|
+
* the whole class of problem.
|
|
2138
|
+
*/
|
|
2139
|
+
onResize(width, height) {
|
|
2140
|
+
const last = state.lastSize;
|
|
2141
|
+
state.lastSize = { width, height };
|
|
2142
|
+
if (last && last.width === width && last.height === height) return;
|
|
2143
|
+
// Anything expressed in screen coordinates is now about the wrong cells.
|
|
2144
|
+
state.selection = null;
|
|
2145
|
+
// The explicit erase wipes cells Ink is not tracking: after the window SHRINKS, the
|
|
2146
|
+
// rows of the taller frame below the new one are not in Ink's set and would stay.
|
|
2147
|
+
//
|
|
2148
|
+
// Then one EMPTY frame, then the real one. Ink redraws line by line
|
|
2149
|
+
// (incrementalRendering) against what it believes is on screen, and its clear()
|
|
2150
|
+
// re-records the old frame as present — so after the erase above, the next frame
|
|
2151
|
+
// rewrote only the lines that had changed, and the screen stayed almost blank
|
|
2152
|
+
// (measured). An empty frame makes Ink forget every line; the frame after it is
|
|
2153
|
+
// then written in full.
|
|
2154
|
+
try {
|
|
2155
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
2156
|
+
} catch { /* mid-teardown */ }
|
|
2157
|
+
state.blankFrame = true;
|
|
2158
|
+
notifyNow();
|
|
2159
|
+
setTimeout(() => { state.blankFrame = false; notifyNow(); }, 120);
|
|
2160
|
+
},
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* What the mouse is actually doing, for `/debug`.
|
|
2164
|
+
*
|
|
2165
|
+
* Enabling reporting and receiving reports are different facts, and only the second
|
|
2166
|
+
* one means the mouse works. A terminal that ignores the request looks identical
|
|
2167
|
+
* from this side to one that is simply not being used — so the count is kept, and
|
|
2168
|
+
* "requested but nothing received" is a thing the diagnostics can say out loud
|
|
2169
|
+
* instead of leaving someone to guess.
|
|
2170
|
+
*/
|
|
2171
|
+
mouseReport() {
|
|
2172
|
+
return {
|
|
2173
|
+
requested: !!state.mouse,
|
|
2174
|
+
reportsSeen: mouseReports,
|
|
2175
|
+
lastAt: lastMouseAt ? new Date(lastMouseAt).toISOString() : null,
|
|
2176
|
+
};
|
|
2177
|
+
},
|
|
2178
|
+
|
|
2179
|
+
start({ onLine, history = [] }) {
|
|
2180
|
+
state.onLine = onLine;
|
|
2181
|
+
state.history = [...history];
|
|
2182
|
+
state.historyIndex = state.history.length;
|
|
2183
|
+
state.active = true;
|
|
2184
|
+
seedBanner();
|
|
2185
|
+
// `alternateScreen` is not a cosmetic preference — three separate defects came
|
|
2186
|
+
// from not passing it.
|
|
2187
|
+
//
|
|
2188
|
+
// Rendering inline, Ink reflows its output in place against a window whose height
|
|
2189
|
+
// it does not own. Resize the terminal and the previous frame is still on screen
|
|
2190
|
+
// under the new one: that is the duplicated composer, the doubled prompt, and the
|
|
2191
|
+
// "sometimes fullscreen, sometimes not" all at once.
|
|
2192
|
+
//
|
|
2193
|
+
// It also breaks the mouse. Every coordinate this file computes assumes row 1 is
|
|
2194
|
+
// the top of the app, which is only true on a screen the app owns. Inline, the
|
|
2195
|
+
// app sits below whatever scrollback happens to be there, so clicks and drag
|
|
2196
|
+
// selection land on the wrong rows — by an amount that changes as the session
|
|
2197
|
+
// grows, which is why it looked intermittent rather than broken.
|
|
2198
|
+
inkInstance = render(h(App, { controller: api }), {
|
|
2199
|
+
alternateScreen: true,
|
|
2200
|
+
exitOnCtrlC: false,
|
|
2201
|
+
patchConsole: false,
|
|
2202
|
+
// Rewrite only the lines that changed. A streamed word changes one line; without
|
|
2203
|
+
// this every token rewrote all ~30 lines (~45KB a second measured while
|
|
2204
|
+
// streaming), which is its own source of tearing on slower terminals.
|
|
2205
|
+
incrementalRendering: true,
|
|
2206
|
+
});
|
|
2207
|
+
// Mouse reporting takes over the terminal's own selection, so it is opt-out and
|
|
2208
|
+
// the status bar says how to get selection back. Enabled after render so Ink's
|
|
2209
|
+
// own setup cannot overwrite the mode.
|
|
2210
|
+
if (settings?.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE) {
|
|
2211
|
+
state.mouse = enableMouse(process.stdout);
|
|
2212
|
+
}
|
|
2213
|
+
notifyNow();
|
|
2214
|
+
},
|
|
2215
|
+
|
|
2216
|
+
stop() {
|
|
2217
|
+
state.active = false;
|
|
2218
|
+
exited = true;
|
|
2219
|
+
if (pending) { clearTimeout(pending); pending = null; }
|
|
2220
|
+
// Before unmount: the terminal keeps reporting after the process is gone
|
|
2221
|
+
// otherwise, and every click in the shell afterwards prints escape codes.
|
|
2222
|
+
disableMouse(process.stdout);
|
|
2223
|
+
inkInstance?.unmount();
|
|
2224
|
+
inkInstance = null;
|
|
2225
|
+
},
|
|
2226
|
+
|
|
2227
|
+
setMouse(on) {
|
|
2228
|
+
if (on) state.mouse = enableMouse(process.stdout);
|
|
2229
|
+
else { disableMouse(process.stdout); state.mouse = false; }
|
|
2230
|
+
notifyNow();
|
|
2231
|
+
return state.mouse;
|
|
2232
|
+
},
|
|
2233
|
+
|
|
2234
|
+
// ── structured entries ──────────────────────────────────────────────────────
|
|
2235
|
+
user(text, attachments) { return push('user', { text, attachments }); },
|
|
2236
|
+
assistant(text) { return push('assistant', { text }); },
|
|
2237
|
+
notice(text, tone = 'dim') { return push('notice', { text, tone }); },
|
|
2238
|
+
todo(items) { return push('todo', { items }); },
|
|
2239
|
+
diff(file, patch, max) { return push('diff', { file, patch, max }); },
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* A tool call. Returns a handle whose `update` mutates the same entry in place, so
|
|
2243
|
+
* a row goes running → ok without a second line appearing under the first.
|
|
2244
|
+
*/
|
|
2245
|
+
tool(data) {
|
|
2246
|
+
const entry = push('tool', { status: 'running', ...data });
|
|
2247
|
+
return {
|
|
2248
|
+
id: entry.id,
|
|
2249
|
+
update(next) {
|
|
2250
|
+
Object.assign(entry.data, next);
|
|
2251
|
+
entry._w = -1;
|
|
2252
|
+
entry._rows = null;
|
|
2253
|
+
notify();
|
|
2254
|
+
},
|
|
2255
|
+
};
|
|
2256
|
+
},
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* A failure, with everything needed to act on it.
|
|
2260
|
+
*
|
|
2261
|
+
* Errors were previously one dim line that scrolled past, so a session where every
|
|
2262
|
+
* request was being rejected looked like a session where the model had nothing to
|
|
2263
|
+
* say. They now get a red-barred block, a hint where one applies, and a folded
|
|
2264
|
+
* stack that `/debug` or Ctrl+D unfolds.
|
|
2265
|
+
*/
|
|
2266
|
+
error(error, context = {}) {
|
|
2267
|
+
const value = error instanceof Error ? error : new Error(String(error?.message ?? error ?? 'Unknown error'));
|
|
2268
|
+
const message = messageForError(value);
|
|
2269
|
+
let hint = context.hint || hintForError(value);
|
|
2270
|
+
// Our server messages often already say what to do ("run `jaitus login`"); the
|
|
2271
|
+
// hint would then say it a second time, one line lower.
|
|
2272
|
+
const flat = (t) => String(t || '').replace(/[`.]/g, '').toLowerCase().trim();
|
|
2273
|
+
if (hint && flat(message).includes(flat(hint))) hint = '';
|
|
2274
|
+
return push('error', {
|
|
2275
|
+
title: context.title || titleForError(value),
|
|
2276
|
+
message,
|
|
2277
|
+
hint,
|
|
2278
|
+
detail: context.detail || (value.requestId ? `request id ${value.requestId}` : ''),
|
|
2279
|
+
// A service error's stack is this CLI's own call path — nothing in it explains
|
|
2280
|
+
// what happened. Only a fault in the CLI keeps one, for /debug.
|
|
2281
|
+
stack: value.name === 'JaitusError' ? '' : (value.stack || ''),
|
|
2282
|
+
expanded: state.debug,
|
|
2283
|
+
});
|
|
2284
|
+
},
|
|
2285
|
+
|
|
2286
|
+
toggleDebug() {
|
|
2287
|
+
state.debug = !state.debug;
|
|
2288
|
+
for (const entry of state.transcript) {
|
|
2289
|
+
if (entry.kind === 'error') { entry.data.expanded = state.debug; entry._w = -1; entry._rows = null; }
|
|
2290
|
+
}
|
|
2291
|
+
flash(state.debug ? 'Debug on — stack traces expanded' : 'Debug off', 'info', 1600);
|
|
2292
|
+
},
|
|
2293
|
+
|
|
2294
|
+
// ── the legacy string surface ───────────────────────────────────────────────
|
|
2295
|
+
//
|
|
2296
|
+
// Most of the CLI still prints pre-formatted ANSI strings. Those keep working and
|
|
2297
|
+
// keep their colour: `raw` parses the escapes back into spans rather than dropping
|
|
2298
|
+
// them, which is what the previous renderer did.
|
|
2299
|
+
print(value, kind = 'assistant') {
|
|
2300
|
+
const text = String(value ?? '');
|
|
2301
|
+
if (!plain(text).trim() && !/\n/.test(text)) return;
|
|
2302
|
+
state.live = null;
|
|
2303
|
+
if (kind === 'user') return push('raw', { ansi: text });
|
|
2304
|
+
if (kind === 'error') {
|
|
2305
|
+
return push('error', { title: 'Error', message: plain(text).replace(/^[✗x]\s*/, ''), stack: '', expanded: false });
|
|
2306
|
+
}
|
|
2307
|
+
return push('raw', { ansi: text });
|
|
2308
|
+
},
|
|
2309
|
+
|
|
2310
|
+
printStream(value) {
|
|
2311
|
+
const text = String(value ?? '');
|
|
2312
|
+
if (!text) return;
|
|
2313
|
+
if (!state.live) {
|
|
2314
|
+
state.live = { text: '', entry: makeEntry('assistant', { text: '', compact: true }) };
|
|
2315
|
+
}
|
|
2316
|
+
state.live.text += plain(text);
|
|
2317
|
+
state.live.entry.data.text = state.live.text;
|
|
2318
|
+
state.live.entry._w = -1;
|
|
2319
|
+
state.live.entry._rows = null;
|
|
2320
|
+
if (!state.turnStartedAt) state.turnStartedAt = Date.now();
|
|
2321
|
+
notify();
|
|
2322
|
+
},
|
|
2323
|
+
|
|
2324
|
+
flushStream() {
|
|
2325
|
+
if (state.live?.text?.trim()) {
|
|
2326
|
+
const entry = state.live.entry;
|
|
2327
|
+
entry.data.compact = false;
|
|
2328
|
+
entry._w = -1;
|
|
2329
|
+
entry._rows = null;
|
|
2330
|
+
state.transcript.push(entry);
|
|
2331
|
+
if (state.viewingTranscript) state.newMessages += 1;
|
|
2332
|
+
}
|
|
2333
|
+
state.live = null;
|
|
2334
|
+
notify();
|
|
2335
|
+
},
|
|
2336
|
+
|
|
2337
|
+
clearTranscript() {
|
|
2338
|
+
state.transcript = [];
|
|
2339
|
+
seedBanner();
|
|
2340
|
+
state.live = null;
|
|
2341
|
+
state.transcriptOffset = 0;
|
|
2342
|
+
state.viewingTranscript = false;
|
|
2343
|
+
state.newMessages = 0;
|
|
2344
|
+
state.home = true;
|
|
2345
|
+
notifyNow();
|
|
2346
|
+
},
|
|
2347
|
+
|
|
2348
|
+
setStatusLine(value) { state.statusLine = plain(value || ''); notify(); },
|
|
2349
|
+
setBusy(value) {
|
|
2350
|
+
state.busy = !!value;
|
|
2351
|
+
if (value) { state.turnStartedAt = Date.now(); state.turnTokens = 0; }
|
|
2352
|
+
else { state.status = ''; state.turnStartedAt = null; }
|
|
2353
|
+
notifyNow();
|
|
2354
|
+
},
|
|
2355
|
+
setStatus(value) { state.status = plain(value); notify(); },
|
|
2356
|
+
flash(text, tone, ms) { flash(plain(text), tone, ms); },
|
|
2357
|
+
clearStatus() { state.status = ''; notify(); },
|
|
2358
|
+
setTurnTokens(n) { state.turnTokens = n | 0; notify(); },
|
|
2359
|
+
setPlaceholder(text) { state.placeholder = String(text || ''); notify(); },
|
|
2360
|
+
setHome(value) { state.home = !!value; notify(); },
|
|
2361
|
+
|
|
2362
|
+
queue(line) { state.queued.push(line); notify(); },
|
|
2363
|
+
takeQueued() { const queued = state.queued; state.queued = []; notify(); return queued; },
|
|
2364
|
+
/** Put text back into the composer (queued messages after an interrupt). */
|
|
2365
|
+
/** The line as typed (chips, not their contents) — what goes into the history file. */
|
|
2366
|
+
lastTyped() { const t = state.lastTyped; state.lastTyped = undefined; return t; },
|
|
2367
|
+
restoreInput(text) {
|
|
2368
|
+
const value = String(text || '');
|
|
2369
|
+
if (!value) return;
|
|
2370
|
+
state.input = state.input ? `${value}\n${state.input}` : value;
|
|
2371
|
+
state.cursor = state.input.length;
|
|
2372
|
+
refreshMenu();
|
|
2373
|
+
notifyNow();
|
|
2374
|
+
},
|
|
2375
|
+
hasQueued() { return state.queued.length > 0; },
|
|
2376
|
+
|
|
2377
|
+
/** Attach an image block (from /image, an @mention, a drop): returns its chip number. */
|
|
2378
|
+
addAttachment(attachment) { return attachImage(attachment); },
|
|
2379
|
+
/** Attach image files by path, shrinking any that are too big. */
|
|
2380
|
+
addImageFiles(files) { return attachImageFiles(files); },
|
|
2381
|
+
/**
|
|
2382
|
+
* The images to send with `line`: only those whose chip is still in it, in the order
|
|
2383
|
+
* the chips appear. A chip deleted, cleared with Ctrl+U or never sent is dropped —
|
|
2384
|
+
* the chip in the text is the one source of truth for what goes with the message.
|
|
2385
|
+
*/
|
|
2386
|
+
drainAttachments(line = '') {
|
|
2387
|
+
const order = [...String(line).matchAll(/\[Image #(\d+)\]/g)].map((m) => Number(m[1]));
|
|
2388
|
+
const blocks = order.map((id) => (state.attachments.find((a) => a.id === id) || imageStore.get(id))?.block).filter(Boolean);
|
|
2389
|
+
state.attachments = [];
|
|
2390
|
+
notify();
|
|
2391
|
+
return blocks;
|
|
2392
|
+
},
|
|
2393
|
+
|
|
2394
|
+
askDialog(question, options, preview = [], opts = {}) {
|
|
2395
|
+
return new Promise((resolve) => {
|
|
2396
|
+
// One dialog at a time, and a replaced one is ANSWERED (as cancelled), never left
|
|
2397
|
+
// pending: whoever was awaiting it — a permission prompt, mid-turn — would
|
|
2398
|
+
// otherwise wait forever.
|
|
2399
|
+
if (state.dialog?.resolve) { const prev = state.dialog.resolve; state.dialog = null; prev(null); }
|
|
2400
|
+
// The turn it belongs to was stopped (Esc): close it, as cancelled.
|
|
2401
|
+
if (opts.signal) {
|
|
2402
|
+
if (opts.signal.aborted) { resolve(null); return; }
|
|
2403
|
+
opts.signal.addEventListener('abort', () => {
|
|
2404
|
+
if (state.dialog?.resolve === resolve) { state.dialog = null; notifyNow(); }
|
|
2405
|
+
resolve(null);
|
|
2406
|
+
}, { once: true });
|
|
2407
|
+
}
|
|
2408
|
+
state.dialog = {
|
|
2409
|
+
question: plain(question),
|
|
2410
|
+
options: options.map((option) => (option && typeof option === 'object'
|
|
2411
|
+
? { label: plain(option.label ?? option.value), value: option.value }
|
|
2412
|
+
: { label: plain(option), value: option })),
|
|
2413
|
+
preview: preview.map(plain),
|
|
2414
|
+
index: 0,
|
|
2415
|
+
resolve,
|
|
2416
|
+
title: opts.title,
|
|
2417
|
+
tone: opts.tone,
|
|
2418
|
+
hideComment: opts.hideComment,
|
|
2419
|
+
comment: '',
|
|
2420
|
+
};
|
|
2421
|
+
state.menu = [];
|
|
2422
|
+
notifyNow();
|
|
2423
|
+
});
|
|
2424
|
+
},
|
|
2425
|
+
|
|
2426
|
+
showPanel(title, lines) {
|
|
2427
|
+
return new Promise((resolve) => {
|
|
2428
|
+
if (state.panel?.resolve) { const prev = state.panel.resolve; state.panel = null; prev(null); }
|
|
2429
|
+
state.panel = {
|
|
2430
|
+
title: plain(title),
|
|
2431
|
+
lines: (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line)),
|
|
2432
|
+
rows: null,
|
|
2433
|
+
resolve,
|
|
2434
|
+
};
|
|
2435
|
+
notifyNow();
|
|
2436
|
+
});
|
|
2437
|
+
},
|
|
2438
|
+
|
|
2439
|
+
updatePanel(lines) {
|
|
2440
|
+
if (!state.panel) return;
|
|
2441
|
+
// A panel that is updated is live output: it follows the newest lines until the
|
|
2442
|
+
// user scrolls up, and again once they scroll back to the bottom.
|
|
2443
|
+
if (!state.panel.live) { state.panel.live = true; state.panel.follow = true; }
|
|
2444
|
+
state.panel.lines = (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line));
|
|
2445
|
+
notify();
|
|
2446
|
+
},
|
|
2447
|
+
|
|
2448
|
+
/** A theme change recolours every cached row, so they all have to be rebuilt. */
|
|
2449
|
+
reskin() {
|
|
2450
|
+
resetPalette();
|
|
2451
|
+
invalidate(state.transcript);
|
|
2452
|
+
if (state.live?.entry) { state.live.entry._w = -1; state.live.entry._rows = null; }
|
|
2453
|
+
notifyNow();
|
|
2454
|
+
},
|
|
2455
|
+
|
|
2456
|
+
recordActivity(event) {
|
|
2457
|
+
const existing = event.id ? state.activity.find((item) => item.id === event.id) : null;
|
|
2458
|
+
if (existing) Object.assign(existing, event, { updatedAt: Date.now() });
|
|
2459
|
+
else {
|
|
2460
|
+
state.activity.push({
|
|
2461
|
+
id: event.id || `${Date.now()}-${state.activity.length}`,
|
|
2462
|
+
name: event.name,
|
|
2463
|
+
label: String(event.label || event.name || ''),
|
|
2464
|
+
status: event.status || 'running',
|
|
2465
|
+
detail: event.detail || '',
|
|
2466
|
+
updatedAt: Date.now(),
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
if (state.activity.length > 200) state.activity.shift();
|
|
2470
|
+
notify();
|
|
2471
|
+
return existing?.id || state.activity.at(-1)?.id;
|
|
2472
|
+
},
|
|
2473
|
+
activitySnapshot() { return state.activity.map((item) => ({ ...item })); },
|
|
2474
|
+
|
|
2475
|
+
bellIfAway() {
|
|
2476
|
+
// A terminal bell is the only cross-platform "I need you" a CLI has. Suppressed
|
|
2477
|
+
// under reduced motion, which is also where people who do not want surprises are.
|
|
2478
|
+
if (!motionOn()) return;
|
|
2479
|
+
try { process.stdout.write(''); } catch { /* a bell is never worth an error */ }
|
|
2480
|
+
},
|
|
2481
|
+
animationsOn() { return state.active && motionOn(); },
|
|
2482
|
+
redraw() { notifyNow(); },
|
|
2483
|
+
|
|
2484
|
+
get exited() { return exited; },
|
|
2485
|
+
handleKey,
|
|
2486
|
+
};
|
|
2487
|
+
|
|
2488
|
+
return api;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// A JaitusError brings its own title and hint, written in errors.js. Anything else is a
|
|
2492
|
+
// fault inside this CLI: it gets a plain title rather than its class name ("TypeError"
|
|
2493
|
+
// means nothing to a user), and its real message and stack wait behind /debug.
|
|
2494
|
+
function titleForError(error) {
|
|
2495
|
+
if (error.name === 'JaitusError' && error.title) return error.title;
|
|
2496
|
+
if (error.name === 'AbortError') return 'Interrupted';
|
|
2497
|
+
return 'Something went wrong in Jaitus';
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function hintForError(error) {
|
|
2501
|
+
if (error.name === 'JaitusError') return error.hint || '';
|
|
2502
|
+
if (error.name === 'AbortError') return '';
|
|
2503
|
+
return 'Try again. /debug shows the details if it keeps happening.';
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function messageForError(error) {
|
|
2507
|
+
if (error.name === 'JaitusError' || error.name === 'AbortError') return error.message || '';
|
|
2508
|
+
return 'This is a fault in the Jaitus CLI, not in your project.';
|
|
2509
|
+
}
|