- adds a rust/ratatui tui with live python previews, file activity, per-cell diffs, history, sessions and esc interrupt.
- uses v2
/ws:subscribe,history,send_input,interrupt,session_list,session_status. - adds cell
starting/argumentslive chunks and optional activity/diff traces from python workspace helpers.
+5705
-143
Diff
Round #0
+1283
-48
Cargo.lock
+1283
-48
Cargo.lock
···
2
2
# It is not intended for manual editing.
3
3
version = 4
4
4
5
+
[[package]]
6
+
name = "ahash"
7
+
version = "0.8.12"
8
+
source = "registry+https://github.com/rust-lang/crates.io-index"
9
+
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10
+
dependencies = [
11
+
"cfg-if",
12
+
"getrandom 0.3.4",
13
+
"once_cell",
14
+
"version_check",
15
+
"zerocopy",
16
+
]
17
+
5
18
[[package]]
6
19
name = "aho-corasick"
7
20
version = "1.1.4"
···
26
39
"alloc-no-stdlib",
27
40
]
28
41
42
+
[[package]]
43
+
name = "allocator-api2"
44
+
version = "0.2.21"
45
+
source = "registry+https://github.com/rust-lang/crates.io-index"
46
+
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
47
+
29
48
[[package]]
30
49
name = "android_system_properties"
31
50
version = "0.1.5"
···
115
134
source = "registry+https://github.com/rust-lang/crates.io-index"
116
135
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
117
136
137
+
[[package]]
138
+
name = "approx"
139
+
version = "0.5.1"
140
+
source = "registry+https://github.com/rust-lang/crates.io-index"
141
+
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
142
+
dependencies = [
143
+
"num-traits",
144
+
]
145
+
118
146
[[package]]
119
147
name = "async-stream"
120
148
version = "0.3.6"
···
134
162
dependencies = [
135
163
"proc-macro2",
136
164
"quote",
137
-
"syn",
165
+
"syn 2.0.117",
166
+
]
167
+
168
+
[[package]]
169
+
name = "atomic"
170
+
version = "0.6.1"
171
+
source = "registry+https://github.com/rust-lang/crates.io-index"
172
+
checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
173
+
dependencies = [
174
+
"bytemuck",
138
175
]
139
176
140
177
[[package]]
···
229
266
source = "registry+https://github.com/rust-lang/crates.io-index"
230
267
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
231
268
269
+
[[package]]
270
+
name = "bit-set"
271
+
version = "0.5.3"
272
+
source = "registry+https://github.com/rust-lang/crates.io-index"
273
+
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
274
+
dependencies = [
275
+
"bit-vec",
276
+
]
277
+
278
+
[[package]]
279
+
name = "bit-vec"
280
+
version = "0.6.3"
281
+
source = "registry+https://github.com/rust-lang/crates.io-index"
282
+
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
283
+
232
284
[[package]]
233
285
name = "bitflags"
234
-
version = "2.11.0"
286
+
version = "1.3.2"
287
+
source = "registry+https://github.com/rust-lang/crates.io-index"
288
+
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
289
+
290
+
[[package]]
291
+
name = "bitflags"
292
+
version = "2.13.2"
293
+
source = "registry+https://github.com/rust-lang/crates.io-index"
294
+
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
295
+
296
+
[[package]]
297
+
name = "bitvec"
298
+
version = "1.1.1"
235
299
source = "registry+https://github.com/rust-lang/crates.io-index"
236
-
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
300
+
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
301
+
dependencies = [
302
+
"funty",
303
+
"radium",
304
+
"tap",
305
+
"wyz",
306
+
]
237
307
238
308
[[package]]
239
309
name = "block-buffer"
···
260
330
source = "registry+https://github.com/rust-lang/crates.io-index"
261
331
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
262
332
333
+
[[package]]
334
+
name = "by_address"
335
+
version = "1.2.1"
336
+
source = "registry+https://github.com/rust-lang/crates.io-index"
337
+
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
338
+
339
+
[[package]]
340
+
name = "bytemuck"
341
+
version = "1.25.2"
342
+
source = "registry+https://github.com/rust-lang/crates.io-index"
343
+
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
344
+
263
345
[[package]]
264
346
name = "byteorder"
265
347
version = "1.5.0"
···
272
354
source = "registry+https://github.com/rust-lang/crates.io-index"
273
355
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
274
356
357
+
[[package]]
358
+
name = "castaway"
359
+
version = "0.2.4"
360
+
source = "registry+https://github.com/rust-lang/crates.io-index"
361
+
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
362
+
dependencies = [
363
+
"rustversion",
364
+
]
365
+
275
366
[[package]]
276
367
name = "cc"
277
368
version = "1.2.59"
···
346
437
"heck",
347
438
"proc-macro2",
348
439
"quote",
349
-
"syn",
440
+
"syn 2.0.117",
350
441
]
351
442
352
443
[[package]]
···
380
471
"memchr",
381
472
]
382
473
474
+
[[package]]
475
+
name = "compact_str"
476
+
version = "0.9.1"
477
+
source = "registry+https://github.com/rust-lang/crates.io-index"
478
+
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
479
+
dependencies = [
480
+
"castaway",
481
+
"cfg-if",
482
+
"itoa",
483
+
"rustversion",
484
+
"ryu",
485
+
"static_assertions",
486
+
]
487
+
488
+
[[package]]
489
+
name = "convert_case"
490
+
version = "0.10.0"
491
+
source = "registry+https://github.com/rust-lang/crates.io-index"
492
+
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
493
+
dependencies = [
494
+
"unicode-segmentation",
495
+
]
496
+
383
497
[[package]]
384
498
name = "core-foundation"
385
499
version = "0.9.4"
···
415
529
"libc",
416
530
]
417
531
532
+
[[package]]
533
+
name = "critical-section"
534
+
version = "1.2.0"
535
+
source = "registry+https://github.com/rust-lang/crates.io-index"
536
+
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
537
+
538
+
[[package]]
539
+
name = "crossterm"
540
+
version = "0.29.0"
541
+
source = "registry+https://github.com/rust-lang/crates.io-index"
542
+
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
543
+
dependencies = [
544
+
"bitflags 2.13.2",
545
+
"crossterm_winapi",
546
+
"derive_more",
547
+
"document-features",
548
+
"futures-core",
549
+
"mio",
550
+
"parking_lot",
551
+
"rustix",
552
+
"signal-hook",
553
+
"signal-hook-mio",
554
+
"winapi",
555
+
]
556
+
557
+
[[package]]
558
+
name = "crossterm_winapi"
559
+
version = "0.9.1"
560
+
source = "registry+https://github.com/rust-lang/crates.io-index"
561
+
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
562
+
dependencies = [
563
+
"winapi",
564
+
]
565
+
418
566
[[package]]
419
567
name = "crypto-common"
420
568
version = "0.1.7"
···
425
573
"typenum",
426
574
]
427
575
576
+
[[package]]
577
+
name = "csscolorparser"
578
+
version = "0.6.2"
579
+
source = "registry+https://github.com/rust-lang/crates.io-index"
580
+
checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
581
+
dependencies = [
582
+
"lab",
583
+
"phf 0.11.3",
584
+
]
585
+
586
+
[[package]]
587
+
name = "darling"
588
+
version = "0.24.1"
589
+
source = "registry+https://github.com/rust-lang/crates.io-index"
590
+
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
591
+
dependencies = [
592
+
"darling_core",
593
+
"darling_macro",
594
+
]
595
+
596
+
[[package]]
597
+
name = "darling_core"
598
+
version = "0.24.1"
599
+
source = "registry+https://github.com/rust-lang/crates.io-index"
600
+
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
601
+
dependencies = [
602
+
"ident_case",
603
+
"proc-macro2",
604
+
"quote",
605
+
"strsim",
606
+
"syn 3.0.5",
607
+
]
608
+
609
+
[[package]]
610
+
name = "darling_macro"
611
+
version = "0.24.1"
612
+
source = "registry+https://github.com/rust-lang/crates.io-index"
613
+
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
614
+
dependencies = [
615
+
"darling_core",
616
+
"quote",
617
+
"syn 3.0.5",
618
+
]
619
+
428
620
[[package]]
429
621
name = "data-encoding"
430
622
version = "2.11.0"
431
623
source = "registry+https://github.com/rust-lang/crates.io-index"
432
624
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
433
625
626
+
[[package]]
627
+
name = "deltae"
628
+
version = "0.3.2"
629
+
source = "registry+https://github.com/rust-lang/crates.io-index"
630
+
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
631
+
434
632
[[package]]
435
633
name = "deranged"
436
634
version = "0.5.8"
···
440
638
"powerfmt",
441
639
]
442
640
641
+
[[package]]
642
+
name = "derive_more"
643
+
version = "2.1.1"
644
+
source = "registry+https://github.com/rust-lang/crates.io-index"
645
+
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
646
+
dependencies = [
647
+
"derive_more-impl",
648
+
]
649
+
650
+
[[package]]
651
+
name = "derive_more-impl"
652
+
version = "2.1.1"
653
+
source = "registry+https://github.com/rust-lang/crates.io-index"
654
+
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
655
+
dependencies = [
656
+
"convert_case",
657
+
"proc-macro2",
658
+
"quote",
659
+
"rustc_version",
660
+
"syn 2.0.117",
661
+
]
662
+
443
663
[[package]]
444
664
name = "digest"
445
665
version = "0.10.7"
···
479
699
dependencies = [
480
700
"proc-macro2",
481
701
"quote",
482
-
"syn",
702
+
"syn 2.0.117",
703
+
]
704
+
705
+
[[package]]
706
+
name = "document-features"
707
+
version = "0.2.12"
708
+
source = "registry+https://github.com/rust-lang/crates.io-index"
709
+
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
710
+
dependencies = [
711
+
"litrs",
483
712
]
484
713
485
714
[[package]]
···
488
717
source = "registry+https://github.com/rust-lang/crates.io-index"
489
718
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
490
719
720
+
[[package]]
721
+
name = "either"
722
+
version = "1.18.0"
723
+
source = "registry+https://github.com/rust-lang/crates.io-index"
724
+
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
725
+
491
726
[[package]]
492
727
name = "emojis"
493
728
version = "0.8.1"
494
729
source = "registry+https://github.com/rust-lang/crates.io-index"
495
730
checksum = "afd562179989ec12b5c82f63a9423be4d4f0c5651930837074488ef4d07f2886"
496
731
dependencies = [
497
-
"phf",
732
+
"phf 0.13.1",
498
733
]
499
734
500
735
[[package]]
···
522
757
"windows-sys 0.61.2",
523
758
]
524
759
760
+
[[package]]
761
+
name = "euclid"
762
+
version = "0.22.14"
763
+
source = "registry+https://github.com/rust-lang/crates.io-index"
764
+
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
765
+
dependencies = [
766
+
"num-traits",
767
+
]
768
+
525
769
[[package]]
526
770
name = "fallible-iterator"
527
771
version = "0.3.0"
···
534
778
source = "registry+https://github.com/rust-lang/crates.io-index"
535
779
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
536
780
781
+
[[package]]
782
+
name = "fancy-regex"
783
+
version = "0.11.0"
784
+
source = "registry+https://github.com/rust-lang/crates.io-index"
785
+
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
786
+
dependencies = [
787
+
"bit-set",
788
+
"regex",
789
+
]
790
+
537
791
[[package]]
538
792
name = "fastrand"
539
793
version = "2.4.0"
540
794
source = "registry+https://github.com/rust-lang/crates.io-index"
541
795
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
542
796
797
+
[[package]]
798
+
name = "filedescriptor"
799
+
version = "0.8.3"
800
+
source = "registry+https://github.com/rust-lang/crates.io-index"
801
+
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
802
+
dependencies = [
803
+
"libc",
804
+
"thiserror 1.0.69",
805
+
"winapi",
806
+
]
807
+
543
808
[[package]]
544
809
name = "find-msvc-tools"
545
810
version = "0.1.9"
546
811
source = "registry+https://github.com/rust-lang/crates.io-index"
547
812
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
548
813
814
+
[[package]]
815
+
name = "finl_unicode"
816
+
version = "1.4.0"
817
+
source = "registry+https://github.com/rust-lang/crates.io-index"
818
+
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
819
+
820
+
[[package]]
821
+
name = "fixedbitset"
822
+
version = "0.4.2"
823
+
source = "registry+https://github.com/rust-lang/crates.io-index"
824
+
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
825
+
549
826
[[package]]
550
827
name = "fnv"
551
828
version = "1.0.7"
···
579
856
source = "registry+https://github.com/rust-lang/crates.io-index"
580
857
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
581
858
859
+
[[package]]
860
+
name = "funty"
861
+
version = "2.0.0"
862
+
source = "registry+https://github.com/rust-lang/crates.io-index"
863
+
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
864
+
582
865
[[package]]
583
866
name = "futures"
584
867
version = "0.3.32"
···
635
918
dependencies = [
636
919
"proc-macro2",
637
920
"quote",
638
-
"syn",
921
+
"syn 2.0.117",
639
922
]
640
923
641
924
[[package]]
···
751
1034
source = "registry+https://github.com/rust-lang/crates.io-index"
752
1035
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
753
1036
dependencies = [
1037
+
"allocator-api2",
1038
+
"equivalent",
1039
+
"foldhash 0.2.0",
1040
+
]
1041
+
1042
+
[[package]]
1043
+
name = "hashbrown"
1044
+
version = "0.17.1"
1045
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1046
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1047
+
dependencies = [
1048
+
"allocator-api2",
1049
+
"equivalent",
754
1050
"foldhash 0.2.0",
755
1051
]
756
1052
···
996
1292
source = "registry+https://github.com/rust-lang/crates.io-index"
997
1293
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
998
1294
1295
+
[[package]]
1296
+
name = "ident_case"
1297
+
version = "1.0.1"
1298
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1299
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1300
+
999
1301
[[package]]
1000
1302
name = "idna"
1001
1303
version = "1.1.0"
···
1029
1331
"serde_core",
1030
1332
]
1031
1333
1334
+
[[package]]
1335
+
name = "indoc"
1336
+
version = "2.0.7"
1337
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1338
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1339
+
dependencies = [
1340
+
"rustversion",
1341
+
]
1342
+
1343
+
[[package]]
1344
+
name = "instability"
1345
+
version = "0.3.13"
1346
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1347
+
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
1348
+
dependencies = [
1349
+
"darling",
1350
+
"indoc",
1351
+
"proc-macro2",
1352
+
"quote",
1353
+
"syn 3.0.5",
1354
+
]
1355
+
1032
1356
[[package]]
1033
1357
name = "ipnet"
1034
1358
version = "2.12.0"
···
1051
1375
source = "registry+https://github.com/rust-lang/crates.io-index"
1052
1376
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1053
1377
1378
+
[[package]]
1379
+
name = "itertools"
1380
+
version = "0.14.0"
1381
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1382
+
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1383
+
dependencies = [
1384
+
"either",
1385
+
]
1386
+
1054
1387
[[package]]
1055
1388
name = "itoa"
1056
1389
version = "1.0.18"
1057
1390
source = "registry+https://github.com/rust-lang/crates.io-index"
1058
1391
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1059
1392
1393
+
[[package]]
1394
+
name = "jiter"
1395
+
version = "0.16.0"
1396
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1397
+
checksum = "820ddcacd75c9782308d3fa594f3885630ea3f49adba78a8a29abdfe4b81630c"
1398
+
dependencies = [
1399
+
"ahash",
1400
+
"bitvec",
1401
+
"lexical-parse-float",
1402
+
"num-bigint",
1403
+
"num-traits",
1404
+
"pyo3",
1405
+
"smallvec",
1406
+
]
1407
+
1060
1408
[[package]]
1061
1409
name = "jni"
1062
1410
version = "0.21.1"
···
1098
1446
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1099
1447
dependencies = [
1100
1448
"quote",
1101
-
"syn",
1449
+
"syn 2.0.117",
1102
1450
]
1103
1451
1104
1452
[[package]]
···
1123
1471
"wasm-bindgen",
1124
1472
]
1125
1473
1474
+
[[package]]
1475
+
name = "kasuari"
1476
+
version = "0.4.12"
1477
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1478
+
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
1479
+
dependencies = [
1480
+
"hashbrown 0.16.1",
1481
+
"portable-atomic",
1482
+
"thiserror 2.0.18",
1483
+
]
1484
+
1126
1485
[[package]]
1127
1486
name = "kdl"
1128
1487
version = "6.5.0"
···
1225
1584
"uuid",
1226
1585
]
1227
1586
1587
+
[[package]]
1588
+
name = "klbr-tui"
1589
+
version = "0.1.0"
1590
+
dependencies = [
1591
+
"anyhow",
1592
+
"clap",
1593
+
"crossterm",
1594
+
"futures",
1595
+
"jiter",
1596
+
"klbr-ipc",
1597
+
"ratatui",
1598
+
"ratatui-textarea",
1599
+
"regex",
1600
+
"serde",
1601
+
"serde_json",
1602
+
"tokio",
1603
+
"tokio-tungstenite",
1604
+
"tree-sitter",
1605
+
"tree-sitter-python",
1606
+
"unicode-segmentation",
1607
+
"unicode-width 0.2.2",
1608
+
"uuid",
1609
+
]
1610
+
1611
+
[[package]]
1612
+
name = "lab"
1613
+
version = "0.11.0"
1614
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1615
+
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
1616
+
1228
1617
[[package]]
1229
1618
name = "lazy_static"
1230
1619
version = "1.5.0"
···
1237
1626
source = "registry+https://github.com/rust-lang/crates.io-index"
1238
1627
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1239
1628
1629
+
[[package]]
1630
+
name = "lexical-parse-float"
1631
+
version = "1.0.6"
1632
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1633
+
checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56"
1634
+
dependencies = [
1635
+
"lexical-parse-integer",
1636
+
"lexical-util",
1637
+
]
1638
+
1639
+
[[package]]
1640
+
name = "lexical-parse-integer"
1641
+
version = "1.0.6"
1642
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1643
+
checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34"
1644
+
dependencies = [
1645
+
"lexical-util",
1646
+
]
1647
+
1648
+
[[package]]
1649
+
name = "lexical-util"
1650
+
version = "1.0.7"
1651
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1652
+
checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17"
1653
+
1240
1654
[[package]]
1241
1655
name = "libc"
1242
1656
version = "0.2.184"
1243
1657
source = "registry+https://github.com/rust-lang/crates.io-index"
1244
1658
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
1245
1659
1660
+
[[package]]
1661
+
name = "libm"
1662
+
version = "0.2.16"
1663
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1664
+
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1665
+
1246
1666
[[package]]
1247
1667
name = "libredox"
1248
1668
version = "0.1.14"
···
1263
1683
"vcpkg",
1264
1684
]
1265
1685
1686
+
[[package]]
1687
+
name = "line-clipping"
1688
+
version = "0.3.8"
1689
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1690
+
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
1691
+
dependencies = [
1692
+
"bitflags 2.13.2",
1693
+
]
1694
+
1266
1695
[[package]]
1267
1696
name = "linux-raw-sys"
1268
1697
version = "0.12.1"
···
1275
1704
source = "registry+https://github.com/rust-lang/crates.io-index"
1276
1705
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
1277
1706
1707
+
[[package]]
1708
+
name = "litrs"
1709
+
version = "1.0.0"
1710
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1711
+
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
1712
+
1278
1713
[[package]]
1279
1714
name = "lock_api"
1280
1715
version = "0.4.14"
···
1290
1725
source = "registry+https://github.com/rust-lang/crates.io-index"
1291
1726
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
1292
1727
1728
+
[[package]]
1729
+
name = "lru"
1730
+
version = "0.18.4"
1731
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1732
+
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
1733
+
dependencies = [
1734
+
"hashbrown 0.17.1",
1735
+
]
1736
+
1293
1737
[[package]]
1294
1738
name = "lru-slab"
1295
1739
version = "0.1.2"
1296
1740
source = "registry+https://github.com/rust-lang/crates.io-index"
1297
1741
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1298
1742
1743
+
[[package]]
1744
+
name = "mac_address"
1745
+
version = "1.1.8"
1746
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1747
+
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
1748
+
dependencies = [
1749
+
"nix",
1750
+
"winapi",
1751
+
]
1752
+
1299
1753
[[package]]
1300
1754
name = "matchit"
1301
1755
version = "0.8.4"
···
1308
1762
source = "registry+https://github.com/rust-lang/crates.io-index"
1309
1763
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
1310
1764
1765
+
[[package]]
1766
+
name = "memmem"
1767
+
version = "0.1.1"
1768
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1769
+
checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
1770
+
1771
+
[[package]]
1772
+
name = "memoffset"
1773
+
version = "0.9.1"
1774
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1775
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
1776
+
dependencies = [
1777
+
"autocfg",
1778
+
]
1779
+
1311
1780
[[package]]
1312
1781
name = "miette"
1313
1782
version = "7.6.0"
···
1315
1784
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
1316
1785
dependencies = [
1317
1786
"cfg-if",
1318
-
"unicode-width",
1787
+
"unicode-width 0.1.14",
1319
1788
]
1320
1789
1321
1790
[[package]]
···
1324
1793
source = "registry+https://github.com/rust-lang/crates.io-index"
1325
1794
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1326
1795
1796
+
[[package]]
1797
+
name = "minimal-lexical"
1798
+
version = "0.2.1"
1799
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1800
+
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1801
+
1327
1802
[[package]]
1328
1803
name = "mio"
1329
1804
version = "1.2.0"
···
1331
1806
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
1332
1807
dependencies = [
1333
1808
"libc",
1809
+
"log",
1334
1810
"wasi",
1335
1811
"windows-sys 0.61.2",
1336
1812
]
1337
1813
1814
+
[[package]]
1815
+
name = "nix"
1816
+
version = "0.29.0"
1817
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1818
+
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
1819
+
dependencies = [
1820
+
"bitflags 2.13.2",
1821
+
"cfg-if",
1822
+
"cfg_aliases",
1823
+
"libc",
1824
+
"memoffset",
1825
+
]
1826
+
1827
+
[[package]]
1828
+
name = "nom"
1829
+
version = "7.1.3"
1830
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1831
+
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1832
+
dependencies = [
1833
+
"memchr",
1834
+
"minimal-lexical",
1835
+
]
1836
+
1338
1837
[[package]]
1339
1838
name = "nu-ansi-term"
1340
1839
version = "0.50.3"
···
1383
1882
source = "registry+https://github.com/rust-lang/crates.io-index"
1384
1883
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
1385
1884
1885
+
[[package]]
1886
+
name = "num-derive"
1887
+
version = "0.4.2"
1888
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1889
+
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
1890
+
dependencies = [
1891
+
"proc-macro2",
1892
+
"quote",
1893
+
"syn 2.0.117",
1894
+
]
1895
+
1386
1896
[[package]]
1387
1897
name = "num-integer"
1388
1898
version = "0.1.46"
···
1423
1933
"autocfg",
1424
1934
]
1425
1935
1936
+
[[package]]
1937
+
name = "num_threads"
1938
+
version = "0.1.7"
1939
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1940
+
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
1941
+
dependencies = [
1942
+
"libc",
1943
+
]
1944
+
1426
1945
[[package]]
1427
1946
name = "once_cell"
1428
1947
version = "1.21.4"
···
1435
1954
source = "registry+https://github.com/rust-lang/crates.io-index"
1436
1955
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1437
1956
1957
+
[[package]]
1958
+
name = "openssl-probe"
1959
+
version = "0.1.6"
1960
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1961
+
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1962
+
1438
1963
[[package]]
1439
1964
name = "openssl-probe"
1440
1965
version = "0.2.1"
···
1456
1981
"num-traits",
1457
1982
]
1458
1983
1984
+
[[package]]
1985
+
name = "ordered-float"
1986
+
version = "4.6.0"
1987
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1988
+
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
1989
+
dependencies = [
1990
+
"num-traits",
1991
+
]
1992
+
1993
+
[[package]]
1994
+
name = "palette"
1995
+
version = "0.7.7"
1996
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1997
+
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
1998
+
dependencies = [
1999
+
"approx",
2000
+
"libm",
2001
+
"palette_derive",
2002
+
"palette_math",
2003
+
]
2004
+
2005
+
[[package]]
2006
+
name = "palette_derive"
2007
+
version = "0.7.7"
2008
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2009
+
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
2010
+
dependencies = [
2011
+
"by_address",
2012
+
"proc-macro2",
2013
+
"quote",
2014
+
"syn 2.0.117",
2015
+
]
2016
+
2017
+
[[package]]
2018
+
name = "palette_math"
2019
+
version = "0.7.7"
2020
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2021
+
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
2022
+
dependencies = [
2023
+
"libm",
2024
+
]
2025
+
1459
2026
[[package]]
1460
2027
name = "parking_lot"
1461
2028
version = "0.12.5"
···
1485
2052
source = "registry+https://github.com/rust-lang/crates.io-index"
1486
2053
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1487
2054
2055
+
[[package]]
2056
+
name = "pest"
2057
+
version = "2.9.1"
2058
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2059
+
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
2060
+
dependencies = [
2061
+
"memchr",
2062
+
"ucd-trie",
2063
+
]
2064
+
2065
+
[[package]]
2066
+
name = "pest_derive"
2067
+
version = "2.9.1"
2068
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2069
+
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
2070
+
dependencies = [
2071
+
"pest",
2072
+
"pest_generator",
2073
+
]
2074
+
2075
+
[[package]]
2076
+
name = "pest_generator"
2077
+
version = "2.9.1"
2078
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2079
+
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
2080
+
dependencies = [
2081
+
"pest",
2082
+
"pest_meta",
2083
+
"proc-macro2",
2084
+
"quote",
2085
+
"syn 2.0.117",
2086
+
]
2087
+
2088
+
[[package]]
2089
+
name = "pest_meta"
2090
+
version = "2.9.1"
2091
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2092
+
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
2093
+
dependencies = [
2094
+
"pest",
2095
+
]
2096
+
2097
+
[[package]]
2098
+
name = "phf"
2099
+
version = "0.11.3"
2100
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2101
+
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
2102
+
dependencies = [
2103
+
"phf_macros",
2104
+
"phf_shared 0.11.3",
2105
+
]
2106
+
1488
2107
[[package]]
1489
2108
name = "phf"
1490
2109
version = "0.13.1"
1491
2110
source = "registry+https://github.com/rust-lang/crates.io-index"
1492
2111
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
1493
2112
dependencies = [
1494
-
"phf_shared",
2113
+
"phf_shared 0.13.1",
2114
+
]
2115
+
2116
+
[[package]]
2117
+
name = "phf_codegen"
2118
+
version = "0.11.3"
2119
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2120
+
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
2121
+
dependencies = [
2122
+
"phf_generator",
2123
+
"phf_shared 0.11.3",
2124
+
]
2125
+
2126
+
[[package]]
2127
+
name = "phf_generator"
2128
+
version = "0.11.3"
2129
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2130
+
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
2131
+
dependencies = [
2132
+
"phf_shared 0.11.3",
2133
+
"rand 0.8.5",
2134
+
]
2135
+
2136
+
[[package]]
2137
+
name = "phf_macros"
2138
+
version = "0.11.3"
2139
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2140
+
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
2141
+
dependencies = [
2142
+
"phf_generator",
2143
+
"phf_shared 0.11.3",
2144
+
"proc-macro2",
2145
+
"quote",
2146
+
"syn 2.0.117",
2147
+
]
2148
+
2149
+
[[package]]
2150
+
name = "phf_shared"
2151
+
version = "0.11.3"
2152
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2153
+
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
2154
+
dependencies = [
2155
+
"siphasher",
1495
2156
]
1496
2157
1497
2158
[[package]]
···
1515
2176
source = "registry+https://github.com/rust-lang/crates.io-index"
1516
2177
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1517
2178
2179
+
[[package]]
2180
+
name = "portable-atomic"
2181
+
version = "1.15.0"
2182
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2183
+
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
2184
+
1518
2185
[[package]]
1519
2186
name = "potential_utf"
1520
2187
version = "0.1.5"
···
1546
2213
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
1547
2214
dependencies = [
1548
2215
"proc-macro2",
1549
-
"syn",
2216
+
"syn 2.0.117",
1550
2217
]
1551
2218
1552
2219
[[package]]
···
1558
2225
"unicode-ident",
1559
2226
]
1560
2227
2228
+
[[package]]
2229
+
name = "pyo3"
2230
+
version = "0.29.2"
2231
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2232
+
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
2233
+
dependencies = [
2234
+
"libc",
2235
+
"num-bigint",
2236
+
"num-traits",
2237
+
"once_cell",
2238
+
"portable-atomic",
2239
+
"pyo3-build-config",
2240
+
"pyo3-ffi",
2241
+
"pyo3-macros",
2242
+
]
2243
+
2244
+
[[package]]
2245
+
name = "pyo3-build-config"
2246
+
version = "0.29.2"
2247
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2248
+
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
2249
+
dependencies = [
2250
+
"target-lexicon",
2251
+
]
2252
+
2253
+
[[package]]
2254
+
name = "pyo3-ffi"
2255
+
version = "0.29.2"
2256
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2257
+
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
2258
+
dependencies = [
2259
+
"libc",
2260
+
"pyo3-build-config",
2261
+
]
2262
+
2263
+
[[package]]
2264
+
name = "pyo3-macros"
2265
+
version = "0.29.2"
2266
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2267
+
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
2268
+
dependencies = [
2269
+
"proc-macro2",
2270
+
"pyo3-macros-backend",
2271
+
"quote",
2272
+
"syn 2.0.117",
2273
+
]
2274
+
2275
+
[[package]]
2276
+
name = "pyo3-macros-backend"
2277
+
version = "0.29.2"
2278
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2279
+
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
2280
+
dependencies = [
2281
+
"heck",
2282
+
"proc-macro2",
2283
+
"quote",
2284
+
"syn 2.0.117",
2285
+
]
2286
+
1561
2287
[[package]]
1562
2288
name = "quinn"
1563
2289
version = "0.11.9"
···
1635
2361
source = "registry+https://github.com/rust-lang/crates.io-index"
1636
2362
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1637
2363
2364
+
[[package]]
2365
+
name = "radium"
2366
+
version = "0.7.0"
2367
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2368
+
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
2369
+
1638
2370
[[package]]
1639
2371
name = "rand"
1640
2372
version = "0.8.5"
···
1680
2412
name = "rand_core"
1681
2413
version = "0.6.4"
1682
2414
source = "registry+https://github.com/rust-lang/crates.io-index"
1683
-
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2415
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2416
+
dependencies = [
2417
+
"getrandom 0.2.17",
2418
+
]
2419
+
2420
+
[[package]]
2421
+
name = "rand_core"
2422
+
version = "0.9.5"
2423
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2424
+
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2425
+
dependencies = [
2426
+
"getrandom 0.3.4",
2427
+
]
2428
+
2429
+
[[package]]
2430
+
name = "ratatui"
2431
+
version = "0.30.2"
2432
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2433
+
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
2434
+
dependencies = [
2435
+
"instability",
2436
+
"ratatui-core",
2437
+
"ratatui-crossterm",
2438
+
"ratatui-macros",
2439
+
"ratatui-termina",
2440
+
"ratatui-termwiz",
2441
+
"ratatui-widgets",
2442
+
"serde",
2443
+
]
2444
+
2445
+
[[package]]
2446
+
name = "ratatui-core"
2447
+
version = "0.1.2"
2448
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2449
+
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
2450
+
dependencies = [
2451
+
"bitflags 2.13.2",
2452
+
"compact_str",
2453
+
"critical-section",
2454
+
"hashbrown 0.17.1",
2455
+
"itertools",
2456
+
"kasuari",
2457
+
"lru",
2458
+
"palette",
2459
+
"serde",
2460
+
"strum",
2461
+
"thiserror 2.0.18",
2462
+
"unicode-segmentation",
2463
+
"unicode-truncate",
2464
+
"unicode-width 0.2.2",
2465
+
]
2466
+
2467
+
[[package]]
2468
+
name = "ratatui-crossterm"
2469
+
version = "0.1.2"
2470
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2471
+
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
2472
+
dependencies = [
2473
+
"cfg-if",
2474
+
"crossterm",
2475
+
"instability",
2476
+
"ratatui-core",
2477
+
]
2478
+
2479
+
[[package]]
2480
+
name = "ratatui-macros"
2481
+
version = "0.7.2"
2482
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2483
+
checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
2484
+
dependencies = [
2485
+
"ratatui-core",
2486
+
"ratatui-widgets",
2487
+
]
2488
+
2489
+
[[package]]
2490
+
name = "ratatui-termina"
2491
+
version = "0.1.0"
2492
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2493
+
checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
2494
+
dependencies = [
2495
+
"instability",
2496
+
"ratatui-core",
2497
+
"termina",
2498
+
]
2499
+
2500
+
[[package]]
2501
+
name = "ratatui-termwiz"
2502
+
version = "0.1.2"
2503
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2504
+
checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
2505
+
dependencies = [
2506
+
"ratatui-core",
2507
+
"termwiz",
2508
+
]
2509
+
2510
+
[[package]]
2511
+
name = "ratatui-textarea"
2512
+
version = "0.9.2"
2513
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2514
+
checksum = "3c78d5ba0f26f97baed69a4c479f268a31c7b5b89d68ab939842152e383d6e73"
1684
2515
dependencies = [
1685
-
"getrandom 0.2.17",
2516
+
"ratatui-core",
2517
+
"ratatui-crossterm",
2518
+
"ratatui-widgets",
2519
+
"unicode-segmentation",
2520
+
"unicode-width 0.2.2",
1686
2521
]
1687
2522
1688
2523
[[package]]
1689
-
name = "rand_core"
1690
-
version = "0.9.5"
2524
+
name = "ratatui-widgets"
2525
+
version = "0.3.2"
1691
2526
source = "registry+https://github.com/rust-lang/crates.io-index"
1692
-
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2527
+
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
1693
2528
dependencies = [
1694
-
"getrandom 0.3.4",
2529
+
"bitflags 2.13.2",
2530
+
"hashbrown 0.17.1",
2531
+
"indoc",
2532
+
"instability",
2533
+
"itertools",
2534
+
"line-clipping",
2535
+
"ratatui-core",
2536
+
"serde",
2537
+
"strum",
2538
+
"time",
2539
+
"unicode-segmentation",
2540
+
"unicode-width 0.2.2",
1695
2541
]
1696
2542
1697
2543
[[package]]
···
1700
2546
source = "registry+https://github.com/rust-lang/crates.io-index"
1701
2547
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1702
2548
dependencies = [
1703
-
"bitflags",
2549
+
"bitflags 2.13.2",
1704
2550
]
1705
2551
1706
2552
[[package]]
···
1817
2663
source = "registry+https://github.com/rust-lang/crates.io-index"
1818
2664
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
1819
2665
dependencies = [
1820
-
"bitflags",
2666
+
"bitflags 2.13.2",
1821
2667
"fallible-iterator",
1822
2668
"fallible-streaming-iterator",
1823
2669
"hashlink",
···
1832
2678
source = "registry+https://github.com/rust-lang/crates.io-index"
1833
2679
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1834
2680
2681
+
[[package]]
2682
+
name = "rustc_version"
2683
+
version = "0.4.1"
2684
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2685
+
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2686
+
dependencies = [
2687
+
"semver",
2688
+
]
2689
+
1835
2690
[[package]]
1836
2691
name = "rustix"
1837
2692
version = "1.1.4"
1838
2693
source = "registry+https://github.com/rust-lang/crates.io-index"
1839
2694
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1840
2695
dependencies = [
1841
-
"bitflags",
2696
+
"bitflags 2.13.2",
1842
2697
"errno",
1843
2698
"libc",
1844
2699
"linux-raw-sys",
···
1861
2716
"zeroize",
1862
2717
]
1863
2718
2719
+
[[package]]
2720
+
name = "rustls-native-certs"
2721
+
version = "0.7.3"
2722
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2723
+
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
2724
+
dependencies = [
2725
+
"openssl-probe 0.1.6",
2726
+
"rustls-pemfile",
2727
+
"rustls-pki-types",
2728
+
"schannel",
2729
+
"security-framework 2.11.1",
2730
+
]
2731
+
1864
2732
[[package]]
1865
2733
name = "rustls-native-certs"
1866
2734
version = "0.8.3"
1867
2735
source = "registry+https://github.com/rust-lang/crates.io-index"
1868
2736
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
1869
2737
dependencies = [
1870
-
"openssl-probe",
2738
+
"openssl-probe 0.2.1",
1871
2739
"rustls-pki-types",
1872
2740
"schannel",
1873
-
"security-framework",
2741
+
"security-framework 3.7.0",
2742
+
]
2743
+
2744
+
[[package]]
2745
+
name = "rustls-pemfile"
2746
+
version = "2.2.0"
2747
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2748
+
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
2749
+
dependencies = [
2750
+
"rustls-pki-types",
1874
2751
]
1875
2752
1876
2753
[[package]]
···
1895
2772
"log",
1896
2773
"once_cell",
1897
2774
"rustls",
1898
-
"rustls-native-certs",
2775
+
"rustls-native-certs 0.8.3",
1899
2776
"rustls-platform-verifier-android",
1900
2777
"rustls-webpki",
1901
-
"security-framework",
2778
+
"security-framework 3.7.0",
1902
2779
"security-framework-sys",
1903
2780
"webpki-root-certs",
1904
2781
"windows-sys 0.61.2",
···
1958
2835
source = "registry+https://github.com/rust-lang/crates.io-index"
1959
2836
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1960
2837
2838
+
[[package]]
2839
+
name = "security-framework"
2840
+
version = "2.11.1"
2841
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2842
+
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
2843
+
dependencies = [
2844
+
"bitflags 2.13.2",
2845
+
"core-foundation 0.9.4",
2846
+
"core-foundation-sys",
2847
+
"libc",
2848
+
"security-framework-sys",
2849
+
]
2850
+
1961
2851
[[package]]
1962
2852
name = "security-framework"
1963
2853
version = "3.7.0"
1964
2854
source = "registry+https://github.com/rust-lang/crates.io-index"
1965
2855
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1966
2856
dependencies = [
1967
-
"bitflags",
2857
+
"bitflags 2.13.2",
1968
2858
"core-foundation 0.10.1",
1969
2859
"core-foundation-sys",
1970
2860
"libc",
···
2003
2893
source = "registry+https://github.com/rust-lang/crates.io-index"
2004
2894
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
2005
2895
dependencies = [
2006
-
"ordered-float",
2896
+
"ordered-float 2.10.1",
2007
2897
"serde",
2008
2898
]
2009
2899
···
2024
2914
dependencies = [
2025
2915
"proc-macro2",
2026
2916
"quote",
2027
-
"syn",
2917
+
"syn 2.0.117",
2028
2918
]
2029
2919
2030
2920
[[package]]
···
2033
2923
source = "registry+https://github.com/rust-lang/crates.io-index"
2034
2924
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
2035
2925
dependencies = [
2926
+
"indexmap",
2036
2927
"itoa",
2037
2928
"memchr",
2038
2929
"serde",
···
2059
2950
dependencies = [
2060
2951
"proc-macro2",
2061
2952
"quote",
2062
-
"syn",
2953
+
"syn 2.0.117",
2063
2954
]
2064
2955
2065
2956
[[package]]
···
2117
3008
source = "registry+https://github.com/rust-lang/crates.io-index"
2118
3009
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
2119
3010
3011
+
[[package]]
3012
+
name = "signal-hook"
3013
+
version = "0.3.18"
3014
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3015
+
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
3016
+
dependencies = [
3017
+
"libc",
3018
+
"signal-hook-registry",
3019
+
]
3020
+
3021
+
[[package]]
3022
+
name = "signal-hook-mio"
3023
+
version = "0.2.5"
3024
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3025
+
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
3026
+
dependencies = [
3027
+
"libc",
3028
+
"mio",
3029
+
"signal-hook",
3030
+
]
3031
+
2120
3032
[[package]]
2121
3033
name = "signal-hook-registry"
2122
3034
version = "1.4.8"
···
2179
3091
source = "registry+https://github.com/rust-lang/crates.io-index"
2180
3092
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2181
3093
3094
+
[[package]]
3095
+
name = "static_assertions"
3096
+
version = "1.1.0"
3097
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3098
+
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
3099
+
3100
+
[[package]]
3101
+
name = "streaming-iterator"
3102
+
version = "0.1.9"
3103
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3104
+
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
3105
+
2182
3106
[[package]]
2183
3107
name = "strsim"
2184
3108
version = "0.11.1"
2185
3109
source = "registry+https://github.com/rust-lang/crates.io-index"
2186
3110
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2187
3111
3112
+
[[package]]
3113
+
name = "strum"
3114
+
version = "0.28.0"
3115
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3116
+
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
3117
+
dependencies = [
3118
+
"strum_macros",
3119
+
]
3120
+
3121
+
[[package]]
3122
+
name = "strum_macros"
3123
+
version = "0.28.0"
3124
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3125
+
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
3126
+
dependencies = [
3127
+
"heck",
3128
+
"proc-macro2",
3129
+
"quote",
3130
+
"syn 2.0.117",
3131
+
]
3132
+
2188
3133
[[package]]
2189
3134
name = "subtle"
2190
3135
version = "2.6.1"
2191
3136
source = "registry+https://github.com/rust-lang/crates.io-index"
2192
3137
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2193
3138
3139
+
[[package]]
3140
+
name = "syn"
3141
+
version = "1.0.109"
3142
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3143
+
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
3144
+
dependencies = [
3145
+
"proc-macro2",
3146
+
"quote",
3147
+
"unicode-ident",
3148
+
]
3149
+
2194
3150
[[package]]
2195
3151
name = "syn"
2196
3152
version = "2.0.117"
···
2202
3158
"unicode-ident",
2203
3159
]
2204
3160
3161
+
[[package]]
3162
+
name = "syn"
3163
+
version = "3.0.5"
3164
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3165
+
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
3166
+
dependencies = [
3167
+
"proc-macro2",
3168
+
"quote",
3169
+
"unicode-ident",
3170
+
]
3171
+
2205
3172
[[package]]
2206
3173
name = "sync_wrapper"
2207
3174
version = "1.0.2"
···
2219
3186
dependencies = [
2220
3187
"proc-macro2",
2221
3188
"quote",
2222
-
"syn",
3189
+
"syn 2.0.117",
2223
3190
]
2224
3191
2225
3192
[[package]]
···
2228
3195
source = "registry+https://github.com/rust-lang/crates.io-index"
2229
3196
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
2230
3197
dependencies = [
2231
-
"bitflags",
3198
+
"bitflags 2.13.2",
2232
3199
"core-foundation 0.9.4",
2233
3200
"system-configuration-sys",
2234
3201
]
···
2243
3210
"libc",
2244
3211
]
2245
3212
3213
+
[[package]]
3214
+
name = "tap"
3215
+
version = "1.0.1"
3216
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3217
+
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
3218
+
3219
+
[[package]]
3220
+
name = "target-lexicon"
3221
+
version = "0.13.5"
3222
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3223
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
3224
+
2246
3225
[[package]]
2247
3226
name = "tempfile"
2248
3227
version = "3.27.0"
···
2256
3235
"windows-sys 0.61.2",
2257
3236
]
2258
3237
3238
+
[[package]]
3239
+
name = "termina"
3240
+
version = "0.3.3"
3241
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3242
+
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
3243
+
dependencies = [
3244
+
"bitflags 2.13.2",
3245
+
"parking_lot",
3246
+
"rustix",
3247
+
"signal-hook",
3248
+
"windows-sys 0.61.2",
3249
+
]
3250
+
3251
+
[[package]]
3252
+
name = "terminfo"
3253
+
version = "0.9.0"
3254
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3255
+
checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
3256
+
dependencies = [
3257
+
"fnv",
3258
+
"nom",
3259
+
"phf 0.11.3",
3260
+
"phf_codegen",
3261
+
]
3262
+
3263
+
[[package]]
3264
+
name = "termios"
3265
+
version = "0.3.3"
3266
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3267
+
checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
3268
+
dependencies = [
3269
+
"libc",
3270
+
]
3271
+
3272
+
[[package]]
3273
+
name = "termwiz"
3274
+
version = "0.23.3"
3275
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3276
+
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
3277
+
dependencies = [
3278
+
"anyhow",
3279
+
"base64",
3280
+
"bitflags 2.13.2",
3281
+
"fancy-regex",
3282
+
"filedescriptor",
3283
+
"finl_unicode",
3284
+
"fixedbitset",
3285
+
"hex",
3286
+
"lazy_static",
3287
+
"libc",
3288
+
"log",
3289
+
"memmem",
3290
+
"nix",
3291
+
"num-derive",
3292
+
"num-traits",
3293
+
"ordered-float 4.6.0",
3294
+
"pest",
3295
+
"pest_derive",
3296
+
"phf 0.11.3",
3297
+
"sha2",
3298
+
"signal-hook",
3299
+
"siphasher",
3300
+
"terminfo",
3301
+
"termios",
3302
+
"thiserror 1.0.69",
3303
+
"ucd-trie",
3304
+
"unicode-segmentation",
3305
+
"vtparse",
3306
+
"wezterm-bidi",
3307
+
"wezterm-blob-leases",
3308
+
"wezterm-color-types",
3309
+
"wezterm-dynamic",
3310
+
"wezterm-input-types",
3311
+
"winapi",
3312
+
]
3313
+
2259
3314
[[package]]
2260
3315
name = "thiserror"
2261
3316
version = "1.0.69"
···
2282
3337
dependencies = [
2283
3338
"proc-macro2",
2284
3339
"quote",
2285
-
"syn",
3340
+
"syn 2.0.117",
2286
3341
]
2287
3342
2288
3343
[[package]]
···
2293
3348
dependencies = [
2294
3349
"proc-macro2",
2295
3350
"quote",
2296
-
"syn",
3351
+
"syn 2.0.117",
2297
3352
]
2298
3353
2299
3354
[[package]]
···
2312
3367
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
2313
3368
dependencies = [
2314
3369
"deranged",
3370
+
"libc",
2315
3371
"num-conv",
3372
+
"num_threads",
2316
3373
"powerfmt",
2317
3374
"serde_core",
2318
3375
"time-core",
···
2385
3442
dependencies = [
2386
3443
"proc-macro2",
2387
3444
"quote",
2388
-
"syn",
3445
+
"syn 2.0.117",
2389
3446
]
2390
3447
2391
3448
[[package]]
···
2406
3463
dependencies = [
2407
3464
"futures-util",
2408
3465
"log",
3466
+
"rustls",
3467
+
"rustls-native-certs 0.7.3",
3468
+
"rustls-pki-types",
2409
3469
"tokio",
3470
+
"tokio-rustls",
2410
3471
"tungstenite",
2411
3472
]
2412
3473
···
2468
3529
source = "registry+https://github.com/rust-lang/crates.io-index"
2469
3530
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
2470
3531
dependencies = [
2471
-
"bitflags",
3532
+
"bitflags 2.13.2",
2472
3533
"bytes",
2473
3534
"futures-util",
2474
3535
"http",
···
2512
3573
dependencies = [
2513
3574
"proc-macro2",
2514
3575
"quote",
2515
-
"syn",
3576
+
"syn 2.0.117",
2516
3577
]
2517
3578
2518
3579
[[package]]
···
2550
3611
"tracing-log",
2551
3612
]
2552
3613
3614
+
[[package]]
3615
+
name = "tree-sitter"
3616
+
version = "0.25.10"
3617
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3618
+
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
3619
+
dependencies = [
3620
+
"cc",
3621
+
"regex",
3622
+
"regex-syntax",
3623
+
"serde_json",
3624
+
"streaming-iterator",
3625
+
"tree-sitter-language",
3626
+
]
3627
+
3628
+
[[package]]
3629
+
name = "tree-sitter-language"
3630
+
version = "0.1.8"
3631
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3632
+
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
3633
+
3634
+
[[package]]
3635
+
name = "tree-sitter-python"
3636
+
version = "0.25.0"
3637
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3638
+
checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c"
3639
+
dependencies = [
3640
+
"cc",
3641
+
"tree-sitter-language",
3642
+
]
3643
+
2553
3644
[[package]]
2554
3645
name = "try-lock"
2555
3646
version = "0.2.5"
···
2569
3660
"httparse",
2570
3661
"log",
2571
3662
"rand 0.8.5",
3663
+
"rustls",
3664
+
"rustls-pki-types",
2572
3665
"sha1",
2573
3666
"thiserror 1.0.69",
2574
3667
"utf-8",
···
2580
3673
source = "registry+https://github.com/rust-lang/crates.io-index"
2581
3674
checksum = "59267541c31f888c1587da5e7cbab182ae6efd98faedd9ea2e35a4eef43ff204"
2582
3675
dependencies = [
2583
-
"bitflags",
3676
+
"bitflags 2.13.2",
2584
3677
"fastrand",
2585
3678
"futures-core",
2586
3679
"futures-sink",
···
2647
3740
source = "registry+https://github.com/rust-lang/crates.io-index"
2648
3741
checksum = "2bf6bb7b93a7f765d89b3388cc710c0ae16104579e06bb30ea1ee6bd41420a8b"
2649
3742
dependencies = [
2650
-
"bitflags",
3743
+
"bitflags 2.13.2",
2651
3744
"serde",
2652
3745
"serde-value",
2653
3746
"serde_repr",
···
2669
3762
source = "registry+https://github.com/rust-lang/crates.io-index"
2670
3763
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
2671
3764
3765
+
[[package]]
3766
+
name = "ucd-trie"
3767
+
version = "0.1.7"
3768
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3769
+
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
3770
+
2672
3771
[[package]]
2673
3772
name = "unicode-ident"
2674
3773
version = "1.0.24"
2675
3774
source = "registry+https://github.com/rust-lang/crates.io-index"
2676
3775
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2677
3776
3777
+
[[package]]
3778
+
name = "unicode-segmentation"
3779
+
version = "1.13.3"
3780
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3781
+
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
3782
+
3783
+
[[package]]
3784
+
name = "unicode-truncate"
3785
+
version = "2.0.1"
3786
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3787
+
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
3788
+
dependencies = [
3789
+
"itertools",
3790
+
"unicode-segmentation",
3791
+
"unicode-width 0.2.2",
3792
+
]
3793
+
2678
3794
[[package]]
2679
3795
name = "unicode-width"
2680
3796
version = "0.1.14"
2681
3797
source = "registry+https://github.com/rust-lang/crates.io-index"
2682
3798
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
2683
3799
3800
+
[[package]]
3801
+
name = "unicode-width"
3802
+
version = "0.2.2"
3803
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3804
+
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3805
+
2684
3806
[[package]]
2685
3807
name = "unicode-xid"
2686
3808
version = "0.2.6"
···
2729
3851
source = "registry+https://github.com/rust-lang/crates.io-index"
2730
3852
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
2731
3853
dependencies = [
3854
+
"atomic",
2732
3855
"getrandom 0.4.2",
2733
3856
"js-sys",
2734
3857
"wasm-bindgen",
···
2752
3875
source = "registry+https://github.com/rust-lang/crates.io-index"
2753
3876
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2754
3877
3878
+
[[package]]
3879
+
name = "vtparse"
3880
+
version = "0.6.2"
3881
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3882
+
checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
3883
+
dependencies = [
3884
+
"utf8parse",
3885
+
]
3886
+
2755
3887
[[package]]
2756
3888
name = "walkdir"
2757
3889
version = "2.5.0"
···
2837
3969
"bumpalo",
2838
3970
"proc-macro2",
2839
3971
"quote",
2840
-
"syn",
3972
+
"syn 2.0.117",
2841
3973
"wasm-bindgen-shared",
2842
3974
]
2843
3975
···
2891
4023
source = "registry+https://github.com/rust-lang/crates.io-index"
2892
4024
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
2893
4025
dependencies = [
2894
-
"bitflags",
4026
+
"bitflags 2.13.2",
2895
4027
"hashbrown 0.15.5",
2896
4028
"indexmap",
2897
4029
"semver",
···
2926
4058
"rustls-pki-types",
2927
4059
]
2928
4060
4061
+
[[package]]
4062
+
name = "wezterm-bidi"
4063
+
version = "0.2.3"
4064
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4065
+
checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
4066
+
dependencies = [
4067
+
"log",
4068
+
"wezterm-dynamic",
4069
+
]
4070
+
4071
+
[[package]]
4072
+
name = "wezterm-blob-leases"
4073
+
version = "0.1.1"
4074
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4075
+
checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
4076
+
dependencies = [
4077
+
"getrandom 0.3.4",
4078
+
"mac_address",
4079
+
"sha2",
4080
+
"thiserror 1.0.69",
4081
+
"uuid",
4082
+
]
4083
+
4084
+
[[package]]
4085
+
name = "wezterm-color-types"
4086
+
version = "0.3.0"
4087
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4088
+
checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
4089
+
dependencies = [
4090
+
"csscolorparser",
4091
+
"deltae",
4092
+
"lazy_static",
4093
+
"wezterm-dynamic",
4094
+
]
4095
+
4096
+
[[package]]
4097
+
name = "wezterm-dynamic"
4098
+
version = "0.2.1"
4099
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4100
+
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
4101
+
dependencies = [
4102
+
"log",
4103
+
"ordered-float 4.6.0",
4104
+
"strsim",
4105
+
"thiserror 1.0.69",
4106
+
"wezterm-dynamic-derive",
4107
+
]
4108
+
4109
+
[[package]]
4110
+
name = "wezterm-dynamic-derive"
4111
+
version = "0.1.1"
4112
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4113
+
checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
4114
+
dependencies = [
4115
+
"proc-macro2",
4116
+
"quote",
4117
+
"syn 1.0.109",
4118
+
]
4119
+
4120
+
[[package]]
4121
+
name = "wezterm-input-types"
4122
+
version = "0.1.0"
4123
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4124
+
checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
4125
+
dependencies = [
4126
+
"bitflags 1.3.2",
4127
+
"euclid",
4128
+
"lazy_static",
4129
+
"serde",
4130
+
"wezterm-dynamic",
4131
+
]
4132
+
4133
+
[[package]]
4134
+
name = "winapi"
4135
+
version = "0.3.9"
4136
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4137
+
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4138
+
dependencies = [
4139
+
"winapi-i686-pc-windows-gnu",
4140
+
"winapi-x86_64-pc-windows-gnu",
4141
+
]
4142
+
4143
+
[[package]]
4144
+
name = "winapi-i686-pc-windows-gnu"
4145
+
version = "0.4.0"
4146
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4147
+
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4148
+
2929
4149
[[package]]
2930
4150
name = "winapi-util"
2931
4151
version = "0.1.11"
···
2935
4155
"windows-sys 0.61.2",
2936
4156
]
2937
4157
4158
+
[[package]]
4159
+
name = "winapi-x86_64-pc-windows-gnu"
4160
+
version = "0.4.0"
4161
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4162
+
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4163
+
2938
4164
[[package]]
2939
4165
name = "windows-core"
2940
4166
version = "0.62.2"
···
2956
4182
dependencies = [
2957
4183
"proc-macro2",
2958
4184
"quote",
2959
-
"syn",
4185
+
"syn 2.0.117",
2960
4186
]
2961
4187
2962
4188
[[package]]
···
2967
4193
dependencies = [
2968
4194
"proc-macro2",
2969
4195
"quote",
2970
-
"syn",
4196
+
"syn 2.0.117",
2971
4197
]
2972
4198
2973
4199
[[package]]
···
3267
4493
"heck",
3268
4494
"indexmap",
3269
4495
"prettyplease",
3270
-
"syn",
4496
+
"syn 2.0.117",
3271
4497
"wasm-metadata",
3272
4498
"wit-bindgen-core",
3273
4499
"wit-component",
···
3283
4509
"prettyplease",
3284
4510
"proc-macro2",
3285
4511
"quote",
3286
-
"syn",
4512
+
"syn 2.0.117",
3287
4513
"wit-bindgen-core",
3288
4514
"wit-bindgen-rust",
3289
4515
]
···
3295
4521
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
3296
4522
dependencies = [
3297
4523
"anyhow",
3298
-
"bitflags",
4524
+
"bitflags 2.13.2",
3299
4525
"indexmap",
3300
4526
"log",
3301
4527
"serde",
···
3331
4557
source = "registry+https://github.com/rust-lang/crates.io-index"
3332
4558
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
3333
4559
4560
+
[[package]]
4561
+
name = "wyz"
4562
+
version = "0.5.1"
4563
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4564
+
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
4565
+
dependencies = [
4566
+
"tap",
4567
+
]
4568
+
3334
4569
[[package]]
3335
4570
name = "yoke"
3336
4571
version = "0.8.2"
···
3350
4585
dependencies = [
3351
4586
"proc-macro2",
3352
4587
"quote",
3353
-
"syn",
4588
+
"syn 2.0.117",
3354
4589
"synstructure",
3355
4590
]
3356
4591
···
3371
4606
dependencies = [
3372
4607
"proc-macro2",
3373
4608
"quote",
3374
-
"syn",
4609
+
"syn 2.0.117",
3375
4610
]
3376
4611
3377
4612
[[package]]
···
3391
4626
dependencies = [
3392
4627
"proc-macro2",
3393
4628
"quote",
3394
-
"syn",
4629
+
"syn 2.0.117",
3395
4630
"synstructure",
3396
4631
]
3397
4632
···
3431
4666
dependencies = [
3432
4667
"proc-macro2",
3433
4668
"quote",
3434
-
"syn",
4669
+
"syn 2.0.117",
3435
4670
]
3436
4671
3437
4672
[[package]]
+1
Cargo.toml
+1
Cargo.toml
+39
README.md
+39
README.md
···
13
13
- `klbr-discord`: Twilight transport adapter feeding the runtime ingress boundary
14
14
- `python/klbr-runtime`: persistent workbench, hooks, and the `klbr` Python SDK
15
15
- `klbr-web`: bundled Svelte lane client
16
+
- `klbr-tui`: native Rust terminal client of the same v2 daemon
16
17
17
18
There is no legacy runtime mode, legacy memory database, Rust model-tool registry, benchmark
18
19
crate, or v1 client protocol in the normal source tree.
···
31
32
`KLBR_DATA_DIR` for durable state. See [DOGFOOD.md](DOGFOOD.md) for paths and operator
32
33
commands. Source dogfooding is the current path; Nix packaging is deferred.
33
34
35
+
## native terminal
36
+
37
+
Start the daemon above, then in another terminal:
38
+
39
+
```text
40
+
cargo run -p klbr-tui -- --session default
41
+
```
42
+
43
+
The terminal keeps the transcript, live activity and composer separate. Prompts appear
44
+
immediately; scratchpad, executed Python output and confirmed speech retain their own
45
+
labels. `esc` interrupts the turn; `ctrl+c` or `/q` detaches without stopping the daemon.
46
+
Wheel or page keys scroll history, `ctrl+end` follows live output, `/t` toggles scratchpad,
47
+
`/v` expands cells and diffs, and `/sessions` opens the session picker. Paste is a draft,
48
+
never an automatic submission. Use `shift+enter`, `alt+enter` or `ctrl+j` for a newline;
49
+
the multiline draft stays in one horizontal composing row. `/` opens completion,
50
+
`tab` completes, and `esc` dismisses the menu before interrupting a turn.
51
+
52
+
`--url ws://127.0.0.1:8765/ws` selects the daemon. Reconnect resumes durable history;
53
+
unconfirmed input is **not** resent. `/retry` explicitly retries it with the original
54
+
admission id. Client drafts survive session switching, not client exit.
55
+
56
+
Generation uses niri’s transient action row: inferred reading/writing/editing labels
57
+
follow the latest recognized Python statement, alongside the fill/clear/continue code
58
+
preview. Unknown or dynamic paths keep a generic label. This is not a claim
59
+
that an edit has run. Completed workspace-helper reads/lists/searches and file edits
60
+
produce bounded, durable per-cell receipts, including edits before a later exception.
61
+
Diffs cover `File.write`, `replace` and `edit`; arbitrary Python, shell writes and
62
+
recursive deletion are not watched or inferred. Binary/large diffs are marked unavailable.
63
+
64
+
See [the source transfer map](klbr-tui/PARITY.md) for tests and explicit v2 API adaptations.
65
+
A provider that sends complete tool arguments in one chunk cannot show live typing;
66
+
the client does not manufacture it by replaying already-completed code.
67
+
68
+
Live durable records and reconnect replay both come from Store in sequence; change
69
+
notifications only wake the reader. Cell `starting`/`arguments` chunks are transient
70
+
generation metadata, not execution results. Trace fields are optional, so older workers
71
+
remain readable without inventing missing activity or diffs.
72
+
34
73
## invariants
35
74
36
75
- runtime Store is the only normal history/work owner
+12
klbr-core/src/models.rs
+12
klbr-core/src/models.rs
···
368
368
/// emitted once when the model decides to call one or more tools
369
369
ToolCalls(Vec<ToolCall>),
370
370
ToolCallStarted,
371
+
ToolCallDelta {
372
+
index: usize,
373
+
name: String,
374
+
arguments: String,
375
+
},
371
376
Completed,
372
377
Error(String),
373
378
}
···
937
942
}
938
943
if let Some(args) = tc["function"]["arguments"].as_str() {
939
944
entry.arguments.push_str(args);
945
+
let _ = tok_tx
946
+
.send(LlmEvent::ToolCallDelta {
947
+
index: idx,
948
+
name: entry.name.clone(),
949
+
arguments: args.to_owned(),
950
+
})
951
+
.await;
940
952
}
941
953
}
942
954
continue;
+5
klbr-core/src/runtime_turn/integrator.rs
+5
klbr-core/src/runtime_turn/integrator.rs
···
265
265
output,
266
266
truncated_bytes,
267
267
error,
268
+
..
268
269
} => {
269
270
for item in output {
270
271
cell_outputs.push(item.clone().into());
···
456
457
let _ = state.record_scratchpad(ev.clone());
457
458
yield (TurnEvent::Scratchpad(ev), state.clone(), Vec::new());
458
459
}
460
+
Some(LlmEvent::ToolCallDelta { index, name, arguments }) => {
461
+
// Transient generation progress is not another durable scratchpad entry.
462
+
yield (TurnEvent::Scratchpad(ScratchpadEvent::ToolCallDelta { index, name, arguments }), state.clone(), Vec::new());
463
+
}
459
464
Some(LlmEvent::ToolCalls(calls)) => {
460
465
for call in &calls {
461
466
let ev = ScratchpadEvent::ToolCallProposed {
+5
klbr-core/src/runtime_turn/state.rs
+5
klbr-core/src/runtime_turn/state.rs
+76
-46
klbr-daemon/src/daemon.rs
+76
-46
klbr-daemon/src/daemon.rs
···
28
28
};
29
29
use klbr_core::{
30
30
models::{LlmClient, Message as CoreMessage, Role, ToolCall, ToolCallFunction},
31
-
runtime_turn::state::{CellOutputStream, ScratchpadEvent, TurnEvent},
31
+
runtime_turn::state::{ScratchpadEvent, TurnEvent},
32
32
};
33
33
use klbr_ipc::v2::{
34
34
CellPayload, CellState, CellStreamKind, CellStreamOutput, InputPayload, Lane, LanePayload,
···
360
360
output: outputs,
361
361
truncated_bytes,
362
362
error,
363
+
trace: ev
364
+
.payload
365
+
.pointer("/outcome/trace")
366
+
.and_then(|trace| serde_json::from_value(trace.clone()).ok()),
363
367
})
364
368
}
365
369
_ => {
···
2008
2012
let mut report = SliceReport::default();
2009
2013
while let Some(turn_event) = turn_stream.next().await {
2010
2014
match &turn_event {
2011
-
TurnEvent::Scratchpad(ScratchpadEvent::ProseDelta(text)) => {
2015
+
TurnEvent::Scratchpad(
2016
+
ScratchpadEvent::ProseDelta(text) | ScratchpadEvent::ThoughtDelta(text),
2017
+
) => {
2012
2018
let _ = tx_clone.send(V2ServerMsg::LiveChunk {
2013
2019
session_id: s_id.to_string(),
2014
2020
lane: Lane::Scratchpad,
2015
2021
execution_id: None,
2016
-
stream: Some("prose".to_string()),
2022
+
stream: Some(
2023
+
if matches!(
2024
+
&turn_event,
2025
+
TurnEvent::Scratchpad(ScratchpadEvent::ThoughtDelta(_))
2026
+
) {
2027
+
"thought"
2028
+
} else {
2029
+
"prose"
2030
+
}
2031
+
.to_string(),
2032
+
),
2017
2033
delta: text.clone(),
2018
2034
});
2019
2035
}
2020
-
TurnEvent::Scratchpad(_) => {}
2021
-
TurnEvent::CellOutput(co) => {
2022
-
let stream_str = match co.stream {
2023
-
CellOutputStream::Stdout => "stdout",
2024
-
CellOutputStream::Stderr => "stderr",
2025
-
CellOutputStream::Result => "result",
2026
-
};
2036
+
TurnEvent::Scratchpad(ScratchpadEvent::ToolCallStarted) => {
2027
2037
let _ = tx_clone.send(V2ServerMsg::LiveChunk {
2028
2038
session_id: s_id.to_string(),
2029
2039
lane: Lane::Cell,
2030
2040
execution_id: None,
2031
-
stream: Some(stream_str.to_string()),
2032
-
delta: co.text.clone(),
2041
+
stream: Some("starting".into()),
2042
+
delta: String::new(),
2033
2043
});
2034
2044
}
2035
-
TurnEvent::Speech(sp) => {
2036
-
let _ = tx_clone.send(V2ServerMsg::LiveChunk {
2037
-
session_id: s_id.to_string(),
2038
-
lane: Lane::Speech,
2039
-
execution_id: None,
2040
-
stream: None,
2041
-
delta: sp.text.clone(),
2042
-
});
2043
-
if let Some(event_id) = sp.event_id {
2044
-
let payload = match sp.status {
2045
-
SpeechStatus::Confirmed => SpeechPayload::Sent {
2046
-
effect_id: sp.effect_id.clone(),
2047
-
text: sp.text.clone(),
2048
-
event_id,
2049
-
status: "confirmed".to_string(),
2050
-
},
2051
-
_ => SpeechPayload::Held {
2052
-
effect_id: sp.effect_id.clone(),
2053
-
text: sp.text.clone(),
2054
-
status: "held".to_string(),
2055
-
reason: sp.reason.clone().unwrap_or_default(),
2056
-
},
2057
-
};
2058
-
let record = V2EventRecord::speech(
2059
-
event_id,
2060
-
s_id.to_string(),
2061
-
payload,
2062
-
chrono::Utc::now().timestamp_millis(),
2063
-
);
2064
-
let _ = tx_clone.send(V2ServerMsg::Event { record });
2065
-
}
2045
+
TurnEvent::Scratchpad(ScratchpadEvent::ToolCallDelta {
2046
+
index,
2047
+
name,
2048
+
arguments,
2049
+
}) => {
2050
+
let _ = tx_clone.send(V2ServerMsg::LiveChunk { session_id: s_id.to_string(), lane: Lane::Cell, execution_id: None, stream: Some("arguments".into()), delta: serde_json::json!({"index": index, "name": name, "arguments": arguments}).to_string() });
2066
2051
}
2052
+
TurnEvent::Scratchpad(_) => {}
2053
+
// Cell output and speech are delivered from Store, once, in sequence.
2067
2054
TurnEvent::Exhausted { step, max_steps } => {
2068
2055
tracing::info!(
2069
2056
session = %s_id,
···
2996
2983
2997
2984
async fn send_v2_msg(ws_tx: &mut WsSink, msg: &V2ServerMsg) -> Result<()> {
2998
2985
let json = serde_json::to_string(msg)?;
2999
-
ws_tx.send(Message::Text(json)).await?;
2986
+
tokio::time::timeout(Duration::from_secs(3), ws_tx.send(Message::Text(json))).await??;
3000
2987
Ok(())
3001
2988
}
3002
2989
2990
+
/// One source for replay and live durable records, including errors and edit receipts.
2991
+
async fn flush_committed(
2992
+
ws: &mut WsSink,
2993
+
store: &Store,
2994
+
session: &SessionId,
2995
+
cursor: &mut i64,
2996
+
) -> Result<()> {
2997
+
loop {
2998
+
let page = store.history(session, *cursor, 1024).await?;
2999
+
let full = page.len() == 1024;
3000
+
for event in page {
3001
+
let seq = event.id;
3002
+
send_v2_msg(
3003
+
ws,
3004
+
&V2ServerMsg::Event {
3005
+
record: project_event(session, event),
3006
+
},
3007
+
)
3008
+
.await?;
3009
+
*cursor = seq;
3010
+
}
3011
+
if !full {
3012
+
return Ok(());
3013
+
}
3014
+
}
3015
+
}
3016
+
3003
3017
async fn send_operator_result(
3004
3018
ws_tx: &mut WsSink,
3005
3019
action: &str,
···
3036
3050
let (mut ws_tx, mut ws_rx): (WsSink, WsSource) = ws.split();
3037
3051
3038
3052
let mut subscribed_session: Option<String> = None;
3053
+
let mut committed_cursor = 0;
3054
+
let mut store_changes = registry.store().changes();
3039
3055
3040
3056
loop {
3041
3057
tokio::select! {
3042
3058
_ = shutdown.changed() => break,
3059
+
changed = store_changes.changed() => {
3060
+
if changed.is_err() { break; }
3061
+
if let Some(session) = &subscribed_session {
3062
+
flush_committed(&mut ws_tx, registry.store(), &parse_session_id(session), &mut committed_cursor).await?;
3063
+
}
3064
+
}
3043
3065
v2_ev = v2_rx.recv() => {
3044
3066
if let Ok(msg) = v2_ev {
3045
3067
if let Some(sub_sess) = &subscribed_session {
···
3049
3071
| V2ServerMsg::LiveChunk { session_id, .. }
3050
3072
| V2ServerMsg::Ack { session_id, .. } => {
3051
3073
if session_id == sub_sess {
3052
-
let _ = send_v2_msg(&mut ws_tx, &msg).await;
3074
+
// Close the prior model step before streaming the next one.
3075
+
if store_changes.has_changed().unwrap_or(false) {
3076
+
store_changes.borrow_and_update();
3077
+
flush_committed(&mut ws_tx, registry.store(), &parse_session_id(sub_sess), &mut committed_cursor).await?;
3078
+
}
3079
+
send_v2_msg(&mut ws_tx, &msg).await?;
3053
3080
}
3054
3081
}
3055
3082
V2ServerMsg::Event { record } => {
3056
3083
if &record.session_id == sub_sess {
3057
-
let _ = send_v2_msg(&mut ws_tx, &msg).await;
3084
+
// Broadcasts are wake hints too; never jump over unbroadcast cells.
3085
+
flush_committed(&mut ws_tx, registry.store(), &parse_session_id(sub_sess), &mut committed_cursor).await?;
3058
3086
}
3059
3087
}
3060
3088
_ => {
···
3081
3109
match v2_msg {
3082
3110
V2ClientMsg::Subscribe { session_id, after_seq, limit } => {
3083
3111
subscribed_session = Some(session_id.clone());
3112
+
committed_cursor = registry.store().seq_bounds(&parse_session_id(&session_id)).await?.map(|(_, high)| high).unwrap_or(0);
3084
3113
// No cursor at all is a live-only subscription: nothing was asked for.
3085
3114
if after_seq.is_some() {
3086
3115
let s_id = parse_session_id(&session_id);
···
3098
3127
earliest_seq: plan.earliest,
3099
3128
fold_seq: plan.fold,
3100
3129
}).await;
3130
+
committed_cursor = plan.to;
3101
3131
if plan.to >= plan.from {
3102
3132
let events = query_events_range(registry.store(), &s_id, plan.from, plan.to)
3103
3133
.await
+94
-9
klbr-daemon/tests/v2_integration_test.rs
+94
-9
klbr-daemon/tests/v2_integration_test.rs
···
194
194
} => {
195
195
started = Some((from_seq, to_seq, earliest_seq, fold_seq));
196
196
}
197
-
V2ServerMsg::Event { record } => records.push(record.seq),
197
+
V2ServerMsg::Event { record } if started.is_some() => records.push(record.seq),
198
198
V2ServerMsg::SyncCompleted { .. } => break,
199
199
_ => {}
200
200
}
···
980
980
let _ = socket.write_all(resp.as_bytes()).await;
981
981
} else {
982
982
let chunk1 = serde_json::json!({
983
-
"choices": [{"delta": {"role": "assistant", "content": "I am thinking in scratchpad."}}]
983
+
"choices": [{"delta": {"role": "assistant", "content": "I am thinking in scratchpad.", "reasoning_content": "checking the workspace"}}]
984
984
});
985
985
let args_str = serde_json::to_string(&serde_json::json!({
986
986
"code": "from klbr import local, runtime\nawait local.send('hello from integration gate')\nawait runtime.wait(seconds=5.0, reason='gate wait')"
···
1093
1093
// 3. Collect LiveChunks across lanes
1094
1094
let mut saw_scratchpad = false;
1095
1095
let mut saw_speech = false;
1096
+
let mut saw_thought = false;
1097
+
let mut saw_arguments = false;
1098
+
let mut saw_start = false;
1096
1099
1097
1100
let deadline = tokio::time::Instant::now() + Duration::from_secs(12);
1098
1101
while tokio::time::Instant::now() < deadline {
1099
1102
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1100
1103
if let Ok(Some(Ok(Message::Text(text)))) = timeout(remaining, read.next()).await {
1101
1104
println!("RECV WS MSG: {}", text);
1102
-
if let Ok(V2ServerMsg::LiveChunk { lane, delta, .. }) =
1103
-
serde_json::from_str::<V2ServerMsg>(&text)
1105
+
if let Ok(V2ServerMsg::Event { record }) = serde_json::from_str::<V2ServerMsg>(&text) {
1106
+
saw_speech |= matches!(record.payload, klbr_ipc::v2::LanePayload::Speech(klbr_ipc::v2::SpeechPayload::Sent { text, status, .. }) if status == "confirmed" && text.contains("hello from integration gate"));
1107
+
}
1108
+
if let Ok(V2ServerMsg::LiveChunk {
1109
+
lane,
1110
+
stream,
1111
+
delta,
1112
+
..
1113
+
}) = serde_json::from_str::<V2ServerMsg>(&text)
1104
1114
{
1105
1115
if lane == Lane::Scratchpad && delta.contains("thinking in scratchpad") {
1106
1116
saw_scratchpad = true;
1107
1117
}
1108
-
if lane == Lane::Speech && delta.contains("hello from integration gate") {
1109
-
saw_speech = true;
1110
-
}
1118
+
saw_thought |= lane == Lane::Scratchpad
1119
+
&& stream.as_deref() == Some("thought")
1120
+
&& delta.contains("checking the workspace");
1121
+
saw_arguments |= lane == Lane::Cell
1122
+
&& stream.as_deref() == Some("arguments")
1123
+
&& delta.contains("local.send");
1124
+
saw_start |= lane == Lane::Cell && stream.as_deref() == Some("starting");
1111
1125
}
1112
1126
}
1113
-
if saw_scratchpad && saw_speech {
1127
+
if saw_scratchpad && saw_speech && saw_thought && saw_arguments && saw_start {
1114
1128
break;
1115
1129
}
1116
1130
}
···
1121
1135
);
1122
1136
assert!(
1123
1137
saw_speech,
1124
-
"Must receive Speech LiveChunk with confirmed local.send text"
1138
+
"Must receive a durable confirmed speech receipt"
1139
+
);
1140
+
1141
+
assert!(
1142
+
saw_thought && saw_arguments && saw_start,
1143
+
"reasoning and call generation must reach the live client"
1125
1144
);
1126
1145
1127
1146
// 4. Invariant checks on Store and SessionRegistry
···
1261
1280
let _ = daemon.shutdown_tx.send(true);
1262
1281
Ok(())
1263
1282
}
1283
+
1284
+
/// No hand-authored broadcast is involved: Store commits must reach a live subscriber.
1285
+
#[tokio::test]
1286
+
async fn live_cells_arrive_before_completion_and_keep_failure_diffs_in_order() -> Result<()> {
1287
+
use klbr_ipc::v2::{CellPayload, CellState, LanePayload};
1288
+
let temp = tempfile::tempdir()?;
1289
+
let daemon = start_test_daemon(&temp).await?;
1290
+
let id = parse_session_id("live-cell-receipts");
1291
+
let release = publish_test_release(&temp)?;
1292
+
let entry = daemon.registry.get_or_open(&id, &release).await?;
1293
+
let (mut socket, _) = connect_async(format!("ws://{}", daemon.addr)).await?;
1294
+
socket
1295
+
.send(Message::Text(serde_json::to_string(
1296
+
&V2ClientMsg::Subscribe {
1297
+
session_id: id.to_string(),
1298
+
after_seq: Some(0),
1299
+
limit: None,
1300
+
},
1301
+
)?))
1302
+
.await?;
1303
+
let (_, initial) = collect_replay(&mut socket).await?;
1304
+
let mut last = initial.last().copied().unwrap_or(0);
1305
+
let code = format!("import asyncio\nfrom pathlib import Path\nfrom klbr.workspace import Workspace\nw = Workspace(Path({:?}))\nf = await w.open_file('live.txt')\nawait f.write('a real write\\n')\nawait asyncio.sleep(0.3)\nraise ValueError('visible failure')", temp.path().to_string_lossy());
1306
+
let running =
1307
+
tokio::spawn(async move { entry.runtime.execute(code, Duration::from_secs(5)).await });
1308
+
let mut started = false;
1309
+
loop {
1310
+
let Some(Ok(Message::Text(text))) = timeout(Duration::from_secs(5), socket.next()).await?
1311
+
else {
1312
+
panic!("missing live record")
1313
+
};
1314
+
let V2ServerMsg::Event { record } = serde_json::from_str(&text)? else {
1315
+
continue;
1316
+
};
1317
+
assert!(
1318
+
record.seq > last,
1319
+
"live records must not skip then backfill or duplicate"
1320
+
);
1321
+
last = record.seq;
1322
+
match record.payload {
1323
+
LanePayload::Cell(CellPayload::Started { .. }) => {
1324
+
started = true;
1325
+
assert!(
1326
+
!running.is_finished(),
1327
+
"started must be visible while Python is still running"
1328
+
);
1329
+
}
1330
+
LanePayload::Cell(CellPayload::Completed {
1331
+
state,
1332
+
trace,
1333
+
error,
1334
+
..
1335
+
}) => {
1336
+
assert!(started);
1337
+
assert_eq!(state, CellState::Error);
1338
+
assert!(error.unwrap().contains("visible failure"));
1339
+
assert_eq!(trace.unwrap().changes[0].path, "live.txt");
1340
+
break;
1341
+
}
1342
+
_ => {}
1343
+
}
1344
+
}
1345
+
running.await??;
1346
+
daemon.stop_and_join().await?;
1347
+
Ok(())
1348
+
}
+29
klbr-ipc/src/v2.rs
+29
klbr-ipc/src/v2.rs
···
159
159
pub text: String,
160
160
}
161
161
162
+
/// Bounded observations of workspace helpers that actually ran, not inferred effects.
163
+
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
164
+
pub struct CellTrace {
165
+
#[serde(default)]
166
+
pub activities: Vec<WorkspaceActivity>,
167
+
#[serde(default)]
168
+
pub changes: Vec<FileChange>,
169
+
#[serde(default)]
170
+
pub truncated: bool,
171
+
}
172
+
173
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174
+
pub struct WorkspaceActivity {
175
+
pub kind: String,
176
+
pub target: String,
177
+
}
178
+
179
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180
+
pub struct FileChange {
181
+
pub path: String,
182
+
pub diff: String,
183
+
pub added: usize,
184
+
pub removed: usize,
185
+
#[serde(default)]
186
+
pub truncated: bool,
187
+
}
188
+
162
189
/// cell execution payload (started or completed)
163
190
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164
191
#[serde(tag = "type", rename_all = "snake_case")]
···
172
199
Completed {
173
200
execution_id: String,
174
201
state: CellState,
202
+
#[serde(default, skip_serializing_if = "Option::is_none")]
203
+
trace: Option<CellTrace>,
175
204
#[serde(default)]
176
205
output: Vec<CellStreamOutput>,
177
206
#[serde(default)]
+28
klbr-runtime/src/protocol.rs
+28
klbr-runtime/src/protocol.rs
···
1323
1323
}
1324
1324
}
1325
1325
1326
+
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1327
+
pub struct CellTrace {
1328
+
#[serde(default)]
1329
+
pub activities: Vec<WorkspaceActivity>,
1330
+
#[serde(default)]
1331
+
pub changes: Vec<FileChange>,
1332
+
#[serde(default)]
1333
+
pub truncated: bool,
1334
+
}
1335
+
1336
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1337
+
pub struct WorkspaceActivity {
1338
+
pub kind: String,
1339
+
pub target: String,
1340
+
}
1341
+
1342
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1343
+
pub struct FileChange {
1344
+
pub path: String,
1345
+
pub diff: String,
1346
+
pub added: usize,
1347
+
pub removed: usize,
1348
+
#[serde(default)]
1349
+
pub truncated: bool,
1350
+
}
1351
+
1326
1352
#[derive(Clone, Debug, Serialize, Deserialize)]
1327
1353
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1328
1354
pub enum Outcome {
···
1331
1357
output: Vec<Output>,
1332
1358
truncated_bytes: u64,
1333
1359
error: Option<String>,
1360
+
#[serde(default, skip_serializing_if = "Option::is_none")]
1361
+
trace: Option<CellTrace>,
1334
1362
},
1335
1363
Hook {
1336
1364
value: Value,
+19
-2
klbr-runtime/src/store.rs
+19
-2
klbr-runtime/src/store.rs
···
15
15
sync::{Arc, Mutex},
16
16
time::{Duration, SystemTime, UNIX_EPOCH},
17
17
};
18
-
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
18
+
use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore};
19
19
20
20
use crate::work::{
21
21
ClaimResult, ConsequenceMetadata, Impact, ObserveResult, ObserverCaseRecord, OccurrenceId,
···
138
138
pub struct Store {
139
139
connection: Arc<Mutex<Connection>>,
140
140
gate: Arc<Semaphore>,
141
+
changed: watch::Sender<()>,
141
142
_owner: Arc<File>,
142
143
}
143
144
···
455
456
Ok(Self {
456
457
connection: Arc::new(Mutex::new(connection)),
457
458
gate: Arc::new(Semaphore::new(BLOCKING_JOBS)),
459
+
changed: watch::channel(()).0,
458
460
_owner: Arc::new(owner),
459
461
})
460
462
}
461
463
464
+
/// A coalesced wake hint, never a second history source. Readers resume by durable sequence.
465
+
pub fn changes(&self) -> watch::Receiver<()> {
466
+
self.changed.subscribe()
467
+
}
468
+
462
469
async fn with<T: Send + 'static>(
463
470
&self,
464
471
run: impl FnOnce(&mut Connection) -> Result<T> + Send + 'static,
···
466
473
let permit = self.gate.clone().acquire_owned().await?;
467
474
let connection = self.connection.clone();
468
475
let owner = self._owner.clone();
476
+
let changed = self.changed.clone();
469
477
tokio::task::spawn_blocking(move || {
470
478
// Cancellation drops interest, not an in-flight database write: the
471
479
// share is held until the transaction finishes, and released before
···
474
482
let mut connection = connection
475
483
.lock()
476
484
.map_err(|_| anyhow!("runtime database lock poisoned"))?;
477
-
run(&mut connection)
485
+
let before = connection.total_changes();
486
+
let result = run(&mut connection);
487
+
let wrote = connection.total_changes() != before;
488
+
drop(connection);
489
+
// Run has committed or rolled back and released its lock. A rollback may wake
490
+
// a reader, but only committed rows can ever become client records.
491
+
if wrote {
492
+
changed.send_modify(|_| {});
493
+
}
494
+
result
478
495
})
479
496
.await
480
497
.context("runtime database task panicked")?
+50
klbr-runtime/tests/session.rs
+50
klbr-runtime/tests/session.rs
···
847
847
assert_ne!(manifest["head_digest"], manifest["known_digest"]);
848
848
Ok(())
849
849
}
850
+
851
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
852
+
async fn workspace_diffs_are_per_cell_and_durable_even_after_an_error() -> Result<()> {
853
+
let rig = Rig::new().await?;
854
+
let initial = rig.run(&format!(
855
+
"from pathlib import Path\nfrom klbr.workspace import Workspace\nw = Workspace(Path({:?}))\nf = await w.open_file('index.html')\nawait f.write('wrong title\\nretained body\\n')\nraise ValueError('intentional test failure')",
856
+
rig.temp.path().to_string_lossy()
857
+
)).await?;
858
+
let Outcome::Cell {
859
+
status: CellStatus::Error,
860
+
trace: Some(trace),
861
+
..
862
+
} = initial.outcome
863
+
else {
864
+
panic!("expected a failed cell with actual edit receipts")
865
+
};
866
+
assert_eq!(trace.changes[0].added, 2);
867
+
let fixed = rig
868
+
.run("await f.replace('wrong title', 'right title')")
869
+
.await?;
870
+
ok(&fixed);
871
+
let Outcome::Cell {
872
+
trace: Some(trace), ..
873
+
} = fixed.outcome
874
+
else {
875
+
panic!("expected a cell trace")
876
+
};
877
+
assert_eq!((trace.changes[0].added, trace.changes[0].removed), (1, 1));
878
+
assert!(trace.changes[0].diff.contains("+right title"));
879
+
let history = rig.session.store.history(&rig.session.id, 0, 100).await?;
880
+
let completions: Vec<_> = history
881
+
.iter()
882
+
.filter(|event| event.kind == "execution.completed")
883
+
.collect();
884
+
assert_eq!(completions.len(), 2);
885
+
assert_eq!(
886
+
completions[0]
887
+
.payload
888
+
.pointer("/outcome/trace/changes/0/path"),
889
+
Some(&json!("index.html"))
890
+
);
891
+
assert_eq!(
892
+
completions[1]
893
+
.payload
894
+
.pointer("/outcome/trace/changes/0/removed"),
895
+
Some(&json!(1))
896
+
);
897
+
rig.session.shutdown().await;
898
+
Ok(())
899
+
}
+1
klbr-runtime/tests/store.rs
+1
klbr-runtime/tests/store.rs
+1
klbr-runtime/tests/worker.rs
+1
klbr-runtime/tests/worker.rs
+24
klbr-tui/Cargo.toml
+24
klbr-tui/Cargo.toml
···
1
+
[package]
2
+
name = "klbr-tui"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
6
+
[dependencies]
7
+
anyhow = { workspace = true }
8
+
clap = { workspace = true }
9
+
crossterm = { version = "0.29", features = ["event-stream"] }
10
+
futures = { workspace = true }
11
+
klbr-ipc = { path = "../klbr-ipc" }
12
+
ratatui = "0.30.2"
13
+
ratatui-textarea = "0.9.2"
14
+
serde = { workspace = true }
15
+
serde_json = { workspace = true }
16
+
tokio = { workspace = true }
17
+
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
18
+
unicode-width = "0.2"
19
+
unicode-segmentation = "1.12"
20
+
uuid = { workspace = true }
21
+
jiter = "0.16"
22
+
tree-sitter = "0.25"
23
+
tree-sitter-python = "0.25"
24
+
regex = { workspace = true }
+43
klbr-tui/PARITY.md
+43
klbr-tui/PARITY.md
···
1
+
# niri transfer map
2
+
3
+
niri's source is the authority, not its screenshots. this native rust client ports
4
+
the coding conversation; changes below are protocol adaptations, not a new layout.
5
+
6
+
| niri source | rust owner | contract |
7
+
| --- | --- | --- |
8
+
| `niri-cli/src/ui/chat.tsx` | `view.rs`, `app.rs`, `main.rs` | six chrome rows, 100-column transcript, two-cell inset at 50 columns, 80ms busy spinner, immediate prompt echo, retained failures |
9
+
| `transcript.ts` | `transcript.rs` | role hierarchy, explored/executed activity, per-cell diffs, 8/3 wrapped-row previews, last 3 error rows, expanded code/output, no successful raw output beside structured traces |
10
+
| `transcript-index.ts` | `transcript.rs` | cached row counts, visible-window rendering, record-relative scroll anchors across incoming output and resize |
11
+
| `markdown.ts` | `markdown.rs`, `text.rs` | headings, lists, quotes, inline emphasis, code rails and syntax colors, whitespace-preserving word/hard wrapping |
12
+
| `text-input.tsx`, `mouse.ts` | `input.rs`, `view.rs` | one-row composer over a multiline draft, inverse cursor, ctrl+u clears draft, page/wheel history, ctrl+end tail, empty-left navigation |
13
+
| `commands.tsx` | `commands.rs`, `input.rs`, `view.rs` | slash-prefix menu, four visible choices, clamped arrows, tab completion, enter selection, escape dismisses before stop |
14
+
| `code-line.ts` | `code_line.rs` | fill, clear on overflow, continue; newlines become spaces; duplicate paints don't type twice |
15
+
| `niri-runtime/src/runner/tool-progress.ts` | `generation.rs`, `intent.rs` | partial JSON decode, incremental Python syntax, literal path bindings, latest-statement intent, generic fallback for unknown/dynamic code |
16
+
17
+
`tests/client.rs` checks the actual rendered frame while generation changes files,
18
+
execution starts and the receipt replaces the transient row. parser tests transfer
19
+
niri's incomplete payload, definition/comment/string, 40KB body, reassignment and
20
+
correction cases and add klbr's async `open_file`/`File` vocabulary. markdown,
21
+
wrapping, menu, paste, escape, narrow terminals and anchor tests cover their own
22
+
contracts; connection tests retain v2 identity/replay safety.
23
+
24
+
## explicit klbr adaptations
25
+
26
+
- `klbr` replaces the speaker/brand. the header identifies the selected **session**;
27
+
v2 does not advertise the daemon's workspace, so the client does not pretend its
28
+
own cwd is that remote workspace.
29
+
- `/sessions` (`/a` alias), `/session`, `/new` and ctrl+n use klbr sessions, not
30
+
niri's persistent-agent management. `/retry` remains an explicit idempotent retry.
31
+
- reasoning and scratchpad keep distinct labels; only a confirmed speech receipt
32
+
is shown as klbr speaking. this is not flattened into niri's assistant lane.
33
+
- server model selection, credential login and persistent-agent creation have no
34
+
corresponding v2 operator API. their niri screens are not claimed as ported.
35
+
- the daemon supplies per-request usage after completion, not niri's live usage
36
+
samples. the status shows measured local elapsed time; details stay in `/status`.
37
+
- generation labels are **inferred**, not evidence that a read/write happened.
38
+
completed activities and diffs come from actual workspace-helper receipts;
39
+
arbitrary Python and shell changes remain outside that capture coverage.
40
+
- partial argument storage is capped at 256KiB per call / 16 calls. beyond the cap
41
+
the UI explicitly shows a preview limit instead of an outdated file label.
42
+
- klbr owns its continuous runtime and explicit waits. the client does not invent
43
+
niri's coding-mode lifecycle or treat scratchpad text as a completed answer.
+47
klbr-tui/examples/render_bench.rs
+47
klbr-tui/examples/render_bench.rs
···
1
+
//! A repeatable CPU/render benchmark, not a claim about terminal pixels.
2
+
use klbr_tui::{
3
+
app::{App, Connection},
4
+
transcript::Entry,
5
+
view,
6
+
};
7
+
use ratatui::{backend::TestBackend, Terminal};
8
+
use std::{hint::black_box, time::Instant};
9
+
fn main() {
10
+
let mut app = App::new("benchmark".into(), "none".into());
11
+
app.connection = Connection::Live;
12
+
for i in 0..5_000 {
13
+
app.transcript.upsert(
14
+
format!("row:{i}"),
15
+
i,
16
+
Entry::Text {
17
+
label: "scratchpad".into(),
18
+
body: format!(
19
+
"record {i}: **inspect** the current file, keep the existing contract.\n{}",
20
+
"a bounded diagnostic line. ".repeat(12)
21
+
),
22
+
muted: true,
23
+
},
24
+
);
25
+
}
26
+
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
27
+
terminal
28
+
.draw(|frame| view::draw(frame, &mut app, 0))
29
+
.unwrap();
30
+
let mut samples = Vec::new();
31
+
for i in 0..500 {
32
+
let start = Instant::now();
33
+
app.editor.insert_str("x");
34
+
terminal
35
+
.draw(|frame| view::draw(frame, &mut app, i))
36
+
.unwrap();
37
+
black_box(terminal.backend().buffer());
38
+
samples.push(start.elapsed().as_secs_f64() * 1000.0);
39
+
app.editor.delete_char();
40
+
app.scroll(if i % 2 == 0 { -3 } else { 3 });
41
+
}
42
+
samples.sort_by(f64::total_cmp);
43
+
println!(
44
+
"5000 records, 100x30, 500 input+render samples: p50={:.3}ms p95={:.3}ms max={:.3}ms",
45
+
samples[250], samples[475], samples[499]
46
+
);
47
+
}
+786
klbr-tui/src/app.rs
+786
klbr-tui/src/app.rs
···
1
+
use crate::{
2
+
text::{clean, ACCENT},
3
+
transcript::{Entry, Format, Transcript},
4
+
};
5
+
use klbr_ipc::v2::{
6
+
CellPayload, CellState, Lane, LanePayload, SpeechPayload, V2ClientMsg, V2EventRecord,
7
+
V2ServerMsg,
8
+
};
9
+
use ratatui::{style::Style, text::Line};
10
+
use ratatui_textarea::TextArea;
11
+
use serde_json::Value;
12
+
use std::{
13
+
collections::{BTreeSet, HashMap},
14
+
time::Instant,
15
+
};
16
+
use uuid::Uuid;
17
+
18
+
#[derive(Clone, Debug, PartialEq, Eq)]
19
+
pub enum Connection {
20
+
Connecting,
21
+
Live,
22
+
Disconnected(String),
23
+
}
24
+
#[derive(Clone, Debug, PartialEq, Eq)]
25
+
pub enum Phase {
26
+
Ready,
27
+
Thinking,
28
+
Generating,
29
+
Running(String),
30
+
Stopping,
31
+
Stopped,
32
+
Waiting,
33
+
Failed,
34
+
}
35
+
pub struct Pending {
36
+
pub id: String,
37
+
pub text: String,
38
+
pub seq: Option<i64>,
39
+
pub uncertain: bool,
40
+
}
41
+
42
+
pub enum Action {
43
+
Send(V2ClientMsg),
44
+
Switch(String),
45
+
Quit,
46
+
}
47
+
48
+
pub struct App {
49
+
pub session: String,
50
+
pub endpoint: String,
51
+
pub connection: Connection,
52
+
pub phase: Phase,
53
+
pub transcript: Transcript,
54
+
pub editor: TextArea<'static>,
55
+
pub expanded: bool,
56
+
pub thinking: bool,
57
+
pub anchor: Option<(String, usize)>,
58
+
pub format: Format,
59
+
pub viewport_height: usize,
60
+
pub code: String,
61
+
pub model: Option<String>,
62
+
pub usage: Option<Value>,
63
+
pub status: Value,
64
+
pub started: Option<Instant>,
65
+
pub sessions: Option<Vec<(String, Value)>>,
66
+
pub selected: usize,
67
+
pub help: bool,
68
+
pub pending: Vec<Pending>,
69
+
pub cursor: i64,
70
+
pub earliest: Option<i64>,
71
+
pub first: Option<i64>,
72
+
pub loading_history: bool,
73
+
seen: BTreeSet<i64>,
74
+
echoes: HashMap<i64, String>,
75
+
live_thought: Option<String>,
76
+
live_prose: Option<String>,
77
+
cell: Option<String>,
78
+
notes: u64,
79
+
pub generation: crate::generation::Generation,
80
+
pub running_intent: Option<crate::intent::Intent>,
81
+
pub code_line: crate::code_line::CodeLine,
82
+
pub menu: crate::commands::Menu,
83
+
pub live_rows: Vec<Line<'static>>,
84
+
}
85
+
86
+
fn editor(text: &str) -> TextArea<'static> {
87
+
let mut editor = TextArea::from(text.split('\n'));
88
+
editor.set_cursor_line_style(Style::default());
89
+
editor.set_cursor_style(Style::default().bg(ACCENT).fg(ratatui::style::Color::Black));
90
+
editor.move_cursor(ratatui_textarea::CursorMove::Bottom);
91
+
editor.move_cursor(ratatui_textarea::CursorMove::End);
92
+
editor
93
+
}
94
+
95
+
impl App {
96
+
pub fn new(session: String, endpoint: String) -> Self {
97
+
Self {
98
+
session,
99
+
endpoint,
100
+
connection: Connection::Connecting,
101
+
phase: Phase::Ready,
102
+
transcript: Transcript::default(),
103
+
editor: editor(""),
104
+
expanded: false,
105
+
thinking: true,
106
+
anchor: None,
107
+
format: Format {
108
+
width: 80,
109
+
expanded: false,
110
+
thinking: true,
111
+
},
112
+
viewport_height: 20,
113
+
code: String::new(),
114
+
model: None,
115
+
usage: None,
116
+
status: Value::Null,
117
+
started: None,
118
+
sessions: None,
119
+
selected: 0,
120
+
help: false,
121
+
pending: Vec::new(),
122
+
cursor: 0,
123
+
earliest: None,
124
+
first: None,
125
+
loading_history: false,
126
+
seen: BTreeSet::new(),
127
+
echoes: HashMap::new(),
128
+
live_thought: None,
129
+
live_prose: None,
130
+
cell: None,
131
+
notes: 0,
132
+
generation: Default::default(),
133
+
running_intent: None,
134
+
code_line: Default::default(),
135
+
menu: Default::default(),
136
+
live_rows: Vec::new(),
137
+
}
138
+
}
139
+
140
+
pub fn busy(&self) -> bool {
141
+
matches!(
142
+
self.phase,
143
+
Phase::Thinking | Phase::Generating | Phase::Running(_) | Phase::Stopping
144
+
)
145
+
}
146
+
pub fn note(&mut self, text: impl Into<String>, error: bool) {
147
+
self.notes += 1;
148
+
self.transcript.upsert(
149
+
format!("notice:{}", self.notes),
150
+
self.cursor.max(1),
151
+
Entry::Note {
152
+
text: text.into(),
153
+
error,
154
+
},
155
+
);
156
+
}
157
+
pub fn disconnect(&mut self, error: String) {
158
+
if !matches!(&self.connection, Connection::Disconnected(previous) if previous == &error) {
159
+
self.note(
160
+
format!("disconnected: {error} · reconnecting; inputs are not resent"),
161
+
true,
162
+
);
163
+
}
164
+
self.connection = Connection::Disconnected(error);
165
+
for pending in &mut self.pending {
166
+
if pending.seq.is_none() {
167
+
pending.uncertain = true;
168
+
}
169
+
}
170
+
}
171
+
pub fn replace_draft(&mut self, text: &str) {
172
+
self.editor = editor(text);
173
+
}
174
+
pub fn total_rows(&mut self) -> usize {
175
+
self.transcript.len(self.format) + self.live_rows.len()
176
+
}
177
+
pub fn submit(&mut self) -> Vec<Action> {
178
+
let text = self.editor.lines().join("\n");
179
+
let text = text.trim();
180
+
if text.is_empty() {
181
+
return Vec::new();
182
+
}
183
+
if text.starts_with('/') {
184
+
let mut words = text.splitn(2, char::is_whitespace);
185
+
let command = words.next().unwrap();
186
+
let argument = words.next().unwrap_or("").trim();
187
+
let actions = match command {
188
+
"/q" | "/quit" => vec![Action::Quit],
189
+
"/v" => {
190
+
self.expanded = !self.expanded;
191
+
Vec::new()
192
+
}
193
+
"/t" => {
194
+
self.thinking = !self.thinking;
195
+
Vec::new()
196
+
}
197
+
"/help" | "/" => {
198
+
self.help = true;
199
+
Vec::new()
200
+
}
201
+
"/new" => vec![Action::Switch(Uuid::new_v4().to_string())],
202
+
"/a" | "/sessions" => vec![Action::Send(V2ClientMsg::SessionList)],
203
+
"/session" if argument.is_empty() => {
204
+
self.replace_draft("/session ");
205
+
return Vec::new();
206
+
}
207
+
"/session" if !argument.is_empty() => vec![Action::Switch(argument.into())],
208
+
"/status" => {
209
+
self.note(
210
+
format!(
211
+
"session {}\nstate: {}\nusage: {}",
212
+
self.session,
213
+
self.status,
214
+
self.usage.as_ref().unwrap_or(&Value::Null)
215
+
),
216
+
false,
217
+
);
218
+
vec![Action::Send(V2ClientMsg::SessionStatus {
219
+
session_id: self.session.clone(),
220
+
})]
221
+
}
222
+
"/retry" => {
223
+
if self.connection != Connection::Live {
224
+
self.note("cannot retry while disconnected", true);
225
+
return Vec::new();
226
+
}
227
+
if let Some(pending) = self.pending.iter_mut().rev().find(|p| p.uncertain) {
228
+
pending.uncertain = false;
229
+
vec![Action::Send(V2ClientMsg::SendInput {
230
+
session_id: self.session.clone(),
231
+
source: "tui".into(),
232
+
text: pending.text.clone(),
233
+
client_request_id: Some(pending.id.clone()),
234
+
})]
235
+
} else {
236
+
self.note("no unconfirmed input to retry", false);
237
+
Vec::new()
238
+
}
239
+
}
240
+
_ => {
241
+
self.note(
242
+
format!("unknown command: {command} · /help lists commands"),
243
+
true,
244
+
);
245
+
return Vec::new();
246
+
}
247
+
};
248
+
self.editor = editor("");
249
+
return actions;
250
+
}
251
+
if self.connection != Connection::Live {
252
+
self.note("not connected · draft kept; nothing was sent", true);
253
+
return Vec::new();
254
+
}
255
+
let text = text.to_owned();
256
+
let id = Uuid::new_v4().to_string();
257
+
self.transcript.upsert(
258
+
format!("input:{id}"),
259
+
i64::MAX,
260
+
Entry::Text {
261
+
label: "you".into(),
262
+
body: text.clone(),
263
+
muted: false,
264
+
},
265
+
);
266
+
self.pending.push(Pending {
267
+
id: id.clone(),
268
+
text: text.clone(),
269
+
seq: None,
270
+
uncertain: false,
271
+
});
272
+
self.editor = editor("");
273
+
self.anchor = None;
274
+
self.phase = Phase::Thinking;
275
+
self.started = Some(Instant::now());
276
+
vec![Action::Send(V2ClientMsg::SendInput {
277
+
session_id: self.session.clone(),
278
+
source: "tui".into(),
279
+
text,
280
+
client_request_id: Some(id),
281
+
})]
282
+
}
283
+
284
+
pub fn interrupt(&mut self) -> Vec<Action> {
285
+
if self.help || self.sessions.is_some() {
286
+
self.help = false;
287
+
self.sessions = None;
288
+
return Vec::new();
289
+
}
290
+
// Operator interruption must remain available even when progress is missing or stale.
291
+
if self.phase == Phase::Stopping {
292
+
return Vec::new();
293
+
}
294
+
if self.connection != Connection::Live {
295
+
self.note(
296
+
"cannot interrupt while disconnected · the daemon may still be working",
297
+
true,
298
+
);
299
+
return Vec::new();
300
+
}
301
+
self.phase = Phase::Stopping;
302
+
vec![Action::Send(V2ClientMsg::Interrupt {
303
+
session_id: self.session.clone(),
304
+
execution_id: None,
305
+
})]
306
+
}
307
+
308
+
pub fn top(&mut self) -> usize {
309
+
let tail = self.total_rows().saturating_sub(self.viewport_height);
310
+
self.anchor
311
+
.as_ref()
312
+
.and_then(|anchor| self.transcript.locate(self.format, anchor))
313
+
.unwrap_or(tail)
314
+
.min(tail)
315
+
}
316
+
pub fn scroll(&mut self, delta: isize) -> Vec<Action> {
317
+
let tail = self.total_rows().saturating_sub(self.viewport_height);
318
+
let top = self.top().saturating_add_signed(delta).min(tail);
319
+
self.anchor = if top == tail {
320
+
None
321
+
} else {
322
+
self.transcript.anchor(self.format, top)
323
+
};
324
+
if top == 0 && delta < 0 && !self.loading_history && self.connection == Connection::Live {
325
+
if let (Some(first), Some(earliest)) = (self.first, self.earliest) {
326
+
if earliest < first {
327
+
self.loading_history = true;
328
+
return vec![Action::Send(V2ClientMsg::History {
329
+
session_id: self.session.clone(),
330
+
before_seq: first,
331
+
limit: 500,
332
+
})];
333
+
}
334
+
}
335
+
}
336
+
Vec::new()
337
+
}
338
+
339
+
pub fn receive(&mut self, message: V2ServerMsg) {
340
+
match message {
341
+
V2ServerMsg::Event { record } if record.session_id == self.session => {
342
+
self.record(record)
343
+
}
344
+
V2ServerMsg::LiveChunk {
345
+
session_id,
346
+
lane,
347
+
stream,
348
+
delta,
349
+
execution_id,
350
+
} if session_id == self.session => {
351
+
match lane {
352
+
Lane::Scratchpad => {
353
+
let thought = matches!(stream.as_deref(), Some("thought" | "think"));
354
+
let slot = if thought {
355
+
&mut self.live_thought
356
+
} else {
357
+
&mut self.live_prose
358
+
};
359
+
let key = slot
360
+
.get_or_insert_with(|| Uuid::new_v4().to_string())
361
+
.clone();
362
+
if let Some(Entry::Text { body, .. }) = self.transcript.entry_mut(&key) {
363
+
body.push_str(&delta);
364
+
} else {
365
+
self.transcript.upsert(
366
+
key,
367
+
i64::MAX,
368
+
Entry::Text {
369
+
label: if thought { "thinking" } else { "scratchpad" }.into(),
370
+
body: delta,
371
+
muted: true,
372
+
},
373
+
);
374
+
}
375
+
self.phase = Phase::Thinking;
376
+
}
377
+
Lane::Cell if stream.as_deref() == Some("arguments") => {
378
+
self.phase = Phase::Generating;
379
+
self.generation.push(&delta);
380
+
}
381
+
Lane::Cell if stream.as_deref() == Some("starting") => {
382
+
self.phase = Phase::Generating;
383
+
self.code.clear();
384
+
self.generation = Default::default();
385
+
self.code_line = Default::default();
386
+
}
387
+
Lane::Cell => {
388
+
if let Some(key) = execution_id
389
+
.map(|id| format!("cell:{id}"))
390
+
.or_else(|| self.cell.clone())
391
+
{
392
+
if let Some(Entry::Cell { output, .. }) =
393
+
self.transcript.entry_mut(&key)
394
+
{
395
+
// Live output is diagnostic and bounded; completion replaces it authoritatively.
396
+
let room = 65536_usize.saturating_sub(output.len());
397
+
let mut end = room.min(delta.len());
398
+
while !delta.is_char_boundary(end) {
399
+
end -= 1;
400
+
}
401
+
output.push_str(&delta[..end]);
402
+
}
403
+
}
404
+
}
405
+
// Only a durable receipt can label speech as delivered.
406
+
Lane::Speech | Lane::Input | Lane::Lifecycle => {}
407
+
}
408
+
}
409
+
V2ServerMsg::Ack {
410
+
session_id,
411
+
seq,
412
+
client_request_id: Some(id),
413
+
..
414
+
} if session_id == self.session => {
415
+
if let Some(pending) = self.pending.iter_mut().find(|p| p.id == id) {
416
+
pending.seq = Some(seq);
417
+
pending.uncertain = false;
418
+
let key = format!("input:{id}");
419
+
self.echoes.insert(seq, key.clone());
420
+
if self.seen.contains(&seq) {
421
+
self.transcript.remove(&format!("event:{seq}"));
422
+
}
423
+
self.transcript.upsert(
424
+
key,
425
+
seq,
426
+
Entry::Text {
427
+
label: "you".into(),
428
+
body: pending.text.clone(),
429
+
muted: false,
430
+
},
431
+
);
432
+
}
433
+
self.pending.retain(|p| p.seq.is_none());
434
+
}
435
+
V2ServerMsg::SyncStarted {
436
+
session_id,
437
+
earliest_seq,
438
+
..
439
+
} if session_id == self.session => {
440
+
self.earliest = earliest_seq;
441
+
self.loading_history = true;
442
+
}
443
+
V2ServerMsg::SyncCompleted {
444
+
session_id,
445
+
latest_seq,
446
+
earliest_seq,
447
+
} if session_id == self.session => {
448
+
self.cursor = self.cursor.max(latest_seq);
449
+
self.earliest = earliest_seq;
450
+
self.loading_history = false;
451
+
}
452
+
V2ServerMsg::Error { code, message } => {
453
+
self.phase = Phase::Failed;
454
+
self.loading_history = false;
455
+
self.note(format!("error · {code}: {message}"), true);
456
+
self.anchor = None;
457
+
}
458
+
V2ServerMsg::OperatorAck {
459
+
action,
460
+
session_id,
461
+
success,
462
+
message,
463
+
} if session_id.as_ref().is_none_or(|id| *id == self.session) => {
464
+
self.note(
465
+
message.unwrap_or_else(|| {
466
+
format!("{action}: {}", if success { "complete" } else { "failed" })
467
+
}),
468
+
!success,
469
+
);
470
+
if action == "interrupt" {
471
+
self.phase = if success {
472
+
Phase::Stopped
473
+
} else {
474
+
Phase::Failed
475
+
};
476
+
self.code.clear();
477
+
}
478
+
}
479
+
V2ServerMsg::SessionStatus { session_id, state } if session_id == self.session => {
480
+
self.status = state.clone();
481
+
match state.as_str() {
482
+
Some("Stopped" | "stopped") => self.phase = Phase::Stopped,
483
+
Some("Ready" | "ready")
484
+
if self.pending.is_empty() && self.phase != Phase::Failed =>
485
+
{
486
+
self.phase = Phase::Ready;
487
+
self.code.clear();
488
+
}
489
+
Some("Draining") => self.phase = Phase::Stopping,
490
+
_ if state.get("Executing").is_some() && !self.busy() => {
491
+
self.phase = Phase::Thinking
492
+
}
493
+
_ if state.get("Waiting").is_some() => self.phase = Phase::Waiting,
494
+
_ => {}
495
+
}
496
+
}
497
+
V2ServerMsg::SessionList { sessions } => {
498
+
self.sessions = Some(sessions);
499
+
self.selected = 0;
500
+
}
501
+
_ => {}
502
+
}
503
+
}
504
+
505
+
fn seal_live(&mut self, seq: i64) {
506
+
for key in [self.live_prose.take(), self.live_thought.take()]
507
+
.into_iter()
508
+
.flatten()
509
+
{
510
+
if let Some(entry) = self.transcript.entry(&key).cloned() {
511
+
self.transcript.upsert(key, seq, entry);
512
+
}
513
+
}
514
+
}
515
+
516
+
fn record(&mut self, record: V2EventRecord) {
517
+
if !self.seen.insert(record.seq) {
518
+
return;
519
+
}
520
+
let seq = record.seq;
521
+
let historical = seq <= self.cursor;
522
+
self.first = Some(self.first.unwrap_or(seq).min(seq));
523
+
self.cursor = self.cursor.max(seq);
524
+
let key = format!("event:{seq}");
525
+
match record.payload {
526
+
LanePayload::Input(input) => {
527
+
let key = self.echoes.get(&seq).cloned().unwrap_or(key);
528
+
self.transcript.upsert(
529
+
key,
530
+
seq,
531
+
Entry::Text {
532
+
label: if input.source == "tui"
533
+
|| input.source == "operator"
534
+
|| input.source == "web"
535
+
{
536
+
"you".into()
537
+
} else {
538
+
input.author.unwrap_or(input.source)
539
+
},
540
+
body: input.text,
541
+
muted: false,
542
+
},
543
+
);
544
+
}
545
+
LanePayload::Scratchpad(scratchpad) => {
546
+
for (thought, body) in [
547
+
(true, scratchpad.reasoning.unwrap_or_default()),
548
+
(false, scratchpad.text),
549
+
] {
550
+
let live = if thought {
551
+
&mut self.live_thought
552
+
} else {
553
+
&mut self.live_prose
554
+
};
555
+
let old = if historical { None } else { live.take() };
556
+
if body.is_empty() {
557
+
if let Some(old) = old {
558
+
self.transcript.remove(&old);
559
+
}
560
+
continue;
561
+
}
562
+
self.transcript.upsert(
563
+
old.unwrap_or_else(|| format!("{key}:{thought}")),
564
+
seq,
565
+
Entry::Text {
566
+
label: if thought { "thinking" } else { "scratchpad" }.into(),
567
+
body,
568
+
muted: true,
569
+
},
570
+
);
571
+
}
572
+
}
573
+
LanePayload::Cell(CellPayload::Started {
574
+
execution_id, code, ..
575
+
}) => {
576
+
let key = format!("cell:{execution_id}");
577
+
if !historical {
578
+
self.phase = Phase::Running("python".into());
579
+
self.running_intent = crate::intent::PythonIntent::default().infer(&code);
580
+
self.code = code.clone();
581
+
self.cell = Some(key.clone());
582
+
}
583
+
if let Some(mut entry @ Entry::Cell { .. }) = self.transcript.entry(&key).cloned() {
584
+
if let Entry::Cell { code: old, .. } = &mut entry {
585
+
*old = code;
586
+
}
587
+
self.transcript.upsert(key, seq, entry);
588
+
} else {
589
+
self.transcript.upsert(
590
+
key,
591
+
seq,
592
+
Entry::Cell {
593
+
code,
594
+
state: None,
595
+
output: String::new(),
596
+
error: None,
597
+
truncated: 0,
598
+
trace: None,
599
+
},
600
+
);
601
+
}
602
+
}
603
+
LanePayload::Cell(CellPayload::Completed {
604
+
execution_id,
605
+
state,
606
+
output,
607
+
error,
608
+
truncated_bytes,
609
+
trace,
610
+
}) => {
611
+
let key = format!("cell:{execution_id}");
612
+
let code = match self.transcript.entry(&key) {
613
+
Some(Entry::Cell { code, .. }) => code.clone(),
614
+
_ => String::new(),
615
+
};
616
+
self.transcript.upsert(
617
+
key,
618
+
seq,
619
+
Entry::Cell {
620
+
code,
621
+
state: Some(state),
622
+
output: output.into_iter().map(|o| o.text).collect(),
623
+
error,
624
+
truncated: truncated_bytes,
625
+
trace,
626
+
},
627
+
);
628
+
if !historical {
629
+
self.cell = None;
630
+
self.code.clear();
631
+
self.phase = if state == CellState::Interrupted {
632
+
Phase::Stopped
633
+
} else {
634
+
Phase::Thinking
635
+
};
636
+
}
637
+
}
638
+
LanePayload::Speech(speech) => {
639
+
let (label, body, muted) = match speech {
640
+
SpeechPayload::Sent { text, status, .. } if status == "confirmed" => {
641
+
("klbr".to_owned(), text, false)
642
+
}
643
+
SpeechPayload::Sent { text, status, .. } => {
644
+
(format!("speech · {status}"), text, true)
645
+
}
646
+
SpeechPayload::Held { text, reason, .. } => {
647
+
(format!("speech held · {reason}"), text, true)
648
+
}
649
+
};
650
+
self.transcript
651
+
.upsert(key, seq, Entry::Text { label, body, muted });
652
+
}
653
+
LanePayload::Lifecycle(event) => match event.kind.as_str() {
654
+
"tool.result" if event.data.get("execution_id").is_none_or(Value::is_null) => {
655
+
// Invalid arguments and rejected/skipped calls never create a cell record.
656
+
// Keep their durable diagnostic visible without inventing an execution.
657
+
self.transcript.upsert(
658
+
key,
659
+
seq,
660
+
Entry::Note {
661
+
text: format!(
662
+
"python · not run\n{}",
663
+
event
664
+
.data
665
+
.get("content")
666
+
.and_then(Value::as_str)
667
+
.unwrap_or("the tool did not produce an execution receipt")
668
+
),
669
+
error: true,
670
+
},
671
+
);
672
+
if !historical {
673
+
self.phase = Phase::Thinking;
674
+
self.code.clear();
675
+
}
676
+
}
677
+
"model.usage" => {
678
+
if !historical || self.usage.is_none() {
679
+
self.model = event
680
+
.data
681
+
.pointer("/route/model")
682
+
.and_then(Value::as_str)
683
+
.map(str::to_owned)
684
+
.or(self.model.take());
685
+
self.usage = Some(event.data);
686
+
}
687
+
}
688
+
"turn.failed" => {
689
+
self.transcript.upsert(
690
+
key,
691
+
seq,
692
+
Entry::Note {
693
+
text: format!(
694
+
"turn failed: {}",
695
+
event
696
+
.data
697
+
.get("error")
698
+
.or_else(|| event.data.get("reason"))
699
+
.unwrap_or(&event.data)
700
+
),
701
+
error: true,
702
+
},
703
+
);
704
+
if !historical {
705
+
self.seal_live(seq);
706
+
self.phase = Phase::Failed;
707
+
self.code.clear();
708
+
self.anchor = None;
709
+
}
710
+
}
711
+
"turn.outcome" => {
712
+
if !historical {
713
+
self.seal_live(seq);
714
+
self.phase = Phase::Ready;
715
+
self.code.clear();
716
+
}
717
+
if let Some(status) = event.data.get("status").and_then(Value::as_str) {
718
+
if status != "replied" {
719
+
self.transcript.upsert(
720
+
key,
721
+
seq,
722
+
Entry::Note {
723
+
text: format!("turn {status}"),
724
+
error: status == "failed",
725
+
},
726
+
);
727
+
}
728
+
}
729
+
}
730
+
kind if kind.contains("failed")
731
+
|| kind.contains("error")
732
+
|| kind.contains("unknown") =>
733
+
{
734
+
self.transcript.upsert(
735
+
key,
736
+
seq,
737
+
Entry::Note {
738
+
text: format!("{kind}: {}", event.data),
739
+
error: true,
740
+
},
741
+
)
742
+
}
743
+
_ => {}
744
+
},
745
+
}
746
+
}
747
+
748
+
pub fn viewport(&mut self, width: usize, height: usize) -> (Vec<Line<'static>>, usize) {
749
+
self.format = Format {
750
+
width,
751
+
expanded: self.expanded,
752
+
thinking: self.thinking,
753
+
};
754
+
self.viewport_height = height;
755
+
let top = self.top();
756
+
let below = self.total_rows().saturating_sub(top + height);
757
+
let durable = self.transcript.len(self.format);
758
+
let mut rows = self.transcript.window(self.format, top, height);
759
+
rows.extend(
760
+
self.live_rows
761
+
.iter()
762
+
.skip(top.saturating_sub(durable))
763
+
.take(height.saturating_sub(rows.len()))
764
+
.cloned(),
765
+
);
766
+
(rows, below)
767
+
}
768
+
pub fn phase_label(&self) -> String {
769
+
if let Connection::Disconnected(_) = self.connection {
770
+
return "disconnected · reconnecting".into();
771
+
}
772
+
if self.connection == Connection::Connecting {
773
+
return "connecting…".into();
774
+
}
775
+
match &self.phase {
776
+
Phase::Ready => "ready".into(),
777
+
Phase::Thinking => "thinking".into(),
778
+
Phase::Generating => "generating call".into(),
779
+
Phase::Running(name) => format!("running {}", clean(name)),
780
+
Phase::Stopping => "stopping…".into(),
781
+
Phase::Stopped => "stopped".into(),
782
+
Phase::Waiting => "waiting".into(),
783
+
Phase::Failed => "turn failed · see error above".into(),
784
+
}
785
+
}
786
+
}
+50
klbr-tui/src/code_line.rs
+50
klbr-tui/src/code_line.rs
···
1
+
//! niri's fill/clear/continue row. Repaints never consume a sample twice.
2
+
use unicode_segmentation::UnicodeSegmentation;
3
+
use unicode_width::UnicodeWidthStr;
4
+
#[derive(Default)]
5
+
pub struct CodeLine {
6
+
source: String,
7
+
width: usize,
8
+
line: String,
9
+
}
10
+
impl CodeLine {
11
+
pub fn advance(&mut self, code: &str, width: usize) -> &str {
12
+
let continued = self.width == width && code.starts_with(&self.source);
13
+
let start = if continued {
14
+
self.source.len()
15
+
} else {
16
+
self.line.clear();
17
+
0
18
+
};
19
+
for grapheme in code[start..].graphemes(true) {
20
+
let safe: String = grapheme
21
+
.chars()
22
+
.map(|c| if c.is_control() { ' ' } else { c })
23
+
.collect();
24
+
if self.line.width() + safe.width() > width {
25
+
self.line.clear();
26
+
}
27
+
if safe.width() <= width {
28
+
self.line.push_str(&safe);
29
+
}
30
+
}
31
+
self.source.clear();
32
+
self.source.push_str(code);
33
+
self.width = width;
34
+
&self.line
35
+
}
36
+
}
37
+
#[cfg(test)]
38
+
mod tests {
39
+
use super::*;
40
+
#[test]
41
+
fn fill_clear_continue_repaint_resize_and_correction() {
42
+
let mut row = CodeLine::default();
43
+
assert_eq!(row.advance("abcdef", 5), "f");
44
+
assert_eq!(row.advance("abcdef", 5), "f");
45
+
assert_eq!(row.advance("abcdef\ng", 5), "f g");
46
+
assert_eq!(row.advance("abcdef\ng", 4), "ef g");
47
+
assert_eq!(row.advance("界界é", 4), "é");
48
+
assert_eq!(row.advance("x", 0), "");
49
+
}
50
+
}
+30
klbr-tui/src/commands.rs
+30
klbr-tui/src/commands.rs
···
1
+
//! Same prefix, selection, dismissal and four-row menu contract as niri/commands.tsx.
2
+
#[derive(Default)]
3
+
pub struct Menu {
4
+
pub selected: usize,
5
+
pub dismissed: String,
6
+
}
7
+
pub const COMMANDS: &[(&str, &str)] = &[
8
+
("/sessions", "browse sessions"),
9
+
("/session", "open a session by name"),
10
+
("/new", "start a new session"),
11
+
("/t", "show or hide thinking"),
12
+
("/v", "expand or collapse code, output and diffs"),
13
+
("/status", "show state, workspace and usage"),
14
+
("/retry", "retry unconfirmed input with the same id"),
15
+
("/help", "show commands and shortcuts"),
16
+
("/q", "quit"),
17
+
];
18
+
impl Menu {
19
+
pub fn matches(&self, text: &str) -> Vec<(&'static str, &'static str)> {
20
+
if !text.starts_with('/') || text.chars().any(char::is_whitespace) || text == self.dismissed
21
+
{
22
+
return Vec::new();
23
+
}
24
+
COMMANDS
25
+
.iter()
26
+
.copied()
27
+
.filter(|(name, _)| name.starts_with(text))
28
+
.collect()
29
+
}
30
+
}
+111
klbr-tui/src/generation.rs
+111
klbr-tui/src/generation.rs
···
1
+
//! Partial arguments are a preview, never evidence that a workspace operation ran.
2
+
use jiter::{JsonValue, PartialMode};
3
+
use serde::Deserialize;
4
+
use std::collections::HashMap;
5
+
6
+
const PREVIEW_BYTES: usize = 262_144;
7
+
#[derive(Deserialize)]
8
+
struct Delta {
9
+
index: usize,
10
+
arguments: String,
11
+
}
12
+
13
+
#[derive(Default)]
14
+
pub struct Generation {
15
+
calls: HashMap<usize, String>,
16
+
active: usize,
17
+
dirty: bool,
18
+
code: String,
19
+
capped: bool,
20
+
parser: crate::intent::PythonIntent,
21
+
intent: Option<crate::intent::Intent>,
22
+
}
23
+
24
+
impl Generation {
25
+
pub fn push(&mut self, wire: &str) {
26
+
let Ok(Delta { index, arguments }) = serde_json::from_str(wire) else {
27
+
return;
28
+
};
29
+
// Progress remains bounded even for a malformed or unusually large provider call.
30
+
if !self.calls.contains_key(&index) && self.calls.len() >= 16 {
31
+
return;
32
+
}
33
+
let buffer = self.calls.entry(index).or_default();
34
+
if buffer.len() + arguments.len() > PREVIEW_BYTES {
35
+
self.capped = true;
36
+
return;
37
+
}
38
+
buffer.push_str(&arguments);
39
+
if self.active != index {
40
+
self.code.clear();
41
+
self.intent = None;
42
+
self.parser = Default::default();
43
+
}
44
+
self.active = index;
45
+
self.dirty = true;
46
+
}
47
+
48
+
pub fn intent(&mut self) -> Option<&crate::intent::Intent> {
49
+
self.code();
50
+
if self.capped {
51
+
None
52
+
} else {
53
+
self.intent.as_ref()
54
+
}
55
+
}
56
+
57
+
pub fn code(&mut self) -> &str {
58
+
if self.capped {
59
+
return "preview limit reached · still generating";
60
+
}
61
+
if self.dirty {
62
+
self.dirty = false;
63
+
let decoded = self
64
+
.calls
65
+
.get(&self.active)
66
+
.and_then(|arguments| {
67
+
let JsonValue::Object(fields) = JsonValue::parse_with_config(
68
+
arguments.as_bytes(),
69
+
false,
70
+
PartialMode::TrailingStrings,
71
+
)
72
+
.ok()?
73
+
else {
74
+
return None;
75
+
};
76
+
match fields.iter().rev().find(|(key, _)| key == "code") {
77
+
Some((_, JsonValue::Str(code))) => Some(code.to_string()),
78
+
_ => None,
79
+
}
80
+
})
81
+
.unwrap_or_default();
82
+
if self.code != decoded {
83
+
self.code = decoded;
84
+
self.intent = self.parser.infer(&self.code);
85
+
}
86
+
}
87
+
&self.code
88
+
}
89
+
}
90
+
91
+
#[cfg(test)]
92
+
mod tests {
93
+
use super::*;
94
+
#[test]
95
+
fn every_json_boundary_preserves_python_escapes() {
96
+
let code = "await f.write(\"hello\\n界😀\")\nprint('done')";
97
+
let wire = serde_json::json!({"code": code}).to_string();
98
+
let mut preview = Generation::default();
99
+
for c in wire.chars() {
100
+
preview.push(&serde_json::json!({"index": 0, "arguments": c.to_string()}).to_string());
101
+
assert!(code.starts_with(preview.code()));
102
+
}
103
+
assert_eq!(preview.code(), code);
104
+
let mut escaped = Generation::default();
105
+
for c in r#"{"code":"\uD83D\uDE00"}"#.chars() {
106
+
escaped.push(&serde_json::json!({"index":0,"arguments":c.to_string()}).to_string());
107
+
assert!("😀".starts_with(escaped.code()));
108
+
}
109
+
assert_eq!(escaped.code(), "😀");
110
+
}
111
+
}
+101
klbr-tui/src/input.rs
+101
klbr-tui/src/input.rs
···
1
+
use crate::{
2
+
app::{Action, App},
3
+
text::clean,
4
+
};
5
+
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
6
+
use klbr_ipc::v2::V2ClientMsg;
7
+
8
+
pub fn handle(app: &mut App, event: Event) -> Vec<Action> {
9
+
let mut actions = Vec::new();
10
+
match event {
11
+
Event::Key(key) if key.kind != KeyEventKind::Release => {
12
+
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
13
+
let draft = app.editor.lines().join("\n");
14
+
let matches = app.menu.matches(&draft);
15
+
if !app.help && app.sessions.is_none() && !matches.is_empty() {
16
+
let index = app.menu.selected.min(matches.len() - 1);
17
+
match key.code {
18
+
KeyCode::Esc => {
19
+
app.menu.dismissed = draft;
20
+
return actions;
21
+
}
22
+
KeyCode::Up => {
23
+
app.menu.selected = index.saturating_sub(1);
24
+
return actions;
25
+
}
26
+
KeyCode::Down => {
27
+
app.menu.selected = (index + 1).min(matches.len() - 1);
28
+
return actions;
29
+
}
30
+
KeyCode::Tab => {
31
+
app.replace_draft(matches[index].0);
32
+
return actions;
33
+
}
34
+
KeyCode::Enter
35
+
if !key
36
+
.modifiers
37
+
.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) =>
38
+
{
39
+
app.replace_draft(matches[index].0);
40
+
return app.submit();
41
+
}
42
+
_ => app.menu.selected = 0,
43
+
}
44
+
}
45
+
match key.code {
46
+
KeyCode::Char('c') if ctrl => actions.push(Action::Quit),
47
+
KeyCode::Esc => actions = app.interrupt(),
48
+
KeyCode::Up if app.sessions.is_some() => {
49
+
app.selected = app.selected.saturating_sub(1)
50
+
}
51
+
KeyCode::Down if app.sessions.is_some() => {
52
+
app.selected = (app.selected + 1)
53
+
.min(app.sessions.as_ref().unwrap().len().saturating_sub(1))
54
+
}
55
+
KeyCode::Enter if app.sessions.is_some() => {
56
+
if let Some((id, _)) = app.sessions.as_ref().unwrap().get(app.selected) {
57
+
actions.push(Action::Switch(id.clone()));
58
+
}
59
+
}
60
+
KeyCode::PageUp => actions = app.scroll(-(app.viewport_height as isize)),
61
+
KeyCode::PageDown => actions = app.scroll(app.viewport_height as isize),
62
+
KeyCode::End if ctrl => app.anchor = None,
63
+
_ if app.help || app.sessions.is_some() => {}
64
+
KeyCode::Enter
65
+
if key
66
+
.modifiers
67
+
.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) =>
68
+
{
69
+
app.editor.insert_newline();
70
+
}
71
+
KeyCode::Char('j') if ctrl => {
72
+
app.editor.insert_newline();
73
+
}
74
+
KeyCode::Char('u') if ctrl => {
75
+
app.replace_draft("");
76
+
}
77
+
KeyCode::Left if draft.is_empty() => {
78
+
actions.push(Action::Send(V2ClientMsg::SessionList))
79
+
}
80
+
KeyCode::Char('n') if ctrl => {
81
+
actions.push(Action::Switch(uuid::Uuid::new_v4().to_string()))
82
+
}
83
+
KeyCode::Up | KeyCode::Down => {}
84
+
KeyCode::Enter => actions = app.submit(),
85
+
_ => {
86
+
app.editor.input(key);
87
+
}
88
+
}
89
+
}
90
+
Event::Paste(text) if !app.help && app.sessions.is_none() => {
91
+
app.editor.insert_str(clean(&text));
92
+
}
93
+
Event::Mouse(mouse) => match mouse.kind {
94
+
MouseEventKind::ScrollUp => actions = app.scroll(-3),
95
+
MouseEventKind::ScrollDown => actions = app.scroll(3),
96
+
_ => {}
97
+
},
98
+
_ => {}
99
+
}
100
+
actions
101
+
}
+521
klbr-tui/src/intent.rs
+521
klbr-tui/src/intent.rs
···
1
+
//! Port of niri-runtime/src/runner/tool-progress.ts: syntax-derived intent, never execution evidence.
2
+
//! Tree-sitter replaces Lezer; klbr's open_file/File helpers adapt the same value rules.
3
+
use std::collections::HashMap;
4
+
use tree_sitter::{InputEdit, Node, Parser, Point, Tree};
5
+
6
+
#[derive(Clone, Debug, PartialEq, Eq)]
7
+
pub struct Intent {
8
+
pub kind: &'static str,
9
+
pub target: String,
10
+
}
11
+
#[derive(Clone)]
12
+
struct Value {
13
+
kind: &'static str,
14
+
text: String,
15
+
}
16
+
17
+
pub struct PythonIntent {
18
+
parser: Parser,
19
+
tree: Option<Tree>,
20
+
source: String,
21
+
}
22
+
impl Default for PythonIntent {
23
+
fn default() -> Self {
24
+
let mut parser = Parser::new();
25
+
parser
26
+
.set_language(&tree_sitter_python::LANGUAGE.into())
27
+
.expect("bundled python grammar");
28
+
Self {
29
+
parser,
30
+
tree: None,
31
+
source: String::new(),
32
+
}
33
+
}
34
+
}
35
+
impl PythonIntent {
36
+
pub fn infer(&mut self, code: &str) -> Option<Intent> {
37
+
let source = close_tail(code);
38
+
if source != self.source {
39
+
let mut start = self
40
+
.source
41
+
.bytes()
42
+
.zip(source.bytes())
43
+
.take_while(|(a, b)| a == b)
44
+
.count();
45
+
while !source.is_char_boundary(start) {
46
+
start -= 1;
47
+
}
48
+
if let Some(tree) = &mut self.tree {
49
+
tree.edit(&InputEdit {
50
+
start_byte: start,
51
+
old_end_byte: self.source.len(),
52
+
new_end_byte: source.len(),
53
+
start_position: point(&source[..start]),
54
+
old_end_position: point(&self.source),
55
+
new_end_position: point(&source),
56
+
});
57
+
}
58
+
self.tree = self.parser.parse(&source, self.tree.as_ref());
59
+
self.source = source;
60
+
}
61
+
let tree = self.tree.as_ref()?;
62
+
let mut reader = Reader {
63
+
source: &self.source,
64
+
original_end: code.len(),
65
+
bindings: HashMap::new(),
66
+
intent: None,
67
+
};
68
+
let root = tree.root_node();
69
+
for node in root.named_children(&mut root.walk()) {
70
+
if node.kind() == "comment" {
71
+
continue;
72
+
}
73
+
reader.intent = None;
74
+
reader.visit(node);
75
+
}
76
+
reader.intent
77
+
}
78
+
}
79
+
fn point(text: &str) -> Point {
80
+
Point::new(
81
+
text.bytes().filter(|b| *b == b'\n').count(),
82
+
text.rsplit('\n').next().unwrap_or("").len(),
83
+
)
84
+
}
85
+
86
+
// Close only lexical delimiters at the streaming edge. Literals extending beyond the real
87
+
// source are never usable values. Keeping the whole string intact prevents embedded code
88
+
// in a large file body from being mistaken for a later workspace action.
89
+
fn close_tail(code: &str) -> String {
90
+
let b = code.as_bytes();
91
+
let mut i = 0;
92
+
let mut stack = Vec::new();
93
+
let mut ending = String::new();
94
+
while i < b.len() {
95
+
match b[i] {
96
+
b'#' => {
97
+
while i < b.len() && b[i] != b'\n' {
98
+
i += 1;
99
+
}
100
+
}
101
+
q @ (b'\'' | b'"') => {
102
+
let n = if b.get(i..i + 3) == Some(&[q, q, q]) {
103
+
3
104
+
} else {
105
+
1
106
+
};
107
+
i += n;
108
+
let mut closed = false;
109
+
while i < b.len() {
110
+
if b[i] == b'\\' {
111
+
i = (i + 2).min(b.len());
112
+
continue;
113
+
}
114
+
if b.get(i..i + n).is_some_and(|s| s.iter().all(|c| *c == q)) {
115
+
i += n;
116
+
closed = true;
117
+
break;
118
+
}
119
+
i += 1;
120
+
}
121
+
if !closed {
122
+
let escapes = b.iter().rev().take_while(|c| **c == b'\\').count();
123
+
if escapes % 2 == 1 {
124
+
ending.push(' ');
125
+
}
126
+
ending.extend(std::iter::repeat_n(q as char, n));
127
+
}
128
+
}
129
+
b'(' => {
130
+
stack.push(')');
131
+
i += 1;
132
+
}
133
+
b'[' => {
134
+
stack.push(']');
135
+
i += 1;
136
+
}
137
+
b'{' => {
138
+
stack.push('}');
139
+
i += 1;
140
+
}
141
+
c @ (b')' | b']' | b'}') => {
142
+
if stack.last() == Some(&(c as char)) {
143
+
stack.pop();
144
+
}
145
+
i += 1;
146
+
}
147
+
_ => i += 1,
148
+
}
149
+
}
150
+
if code
151
+
.lines()
152
+
.last()
153
+
.is_some_and(|line| line.trim_start().starts_with('#'))
154
+
{
155
+
ending.insert(0, '\n');
156
+
}
157
+
ending.extend(stack.into_iter().rev());
158
+
format!("{code}{ending}")
159
+
}
160
+
struct Reader<'a> {
161
+
source: &'a str,
162
+
original_end: usize,
163
+
bindings: HashMap<String, Value>,
164
+
intent: Option<Intent>,
165
+
}
166
+
impl Reader<'_> {
167
+
fn text(&self, node: Node<'_>) -> &str {
168
+
&self.source[node.byte_range()]
169
+
}
170
+
fn arg<'a>(&self, call: Node<'a>, index: usize, names: &[&str]) -> Option<Node<'a>> {
171
+
let args = call.child_by_field_name("arguments")?;
172
+
let mut position = 0;
173
+
for n in args.named_children(&mut args.walk()) {
174
+
if n.kind() == "keyword_argument" {
175
+
if names.contains(&self.text(n.child_by_field_name("name")?)) {
176
+
return n.child_by_field_name("value");
177
+
}
178
+
} else {
179
+
if position == index {
180
+
return Some(n);
181
+
}
182
+
position += 1;
183
+
}
184
+
}
185
+
None
186
+
}
187
+
fn value(&self, node: Node<'_>) -> Option<Value> {
188
+
match node.kind() {
189
+
"identifier" => self.bindings.get(self.text(node)).cloned(),
190
+
"await" | "parenthesized_expression" => self.value(node.named_child(0)?),
191
+
"string" if node.end_byte() <= self.original_end && !node.has_error() => {
192
+
let s = self.text(node);
193
+
let s = if s.starts_with(['r', 'R']) {
194
+
&s[1..]
195
+
} else {
196
+
s
197
+
};
198
+
let quote = s.chars().next()?;
199
+
if !matches!(quote, '\'' | '"') || !s.ends_with(quote) || s.len() < 2 {
200
+
return None;
201
+
}
202
+
let raw = self.text(node).starts_with(['r', 'R']);
203
+
let body = &s[1..s.len() - 1];
204
+
if body.starts_with(&format!("{quote}{quote}")) {
205
+
return None;
206
+
}
207
+
let mut chars = body.chars();
208
+
let mut text = String::new();
209
+
while let Some(c) = chars.next() {
210
+
if c == '\\' && !raw {
211
+
let next = chars.next()?;
212
+
if !matches!(next, '\\' | '\'' | '"') {
213
+
return None;
214
+
}
215
+
text.push(next);
216
+
} else {
217
+
text.push(c);
218
+
}
219
+
}
220
+
Some(Value {
221
+
kind: "string",
222
+
text,
223
+
})
224
+
}
225
+
"call" => {
226
+
let function = node.child_by_field_name("function")?;
227
+
let callee = self.text(function);
228
+
let kind = match callee {
229
+
"here" | "workspace.here" | "workspace.current" => {
230
+
return Some(Value {
231
+
kind: "workspace",
232
+
text: String::new(),
233
+
})
234
+
}
235
+
"Path" | "pathlib.Path" => "path",
236
+
"open_file" | "workspace.open_file" => "file",
237
+
_ if function.kind() == "attribute"
238
+
&& function
239
+
.child_by_field_name("attribute")
240
+
.is_some_and(|n| self.text(n) == "open_file")
241
+
&& function
242
+
.child_by_field_name("object")
243
+
.and_then(|n| self.value(n))
244
+
.is_some_and(|v| v.kind == "workspace") =>
245
+
{
246
+
"file"
247
+
}
248
+
_ => return None,
249
+
};
250
+
let value = self.value(self.arg(node, 0, &["path"])?)?;
251
+
Some(Value {
252
+
kind,
253
+
text: value.text,
254
+
})
255
+
}
256
+
_ => None,
257
+
}
258
+
}
259
+
fn add(&mut self, kind: &'static str, value: Option<Value>) {
260
+
if let Some(value) = value {
261
+
let target = value
262
+
.text
263
+
.chars()
264
+
.map(|c| if c.is_control() { ' ' } else { c })
265
+
.collect::<String>()
266
+
.split_whitespace()
267
+
.collect::<Vec<_>>()
268
+
.join(" ")
269
+
.chars()
270
+
.take(300)
271
+
.collect::<String>();
272
+
if !target.is_empty() {
273
+
self.intent = Some(Intent { kind, target });
274
+
}
275
+
}
276
+
}
277
+
fn visit(&mut self, node: Node<'_>) {
278
+
if matches!(
279
+
node.kind(),
280
+
"function_definition"
281
+
| "class_definition"
282
+
| "if_statement"
283
+
| "for_statement"
284
+
| "while_statement"
285
+
| "lambda"
286
+
| "string"
287
+
| "comment"
288
+
) {
289
+
return;
290
+
}
291
+
if node.kind() == "assignment" {
292
+
if let Some(name) = node
293
+
.child_by_field_name("left")
294
+
.filter(|n| n.kind() == "identifier")
295
+
{
296
+
let name = self.text(name).to_owned();
297
+
if let Some(value) = node
298
+
.child_by_field_name("right")
299
+
.and_then(|n| self.value(n))
300
+
{
301
+
self.bindings.insert(name, value);
302
+
} else {
303
+
self.bindings.remove(&name);
304
+
}
305
+
}
306
+
}
307
+
if node.kind() == "call" {
308
+
if let Some(callee) = node.child_by_field_name("function") {
309
+
let name = self.text(callee).to_owned();
310
+
let first = self
311
+
.arg(node, 0, &["path", "file"])
312
+
.and_then(|n| self.value(n));
313
+
match name.as_str() {
314
+
"read" => self.add("read", first),
315
+
"edit" => self.add("edit", first),
316
+
"sh" => self.add(
317
+
"run",
318
+
self.arg(node, 0, &["command"]).and_then(|n| self.value(n)),
319
+
),
320
+
"ls" => self.add(
321
+
"list",
322
+
first.or(Some(Value {
323
+
kind: "string",
324
+
text: ".".into(),
325
+
})),
326
+
),
327
+
"search" => self.add(
328
+
"search",
329
+
self.arg(node, 0, &["needle"]).and_then(|n| self.value(n)),
330
+
),
331
+
"open" => {
332
+
if let Some(mode) = self.arg(node, 1, &["mode"]).and_then(|n| self.value(n))
333
+
{
334
+
if mode.text.chars().all(|c| "rwaxbt+".contains(c)) {
335
+
self.add(
336
+
if mode.text.contains(['w', 'a', 'x', '+']) {
337
+
"write"
338
+
} else {
339
+
"read"
340
+
},
341
+
first,
342
+
);
343
+
}
344
+
}
345
+
}
346
+
_ if callee.kind() == "attribute" => {
347
+
let value = callee
348
+
.child_by_field_name("object")
349
+
.and_then(|n| self.value(n));
350
+
let method = callee
351
+
.child_by_field_name("attribute")
352
+
.map(|n| self.text(n))
353
+
.unwrap_or("");
354
+
if let Some(value) = value {
355
+
let action = match (value.kind, method) {
356
+
("path", "write_text" | "write_bytes") | ("file", "write") => {
357
+
Some("write")
358
+
}
359
+
("path", "read_text" | "read_bytes")
360
+
| ("file", "text" | "read" | "hashlines") => Some("read"),
361
+
("file", "replace" | "edit") => Some("edit"),
362
+
_ => None,
363
+
};
364
+
if let Some(kind) = action {
365
+
self.add(kind, Some(value));
366
+
} else if value.kind == "path" && method == "open" {
367
+
if let Some(mode) =
368
+
self.arg(node, 0, &["mode"]).and_then(|n| self.value(n))
369
+
{
370
+
if mode.text.chars().all(|c| "rwaxbt+".contains(c)) {
371
+
self.add(
372
+
if mode.text.contains(['w', 'a', 'x', '+']) {
373
+
"write"
374
+
} else {
375
+
"read"
376
+
},
377
+
Some(value),
378
+
);
379
+
}
380
+
}
381
+
}
382
+
}
383
+
}
384
+
_ => {}
385
+
}
386
+
}
387
+
}
388
+
for child in node.named_children(&mut node.walk()) {
389
+
self.visit(child);
390
+
}
391
+
}
392
+
}
393
+
394
+
#[cfg(test)]
395
+
mod tests {
396
+
use super::*;
397
+
#[test]
398
+
fn niri_literal_intents_before_payload_completion() {
399
+
for (code, kind, target) in [
400
+
(
401
+
"Path('index.html').write_text('''<html>",
402
+
"write",
403
+
"index.html",
404
+
),
405
+
(
406
+
"from pathlib import Path\np = Path('页面.html')\np.write_text('''",
407
+
"write",
408
+
"页面.html",
409
+
),
410
+
(
411
+
"name = 'app.py'\np = pathlib.Path(name)\np.write_bytes(b'",
412
+
"write",
413
+
"app.py",
414
+
),
415
+
(
416
+
"with open('index.html', mode='w') as file:\n file.write('",
417
+
"write",
418
+
"index.html",
419
+
),
420
+
("Path('log.txt').open('a')", "write", "log.txt"),
421
+
("edit(path='app.py', changes=[", "edit", "app.py"),
422
+
("print(read('app.py'))", "read", "app.py"),
423
+
(
424
+
"sh('node --check script.js')",
425
+
"run",
426
+
"node --check script.js",
427
+
),
428
+
(
429
+
"f = await open_file('index.html')\nawait f.write('''hello",
430
+
"write",
431
+
"index.html",
432
+
),
433
+
("await (await open_file('app.py')).text()", "read", "app.py"),
434
+
(
435
+
"f = await open_file('app.py')\nawait f.replace('a', 'b')",
436
+
"edit",
437
+
"app.py",
438
+
),
439
+
] {
440
+
assert_eq!(
441
+
PythonIntent::default().infer(code),
442
+
Some(Intent {
443
+
kind,
444
+
target: target.into()
445
+
}),
446
+
"{code}"
447
+
);
448
+
}
449
+
}
450
+
#[test]
451
+
fn unknowns_and_deferred_code_are_not_actions() {
452
+
for code in [
453
+
"# Path('fake').write_text('x')",
454
+
"code = \"Path('fake').write_text('x')\"",
455
+
"Path('inde",
456
+
"Path(f'{name}.html').write_text('x')",
457
+
"Path(directory / name).write_text('x')",
458
+
"p = Path('old')\np = unknown()\np.write_text('x')",
459
+
"def later():\n Path('fake').write_text('x')",
460
+
"if False:\n Path('fake').write_text('x')",
461
+
"some_object.write_text('x')",
462
+
"f = await open_file(name)\nawait f.write('x')",
463
+
] {
464
+
assert_eq!(PythonIntent::default().infer(code), None, "{code}");
465
+
}
466
+
}
467
+
#[test]
468
+
fn multi_file_stream_updates_and_unknown_statement_clears_old_path() {
469
+
let code = "from pathlib import Path\np = Path('first.html')\np.write_text('''\nPath('fake').write_text('no')\n''')\np = Path('second.css')\np.write_text('''body {''')";
470
+
let mut parser = PythonIntent::default();
471
+
for end in 0..=code.len() {
472
+
let intent = parser.infer(&code[..end]);
473
+
assert_ne!(
474
+
intent.as_ref().map(|i| i.target.as_str()),
475
+
Some("fake"),
476
+
"{}",
477
+
&code[..end]
478
+
);
479
+
}
480
+
assert_eq!(parser.infer(code).unwrap().target, "second.css");
481
+
let first = format!(
482
+
"Path('first.html').write_text('''{}''')\n",
483
+
"x".repeat(40000)
484
+
);
485
+
assert_eq!(
486
+
parser
487
+
.infer(&(first.clone() + "Path('second.css').write_text('''body {"))
488
+
.unwrap()
489
+
.target,
490
+
"second.css"
491
+
);
492
+
assert!(parser
493
+
.infer(&(first + "next_body = '''unknown destination"))
494
+
.is_none());
495
+
assert_eq!(
496
+
parser
497
+
.infer("Path('corrected').write_text('''")
498
+
.unwrap()
499
+
.target,
500
+
"corrected"
501
+
);
502
+
}
503
+
}
504
+
505
+
#[cfg(test)]
506
+
mod workspace_bindings {
507
+
use super::*;
508
+
#[test]
509
+
fn workspace_receivers_need_a_known_binding() {
510
+
assert_eq!(
511
+
PythonIntent::default()
512
+
.infer("ws = await here()\nf = await ws.open_file('a.py')\nawait f.text()")
513
+
.unwrap()
514
+
.target,
515
+
"a.py"
516
+
);
517
+
assert!(PythonIntent::default()
518
+
.infer("f = await ws.open_file('a.py')\nawait f.text()")
519
+
.is_none());
520
+
}
521
+
}
+15
klbr-tui/src/lib.rs
+15
klbr-tui/src/lib.rs
+140
klbr-tui/src/main.rs
+140
klbr-tui/src/main.rs
···
1
+
use anyhow::{ensure, Result};
2
+
use clap::Parser;
3
+
use crossterm::{
4
+
event::{
5
+
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
6
+
EventStream,
7
+
},
8
+
execute,
9
+
};
10
+
use futures::StreamExt;
11
+
use klbr_tui::{
12
+
app::{Action, App, Connection},
13
+
transport::{self, Command, Envelope, Target},
14
+
view,
15
+
};
16
+
use ratatui_textarea::WrapMode;
17
+
use std::{
18
+
collections::HashMap,
19
+
io::{self, IsTerminal},
20
+
sync::{
21
+
atomic::{AtomicI64, Ordering},
22
+
Arc,
23
+
},
24
+
time::{Duration, Instant},
25
+
};
26
+
use tokio::sync::{mpsc, watch};
27
+
28
+
#[derive(Parser)]
29
+
#[command(about = "klbr's native v2 terminal client; quitting detaches, esc interrupts the turn")]
30
+
struct Cli {
31
+
#[arg(long, default_value = "ws://127.0.0.1:8765/ws")]
32
+
url: String,
33
+
#[arg(long, default_value = "default")]
34
+
session: String,
35
+
}
36
+
37
+
struct Restore;
38
+
impl Drop for Restore {
39
+
fn drop(&mut self) {
40
+
let _ = execute!(io::stdout(), DisableMouseCapture, DisableBracketedPaste);
41
+
ratatui::restore();
42
+
}
43
+
}
44
+
45
+
#[tokio::main(flavor = "current_thread")]
46
+
async fn main() -> Result<()> {
47
+
let cli = Cli::parse();
48
+
ensure!(
49
+
io::stdin().is_terminal() && io::stdout().is_terminal(),
50
+
"klbr-tui needs an interactive terminal"
51
+
);
52
+
ensure!(
53
+
cli.url.starts_with("ws://") || cli.url.starts_with("wss://"),
54
+
"--url must be a websocket URL"
55
+
);
56
+
let mut terminal = ratatui::try_init()?;
57
+
let _restore = Restore;
58
+
execute!(io::stdout(), EnableMouseCapture, EnableBracketedPaste)?;
59
+
let mut app = App::new(cli.session.clone(), cli.url.clone());
60
+
app.editor.set_wrap_mode(WrapMode::Word);
61
+
let mut saved = HashMap::new();
62
+
let mut epoch = 0;
63
+
let mut connection = 0;
64
+
let mut cursor = Arc::new(AtomicI64::new(0));
65
+
let (target_tx, target_rx) = watch::channel(Target {
66
+
epoch,
67
+
session: cli.session,
68
+
cursor: cursor.clone(),
69
+
});
70
+
let (commands, command_rx) = mpsc::channel(64);
71
+
let (event_tx, mut events) = mpsc::channel(256);
72
+
let network = tokio::spawn(transport::run(cli.url, target_rx, command_rx, event_tx));
73
+
let mut input = EventStream::new();
74
+
let mut ticker = tokio::time::interval(Duration::from_millis(80));
75
+
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
76
+
let mut tick = 0;
77
+
let mut dirty = true;
78
+
let mut urgent = true;
79
+
let mut last_draw = Instant::now();
80
+
let result = async {
81
+
'run: loop {
82
+
if dirty && (urgent || last_draw.elapsed() >= Duration::from_millis(4)) {
83
+
terminal.draw(|frame| view::draw(frame, &mut app, tick))?;
84
+
dirty = false; urgent = false; last_draw = Instant::now();
85
+
}
86
+
let mut actions = Vec::new();
87
+
tokio::select! {
88
+
biased;
89
+
event = input.next() => {
90
+
let Some(event) = event else { break; };
91
+
urgent = true; dirty = true;
92
+
actions = klbr_tui::input::handle(&mut app, event?);
93
+
}
94
+
event = events.recv() => {
95
+
let Some(event) = event else { app.disconnect("connection task ended".into()); break; };
96
+
let mut batch = vec![event];
97
+
// Bounded ingestion: an event flood cannot monopolize input or the renderer.
98
+
for _ in 0..63 { match events.try_recv() { Ok(event) => batch.push(event), Err(_) => break } }
99
+
for Envelope { epoch: received, event } in batch {
100
+
if received != epoch { continue; }
101
+
match event {
102
+
transport::Event::Connected { connection: id } => { connection = id; app.connection = Connection::Live; app.loading_history = true; },
103
+
transport::Event::Disconnected(error) => app.disconnect(error),
104
+
transport::Event::Message(message) => app.receive(message),
105
+
}
106
+
dirty = true;
107
+
}
108
+
cursor.store(app.cursor, Ordering::Relaxed);
109
+
}
110
+
_ = ticker.tick() => { if app.busy() { tick += 1; dirty = true; } }
111
+
_ = tokio::time::sleep_until((last_draw + Duration::from_millis(4)).into()), if dirty => {}
112
+
}
113
+
for action in actions {
114
+
match action {
115
+
Action::Quit => break 'run,
116
+
Action::Switch(session) if session != app.session => {
117
+
let mut next = saved.remove(&session).unwrap_or_else(|| App::new(session.clone(), app.endpoint.clone()));
118
+
next.connection = Connection::Connecting; next.sessions = None;
119
+
let mut previous = std::mem::replace(&mut app, next);
120
+
for pending in &mut previous.pending { pending.uncertain = true; }
121
+
saved.insert(previous.session.clone(), previous);
122
+
epoch += 1; cursor = Arc::new(AtomicI64::new(app.cursor));
123
+
target_tx.send(Target { epoch, session, cursor: cursor.clone() })?;
124
+
}
125
+
Action::Switch(_) => { app.sessions = None; }
126
+
Action::Send(message) => {
127
+
if commands.try_send(Command { epoch, connection, message }).is_err() {
128
+
app.disconnect("outbound queue unavailable; input delivery is unconfirmed".into());
129
+
}
130
+
}
131
+
}
132
+
}
133
+
app.editor.set_wrap_mode(WrapMode::Word);
134
+
}
135
+
anyhow::Ok(())
136
+
}.await;
137
+
network.abort();
138
+
let _ = network.await;
139
+
result
140
+
}
+248
klbr-tui/src/markdown.rs
+248
klbr-tui/src/markdown.rs
···
1
+
//! niri/markdown.ts expressed as styled terminal spans rather than embedded ANSI.
2
+
use crate::text::{clean, MUTED};
3
+
use ratatui::{
4
+
style::{Color, Modifier, Style},
5
+
text::{Line, Span},
6
+
};
7
+
use regex::Regex;
8
+
use std::sync::LazyLock;
9
+
static INLINE: LazyLock<Regex> = LazyLock::new(|| {
10
+
Regex::new(r"\*\*\*(.*?)\*\*\*|\*\*(.*?)\*\*|\*(.*?)\*|~~(.*?)~~|`([^`]+)`").unwrap()
11
+
});
12
+
static HEADING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)").unwrap());
13
+
static LIST: LazyLock<Regex> =
14
+
LazyLock::new(|| Regex::new(r"^(\s*)(?:[*+\-]|(\d+)\.)\s+(.*)").unwrap());
15
+
fn inline(text: &str, style: Style) -> Vec<Span<'static>> {
16
+
let mut spans = Vec::new();
17
+
let mut end = 0;
18
+
for c in INLINE.captures_iter(text) {
19
+
let m = c.get(0).unwrap();
20
+
spans.push(Span::styled(text[end..m.start()].to_owned(), style));
21
+
let (content, modifier, color) = if let Some(m) = c.get(1) {
22
+
(m.as_str(), Modifier::BOLD | Modifier::ITALIC, None)
23
+
} else if let Some(m) = c.get(2) {
24
+
(m.as_str(), Modifier::BOLD, None)
25
+
} else if let Some(m) = c.get(3) {
26
+
(m.as_str(), Modifier::ITALIC, None)
27
+
} else if let Some(m) = c.get(4) {
28
+
(m.as_str(), Modifier::DIM, None)
29
+
} else {
30
+
(
31
+
c.get(5).unwrap().as_str(),
32
+
Modifier::empty(),
33
+
Some(Color::Cyan),
34
+
)
35
+
};
36
+
let mut emphasis = style.add_modifier(modifier);
37
+
if let Some(color) = color {
38
+
emphasis = emphasis.fg(color);
39
+
}
40
+
spans.extend(if content.contains(['*', '`']) {
41
+
inline(content, emphasis)
42
+
} else {
43
+
vec![Span::styled(content.to_owned(), emphasis)]
44
+
});
45
+
end = m.end();
46
+
}
47
+
spans.push(Span::styled(text[end..].to_owned(), style));
48
+
spans
49
+
}
50
+
51
+
pub fn markdown(text: &str, muted: bool, width: usize) -> Vec<Line<'static>> {
52
+
let text = clean(text);
53
+
let lines: Vec<_> = text.split('\n').collect();
54
+
let base = if muted {
55
+
Style::default().fg(MUTED)
56
+
} else {
57
+
Style::default()
58
+
};
59
+
let dim = base.add_modifier(Modifier::DIM);
60
+
let mut out = Vec::new();
61
+
let mut i = 0;
62
+
while i < lines.len() {
63
+
let line = lines[i];
64
+
i += 1;
65
+
if let Some(lang) = line.strip_prefix("```") {
66
+
let lang = lang.trim();
67
+
let start = i;
68
+
while i < lines.len() && !lines[i].starts_with("```") {
69
+
i += 1;
70
+
}
71
+
let code = lines[start..i].join("\n");
72
+
i += 1;
73
+
if !lang.is_empty() {
74
+
out.push(Line::styled(format!("┌─ {lang}"), dim.fg(Color::Cyan)));
75
+
}
76
+
let code_rows =
77
+
crate::text::wrap(vec![Line::from(highlight(&code, lang, base))], usize::MAX);
78
+
for row in code_rows {
79
+
let mut spans = vec![Span::styled("│", dim), Span::raw(" ")];
80
+
spans.extend(row.spans);
81
+
out.push(Line::from(spans));
82
+
}
83
+
out.push(Line::styled("└", dim));
84
+
continue;
85
+
}
86
+
if let Some(c) = HEADING.captures(line) {
87
+
let colors = [
88
+
Color::LightCyan,
89
+
Color::Cyan,
90
+
Color::LightBlue,
91
+
Color::LightBlue,
92
+
Color::LightMagenta,
93
+
Color::LightMagenta,
94
+
];
95
+
out.push(Line::from(inline(
96
+
&c[2],
97
+
base.fg(colors[c[1].len() - 1]).add_modifier(Modifier::BOLD),
98
+
)));
99
+
continue;
100
+
}
101
+
if matches!(line.trim(), "***" | "---" | "___") {
102
+
out.push(Line::styled("─".repeat(width), dim));
103
+
continue;
104
+
}
105
+
if let Some(c) = LIST.captures(line) {
106
+
let marker = c
107
+
.get(2)
108
+
.map(|m| format!("{}.", m.as_str()))
109
+
.unwrap_or_else(|| "•".into());
110
+
let mut spans = vec![
111
+
Span::raw(c[1].to_owned()),
112
+
Span::styled(marker, dim),
113
+
Span::raw(" "),
114
+
];
115
+
spans.extend(inline(&c[3], base));
116
+
out.push(Line::from(spans));
117
+
continue;
118
+
}
119
+
if let Some(quote) = line.strip_prefix("> ") {
120
+
let mut spans = vec![Span::styled("│", dim), Span::raw(" ")];
121
+
spans.extend(inline(quote, base.add_modifier(Modifier::ITALIC)));
122
+
out.push(Line::from(spans));
123
+
continue;
124
+
}
125
+
out.push(Line::from(inline(line, base)));
126
+
}
127
+
out
128
+
}
129
+
130
+
fn highlight(code: &str, lang: &str, base: Style) -> Vec<Span<'static>> {
131
+
let lang = lang.to_lowercase();
132
+
if matches!(lang.as_str(), "" | "text" | "txt" | "plain") {
133
+
return vec![Span::styled(code.to_owned(), base)];
134
+
}
135
+
let hash = matches!(
136
+
lang.as_str(),
137
+
"python" | "py" | "bash" | "sh" | "shell" | "zsh" | "fish"
138
+
);
139
+
let keywords = match lang.as_str() {
140
+
"python" | "py" => KW_PY,
141
+
"rust" | "rs" => KW_RUST,
142
+
"bash" | "sh" | "shell" | "zsh" | "fish" => KW_BASH,
143
+
_ => KW_JS,
144
+
};
145
+
let mut spans = Vec::new();
146
+
let mut i = 0;
147
+
while i < code.len() {
148
+
let rest = &code[i..];
149
+
let ch = rest.chars().next().unwrap();
150
+
let (len, style) = if (!hash && rest.starts_with("//")) || (hash && ch == '#') {
151
+
(
152
+
rest.find('\n').unwrap_or(rest.len()),
153
+
base.fg(MUTED).add_modifier(Modifier::ITALIC),
154
+
)
155
+
} else if !hash && rest.starts_with("/*") {
156
+
(
157
+
rest[2..].find("*/").map(|n| n + 4).unwrap_or(rest.len()),
158
+
base.fg(MUTED).add_modifier(Modifier::ITALIC),
159
+
)
160
+
} else if matches!(ch, '\'' | '"' | '`') {
161
+
let mut end = ch.len_utf8();
162
+
let mut escaped = false;
163
+
for c in rest[end..].chars() {
164
+
end += c.len_utf8();
165
+
if escaped {
166
+
escaped = false;
167
+
} else if c == '\\' {
168
+
escaped = true;
169
+
} else if c == ch {
170
+
break;
171
+
}
172
+
}
173
+
(end, base.fg(Color::Green))
174
+
} else if ch.is_ascii_digit() {
175
+
(
176
+
rest.chars()
177
+
.take_while(|c| "0123456789._xXaAbBcCdDeEfFoOnNlL+-".contains(*c))
178
+
.map(char::len_utf8)
179
+
.sum(),
180
+
base.fg(Color::LightYellow),
181
+
)
182
+
} else if ch.is_ascii_alphabetic() || matches!(ch, '_' | '$') {
183
+
let end = rest
184
+
.chars()
185
+
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$'))
186
+
.map(char::len_utf8)
187
+
.sum();
188
+
let word = &rest[..end];
189
+
let color = if keywords.split_whitespace().any(|kw| kw == word) {
190
+
Some(Color::LightMagenta)
191
+
} else if ch.is_ascii_uppercase() {
192
+
Some(Color::LightYellow)
193
+
} else if rest[end..].starts_with('(') {
194
+
Some(Color::LightBlue)
195
+
} else {
196
+
None
197
+
};
198
+
(end, color.map(|c| base.fg(c)).unwrap_or(base))
199
+
} else {
200
+
(ch.len_utf8(), base)
201
+
};
202
+
spans.push(Span::styled(rest[..len].to_owned(), style));
203
+
i += len;
204
+
}
205
+
spans
206
+
}
207
+
const KW_JS: &str = "if else for while do switch case break continue return function class const let var new delete typeof instanceof import export default from async await try catch finally throw null undefined true false void in of this super extends implements interface type enum namespace declare abstract public private protected static readonly override satisfies as keyof infer never unknown any";
208
+
const KW_PY: &str = "if elif else for while in not and or is import from as def class return yield pass break continue try except finally raise with lambda True False None global nonlocal del assert async await self super";
209
+
const KW_RUST: &str = "fn let mut const static pub use mod crate super self struct enum impl trait type where for while if else match return break continue loop move ref in as unsafe dyn async await true false Some None Ok Err";
210
+
const KW_BASH: &str = "if then else elif fi for do done while until case in esac function return local export echo exit source set unset";
211
+
212
+
#[cfg(test)]
213
+
mod tests {
214
+
use super::*;
215
+
#[test]
216
+
fn niri_markdown_structure_and_syntax_palette() {
217
+
let rows = markdown(
218
+
"# title\n- list **bold**\n> quote\n```python\nawait f.write('ok') # note\n```",
219
+
false,
220
+
40,
221
+
);
222
+
assert_eq!(
223
+
rows.iter().map(ToString::to_string).collect::<Vec<_>>(),
224
+
[
225
+
"title",
226
+
"• list bold",
227
+
"│ quote",
228
+
"┌─ python",
229
+
"│ await f.write('ok') # note",
230
+
"└"
231
+
]
232
+
);
233
+
let code = &rows[4];
234
+
for (token, color) in [
235
+
("await", Color::LightMagenta),
236
+
("write", Color::LightBlue),
237
+
("'ok'", Color::Green),
238
+
("# note", MUTED),
239
+
] {
240
+
assert!(
241
+
code.spans
242
+
.iter()
243
+
.any(|span| span.content == token && span.style.fg == Some(color)),
244
+
"{token}"
245
+
);
246
+
}
247
+
}
248
+
}
+165
klbr-tui/src/text.rs
+165
klbr-tui/src/text.rs
···
1
+
use ratatui::{
2
+
style::Color,
3
+
text::{Line, Span},
4
+
};
5
+
use unicode_segmentation::UnicodeSegmentation;
6
+
use unicode_width::UnicodeWidthStr;
7
+
8
+
pub const MUTED: Color = Color::DarkGray;
9
+
pub const ACCENT: Color = Color::LightCyan;
10
+
pub const ERROR: Color = Color::LightRed;
11
+
12
+
/// No server/model text may write terminal controls, even in an expanded record.
13
+
pub fn clean(text: &str) -> String {
14
+
text.chars()
15
+
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
16
+
.collect::<String>()
17
+
.replace('\t', " ")
18
+
}
19
+
20
+
pub fn clip(text: &str, width: usize) -> String {
21
+
let text = clean(text).replace('\n', " ");
22
+
if text.width() <= width {
23
+
return text;
24
+
}
25
+
if width == 0 {
26
+
return String::new();
27
+
}
28
+
let mut used = 0;
29
+
text.graphemes(true)
30
+
.take_while(|g| {
31
+
used += g.width();
32
+
used < width
33
+
})
34
+
.collect::<String>()
35
+
+ "…"
36
+
}
37
+
38
+
/// Preserve the distinguishing end of long model identities.
39
+
pub fn clip_tail(text: &str, width: usize) -> String {
40
+
let text = clean(text).replace('\n', " ");
41
+
if text.width() <= width {
42
+
return text;
43
+
}
44
+
if width == 0 {
45
+
return String::new();
46
+
}
47
+
let mut used = 1;
48
+
let tail = text
49
+
.graphemes(true)
50
+
.rev()
51
+
.take_while(|g| {
52
+
used += g.width();
53
+
used <= width
54
+
})
55
+
.collect::<Vec<_>>()
56
+
.into_iter()
57
+
.rev()
58
+
.collect::<String>();
59
+
format!("…{tail}")
60
+
}
61
+
pub fn clip_middle(text: &str, width: usize) -> String {
62
+
let text = clean(text).replace('\n', " ");
63
+
if text.width() <= width {
64
+
return text;
65
+
}
66
+
if width == 0 {
67
+
return String::new();
68
+
}
69
+
let left_width = width.saturating_sub(1).div_ceil(2);
70
+
let mut used = 0;
71
+
let left = text
72
+
.graphemes(true)
73
+
.take_while(|g| {
74
+
used += g.width();
75
+
used <= left_width
76
+
})
77
+
.collect::<String>();
78
+
format!("{left}{}", clip_tail(&text, width - left.width()))
79
+
}
80
+
81
+
/// wrap-ansi's word-first, hard-wrap fallback with whitespace retained and span styles intact.
82
+
pub fn wrap(lines: Vec<Line<'static>>, width: usize) -> Vec<Line<'static>> {
83
+
let width = width.max(1);
84
+
let mut result = Vec::new();
85
+
for line in lines {
86
+
let glyphs: Vec<_> = line
87
+
.spans
88
+
.iter()
89
+
.flat_map(|span| {
90
+
span.content
91
+
.graphemes(true)
92
+
.map(|g| (g, line.style.patch(span.style)))
93
+
})
94
+
.collect();
95
+
let mut row = Line::default();
96
+
let mut used = 0;
97
+
for (i, (g, style)) in glyphs.iter().enumerate() {
98
+
let space = g.chars().all(char::is_whitespace);
99
+
let start_word = !space && (i == 0 || glyphs[i - 1].0.chars().all(char::is_whitespace));
100
+
let word_width = if start_word {
101
+
glyphs[i..]
102
+
.iter()
103
+
.take_while(|(g, _)| !g.chars().all(char::is_whitespace))
104
+
.map(|(g, _)| g.width())
105
+
.sum::<usize>()
106
+
} else {
107
+
0
108
+
};
109
+
if *g == "\n"
110
+
|| used > 0
111
+
&& (used + g.width() > width
112
+
|| start_word && word_width <= width && used + word_width > width)
113
+
{
114
+
result.push(std::mem::take(&mut row));
115
+
used = 0;
116
+
}
117
+
if *g == "\n" {
118
+
continue;
119
+
}
120
+
let g = if g.width() > width { "�" } else { g };
121
+
if let Some(last) = row.spans.last_mut().filter(|last| last.style == *style) {
122
+
last.content.to_mut().push_str(g);
123
+
} else {
124
+
row.spans.push(Span::styled(g.to_owned(), *style));
125
+
}
126
+
used += g.width();
127
+
}
128
+
result.push(row);
129
+
}
130
+
result
131
+
}
132
+
133
+
#[cfg(test)]
134
+
mod tests {
135
+
use super::*;
136
+
#[test]
137
+
fn unicode_and_controls_stay_in_their_rows() {
138
+
let rows = wrap(vec![Line::raw(clean("界界é\t\x1b[31mx"))], 4);
139
+
assert!(rows.iter().all(|row| row.width() <= 4));
140
+
assert!(!clean("\x1b]52;c;secrets\x07").contains('\x1b'));
141
+
assert_eq!(clip("界界界", 5), "界界…");
142
+
}
143
+
}
144
+
145
+
#[cfg(test)]
146
+
mod parity {
147
+
use super::*;
148
+
#[test]
149
+
fn niri_wrap_ansi_retains_spaces_and_breaks_long_words() {
150
+
for (input, expected) in [
151
+
("hello world again", "hello \nworld \nagain"),
152
+
("hello world", "hello \n world"),
153
+
("xx abcdefg z", "xx abc\ndefg z"),
154
+
] {
155
+
assert_eq!(
156
+
wrap(vec![Line::raw(input)], 6)
157
+
.iter()
158
+
.map(ToString::to_string)
159
+
.collect::<Vec<_>>()
160
+
.join("\n"),
161
+
expected
162
+
);
163
+
}
164
+
}
165
+
}
+492
klbr-tui/src/transcript.rs
+492
klbr-tui/src/transcript.rs
···
1
+
use crate::markdown::markdown;
2
+
use crate::text::{clean, wrap, ACCENT, ERROR, MUTED};
3
+
use klbr_ipc::v2::{CellState, CellTrace};
4
+
use ratatui::{
5
+
style::{Color, Modifier, Style},
6
+
text::{Line, Span},
7
+
};
8
+
use std::collections::HashMap;
9
+
10
+
#[derive(Clone, Debug)]
11
+
pub enum Entry {
12
+
Text {
13
+
label: String,
14
+
body: String,
15
+
muted: bool,
16
+
},
17
+
Cell {
18
+
code: String,
19
+
state: Option<CellState>,
20
+
output: String,
21
+
error: Option<String>,
22
+
truncated: usize,
23
+
trace: Option<CellTrace>,
24
+
},
25
+
Note {
26
+
text: String,
27
+
error: bool,
28
+
},
29
+
}
30
+
31
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32
+
pub struct Format {
33
+
pub width: usize,
34
+
pub expanded: bool,
35
+
pub thinking: bool,
36
+
}
37
+
38
+
fn label(text: impl Into<String>, color: Color) -> Line<'static> {
39
+
Line::styled(
40
+
text.into(),
41
+
Style::default().fg(color).add_modifier(Modifier::BOLD),
42
+
)
43
+
}
44
+
45
+
fn preview(
46
+
mut rows: Vec<Line<'static>>,
47
+
limit: usize,
48
+
expanded: bool,
49
+
tail: bool,
50
+
) -> Vec<Line<'static>> {
51
+
if expanded || rows.len() <= limit {
52
+
return rows;
53
+
}
54
+
let hidden = rows.len() - limit;
55
+
let hint = Line::styled(
56
+
format!(
57
+
"… {hidden} {} hidden · /v expand",
58
+
if hidden == 1 { "row" } else { "rows" }
59
+
),
60
+
Style::default().fg(MUTED),
61
+
);
62
+
if tail {
63
+
rows.drain(..hidden);
64
+
rows.insert(0, hint);
65
+
} else {
66
+
rows.truncate(limit);
67
+
rows.push(hint);
68
+
}
69
+
rows
70
+
}
71
+
72
+
impl Entry {
73
+
pub fn rows(&self, format: Format) -> Vec<Line<'static>> {
74
+
let mut rows = match self {
75
+
Self::Text {
76
+
label: name,
77
+
body,
78
+
muted,
79
+
} => {
80
+
let style = if name == "you" {
81
+
Style::default().fg(ACCENT)
82
+
} else if *muted {
83
+
Style::default().fg(MUTED)
84
+
} else {
85
+
Style::default().add_modifier(Modifier::BOLD)
86
+
};
87
+
let mut rows = vec![Line::styled(clean(name), style)];
88
+
rows.extend(
89
+
if matches!(name.as_str(), "thinking" | "scratchpad") && !format.thinking {
90
+
vec![Line::styled("hidden · /t show", Style::default().fg(MUTED))]
91
+
} else {
92
+
wrap(markdown(body, *muted, format.width), format.width)
93
+
},
94
+
);
95
+
rows
96
+
}
97
+
Self::Note { text, error } => wrap(
98
+
vec![Line::styled(
99
+
clean(text),
100
+
Style::default().fg(if *error { ERROR } else { MUTED }),
101
+
)],
102
+
format.width,
103
+
),
104
+
// A start is retained for correlation/backfill, but only the transient action is drawn.
105
+
Self::Cell { state: None, .. } => return Vec::new(),
106
+
Self::Cell {
107
+
code,
108
+
state,
109
+
output,
110
+
error,
111
+
truncated,
112
+
trace,
113
+
} => {
114
+
let failed = matches!(state, Some(CellState::Error | CellState::Interrupted));
115
+
let has_trace = trace
116
+
.as_ref()
117
+
.is_some_and(|t| !t.activities.is_empty() || !t.changes.is_empty());
118
+
let mut rows = Vec::new();
119
+
if !has_trace || format.expanded || failed {
120
+
let count = code.split('\n').count();
121
+
let suffix = match state {
122
+
Some(CellState::Error) => " · failed",
123
+
Some(CellState::Interrupted) => " · interrupted",
124
+
Some(CellState::Yielded) => " · yielded",
125
+
_ => "",
126
+
};
127
+
rows.push(Line::styled(
128
+
format!(
129
+
"python · {count} {}{suffix}",
130
+
if count == 1 { "line" } else { "lines" }
131
+
),
132
+
Style::default().fg(if failed { ERROR } else { MUTED }),
133
+
));
134
+
}
135
+
if let Some(trace) = trace {
136
+
if !trace.activities.is_empty() {
137
+
rows.push(label(
138
+
if trace.activities.iter().any(|a| a.kind == "run") {
139
+
"executed"
140
+
} else {
141
+
"explored"
142
+
},
143
+
Color::Reset,
144
+
));
145
+
for activity in &trace.activities {
146
+
rows.extend(wrap(
147
+
vec![Line::from(vec![
148
+
Span::raw(" "),
149
+
Span::styled(
150
+
clean(&activity.kind),
151
+
Style::default().fg(Color::Cyan),
152
+
),
153
+
Span::raw(format!(" {}", clean(&activity.target))),
154
+
])],
155
+
format.width,
156
+
));
157
+
}
158
+
}
159
+
for change in &trace.changes {
160
+
let unavailable = change.truncated && change.diff.is_empty();
161
+
let mut heading = vec![Span::styled(
162
+
format!("edited {}", clean(&change.path)),
163
+
Style::default().add_modifier(Modifier::BOLD),
164
+
)];
165
+
if !unavailable {
166
+
heading.extend([
167
+
Span::styled(
168
+
format!(" +{}", change.added),
169
+
Style::default().fg(Color::Green),
170
+
),
171
+
Span::styled(
172
+
format!(" −{}", change.removed),
173
+
Style::default().fg(Color::Red),
174
+
),
175
+
]);
176
+
}
177
+
rows.push(Line::from(heading));
178
+
let diff = clean(&change.diff)
179
+
.split('\n')
180
+
.map(|line| {
181
+
Line::styled(
182
+
line.to_owned(),
183
+
Style::default().fg(if line.starts_with('+') {
184
+
Color::Green
185
+
} else if line.starts_with('-') {
186
+
Color::Red
187
+
} else {
188
+
MUTED
189
+
}),
190
+
)
191
+
})
192
+
.collect();
193
+
rows.extend(preview(wrap(diff, format.width), 8, format.expanded, false));
194
+
if change.truncated {
195
+
rows.push(Line::styled(
196
+
"diff truncated at source",
197
+
Style::default().fg(MUTED),
198
+
));
199
+
}
200
+
}
201
+
if trace.truncated {
202
+
rows.push(Line::styled(
203
+
"activity capture limited; some operations are not shown",
204
+
Style::default().fg(MUTED),
205
+
));
206
+
}
207
+
}
208
+
if format.expanded {
209
+
rows.extend(wrap(
210
+
markdown(&format!("```python\n{code}\n```"), false, format.width),
211
+
format.width,
212
+
));
213
+
}
214
+
if format.expanded || failed || !has_trace {
215
+
let mut result = output.trim_end().to_owned();
216
+
if let Some(error) = error {
217
+
if !result.is_empty() {
218
+
result.push('\n');
219
+
}
220
+
result.push_str(error.trim_end());
221
+
}
222
+
if result.is_empty() {
223
+
result = "(no output)".into();
224
+
}
225
+
let output = wrap(
226
+
clean(&result)
227
+
.split('\n')
228
+
.map(|s| {
229
+
Line::styled(
230
+
s.to_owned(),
231
+
if failed {
232
+
Style::default().fg(ERROR)
233
+
} else {
234
+
Style::default()
235
+
},
236
+
)
237
+
})
238
+
.collect(),
239
+
format.width,
240
+
);
241
+
rows.extend(preview(output, 3, format.expanded, failed));
242
+
}
243
+
if *truncated > 0 {
244
+
rows.push(Line::styled(
245
+
format!("{truncated} output bytes omitted by runtime"),
246
+
Style::default().fg(MUTED),
247
+
));
248
+
}
249
+
rows
250
+
}
251
+
};
252
+
rows = wrap(rows, format.width);
253
+
rows.push(Line::default());
254
+
rows
255
+
}
256
+
}
257
+
258
+
struct Block {
259
+
key: String,
260
+
seq: i64,
261
+
revision: u64,
262
+
entry: Entry,
263
+
}
264
+
#[derive(Default)]
265
+
struct Layout {
266
+
format: Option<Format>,
267
+
rows: Vec<Vec<Line<'static>>>,
268
+
revisions: Vec<u64>,
269
+
ends: Vec<usize>,
270
+
dirty_from: usize,
271
+
}
272
+
273
+
#[derive(Default)]
274
+
pub struct Transcript {
275
+
blocks: Vec<Block>,
276
+
lookup: HashMap<String, usize>,
277
+
layouts: Vec<Layout>,
278
+
sorted: bool,
279
+
}
280
+
281
+
impl Transcript {
282
+
pub fn upsert(&mut self, key: String, seq: i64, entry: Entry) {
283
+
if let Some(&index) = self.lookup.get(&key) {
284
+
for layout in &mut self.layouts {
285
+
layout.dirty_from = layout.dirty_from.min(index);
286
+
}
287
+
let block = &mut self.blocks[index];
288
+
if seq < block.seq {
289
+
block.seq = seq;
290
+
self.sorted = false;
291
+
}
292
+
block.entry = entry;
293
+
block.revision += 1;
294
+
} else {
295
+
for layout in &mut self.layouts {
296
+
layout.dirty_from = layout.dirty_from.min(self.blocks.len());
297
+
}
298
+
if self.blocks.is_empty() {
299
+
self.sorted = true;
300
+
}
301
+
if self.blocks.last().is_some_and(|last| last.seq > seq) {
302
+
self.sorted = false;
303
+
}
304
+
self.lookup.insert(key.clone(), self.blocks.len());
305
+
self.blocks.push(Block {
306
+
key,
307
+
seq,
308
+
entry,
309
+
revision: 1,
310
+
});
311
+
}
312
+
}
313
+
314
+
pub fn entry_mut(&mut self, key: &str) -> Option<&mut Entry> {
315
+
let index = *self.lookup.get(key)?;
316
+
for layout in &mut self.layouts {
317
+
layout.dirty_from = layout.dirty_from.min(index);
318
+
}
319
+
let block = &mut self.blocks[index];
320
+
block.revision += 1;
321
+
Some(&mut block.entry)
322
+
}
323
+
324
+
pub fn entry(&self, key: &str) -> Option<&Entry> {
325
+
Some(&self.blocks[*self.lookup.get(key)?].entry)
326
+
}
327
+
328
+
pub fn remove(&mut self, key: &str) {
329
+
if let Some(index) = self.lookup.remove(key) {
330
+
self.blocks.remove(index);
331
+
self.sorted = false;
332
+
self.lookup = self
333
+
.blocks
334
+
.iter()
335
+
.enumerate()
336
+
.map(|(i, block)| (block.key.clone(), i))
337
+
.collect();
338
+
}
339
+
}
340
+
341
+
fn layout(&mut self, format: Format) -> &Layout {
342
+
if !self.sorted {
343
+
self.blocks.sort_by_key(|block| block.seq);
344
+
self.lookup = self
345
+
.blocks
346
+
.iter()
347
+
.enumerate()
348
+
.map(|(i, block)| (block.key.clone(), i))
349
+
.collect();
350
+
self.layouts.clear();
351
+
self.sorted = true;
352
+
}
353
+
let index = self
354
+
.layouts
355
+
.iter()
356
+
.position(|layout| layout.format == Some(format));
357
+
let mut layout = index
358
+
.map(|i| self.layouts.remove(i))
359
+
.unwrap_or_else(|| Layout {
360
+
format: Some(format),
361
+
..Layout::default()
362
+
});
363
+
// Only changed blocks are formatted. Offscreen rows are never cloned into a frame.
364
+
let mut changed = false;
365
+
for (i, block) in self.blocks.iter().enumerate().skip(layout.dirty_from) {
366
+
if layout.revisions.get(i) != Some(&block.revision) {
367
+
let rows = block.entry.rows(format);
368
+
if i == layout.rows.len() {
369
+
layout.rows.push(rows);
370
+
layout.revisions.push(block.revision);
371
+
layout.ends.push(0);
372
+
} else {
373
+
layout.rows[i] = rows;
374
+
layout.revisions[i] = block.revision;
375
+
}
376
+
changed = true;
377
+
}
378
+
if changed {
379
+
layout.ends[i] =
380
+
layout.ends.get(i.wrapping_sub(1)).copied().unwrap_or(0) + layout.rows[i].len();
381
+
}
382
+
}
383
+
layout.dirty_from = usize::MAX;
384
+
self.layouts.insert(0, layout);
385
+
self.layouts.truncate(2);
386
+
&self.layouts[0]
387
+
}
388
+
389
+
pub fn len(&mut self, format: Format) -> usize {
390
+
self.layout(format).ends.last().copied().unwrap_or(0)
391
+
}
392
+
393
+
pub fn window(&mut self, format: Format, top: usize, height: usize) -> Vec<Line<'static>> {
394
+
let layout = self.layout(format);
395
+
let first = layout.ends.partition_point(|end| *end <= top);
396
+
let mut offset =
397
+
top.saturating_sub(layout.ends.get(first.wrapping_sub(1)).copied().unwrap_or(0));
398
+
let mut rows = Vec::with_capacity(height);
399
+
for block in &layout.rows[first..] {
400
+
for row in block.iter().skip(offset).take(height - rows.len()) {
401
+
rows.push(row.clone());
402
+
}
403
+
if rows.len() == height {
404
+
break;
405
+
}
406
+
offset = 0;
407
+
}
408
+
rows
409
+
}
410
+
411
+
pub fn anchor(&mut self, format: Format, row: usize) -> Option<(String, usize)> {
412
+
let layout = self.layout(format);
413
+
let index = layout.ends.partition_point(|end| *end <= row);
414
+
let offset =
415
+
row.saturating_sub(layout.ends.get(index.wrapping_sub(1)).copied().unwrap_or(0));
416
+
self.blocks
417
+
.get(index)
418
+
.map(|block| (block.key.clone(), offset))
419
+
}
420
+
421
+
pub fn locate(&mut self, format: Format, anchor: &(String, usize)) -> Option<usize> {
422
+
self.layout(format);
423
+
let index = *self.lookup.get(&anchor.0)?;
424
+
let layout = &self.layouts[0];
425
+
Some(
426
+
layout.ends.get(index.wrapping_sub(1)).copied().unwrap_or(0)
427
+
+ anchor.1.min(layout.rows[index].len().saturating_sub(1)),
428
+
)
429
+
}
430
+
}
431
+
432
+
#[cfg(test)]
433
+
mod tests {
434
+
use super::*;
435
+
fn format(width: usize) -> Format {
436
+
Format {
437
+
width,
438
+
expanded: false,
439
+
thinking: true,
440
+
}
441
+
}
442
+
#[test]
443
+
fn previews_bound_display_rows_and_keep_errors() {
444
+
let entry = Entry::Cell {
445
+
code: "print(1)".into(),
446
+
state: Some(CellState::Error),
447
+
output: "x".repeat(10000),
448
+
error: Some("SyntaxError: broken".into()),
449
+
truncated: 0,
450
+
trace: None,
451
+
};
452
+
let rows = entry.rows(format(40));
453
+
assert!(rows.len() < 12);
454
+
assert!(rows
455
+
.iter()
456
+
.any(|row| row.to_string().contains("SyntaxError")));
457
+
assert!(rows
458
+
.iter()
459
+
.any(|row| row.to_string().contains("1 line · failed")));
460
+
}
461
+
#[test]
462
+
fn history_and_reflow_keep_the_same_record_anchor() {
463
+
let mut transcript = Transcript::default();
464
+
for i in 10..110 {
465
+
transcript.upsert(
466
+
i.to_string(),
467
+
i,
468
+
Entry::Text {
469
+
label: "you".into(),
470
+
body: format!("row {i} {}", "context ".repeat(30)),
471
+
muted: false,
472
+
},
473
+
);
474
+
}
475
+
let anchor = transcript.anchor(format(80), 50).unwrap();
476
+
for i in 0..10 {
477
+
transcript.upsert(
478
+
i.to_string(),
479
+
i,
480
+
Entry::Note {
481
+
text: format!("older {i}"),
482
+
error: false,
483
+
},
484
+
);
485
+
}
486
+
let position = transcript.locate(format(40), &anchor).unwrap();
487
+
assert_eq!(transcript.anchor(format(40), position).unwrap().0, anchor.0);
488
+
let rows = transcript.window(format(40), position, 15);
489
+
assert_eq!(rows.len(), 15);
490
+
assert!(rows.iter().all(|row| row.width() <= 40));
491
+
}
492
+
}
+152
klbr-tui/src/transport.rs
+152
klbr-tui/src/transport.rs
···
1
+
use futures::{SinkExt, StreamExt};
2
+
use klbr_ipc::v2::{V2ClientMsg, V2ServerMsg};
3
+
use std::{
4
+
sync::{
5
+
atomic::{AtomicI64, Ordering},
6
+
Arc,
7
+
},
8
+
time::{Duration, Instant},
9
+
};
10
+
use tokio::{
11
+
sync::{mpsc, watch},
12
+
time::{sleep, timeout},
13
+
};
14
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
15
+
16
+
#[derive(Clone)]
17
+
pub struct Target {
18
+
pub epoch: u64,
19
+
pub session: String,
20
+
pub cursor: Arc<AtomicI64>,
21
+
}
22
+
pub enum Event {
23
+
Connected { connection: u64 },
24
+
Disconnected(String),
25
+
Message(V2ServerMsg),
26
+
}
27
+
pub struct Envelope {
28
+
pub epoch: u64,
29
+
pub event: Event,
30
+
}
31
+
pub struct Command {
32
+
pub epoch: u64,
33
+
pub connection: u64,
34
+
pub message: V2ClientMsg,
35
+
}
36
+
37
+
/// The socket owns neither conversation state nor retry authority. Only subscription cursors replay.
38
+
pub async fn run(
39
+
url: String,
40
+
mut target: watch::Receiver<Target>,
41
+
mut commands: mpsc::Receiver<Command>,
42
+
events: mpsc::Sender<Envelope>,
43
+
) {
44
+
let mut connection = 0;
45
+
let mut backoff = Duration::from_millis(200);
46
+
loop {
47
+
let current = target.borrow_and_update().clone();
48
+
let emit = |event| {
49
+
events.send(Envelope {
50
+
epoch: current.epoch,
51
+
event,
52
+
})
53
+
};
54
+
let connected = tokio::select! {
55
+
biased;
56
+
changed = target.changed() => { if changed.is_err() { return; } continue; }
57
+
result = timeout(Duration::from_secs(5), connect_async(&url)) => result,
58
+
};
59
+
let failure = match connected {
60
+
Ok(Ok((mut socket, _))) => {
61
+
connection += 1;
62
+
let request = V2ClientMsg::Subscribe {
63
+
session_id: current.session.clone(),
64
+
after_seq: Some(current.cursor.load(Ordering::Relaxed)),
65
+
limit: Some(2000),
66
+
};
67
+
let handshake = async {
68
+
send(&mut socket, Message::Text(serde_json::to_string(&request)?)).await?;
69
+
send(
70
+
&mut socket,
71
+
Message::Text(serde_json::to_string(&V2ClientMsg::SessionStatus {
72
+
session_id: current.session.clone(),
73
+
})?),
74
+
)
75
+
.await?;
76
+
anyhow::Ok(())
77
+
}
78
+
.await;
79
+
if let Err(error) = handshake {
80
+
error.to_string()
81
+
} else {
82
+
if emit(Event::Connected { connection }).await.is_err() {
83
+
return;
84
+
}
85
+
backoff = Duration::from_millis(200);
86
+
let mut last_received = Instant::now();
87
+
let mut ping = tokio::time::interval(Duration::from_secs(15));
88
+
ping.tick().await;
89
+
loop {
90
+
tokio::select! {
91
+
biased;
92
+
changed = target.changed() => { if changed.is_err() { return; } break; }
93
+
command = commands.recv() => {
94
+
let Some(command) = command else { return; };
95
+
if command.epoch != current.epoch || command.connection != connection { continue; }
96
+
let text = match serde_json::to_string(&command.message) { Ok(text) => text, Err(_) => continue };
97
+
if let Err(error) = send(&mut socket, Message::Text(text)).await {
98
+
if emit(Event::Disconnected(error.to_string())).await.is_err() { return; }
99
+
break;
100
+
}
101
+
}
102
+
incoming = socket.next() => {
103
+
last_received = Instant::now();
104
+
match incoming {
105
+
Some(Ok(Message::Text(text))) => match serde_json::from_str::<V2ServerMsg>(&text) {
106
+
Ok(message) => { if emit(Event::Message(message)).await.is_err() { return; } }
107
+
Err(error) => {
108
+
if emit(Event::Message(V2ServerMsg::Error { code: "protocol".into(), message: format!("invalid server frame: {error}") })).await.is_err() { return; }
109
+
}
110
+
},
111
+
Some(Ok(Message::Ping(data))) => { if send(&mut socket, Message::Pong(data)).await.is_err() { break; } }
112
+
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
113
+
_ => {}
114
+
}
115
+
}
116
+
_ = ping.tick() => {
117
+
if last_received.elapsed() > Duration::from_secs(45) { break; }
118
+
if send(&mut socket, Message::Text(serde_json::to_string(&V2ClientMsg::SessionStatus { session_id: current.session.clone() }).expect("serializable request"))).await.is_err() { break; }
119
+
}
120
+
}
121
+
}
122
+
if target.has_changed().unwrap_or(true) {
123
+
continue;
124
+
}
125
+
"event stream ended".into()
126
+
}
127
+
}
128
+
Ok(Err(error)) => error.to_string(),
129
+
Err(_) => "connection timed out".into(),
130
+
};
131
+
if emit(Event::Disconnected(failure)).await.is_err() {
132
+
return;
133
+
}
134
+
// A queued message from the old connection is uncertain, never a reconnect retry.
135
+
while commands.try_recv().is_ok() {}
136
+
tokio::select! {
137
+
changed = target.changed() => { if changed.is_err() { return; } }
138
+
_ = sleep(backoff) => {}
139
+
}
140
+
backoff = (backoff * 2).min(Duration::from_secs(3));
141
+
}
142
+
}
143
+
144
+
async fn send(
145
+
socket: &mut tokio_tungstenite::WebSocketStream<
146
+
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
147
+
>,
148
+
message: Message,
149
+
) -> anyhow::Result<()> {
150
+
timeout(Duration::from_secs(3), socket.send(message)).await??;
151
+
Ok(())
152
+
}
+367
klbr-tui/src/view.rs
+367
klbr-tui/src/view.rs
···
1
+
//! Source port of niri/chat.tsx and transcript.ts; only protocol-specific lanes differ.
2
+
use crate::{
3
+
app::{App, Connection, Phase},
4
+
text::{clip, clip_middle, clip_tail, ACCENT, ERROR, MUTED},
5
+
};
6
+
use ratatui::{
7
+
layout::{Alignment, Constraint, Layout, Rect},
8
+
style::{Color, Modifier, Style},
9
+
text::{Line, Span},
10
+
widgets::{Clear, Paragraph},
11
+
Frame,
12
+
};
13
+
use unicode_segmentation::UnicodeSegmentation;
14
+
use unicode_width::UnicodeWidthStr;
15
+
16
+
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
+
18
+
/// The action is a transcript tail, not a second footer or a durable execution claim.
19
+
fn progress(app: &mut App, width: usize, tick: usize) -> Option<Line<'static>> {
20
+
if app.connection != Connection::Live {
21
+
return None;
22
+
}
23
+
let generating = app.phase == Phase::Generating;
24
+
if !generating && !matches!(app.phase, Phase::Running(_)) {
25
+
return None;
26
+
}
27
+
let intent = if generating {
28
+
app.generation.intent()
29
+
} else {
30
+
app.running_intent.as_ref()
31
+
};
32
+
let label = if let Some(intent) = intent {
33
+
let verb = match intent.kind {
34
+
"write" => "writing",
35
+
"edit" => "editing",
36
+
"read" => "reading",
37
+
"list" => "listing",
38
+
"search" => "searching",
39
+
_ if generating => "preparing",
40
+
_ => "running",
41
+
};
42
+
format!("{verb} {}", intent.target)
43
+
} else if generating {
44
+
"making a python call".into()
45
+
} else {
46
+
"running python".into()
47
+
};
48
+
let code = if generating {
49
+
app.generation.code()
50
+
} else {
51
+
""
52
+
};
53
+
let code_width = if code.is_empty() {
54
+
0
55
+
} else {
56
+
width.saturating_sub(5 + label.width().min(width.saturating_sub(5) / 2))
57
+
};
58
+
let label_width = width.saturating_sub(2 + if code_width > 0 { code_width + 3 } else { 0 });
59
+
let mut spans = vec![
60
+
Span::styled(
61
+
format!("{} ", SPINNER[tick % 10]),
62
+
Style::default().fg(Color::Cyan),
63
+
),
64
+
Span::styled(clip(&label, label_width), Style::default().fg(Color::Gray)),
65
+
];
66
+
if code_width > 0 {
67
+
spans.push(Span::styled(" · ", Style::default().fg(MUTED)));
68
+
spans.push(Span::styled(
69
+
app.code_line.advance(code, code_width).to_owned(),
70
+
Style::default().fg(Color::Gray),
71
+
));
72
+
}
73
+
Some(Line::from(spans))
74
+
}
75
+
76
+
/// A multiline draft uses niri's single horizontal composing row, with an inverse cursor.
77
+
fn composer(app: &App, width: usize) -> Line<'static> {
78
+
if width == 0 {
79
+
return Line::default();
80
+
}
81
+
let ratatui_textarea::DataCursor(row, column) = app.editor.cursor();
82
+
let cursor = app
83
+
.editor
84
+
.lines()
85
+
.iter()
86
+
.take(row)
87
+
.map(|s| s.chars().count() + 1)
88
+
.sum::<usize>()
89
+
+ column;
90
+
let text = app.editor.lines().join(" ");
91
+
let before = text.chars().take(cursor).collect::<String>();
92
+
let after = text.chars().skip(cursor).collect::<String>();
93
+
let mut graphemes = after.graphemes(true);
94
+
let caret = graphemes.next().unwrap_or(" ");
95
+
let mut used = caret.width().min(width);
96
+
let left = before
97
+
.graphemes(true)
98
+
.rev()
99
+
.take_while(|g| {
100
+
used += g.width();
101
+
used <= width
102
+
})
103
+
.collect::<Vec<_>>()
104
+
.into_iter()
105
+
.rev()
106
+
.collect::<String>();
107
+
used = left.width() + caret.width();
108
+
let right = graphemes
109
+
.take_while(|g| {
110
+
used += g.width();
111
+
used <= width
112
+
})
113
+
.collect::<String>();
114
+
Line::from(vec![
115
+
Span::raw(left),
116
+
Span::styled(
117
+
if caret.width() <= width {
118
+
caret.to_owned()
119
+
} else {
120
+
" ".into()
121
+
},
122
+
Style::default().add_modifier(Modifier::REVERSED),
123
+
),
124
+
Span::raw(right),
125
+
])
126
+
}
127
+
128
+
pub fn draw(frame: &mut Frame, app: &mut App, tick: usize) {
129
+
let full = frame.area();
130
+
let padding = (if full.width >= 50 { 2 } else { 1 }).min(full.width / 2);
131
+
let area = Rect::new(
132
+
full.x + padding,
133
+
full.y,
134
+
full.width.saturating_sub(padding * 2),
135
+
full.height,
136
+
);
137
+
let draft = app.editor.lines().join("\n");
138
+
let matches = app.menu.matches(&draft);
139
+
let selected = app.menu.selected.min(matches.len().saturating_sub(1));
140
+
let menu_start = selected.saturating_sub(3);
141
+
let menu_height = matches
142
+
.len()
143
+
.min(4)
144
+
.min(area.height.saturating_sub(9) as usize);
145
+
let layout = Layout::vertical([
146
+
Constraint::Length(2),
147
+
Constraint::Min(0),
148
+
Constraint::Length(1),
149
+
Constraint::Length(1),
150
+
Constraint::Length(1),
151
+
Constraint::Length(menu_height as u16),
152
+
Constraint::Length(1),
153
+
])
154
+
.split(area);
155
+
let model = app.model.as_deref().unwrap_or("");
156
+
let model_width = model.width().min(area.width.min(100) as usize / 2);
157
+
let header_width = (area.width as usize).saturating_sub(model_width + 2);
158
+
frame.render_widget(
159
+
Line::from(vec![
160
+
Span::styled("klbr ", Style::default().add_modifier(Modifier::BOLD)),
161
+
Span::styled(
162
+
clip_middle(&app.session, header_width.saturating_sub(6)),
163
+
Style::default().fg(MUTED),
164
+
),
165
+
]),
166
+
Rect {
167
+
height: 1,
168
+
width: header_width as u16,
169
+
..layout[0]
170
+
},
171
+
);
172
+
frame.render_widget(
173
+
Paragraph::new(clip_tail(model, model_width))
174
+
.style(Style::default().fg(MUTED))
175
+
.alignment(Alignment::Right),
176
+
Rect {
177
+
x: area.right().saturating_sub(model_width as u16),
178
+
width: model_width as u16,
179
+
height: 1,
180
+
..layout[0]
181
+
},
182
+
);
183
+
let transcript = Rect {
184
+
width: layout[1].width.min(100),
185
+
..layout[1]
186
+
};
187
+
let action = progress(app, transcript.width as usize, tick);
188
+
let live = action.is_some();
189
+
app.live_rows = action.into_iter().collect();
190
+
let (rows, below) = app.viewport(transcript.width as usize, transcript.height as usize);
191
+
let rows = if rows.is_empty() {
192
+
vec![Line::styled(
193
+
"what are we working on?",
194
+
Style::default().fg(MUTED),
195
+
)]
196
+
} else {
197
+
rows
198
+
};
199
+
frame.render_widget(Paragraph::new(rows), transcript);
200
+
let busy = app.busy() && !matches!(app.connection, Connection::Disconnected(_));
201
+
let spinner = if busy && (!live || below > 0) {
202
+
format!("{} ", SPINNER[tick % 10])
203
+
} else {
204
+
String::new()
205
+
};
206
+
let status = if app.pending.iter().any(|p| p.uncertain) {
207
+
"delivery unconfirmed · /retry uses the same request id".into()
208
+
} else if app.loading_history {
209
+
"loading history…".into()
210
+
} else if below > 0 {
211
+
format!("{spinner}history · {below} rows below · pgdn")
212
+
} else {
213
+
format!("{spinner}{}", app.phase_label())
214
+
};
215
+
let stats = if full.width >= 60 && below == 0 && app.phase != Phase::Failed {
216
+
app.started
217
+
.filter(|_| app.busy())
218
+
.map(|start| format!("{:.1}s", start.elapsed().as_secs_f64()))
219
+
.unwrap_or_default()
220
+
} else {
221
+
String::new()
222
+
};
223
+
let color =
224
+
if app.phase == Phase::Failed || matches!(app.connection, Connection::Disconnected(_)) {
225
+
ERROR
226
+
} else {
227
+
MUTED
228
+
};
229
+
let status_width = layout[2].width.saturating_sub(if stats.is_empty() {
230
+
0
231
+
} else {
232
+
stats.len() as u16 + 2
233
+
});
234
+
frame.render_widget(
235
+
Line::styled(
236
+
clip(&status, status_width as usize),
237
+
Style::default().fg(color),
238
+
),
239
+
Rect {
240
+
width: status_width,
241
+
..layout[2]
242
+
},
243
+
);
244
+
frame.render_widget(
245
+
Paragraph::new(stats)
246
+
.alignment(Alignment::Right)
247
+
.style(Style::default().fg(MUTED)),
248
+
Rect {
249
+
x: layout[2].x + status_width,
250
+
width: layout[2].width - status_width,
251
+
..layout[2]
252
+
},
253
+
);
254
+
frame.render_widget(
255
+
Line::styled(
256
+
"─".repeat(layout[3].width as usize),
257
+
Style::default().fg(MUTED),
258
+
),
259
+
layout[3],
260
+
);
261
+
frame.render_widget(Line::styled("›", Style::default().fg(ACCENT)), layout[4]);
262
+
let input = Rect {
263
+
x: layout[4].x.saturating_add(2),
264
+
width: layout[4].width.saturating_sub(2),
265
+
..layout[4]
266
+
};
267
+
frame.render_widget(composer(app, input.width as usize), input);
268
+
for (offset, (name, description)) in matches
269
+
.iter()
270
+
.skip(menu_start)
271
+
.take(menu_height)
272
+
.enumerate()
273
+
{
274
+
frame.render_widget(
275
+
Line::styled(
276
+
clip(&format!("{name} {description}"), area.width as usize),
277
+
if menu_start + offset == selected {
278
+
Style::default().add_modifier(Modifier::REVERSED)
279
+
} else {
280
+
Style::default()
281
+
},
282
+
),
283
+
Rect {
284
+
y: layout[5].y + offset as u16,
285
+
height: 1,
286
+
..layout[5]
287
+
},
288
+
);
289
+
}
290
+
let mut footer = "/ commands · scroll history".to_owned();
291
+
if app.busy() {
292
+
footer.push_str(" · esc stop");
293
+
}
294
+
if !app.thinking {
295
+
footer.push_str(" · thinking hidden");
296
+
}
297
+
if app.expanded {
298
+
footer.push_str(" · tools expanded");
299
+
}
300
+
if full.width >= 75 {
301
+
footer.push_str(" · ← sessions · ctrl+n new");
302
+
}
303
+
frame.render_widget(
304
+
Line::styled(
305
+
clip(&footer, area.width as usize),
306
+
Style::default().fg(MUTED),
307
+
),
308
+
layout[6],
309
+
);
310
+
if app.help || app.sessions.is_some() {
311
+
let dialog = Rect {
312
+
y: area.y + 2,
313
+
height: area.height.saturating_sub(5),
314
+
..area
315
+
};
316
+
frame.render_widget(Clear, dialog);
317
+
let lines = if let Some(sessions) = &app.sessions {
318
+
let mut lines = vec![
319
+
Line::styled("sessions", Style::default().add_modifier(Modifier::BOLD)),
320
+
Line::default(),
321
+
];
322
+
let visible = dialog.height.saturating_sub(4) as usize;
323
+
let start = app.selected.saturating_sub(visible.saturating_sub(1));
324
+
for (i, (id, state)) in sessions.iter().enumerate().skip(start).take(visible) {
325
+
lines.push(Line::styled(
326
+
clip(
327
+
&format!(
328
+
"{} {id} {state}",
329
+
if i == app.selected { "›" } else { " " }
330
+
),
331
+
dialog.width as usize,
332
+
),
333
+
Style::default().fg(if i == app.selected { ACCENT } else { MUTED }),
334
+
));
335
+
}
336
+
if sessions.is_empty() {
337
+
lines.push(Line::raw("no sessions yet · /session <name> to choose one"));
338
+
}
339
+
lines.push(Line::default());
340
+
lines.push(Line::styled(
341
+
"↑↓ select · enter open · esc close",
342
+
Style::default().fg(MUTED),
343
+
));
344
+
lines
345
+
} else {
346
+
[
347
+
"commands",
348
+
"",
349
+
"/session <name> open a session (created on first input)",
350
+
"/sessions choose an existing session",
351
+
"/t show/hide thinking and scratchpad",
352
+
"/v expand/collapse cells and diffs",
353
+
"/status detailed runtime state and usage",
354
+
"/retry retry unconfirmed input with the same id",
355
+
"/q detach, leaving the daemon running",
356
+
"",
357
+
"enter send · alt+enter or ctrl+j newline",
358
+
"pgup/pgdn or wheel scroll · ctrl+end return to live",
359
+
"esc interrupt the turn, or close this help",
360
+
]
361
+
.into_iter()
362
+
.map(|line| Line::raw(clip(line, dialog.width as usize)))
363
+
.collect()
364
+
};
365
+
frame.render_widget(Paragraph::new(lines), dialog);
366
+
}
367
+
}
+420
klbr-tui/tests/client.rs
+420
klbr-tui/tests/client.rs
···
1
+
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
2
+
use klbr_ipc::v2::*;
3
+
use klbr_tui::{
4
+
app::{Action, App, Connection, Phase},
5
+
input,
6
+
transcript::Entry,
7
+
view,
8
+
};
9
+
use ratatui::{backend::TestBackend, Terminal};
10
+
use serde_json::json;
11
+
12
+
fn app() -> App {
13
+
let mut app = App::new("test".into(), "ws://localhost/ws".into());
14
+
app.connection = Connection::Live;
15
+
app
16
+
}
17
+
fn key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> Vec<Action> {
18
+
input::handle(app, Event::Key(KeyEvent::new(code, modifiers)))
19
+
}
20
+
fn frame(app: &mut App, width: u16, height: u16) -> String {
21
+
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
22
+
terminal.draw(|f| view::draw(f, app, 2)).unwrap();
23
+
terminal
24
+
.backend()
25
+
.buffer()
26
+
.content
27
+
.chunks(width as usize)
28
+
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>())
29
+
.collect::<Vec<_>>()
30
+
.join("\n")
31
+
}
32
+
fn record(app: &mut App, seq: i64, payload: LanePayload) {
33
+
app.receive(V2ServerMsg::Event {
34
+
record: V2EventRecord::new(seq, "test", payload, 0),
35
+
});
36
+
}
37
+
38
+
#[test]
39
+
fn echo_ack_replay_and_explicit_retry_use_identity_not_text() {
40
+
let mut app = app();
41
+
app.editor.insert_str("hello");
42
+
let action = key(&mut app, KeyCode::Enter, KeyModifiers::NONE)
43
+
.pop()
44
+
.unwrap();
45
+
let Action::Send(V2ClientMsg::SendInput {
46
+
client_request_id: Some(id),
47
+
..
48
+
}) = action
49
+
else {
50
+
panic!()
51
+
};
52
+
assert!(frame(&mut app, 80, 24).contains("hello"));
53
+
app.editor.insert_str("next draft");
54
+
app.disconnect("test".into());
55
+
assert_eq!(app.editor.lines(), &["next draft"]);
56
+
assert!(app.pending[0].uncertain);
57
+
app.connection = Connection::Live;
58
+
record(
59
+
&mut app,
60
+
10,
61
+
LanePayload::Input(InputPayload {
62
+
source: "tui".into(),
63
+
text: "hello".into(),
64
+
author: None,
65
+
}),
66
+
);
67
+
app.receive(V2ServerMsg::Ack {
68
+
session_id: "test".into(),
69
+
seq: 10,
70
+
client_request_id: Some(id),
71
+
duplicate: Some(true),
72
+
});
73
+
assert_eq!(frame(&mut app, 80, 24).matches("hello").count(), 1);
74
+
assert!(app.pending.is_empty());
75
+
// A separately committed identical prompt is not a duplicate.
76
+
record(
77
+
&mut app,
78
+
11,
79
+
LanePayload::Input(InputPayload {
80
+
source: "tui".into(),
81
+
text: "hello".into(),
82
+
author: None,
83
+
}),
84
+
);
85
+
assert_eq!(frame(&mut app, 80, 24).matches("hello").count(), 2);
86
+
}
87
+
88
+
#[test]
89
+
fn esc_interrupts_once_paste_never_sends_and_mouse_keeps_history_anchored() {
90
+
let mut app = app();
91
+
app.phase = Phase::Thinking;
92
+
assert!(matches!(
93
+
key(&mut app, KeyCode::Esc, KeyModifiers::NONE).as_slice(),
94
+
[Action::Send(V2ClientMsg::Interrupt { .. })]
95
+
));
96
+
assert!(key(&mut app, KeyCode::Esc, KeyModifiers::NONE).is_empty());
97
+
app.phase = Phase::Failed;
98
+
assert!(matches!(
99
+
key(&mut app, KeyCode::Esc, KeyModifiers::NONE).as_slice(),
100
+
[Action::Send(V2ClientMsg::Interrupt { .. })]
101
+
));
102
+
assert!(matches!(
103
+
key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).as_slice(),
104
+
[Action::Quit]
105
+
));
106
+
assert!(input::handle(&mut app, Event::Paste("two\nlines\x1b".into())).is_empty());
107
+
assert_eq!(app.editor.lines(), &["two", "lines"]);
108
+
for seq in 1..80 {
109
+
record(
110
+
&mut app,
111
+
seq,
112
+
LanePayload::Input(InputPayload {
113
+
source: "tui".into(),
114
+
text: format!("prompt {seq}"),
115
+
author: None,
116
+
}),
117
+
);
118
+
}
119
+
frame(&mut app, 80, 24);
120
+
input::handle(
121
+
&mut app,
122
+
Event::Mouse(MouseEvent {
123
+
kind: MouseEventKind::ScrollUp,
124
+
column: 3,
125
+
row: 4,
126
+
modifiers: KeyModifiers::NONE,
127
+
}),
128
+
);
129
+
let anchor = app.anchor.clone().unwrap();
130
+
record(
131
+
&mut app,
132
+
90,
133
+
LanePayload::Input(InputPayload {
134
+
source: "tui".into(),
135
+
text: "newest".into(),
136
+
author: None,
137
+
}),
138
+
);
139
+
frame(&mut app, 40, 18);
140
+
assert_eq!(app.anchor.as_ref(), Some(&anchor));
141
+
key(&mut app, KeyCode::End, KeyModifiers::CONTROL);
142
+
assert!(app.anchor.is_none());
143
+
}
144
+
145
+
#[test]
146
+
fn live_lanes_errors_and_small_terminals_remain_honest() {
147
+
let mut app = app();
148
+
app.receive(V2ServerMsg::LiveChunk {
149
+
session_id: "test".into(),
150
+
lane: Lane::Scratchpad,
151
+
execution_id: None,
152
+
stream: Some("thought".into()),
153
+
delta: "considering the edit".into(),
154
+
});
155
+
let thought = frame(&mut app, 80, 24);
156
+
assert!(thought.contains("considering the edit"));
157
+
assert!(thought.contains("⠹ thinking"));
158
+
app.receive(V2ServerMsg::LiveChunk {
159
+
session_id: "test".into(),
160
+
lane: Lane::Cell,
161
+
execution_id: None,
162
+
stream: Some("starting".into()),
163
+
delta: String::new(),
164
+
});
165
+
app.receive(V2ServerMsg::LiveChunk {
166
+
session_id: "test".into(),
167
+
lane: Lane::Cell,
168
+
execution_id: None,
169
+
stream: Some("arguments".into()),
170
+
delta: json!({"index":0,"arguments":"{\"code\":\"await f.write('hello"}).to_string(),
171
+
});
172
+
assert!(frame(&mut app, 80, 24).contains("await f.write('hello"));
173
+
record(
174
+
&mut app,
175
+
2,
176
+
LanePayload::Speech(SpeechPayload::Held {
177
+
effect_id: "e".into(),
178
+
text: "not delivered".into(),
179
+
status: "held".into(),
180
+
reason: "guard".into(),
181
+
}),
182
+
);
183
+
app.thinking = false;
184
+
assert!(frame(&mut app, 80, 24).contains("not delivered"));
185
+
record(
186
+
&mut app,
187
+
3,
188
+
LanePayload::Lifecycle(LifecyclePayload {
189
+
kind: "turn.failed".into(),
190
+
data: json!({"error":"provider refused"}),
191
+
}),
192
+
);
193
+
assert!(frame(&mut app, 80, 24).contains("provider refused"));
194
+
for (w, h) in [(40, 18), (20, 8), (4, 3), (1, 1)] {
195
+
frame(&mut app, w, h);
196
+
app.help = true;
197
+
frame(&mut app, w, h);
198
+
app.help = false;
199
+
}
200
+
}
201
+
202
+
#[test]
203
+
fn backfilled_cell_start_preserves_completion_and_trace() {
204
+
let mut app = app();
205
+
record(
206
+
&mut app,
207
+
12,
208
+
LanePayload::Cell(CellPayload::Completed {
209
+
execution_id: "x".into(),
210
+
state: CellState::Error,
211
+
output: vec![],
212
+
truncated_bytes: 0,
213
+
error: Some("bad cell".into()),
214
+
trace: Some(CellTrace::default()),
215
+
}),
216
+
);
217
+
record(
218
+
&mut app,
219
+
10,
220
+
LanePayload::Cell(CellPayload::Started {
221
+
execution_id: "x".into(),
222
+
generation: "g".into(),
223
+
revision: "r".into(),
224
+
code: "raise ValueError()".into(),
225
+
}),
226
+
);
227
+
assert!(
228
+
matches!(app.transcript.entry("cell:x"), Some(Entry::Cell { state: Some(CellState::Error), trace: Some(_), code, .. }) if code == "raise ValueError()")
229
+
);
230
+
assert_eq!(app.cursor, 12);
231
+
}
232
+
233
+
#[test]
234
+
fn unexecuted_tools_keep_their_durable_error_without_duplicating_cells() {
235
+
let mut app = app();
236
+
for (seq, status) in [(1, "completed"), (2, "error"), (3, "skipped")] {
237
+
app.phase = Phase::Generating;
238
+
record(
239
+
&mut app,
240
+
seq,
241
+
LanePayload::Lifecycle(LifecyclePayload {
242
+
kind: "tool.result".into(),
243
+
data: json!({"execution_id": null, "status": status, "content": format!("diagnostic {seq}")}),
244
+
}),
245
+
);
246
+
assert_eq!(app.phase, Phase::Thinking);
247
+
assert!(frame(&mut app, 80, 24).contains(&format!("diagnostic {seq}")));
248
+
}
249
+
record(
250
+
&mut app,
251
+
4,
252
+
LanePayload::Lifecycle(LifecyclePayload {
253
+
kind: "tool.result".into(),
254
+
data: json!({"execution_id": "real-cell", "status": "completed", "content": "already in the cell"}),
255
+
}),
256
+
);
257
+
assert!(!frame(&mut app, 80, 24).contains("already in the cell"));
258
+
}
259
+
260
+
#[test]
261
+
fn niri_live_action_follows_files_then_becomes_the_actual_diff() {
262
+
let mut app = app();
263
+
record(
264
+
&mut app,
265
+
1,
266
+
LanePayload::Input(InputPayload {
267
+
source: "tui".into(),
268
+
text: "edit two files".into(),
269
+
author: None,
270
+
}),
271
+
);
272
+
let first = "f = await open_file('index.html')\nawait f.write('''<html>";
273
+
let second="f = await open_file('index.html')\nawait f.write('''<html>''')\nf = await open_file('styles.css')\nawait f.write('''body {";
274
+
let mut previous = String::new();
275
+
for (code, expected) in [
276
+
(first, "writing index.html"),
277
+
(second, "writing styles.css"),
278
+
] {
279
+
let wire = json!({"code":code}).to_string();
280
+
let partial = &wire[..wire.len() - 2];
281
+
app.receive(V2ServerMsg::LiveChunk {
282
+
session_id: "test".into(),
283
+
lane: Lane::Cell,
284
+
execution_id: None,
285
+
stream: Some("arguments".into()),
286
+
delta: json!({"index":0,"arguments":&partial[previous.len()..]}).to_string(),
287
+
});
288
+
previous = partial.to_owned();
289
+
let screen = frame(&mut app, 100, 30);
290
+
let row = screen
291
+
.lines()
292
+
.find(|row| row.contains(expected))
293
+
.expect(&screen);
294
+
assert!(row.contains("⠹") && row.contains(" · "));
295
+
assert_eq!(screen.matches(expected).count(), 1);
296
+
assert!(screen.contains("generating call"));
297
+
assert!(!screen.contains("python ·"));
298
+
assert!(
299
+
screen
300
+
.lines()
301
+
.skip(10)
302
+
.take(15)
303
+
.all(|l| l.trim().is_empty()),
304
+
"action belongs beside the transcript, not pinned to the footer"
305
+
);
306
+
}
307
+
let code = format!("{second}}}''')");
308
+
record(
309
+
&mut app,
310
+
2,
311
+
LanePayload::Cell(CellPayload::Started {
312
+
execution_id: "x".into(),
313
+
generation: "g".into(),
314
+
revision: "r".into(),
315
+
code,
316
+
}),
317
+
);
318
+
assert!(frame(&mut app, 100, 30).contains("writing styles.css"));
319
+
record(
320
+
&mut app,
321
+
3,
322
+
LanePayload::Cell(CellPayload::Completed {
323
+
execution_id: "x".into(),
324
+
state: CellState::Ok,
325
+
output: vec![CellStreamOutput {
326
+
stream: CellStreamKind::Stdout,
327
+
text: "noisy raw result".into(),
328
+
}],
329
+
error: None,
330
+
truncated_bytes: 0,
331
+
trace: Some(CellTrace {
332
+
activities: vec![],
333
+
changes: vec![FileChange {
334
+
path: "styles.css".into(),
335
+
diff: "+body {}".into(),
336
+
added: 1,
337
+
removed: 0,
338
+
truncated: false,
339
+
}],
340
+
truncated: false,
341
+
}),
342
+
}),
343
+
);
344
+
let done = frame(&mut app, 100, 30);
345
+
assert!(done.contains("edited styles.css +1 −0"));
346
+
assert!(!done.contains("writing styles.css") && !done.contains("noisy raw result"));
347
+
app.expanded = true;
348
+
let expanded = frame(&mut app, 100, 30);
349
+
assert!(expanded.contains("noisy raw result") && expanded.contains("┌─ python"));
350
+
}
351
+
352
+
#[test]
353
+
fn niri_menu_composer_geometry_and_escape_precedence() {
354
+
let mut app = app();
355
+
app.phase = Phase::Thinking;
356
+
app.editor.insert_str("/");
357
+
let screen = frame(&mut app, 80, 24);
358
+
assert!(screen.contains("/sessions browse sessions"));
359
+
assert!(screen.lines().nth(18).unwrap().contains('›'));
360
+
assert!(key(&mut app, KeyCode::Esc, KeyModifiers::NONE).is_empty());
361
+
assert_eq!(app.phase, Phase::Thinking);
362
+
assert!(!frame(&mut app, 80, 24).contains("browse sessions"));
363
+
assert!(matches!(
364
+
key(&mut app, KeyCode::Esc, KeyModifiers::NONE).as_slice(),
365
+
[Action::Send(V2ClientMsg::Interrupt { .. })]
366
+
));
367
+
app.replace_draft("/v");
368
+
key(&mut app, KeyCode::Enter, KeyModifiers::NONE);
369
+
assert!(app.expanded);
370
+
input::handle(&mut app, Event::Paste("first line\nsecond line".into()));
371
+
let draft = frame(&mut app, 80, 24);
372
+
assert!(draft
373
+
.lines()
374
+
.nth(22)
375
+
.unwrap()
376
+
.contains("first line second line"));
377
+
key(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
378
+
assert_eq!(app.editor.lines(), &[""]);
379
+
assert!(matches!(
380
+
key(&mut app, KeyCode::Left, KeyModifiers::NONE).as_slice(),
381
+
[Action::Send(V2ClientMsg::SessionList)]
382
+
));
383
+
app.replace_draft("界".repeat(100).as_str());
384
+
frame(&mut app, 40, 18);
385
+
}
386
+
387
+
#[test]
388
+
fn niri_empty_header_thresholds_and_busy_history_feedback() {
389
+
let mut app = app();
390
+
assert!(frame(&mut app, 80, 24).contains("what are we working on?"));
391
+
app.model = Some(format!("{}model-suffix", "provider/".repeat(20)));
392
+
let header = frame(&mut app, 180, 24).lines().next().unwrap().to_owned();
393
+
assert!(header.ends_with("model-suffix "));
394
+
assert!(header.contains('…'));
395
+
app.model = None;
396
+
assert!(!frame(&mut app, 74, 24).contains("← sessions"));
397
+
assert!(frame(&mut app, 75, 24).contains("← sessions"));
398
+
app.replace_draft("/");
399
+
let small = frame(&mut app, 80, 11);
400
+
assert!(small.contains("/sessions browse sessions"));
401
+
assert!(!small.contains("/new start a new session"));
402
+
app.replace_draft("");
403
+
for i in 0..40 {
404
+
app.transcript.upsert(
405
+
i.to_string(),
406
+
i,
407
+
Entry::Text {
408
+
label: "you".into(),
409
+
body: format!("message {i}"),
410
+
muted: false,
411
+
},
412
+
);
413
+
}
414
+
app.phase = Phase::Generating;
415
+
frame(&mut app, 80, 24);
416
+
app.scroll(-10);
417
+
let history = frame(&mut app, 80, 24);
418
+
assert!(history.contains("⠹ history"));
419
+
assert!(!history.contains("making a python call"));
420
+
}
+133
klbr-tui/tests/connection.rs
+133
klbr-tui/tests/connection.rs
···
1
+
use futures::{SinkExt, StreamExt};
2
+
use klbr_ipc::v2::{V2ClientMsg, V2ServerMsg};
3
+
use klbr_tui::transport::{self, Command, Event, Target};
4
+
use std::{
5
+
sync::{
6
+
atomic::{AtomicI64, Ordering},
7
+
Arc,
8
+
},
9
+
time::Duration,
10
+
};
11
+
use tokio::{
12
+
net::TcpListener,
13
+
sync::{mpsc, watch},
14
+
time::timeout,
15
+
};
16
+
use tokio_tungstenite::{accept_async, tungstenite::Message};
17
+
18
+
#[tokio::test]
19
+
async fn reconnect_only_replays_history_not_uncertain_input() {
20
+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
21
+
let url = format!("ws://{}", listener.local_addr().unwrap());
22
+
let cursor = Arc::new(AtomicI64::new(0));
23
+
let (_target_tx, target) = watch::channel(Target {
24
+
epoch: 7,
25
+
session: "test".into(),
26
+
cursor: cursor.clone(),
27
+
});
28
+
let (command, commands) = mpsc::channel(64);
29
+
let (events, mut receive) = mpsc::channel(256);
30
+
let task = tokio::spawn(transport::run(url, target, commands, events));
31
+
let mut first = accept_async(listener.accept().await.unwrap().0)
32
+
.await
33
+
.unwrap();
34
+
let request = first.next().await.unwrap().unwrap().into_text().unwrap();
35
+
assert!(matches!(
36
+
serde_json::from_str(&request).unwrap(),
37
+
V2ClientMsg::Subscribe {
38
+
after_seq: Some(0),
39
+
..
40
+
}
41
+
));
42
+
first.next().await.unwrap().unwrap(); // status
43
+
let Event::Connected { connection } = receive.recv().await.unwrap().event else {
44
+
panic!()
45
+
};
46
+
let input = V2ClientMsg::SendInput {
47
+
session_id: "test".into(),
48
+
source: "tui".into(),
49
+
text: "do not replay".into(),
50
+
client_request_id: Some("stable".into()),
51
+
};
52
+
command
53
+
.send(Command {
54
+
epoch: 7,
55
+
connection,
56
+
message: input.clone(),
57
+
})
58
+
.await
59
+
.unwrap();
60
+
assert!(first
61
+
.next()
62
+
.await
63
+
.unwrap()
64
+
.unwrap()
65
+
.into_text()
66
+
.unwrap()
67
+
.contains("do not replay"));
68
+
cursor.store(42, Ordering::Relaxed);
69
+
first.close(None).await.unwrap();
70
+
drop(first);
71
+
loop {
72
+
if matches!(receive.recv().await.unwrap().event, Event::Disconnected(_)) {
73
+
break;
74
+
}
75
+
}
76
+
// Simulate an input already in flight from the UI when it learns of the lost socket.
77
+
command
78
+
.send(Command {
79
+
epoch: 7,
80
+
connection,
81
+
message: input,
82
+
})
83
+
.await
84
+
.unwrap();
85
+
let mut second = accept_async(
86
+
timeout(Duration::from_secs(3), listener.accept())
87
+
.await
88
+
.unwrap()
89
+
.unwrap()
90
+
.0,
91
+
)
92
+
.await
93
+
.unwrap();
94
+
let request = second.next().await.unwrap().unwrap().into_text().unwrap();
95
+
assert!(matches!(
96
+
serde_json::from_str(&request).unwrap(),
97
+
V2ClientMsg::Subscribe {
98
+
after_seq: Some(42),
99
+
..
100
+
}
101
+
));
102
+
second.next().await.unwrap().unwrap();
103
+
assert!(
104
+
timeout(Duration::from_millis(150), second.next())
105
+
.await
106
+
.is_err(),
107
+
"old input must not cross connection identity"
108
+
);
109
+
second
110
+
.send(Message::Text(
111
+
serde_json::to_string(&V2ServerMsg::Error {
112
+
code: "provider".into(),
113
+
message: "visible refusal".into(),
114
+
})
115
+
.unwrap(),
116
+
))
117
+
.await
118
+
.unwrap();
119
+
loop {
120
+
if let Event::Message(V2ServerMsg::Error { message, .. }) =
121
+
timeout(Duration::from_secs(2), receive.recv())
122
+
.await
123
+
.unwrap()
124
+
.unwrap()
125
+
.event
126
+
{
127
+
assert_eq!(message, "visible refusal");
128
+
break;
129
+
}
130
+
}
131
+
task.abort();
132
+
let _ = task.await;
133
+
}
+7
klbr-web/src/lib/streaming-v2.test.ts
+7
klbr-web/src/lib/streaming-v2.test.ts
···
425
425
expect(cells[0].exit_code).toBe(0);
426
426
});
427
427
428
+
test("generation progress is not executed cell output", () => {
429
+
for (const stream of ["starting", "arguments"]) {
430
+
ws.emitMessage({type: "live_chunk", session_id: "test", lane: "cell", stream, delta: '{"code":"not executed"}'});
431
+
}
432
+
expect(get(client.cellStore)).toEqual([]);
433
+
});
434
+
428
435
test("handles live chunks streaming into active entries", () => {
429
436
ws.emitMessage({
430
437
type: "live_chunk",
+2
klbr-web/src/lib/streaming-v2.ts
+2
klbr-web/src/lib/streaming-v2.ts
···
769
769
return entries;
770
770
});
771
771
} else if (chunk.lane === "cell") {
772
+
// Generation metadata is not executed cell output. Older clients may ignore it.
773
+
if (chunk.stream === "starting" || chunk.stream === "arguments") return;
772
774
const execId = chunk.execution_id || this.activeCellId || `cell_${Date.now()}`;
773
775
this.activeCellId = execId;
774
776
const streamKind = (chunk.stream as "stdout" | "stderr" | "result") || "stdout";
+49
-37
python/klbr-runtime/src/klbr/workspace.py
+49
-37
python/klbr-runtime/src/klbr/workspace.py
···
28
28
from pathlib import Path
29
29
30
30
from . import runtime
31
+
from klbr_runtime import workspace_trace as trace
31
32
32
33
33
34
@dataclass(frozen=True, slots=True)
···
109
110
110
111
async def text(self, encoding: str = "utf-8") -> str:
111
112
"""The file as text, decoded with ``encoding``."""
112
-
return await asyncio.to_thread(self.path.read_text, encoding=encoding)
113
+
result = await asyncio.to_thread(self.path.read_text, encoding=encoding)
114
+
trace.activity("read", self.relative)
115
+
return result
113
116
114
117
async def read(self) -> bytes:
115
118
"""The file as bytes, with no decoding."""
116
-
return await asyncio.to_thread(self.path.read_bytes)
119
+
result = await asyncio.to_thread(self.path.read_bytes)
120
+
trace.activity("read", self.relative)
121
+
return result
117
122
118
123
async def exists(self) -> bool:
119
124
"""Whether anything is at this path right now.
···
134
139
raise TypeError("workspace content must be str or bytes")
135
140
136
141
def write() -> None:
137
-
self.path.parent.mkdir(parents=True, exist_ok=True)
138
-
if isinstance(content, str):
139
-
self.path.write_text(content, encoding=encoding)
140
-
else:
141
-
self.path.write_bytes(content)
142
+
with trace.edit(self.path, self.relative):
143
+
self.path.parent.mkdir(parents=True, exist_ok=True)
144
+
if isinstance(content, str):
145
+
self.path.write_text(content, encoding=encoding)
146
+
else:
147
+
self.path.write_bytes(content)
142
148
143
149
await asyncio.to_thread(write)
144
150
···
152
158
raise ValueError("replacement text must not be empty")
153
159
154
160
def replace() -> None:
155
-
text = self.path.read_text(encoding=encoding)
156
-
count = text.count(old)
157
-
if count != 1:
158
-
raise ValueError(f"expected one occurrence in {self.path}, found {count}")
159
-
self.path.write_text(text.replace(old, new, 1), encoding=encoding)
161
+
with trace.edit(self.path, self.relative):
162
+
text = self.path.read_text(encoding=encoding)
163
+
count = text.count(old)
164
+
if count != 1:
165
+
raise ValueError(f"expected one occurrence in {self.path}, found {count}")
166
+
self.path.write_text(text.replace(old, new, 1), encoding=encoding)
160
167
161
168
await asyncio.to_thread(replace)
162
169
···
185
192
raise ValueError(f"anchor range {start}..{end} runs backwards")
186
193
187
194
def apply() -> None:
188
-
text = self.path.read_text(encoding=encoding)
189
-
lines = text.splitlines()
190
-
for number, expected in ((start, parsed["start_hash"]), (end, parsed["end_hash"] or "")):
191
-
if number > len(lines):
192
-
raise ValueError(
193
-
f"line {number} is past the end of {self.path} ({len(lines)} lines)"
194
-
)
195
-
actual = line_hash(lines[number - 1])
196
-
if expected and actual != expected.lower():
197
-
raise ValueError(
198
-
f"line {number} now hashes {actual}, not {expected.lower()}: "
199
-
f"{self.path} changed since it was read; read it again"
200
-
)
201
-
body = [] if new is None else new.splitlines()
202
-
if after:
203
-
lines[start:start] = body
204
-
else:
205
-
lines[start - 1 : end] = body
206
-
newline = "\r\n" if "\r\n" in text else "\n"
207
-
joined = newline.join(lines)
208
-
self.path.write_text(
209
-
joined + (newline if text.endswith(("\n", "\r\n")) else ""), encoding=encoding
210
-
)
195
+
with trace.edit(self.path, self.relative):
196
+
text = self.path.read_text(encoding=encoding)
197
+
lines = text.splitlines()
198
+
for number, expected in ((start, parsed["start_hash"]), (end, parsed["end_hash"] or "")):
199
+
if number > len(lines):
200
+
raise ValueError(
201
+
f"line {number} is past the end of {self.path} ({len(lines)} lines)"
202
+
)
203
+
actual = line_hash(lines[number - 1])
204
+
if expected and actual != expected.lower():
205
+
raise ValueError(
206
+
f"line {number} now hashes {actual}, not {expected.lower()}: "
207
+
f"{self.path} changed since it was read; read it again"
208
+
)
209
+
body = [] if new is None else new.splitlines()
210
+
if after:
211
+
lines[start:start] = body
212
+
else:
213
+
lines[start - 1 : end] = body
214
+
newline = "\r\n" if "\r\n" in text else "\n"
215
+
joined = newline.join(lines)
216
+
self.path.write_text(
217
+
joined + (newline if text.endswith(("\n", "\r\n")) else ""), encoding=encoding
218
+
)
211
219
212
220
await asyncio.to_thread(apply)
213
221
···
383
391
entries = tuple(sorted(found, key=lambda entry: entry.relative))
384
392
return Listing(self.root, start, entries, tuple(sorted(pruned)), truncated)
385
393
386
-
return await asyncio.to_thread(tree if recursive else one_level)
394
+
result = await asyncio.to_thread(tree if recursive else one_level)
395
+
trace.activity("list", str(path))
396
+
return result
387
397
388
398
async def search(
389
399
self,
···
444
454
offset = position + len(needle)
445
455
return SearchResult(tuple(matches), False, searched, unreadable, listing.pruned)
446
456
447
-
return await asyncio.to_thread(find)
457
+
result = await asyncio.to_thread(find)
458
+
trace.activity("search", f"{needle} in {path}")
459
+
return result
448
460
449
461
async def delete(
450
462
self, path: str | Path, *, force: bool = False, recursive: bool = False
+2
python/klbr-runtime/src/klbr_runtime/context.py
+2
python/klbr-runtime/src/klbr_runtime/context.py
···
9
9
from typing import Any, Literal, get_args
10
10
11
11
from .wire import Wire, fields
12
+
from .workspace_trace import WorkspaceTrace
12
13
13
14
OUTPUT_LIMIT = 65_536
14
15
OUTPUT_RECORD_LIMIT = 256
···
48
49
output: list[dict[str, str]] = field(default_factory=list)
49
50
stored_bytes: int = 0
50
51
truncated_bytes: int = 0
52
+
trace: WorkspaceTrace = field(default_factory=WorkspaceTrace)
51
53
purpose: str = PURPOSE_INTERACTIVE
52
54
53
55
def __post_init__(self) -> None:
+2
-1
python/klbr-runtime/src/klbr_runtime/kernel.py
+2
-1
python/klbr-runtime/src/klbr_runtime/kernel.py
···
86
86
# Catching the yield sentinel does not restore effect authority.
87
87
status = execution.terminal or status
88
88
return {"kind": "cell", "status": status, "output": execution.output,
89
-
"truncated_bytes": execution.truncated_bytes, "error": error}
89
+
"truncated_bytes": execution.truncated_bytes, "error": error,
90
+
"trace": execution.trace.finish()}
+98
python/klbr-runtime/src/klbr_runtime/workspace_trace.py
+98
python/klbr-runtime/src/klbr_runtime/workspace_trace.py
···
1
+
"""Bounded, execution-local observations. Never a filesystem watcher or an effect channel."""
2
+
from __future__ import annotations
3
+
4
+
import difflib
5
+
import threading
6
+
from contextlib import contextmanager
7
+
from dataclasses import dataclass, field
8
+
from pathlib import Path
9
+
10
+
LIMIT = 65_536
11
+
FILES = 16
12
+
LINES = 2_048
13
+
14
+
15
+
def snapshot(path: Path) -> bytes | None:
16
+
try:
17
+
with path.open("rb") as file:
18
+
data = file.read(LIMIT + 1)
19
+
return data if len(data) <= LIMIT else None
20
+
except FileNotFoundError:
21
+
return b""
22
+
except OSError:
23
+
return None
24
+
25
+
26
+
@dataclass
27
+
class WorkspaceTrace:
28
+
activities: list = field(default_factory=list)
29
+
changes: dict = field(default_factory=dict)
30
+
truncated: bool = False
31
+
lock: threading.Lock = field(default_factory=threading.Lock)
32
+
33
+
def activity(self, kind: str, target: str) -> None:
34
+
with self.lock:
35
+
if len(self.activities) < 64:
36
+
self.activities.append({"kind": kind, "target": target[:512]})
37
+
self.truncated |= len(target) > 512
38
+
else:
39
+
self.truncated = True
40
+
41
+
def changed(self, name: str, before: bytes | None, after: bytes | None) -> None:
42
+
if before == after and before is not None:
43
+
return
44
+
with self.lock:
45
+
if name not in self.changes and len(self.changes) >= FILES:
46
+
self.truncated = True
47
+
return
48
+
original = self.changes.get(name, (before, after))[0]
49
+
self.changes[name] = (original, after)
50
+
51
+
def finish(self) -> dict:
52
+
changes, room = [], LIMIT
53
+
with self.lock:
54
+
for path, (before, after) in self.changes.items():
55
+
if before == after and before is not None:
56
+
continue
57
+
try:
58
+
if before is None or after is None or b"\0" in before or b"\0" in after:
59
+
raise ValueError("not bounded text")
60
+
old, new = before.decode("utf-8").splitlines(keepends=True), after.decode("utf-8").splitlines(keepends=True)
61
+
if max(len(old), len(new)) > LINES:
62
+
raise ValueError("too many lines")
63
+
diff = list(difflib.unified_diff(old, new, n=3, lineterm=""))[2:]
64
+
added = sum(line.startswith("+") for line in diff)
65
+
removed = sum(line.startswith("-") for line in diff)
66
+
display = [line.rstrip("\r\n") + ("\n\\ No newline at end of file" if line[:1] in ("+", "-", " ") and not line.endswith(("\n", "\r")) else "") for line in diff]
67
+
encoded = "\n".join(display).encode("utf-8")
68
+
text = encoded[:room].decode("utf-8", errors="ignore")
69
+
truncated = len(encoded) > room
70
+
room -= len(text.encode("utf-8"))
71
+
except (ValueError, UnicodeError):
72
+
text, added, removed, truncated = "", 0, 0, True
73
+
changes.append({"path": path[:512], "diff": text, "added": added,
74
+
"removed": removed, "truncated": truncated})
75
+
return {"activities": list(self.activities), "changes": changes, "truncated": self.truncated}
76
+
77
+
78
+
def active():
79
+
from .context import current
80
+
execution = current.get()
81
+
return execution if execution and execution.open and execution.terminal is None else None
82
+
83
+
84
+
def activity(kind: str, target: str) -> None:
85
+
if execution := active():
86
+
execution.trace.activity(kind, target)
87
+
88
+
89
+
@contextmanager
90
+
def edit(path: Path, name: str):
91
+
execution = active()
92
+
before = snapshot(path) if execution else None
93
+
try:
94
+
yield
95
+
finally:
96
+
# A late thread may finish after interruption. It must not contaminate another cell.
97
+
if execution and execution.open and execution.terminal is None:
98
+
execution.trace.changed(name, before, snapshot(path))
+57
python/klbr-runtime/tests/test_workspace_trace.py
+57
python/klbr-runtime/tests/test_workspace_trace.py
···
1
+
from pathlib import Path
2
+
import unittest
3
+
from peer import Peer
4
+
5
+
6
+
class WorkspaceTraceTests(unittest.IsolatedAsyncioTestCase):
7
+
async def test_receipts_survive_failure_and_next_cell_only_contains_its_own_edit(self):
8
+
async with Peer() as peer:
9
+
root = Path(peer.directory.name)
10
+
result = await peer.execute(f"""
11
+
from pathlib import Path
12
+
from klbr.workspace import Workspace
13
+
w = Workspace(Path({str(root)!r}))
14
+
f = await w.open_file('index.html')
15
+
await f.write('wrong title\\nbody\\n')
16
+
await f.replace('body', 'retained body')
17
+
raise ValueError('intentional failure after writing')
18
+
""")
19
+
self.assertEqual(result['status'], 'error')
20
+
change, = result['trace']['changes']
21
+
self.assertEqual(change['path'], 'index.html')
22
+
self.assertIn('+wrong title', change['diff'])
23
+
self.assertIn('+retained body', change['diff'])
24
+
self.assertEqual(change['added'], 2)
25
+
fixed = await peer.execute("await f.replace('wrong title', 'right title')")
26
+
change, = fixed['trace']['changes']
27
+
self.assertEqual((change['added'], change['removed']), (1, 1))
28
+
self.assertIn('-wrong title', change['diff'])
29
+
self.assertIn('+right title', change['diff'])
30
+
refused = await peer.execute("await f.replace('not present', 'oops')")
31
+
self.assertEqual(refused['status'], 'error')
32
+
self.assertEqual(refused['trace']['changes'], [])
33
+
34
+
async def test_multiple_files_bounds_and_no_phantom_changes(self):
35
+
async with Peer() as peer:
36
+
root = Path(peer.directory.name)
37
+
result = await peer.execute(f"""
38
+
from pathlib import Path
39
+
from klbr.workspace import Workspace
40
+
w = Workspace(Path({str(root)!r}))
41
+
a = await w.open_file('a.txt')
42
+
b = await w.open_file('b.txt')
43
+
await a.write('a\\n')
44
+
await b.write(b'\\x00binary')
45
+
await a.text()
46
+
await w.ls()
47
+
""")
48
+
self.assertEqual(result['status'], 'ok')
49
+
self.assertEqual([c['path'] for c in result['trace']['changes']], ['a.txt', 'b.txt'])
50
+
self.assertTrue(result['trace']['changes'][1]['truncated'])
51
+
self.assertEqual([a['kind'] for a in result['trace']['activities']], ['read', 'list'])
52
+
unchanged = await peer.execute("await a.write('a\\n')")
53
+
self.assertEqual(unchanged['trace']['changes'], [])
54
+
huge = await peer.execute("await a.write('x' * 100000)")
55
+
self.assertTrue(huge['trace']['changes'][0]['truncated'])
56
+
syntax = await peer.execute("await a.write('unterminated")
57
+
self.assertEqual(syntax['trace']['changes'], [])