close
ive harnessed the harness
4

Configure Feed

Select the types of activity you want to include in your feed.

add native tui with live cell progress and diffs #1

Merged opened by okami.mom targeting main from okami.mom/klbr: codex/niri-tui
  • 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/arguments live chunks and optional activity/diff traces from python workspace helpers.
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3rwz3xfw2crswgifqgc3g7zh/sh.tangled.repo.pull/3mvo4xztnco22
+5705 -143
Diff #0
+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
··· 5 5 "klbr-daemon", 6 6 "klbr-discord", 7 7 "klbr-ipc", 8 + "klbr-tui", 8 9 "antigravity-bridge", 9 10 ] 10 11 resolver = "2"
+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
··· 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
··· 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
··· 194 194 ThoughtDelta(String), 195 195 ProseDelta(String), 196 196 ToolCallStarted, 197 + ToolCallDelta { 198 + index: usize, 199 + name: String, 200 + arguments: String, 201 + }, 197 202 ToolCallProposed { 198 203 id: String, 199 204 name: String,
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 28 28 output: vec![], 29 29 truncated_bytes: 0, 30 30 error: None, 31 + trace: None, 31 32 } 32 33 } 33 34 fn publish(path: &Path) -> Result<Release> {
+1
klbr-runtime/tests/worker.rs
··· 178 178 }], 179 179 truncated_bytes: 0, 180 180 error: None, 181 + trace: None, 181 182 }, 182 183 }, 183 184 target: stamp.clone(),
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + pub mod app; 2 + pub mod text; 3 + pub mod transcript; 4 + pub mod transport; 5 + pub mod view; 6 + 7 + pub mod generation; 8 + pub mod input; 9 + 10 + pub mod intent; 11 + 12 + pub mod code_line; 13 + pub mod commands; 14 + 15 + pub mod markdown;
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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'], [])

History

1 round 0 comments
Sign up or Login to add to the discussion
okami.mom submitted #0
1 commit
Expand
add native tui with live cell progress and diffs
Expand 0 comments
Pull request successfully merged