From feb3a51d1ec7142a23f2a588de2d3077e2c3f2e3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Feb 2026 16:51:06 +0100 Subject: [PATCH] feat(deps): upgraded brush-core to 0.4.0 and refactored shell execution with new types - Upgraded brush-core from 0.3.5 to 0.4.0 and added brush-builtins 0.1.0 dependency to support new builtin command framework. - Refactored shell execution to use new ExecutionResult and ExecutionControlFlow types from brush-core 0.4.0, replacing previous ExitCode-based approach. - Updated system_info disk refresh to use new sysinfo 0.38 API with single refresh(true) call instead of separate refresh_list() and refresh() calls. - Added tokio full-featured runtime dependency to support async operations in pi-natives. - Updated phf from 0.11 to 0.13 with macros feature for improved perfect hash function support. - Fixed UTF-16 string handling in text.rs to properly strip trailing null bytes before conversion. --- Cargo.lock | 513 ++++++++++++++++------ crates/pi-natives/Cargo.toml | 8 +- crates/pi-natives/src/shell.rs | 92 ++-- crates/pi-natives/src/system_info.rs | 3 +- crates/pi-natives/src/text.rs | 17 +- packages/coding-agent/src/ipy/executor.ts | 3 +- packages/tui/test/markdown.test.ts | 297 +++++++++++-- packages/tui/test/wrap-ansi.test.ts | 43 +- rust-toolchain.toml | 1 - 9 files changed, 713 insertions(+), 264 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 263731281..dfcafccf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,13 +217,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] -name = "brush-core" -version = "0.3.5" +name = "bon" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb3a9488ef37cd8b951748d861362af5b1d5a815ea325f83c1c4bbb7da5e328" +checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "brush-builtins" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454219f460ee4308ae5b8befc47936b4ef7ac3a94347052270e59b6c6cfe14ec" +dependencies = [ + "brush-core", + "brush-parser", + "cfg-if", + "chrono", + "clap", + "fancy-regex", + "futures", + "itertools", + "nix 0.30.1", + "procfs", + "rlimit", + "strum", + "strum_macros", + "thiserror", + "tokio", + "tracing", + "uucore", +] + +[[package]] +name = "brush-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3ad2f2d4eb45ef7f11e74bc1a0816c8f3dce298072352c6b3b41a2c4bfdb74" dependencies = [ "async-recursion", "async-trait", + "bon", "brush-parser", "cached", "cfg-if", @@ -237,12 +288,9 @@ dependencies = [ "hostname", "indexmap", "itertools", - "nix", + "nix 0.30.1", "normalize-path", - "os_pipe", - "procfs", "rand 0.9.2", - "rlimit", "rpds", "strum", "strum_macros", @@ -250,7 +298,6 @@ dependencies = [ "thiserror", "tokio", "tracing", - "uucore", "uuid", "uzers", "whoami", @@ -258,10 +305,11 @@ dependencies = [ [[package]] name = "brush-parser" -version = "0.2.20" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6924d0efd702b4fe3d65f3a63c03520edfe53b67d0955b9d3f8e66b192a410ff" +checksum = "f7367124d4f38fdcd65f4b815bda7caeb3de377b9cd95ffa1b23627989c93718" dependencies = [ + "bon", "cached", "indenter", "peg", @@ -370,7 +418,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -441,7 +489,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" dependencies = [ - "nix", + "nix 0.30.1", "thiserror", ] @@ -863,8 +911,8 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", - "windows-link 0.2.1", + "rustix", + "windows-link", ] [[package]] @@ -994,14 +1042,14 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "homedir" -version = "0.3.6" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" +checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" dependencies = [ "cfg-if", - "nix", + "nix 0.29.0", "widestring", - "windows 0.61.3", + "windows 0.57.0", ] [[package]] @@ -1012,7 +1060,7 @@ checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ "cfg-if", "libc", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1076,6 +1124,71 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1200,7 +1313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1220,18 +1333,18 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -1401,6 +1514,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -1540,6 +1665,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -1602,7 +1737,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1644,7 +1779,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", "phf_shared 0.11.3", ] @@ -1654,6 +1788,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ + "phf_macros", "phf_shared 0.13.1", "serde", ] @@ -1700,12 +1835,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", @@ -1734,6 +1869,7 @@ name = "pi-natives" version = "9.7.0" dependencies = [ "arboard", + "brush-builtins", "brush-core", "bstr", "clap", @@ -1750,10 +1886,11 @@ dependencies = [ "napi-derive", "os_pipe", "parking_lot", - "phf 0.11.3", + "phf 0.13.1", "rayon", "syntect", "sysinfo", + "tokio", "unicode-segmentation", "unicode-width", ] @@ -1783,6 +1920,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1798,6 +1946,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1809,23 +1967,22 @@ dependencies = [ [[package]] name = "procfs" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ "bitflags", "chrono", "flate2", - "hex", "procfs-core", - "rustix 0.38.44", + "rustix", ] [[package]] name = "procfs-core" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ "bitflags", "chrono", @@ -1997,19 +2154,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.3" @@ -2019,7 +2163,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -2126,6 +2270,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "string_cache" version = "0.9.0" @@ -2185,6 +2345,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syntect" version = "5.3.0" @@ -2205,17 +2376,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.30.13" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +checksum = "fe840c5b1afe259a5657392a4dbb74473a14c8db999c3ec2f4ae812e028a94da" dependencies = [ - "cfg-if", - "core-foundation-sys", "libc", + "memchr", "ntapi", - "once_cell", - "rayon", - "windows 0.52.0", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", ] [[package]] @@ -2235,7 +2405,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.3", + "rustix", "windows-sys 0.60.2", ] @@ -2305,8 +2475,10 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2433,20 +2605,25 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uucore" -version = "0.1.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9032bf981784f22fcc5ddc7e74b7cf3bae3d5f44a48d2054138ed38068b9f4e0" +checksum = "2003164a38a7f39da1de103a70fa66b745f572f0045ec261481539516c0a8a0e" dependencies = [ "bigdecimal", + "bstr", "clap", "fluent", "fluent-bundle", + "fluent-syntax", "glob", + "icu_locale", "itertools", - "nix", + "nix 0.30.1", "num-traits", "number_prefix", "os_display", + "phf 0.13.1", + "procfs", "thiserror", "unic-langid", "uucore_procs", @@ -2455,21 +2632,14 @@ dependencies = [ [[package]] name = "uucore_procs" -version = "0.1.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c933945fdac5b7779eae1fc746146e61f5b0298deb6ede002ce0b6e93e1b3bfc" +checksum = "c76f0308f7810d915246a39748e7f5d64e43e6bb9d6c8107224f9d741aefc375" dependencies = [ "proc-macro2", "quote", - "uuhelp_parser", ] -[[package]] -name = "uuhelp_parser" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beda381dd5c7927f8682f50b055b0903bb694ba5a4b27fad1b4934bc4fbf7b8d" - [[package]] name = "uuid" version = "1.20.0" @@ -2669,82 +2839,82 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.52.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ - "windows-core 0.52.0", + "windows-core 0.57.0", "windows-targets 0.52.6", ] [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core 0.62.2", "windows-future", - "windows-link 0.1.3", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] name = "windows-core" -version = "0.52.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2756,6 +2926,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -2767,12 +2948,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -2781,21 +2956,21 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core 0.62.2", + "windows-link", ] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.52.6", ] [[package]] @@ -2804,16 +2979,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -2822,16 +2988,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -2849,7 +3006,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2874,7 +3031,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -2887,11 +3044,11 @@ dependencies = [ [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -2996,6 +3153,12 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "x11rb" version = "0.13.2" @@ -3003,7 +3166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix", "x11rb-protocol", ] @@ -3023,6 +3186,29 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.37" @@ -3048,6 +3234,30 @@ name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", +] [[package]] name = "zerovec" @@ -3056,7 +3266,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "serde", + "yoke", "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 44d280e99..3ee98d78f 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -13,13 +13,15 @@ crate-type = ["cdylib"] workspace = true [dependencies] +tokio = { version = "1", features = ["full"] } napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" -brush-core = "0.3.5" +brush-core = "0.4.0" +brush-builtins = "0.1.0" parking_lot = "0.12.5" clap = { version = "4", features = ["derive"] } os_pipe = "1" -sysinfo = "0.30" +sysinfo = "0.38" grep-regex = "0.1" grep-searcher = "0.1" grep-matcher = "0.1" @@ -42,7 +44,7 @@ syntect = { version = "5.3", default-features = false, features = [ "regex-fancy", ] } html-to-markdown-rs = { version = "2.24", default-features = false } -phf = { version = "0.11", features = ["macros"] } +phf = { version = "0.13", features = ["macros"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index 54941cc3d..7f9e0e9c9 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -24,9 +24,12 @@ use std::{ }; use brush_core::{ - CreateOptions, ExecutionContext, OpenFile, OpenFiles, ProcessGroupPolicy, Shell as BrushShell, - ShellValue, ShellVariable, builtins, env::EnvironmentScope, + CreateOptions, ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, + ProcessGroupPolicy, Shell as BrushShell, ShellValue, ShellVariable, builtins, + env::EnvironmentScope, + openfiles::{self, OpenFile, OpenFiles}, }; +use brush_builtins::{BuiltinSet, default_builtins}; use clap::Parser; use napi::{ bindgen_prelude::*, @@ -357,7 +360,25 @@ async fn execute_shell_with_options( remove_session(&options.session_key); } - Ok(ShellExecuteResult { exit_code: Some(i32::from(run_result.exit_code)), cancelled, timed_out }) + Ok(ShellExecuteResult { exit_code: exit_code(&run_result), cancelled, timed_out }) +} + +fn null_file() -> Result { + Ok(openfiles::null() + .map_err(|err| Error::from_reason(format!("Failed to create null file: {err}")))?) +} + +fn exit_code(result: &ExecutionResult) -> Option { + match result.exit_code { + ExecutionExitCode::Success => Some(0), + ExecutionExitCode::GeneralError => Some(1), + ExecutionExitCode::InvalidUsage => Some(2), + ExecutionExitCode::Unimplemented => Some(99), + ExecutionExitCode::CannotExecute => Some(126), + ExecutionExitCode::NotFound => Some(127), + ExecutionExitCode::Interrupted => Some(130), + ExecutionExitCode::Custom(code) => Some(code as i32), + } } /// Abort a running shell execution by ID. @@ -397,17 +418,18 @@ async fn create_session(options: &ShellExecuteOptions) -> Result { no_profile: true, no_rc: true, do_not_inherit_env: true, + builtins: default_builtins(BuiltinSet::BashMode), ..Default::default() }; - let mut shell = BrushShell::new(&create_options) + let mut shell = BrushShell::new(create_options) .await .map_err(|err| Error::from_reason(format!("Failed to initialize shell: {err}")))?; - if let Some(exec_builtin) = shell.builtins.get_mut("exec") { + if let Some(exec_builtin) = shell.builtin_mut("exec") { exec_builtin.disabled = true; } - if let Some(suspend_builtin) = shell.builtins.get_mut("suspend") { + if let Some(suspend_builtin) = shell.builtin_mut("suspend") { suspend_builtin.disabled = true; } shell.register_builtin("sleep", builtins::builtin::()); @@ -436,11 +458,9 @@ async fn create_session(options: &ShellExecuteOptions) -> Result { async fn source_snapshot(shell: &mut BrushShell, snapshot_path: &str) -> Result<()> { let mut params = shell.default_exec_params(); - let mut open_files = shell.open_files.clone(); - open_files.set(OpenFiles::STDIN_FD, OpenFile::Null); - open_files.set(OpenFiles::STDOUT_FD, OpenFile::Null); - open_files.set(OpenFiles::STDERR_FD, OpenFile::Null); - params.open_files = open_files; + params.set_fd(OpenFiles::STDIN_FD, null_file()?); + params.set_fd(OpenFiles::STDOUT_FD, null_file()?); + params.set_fd(OpenFiles::STDERR_FD, null_file()?); let escaped = snapshot_path.replace('\'', "'\\''"); let command = format!("source '{escaped}'"); @@ -455,7 +475,7 @@ async fn run_shell_command( session: &mut ShellSession, options: &ShellExecuteOptions, on_chunk: Option>, -) -> Result { +) -> Result { if let Some(cwd) = options.cwd.as_deref() { session .shell @@ -472,13 +492,10 @@ async fn run_shell_command( ); let stderr_file = OpenFile::from(writer_file); - let mut open_files = session.shell.open_files.clone(); - open_files.set(OpenFiles::STDIN_FD, OpenFile::Null); - open_files.set(OpenFiles::STDOUT_FD, stdout_file); - open_files.set(OpenFiles::STDERR_FD, stderr_file); - let mut params = session.shell.default_exec_params(); - params.open_files = open_files; + params.set_fd(OpenFiles::STDIN_FD, null_file()?); + params.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.set_fd(OpenFiles::STDERR_FD, stderr_file); params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; if let Some(env) = options.env.as_ref() { @@ -516,7 +533,7 @@ async fn run_shell_command( drop(params); - let _ = reader_handle.wait().await; + let _: Result<()> = reader_handle.wait().await; result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}"))) } @@ -589,11 +606,14 @@ fn next_execution_id() -> String { format!("exec-{}-{counter}", std::process::id()) } -const fn should_reset_session(result: &brush_core::ExecutionResult) -> bool { - result.exit_shell - || result.return_from_function_or_script - || result.break_loop.is_some() - || result.continue_loop.is_some() +const fn should_reset_session(result: &ExecutionResult) -> bool { + match result.next_control_flow { + ExecutionControlFlow::Normal => false, + ExecutionControlFlow::BreakLoop { .. } => true, + ExecutionControlFlow::ContinueLoop { .. } => true, + ExecutionControlFlow::ReturnFromFunctionOrScript => true, + ExecutionControlFlow::ExitShell => true, + } } fn remove_session(session_key: &str) { @@ -762,23 +782,24 @@ struct SleepCommand { } impl builtins::Command for SleepCommand { + type Error = brush_core::Error; + fn execute( &self, context: ExecutionContext<'_>, - ) -> impl std::future::Future> - + std::marker::Send { + ) -> impl Future> + Send { let durations = self.durations.clone(); async move { let mut total = Duration::from_millis(0); for duration in &durations { let Some(parsed) = parse_duration(duration) else { let _ = writeln!(context.stderr(), "sleep: invalid time interval '{duration}'"); - return Ok(builtins::ExitCode::Custom(1)); + return Ok(ExecutionResult::new(1)); }; total += parsed; } time::sleep(total).await; - Ok(builtins::ExitCode::Success) + Ok(ExecutionResult::success()) } } } @@ -793,21 +814,22 @@ struct TimeoutCommand { } impl builtins::Command for TimeoutCommand { + type Error = brush_core::Error; + fn execute( &self, context: ExecutionContext<'_>, - ) -> impl std::future::Future> - + std::marker::Send { + ) -> impl Future> + Send { let duration = self.duration.clone(); let command = self.command.clone(); async move { let Some(timeout) = parse_duration(&duration) else { let _ = writeln!(context.stderr(), "timeout: invalid time interval '{duration}'"); - return Ok(builtins::ExitCode::Custom(125)); + return Ok(ExecutionResult::new(125)); }; if command.is_empty() { let _ = writeln!(context.stderr(), "timeout: missing command"); - return Ok(builtins::ExitCode::Custom(125)); + return Ok(ExecutionResult::new(125)); } let mut params = context.params.clone(); @@ -844,18 +866,18 @@ impl builtins::Command for TimeoutCommand { let _ = time::timeout(Duration::from_millis(1500), &mut run_future).await; tracker_done.store(true, Ordering::Release); let _ = tracker_handle.await; - return Ok(builtins::ExitCode::Custom(124)); + return Ok(ExecutionResult::new(124)); } tracker_done.store(true, Ordering::Release); let _ = tracker_handle.await; if timed_out { - return Ok(builtins::ExitCode::Custom(124)); + return Ok(ExecutionResult::new(124)); } let result = result.expect("result ensured")?; - Ok(builtins::ExitCode::from(result)) + Ok(result) } } } diff --git a/crates/pi-natives/src/system_info.rs b/crates/pi-natives/src/system_info.rs index 804b2908c..85a5f782f 100644 --- a/crates/pi-natives/src/system_info.rs +++ b/crates/pi-natives/src/system_info.rs @@ -100,8 +100,7 @@ fn strip_quotes(value: &str) -> String { fn get_disk_info() -> Option { let mut disks = Disks::new_with_refreshed_list(); - disks.refresh_list(); - disks.refresh(); + disks.refresh(true); if cfg!(target_os = "windows") { let mut entries = Vec::new(); diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index b694f09f8..76e8801c2 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -18,8 +18,11 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const TAB_WIDTH: usize = 3; const ESC: u16 = 0x1b; -fn build_utf16_string(data: Vec) -> Utf16String { +fn build_utf16_string(mut data: Vec) -> Utf16String { // SAFETY: we know Utf16String == struct(Vec) + while data.last() == Some(&0) { + data.pop(); + } unsafe { std::mem::transmute(data) } } @@ -725,7 +728,8 @@ fn wrap_text_with_ansi_impl(text: &[u16], width: usize) -> Vec> { result } -/// Wrap text to a visible width, preserving ANSI escape codes across line breaks. +/// Wrap text to a visible width, preserving ANSI escape codes across line +/// breaks. /// /// Returns UTF-16 lines with active SGR codes carried across line boundaries. #[napi(js_name = "wrapTextWithAnsi")] @@ -741,7 +745,8 @@ pub fn wrap_text_with_ansi(text: JsString, width: u32) -> Result, @@ -1000,7 +1005,8 @@ fn slice_with_width_impl( /// Slice a range of visible columns from a line. /// -/// Counts terminal cells, skipping ANSI escapes, and optionally enforces strict width. +/// Counts terminal cells, skipping ANSI escapes, and optionally enforces strict +/// width. #[napi(js_name = "sliceWithWidth")] pub fn slice_with_width( line: JsString, @@ -1155,7 +1161,8 @@ fn extract_segments_impl( /// Extract the before/after slices around an overlay region. /// -/// Preserves ANSI state so the `after` segment renders correctly after truncation. +/// Preserves ANSI state so the `after` segment renders correctly after +/// truncation. #[napi(js_name = "extractSegments")] pub fn extract_segments( line: JsString, diff --git a/packages/coding-agent/src/ipy/executor.ts b/packages/coding-agent/src/ipy/executor.ts index 3378f33b8..de7ea2401 100644 --- a/packages/coding-agent/src/ipy/executor.ts +++ b/packages/coding-agent/src/ipy/executor.ts @@ -424,7 +424,8 @@ export async function executePython(code: string, options?: PythonExecutorOption await ensureKernelAvailable(cwd); const kernelMode = options?.kernelMode ?? "session"; - const useSharedGateway = options?.useSharedGateway; + const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test"; + const useSharedGateway = isTestEnv ? false : options?.useSharedGateway; const sessionFile = options?.sessionFile; const artifactsDir = options?.artifactsDir; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 5891e4255..f85fc6024 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from "bun:test"; -import { Markdown } from "@oh-my-pi/pi-tui/components/markdown"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; -import { defaultMarkdownTheme } from "./test-themes"; +import { Markdown } from "../src/components/markdown.js"; +import { type Component, TUI } from "../src/tui.js"; +import { defaultMarkdownTheme } from "./test-themes.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + expect(line, `Missing buffer line at row ${row}`).toBeTruthy(); + const cell = line!.getCell(col); + expect(cell, `Missing cell at row ${row} col ${col}`).toBeTruthy(); + return cell!.isItalic(); +} + describe("Markdown component", () => { describe("Nested lists", () => { it("should render simple nested list", () => { @@ -94,6 +107,43 @@ describe("Markdown component", () => { expect(plainLines.some(line => line.includes(" - Unordered nested"))).toBeTruthy(); expect(plainLines.some(line => line.includes("2. Second ordered"))).toBeTruthy(); }); + + it("should maintain numbering when code blocks are not indented (LLM output)", () => { + // When code blocks aren't indented, marked parses each item as a separate list. + // We use token.start to preserve the original numbering. + const markdown = new Markdown( + `1. First item + +\`\`\`typescript +// code block +\`\`\` + +2. Second item + +\`\`\`typescript +// another code block +\`\`\` + +3. Third item`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trim()); + + // Find all lines that start with a number and period + const numberedLines = plainLines.filter(line => /^\d+\./.test(line)); + + // Should have 3 numbered items + expect(numberedLines.length, `Expected 3 numbered items, got: ${numberedLines.join(", ")}`).toBe(3); + + // Check the actual numbers + expect(numberedLines[0].startsWith("1."), `First item should be "1.", got: ${numberedLines[0]}`).toBeTruthy(); + expect(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`).toBeTruthy(); + expect(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`).toBeTruthy(); + }); }); describe("Tables", () => { @@ -121,6 +171,52 @@ describe("Markdown component", () => { expect(plainLines.some(line => line.includes("-"))).toBeTruthy(); }); + it("should render row dividers between data rows", () => { + const markdown = new Markdown( + `| Name | Age | +| --- | --- | +| Alice | 30 | +| Bob | 25 |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const dividerLines = plainLines.filter(line => line.includes("+")); + + expect(dividerLines.length >= 2, "Expected header + row divider").toBeTruthy(); + }); + + it("should keep column width at least the longest word", () => { + const longestWord = "superlongword"; + const markdown = new Markdown( + `| Column One | Column Two | +| --- | --- | +| ${longestWord} short | otherword | +| small | tiny |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(32); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const dataLine = plainLines.find(line => line.includes(longestWord)); + expect(dataLine, "Expected data row containing longest word").toBeTruthy(); + + const segments = dataLine!.split("|").slice(1, -1); + const [firstSegment] = segments; + expect(firstSegment, "Expected first column segment").toBeTruthy(); + const firstColumnWidth = firstSegment.length - 2; + + expect( + firstColumnWidth >= longestWord.length, + `Expected first column width >= ${longestWord.length}, got ${firstColumnWidth}`, + ).toBeTruthy(); + }); + it("should render table with alignment", () => { const markdown = new Markdown( `| Left | Center | Right | @@ -181,15 +277,15 @@ describe("Markdown component", () => { // All lines should fit within width for (const line of plainLines) { - expect(line.length <= 50, `Line exceeds width 50: "${line}" (length: ${line.length}).toBeTruthy()`); + expect(line.length <= 50, `Line exceeds width 50: "${line}" (length: ${line.length})`).toBeTruthy(); } // Content should still be present (possibly wrapped across lines) const allText = plainLines.join(" "); - expect(allText.includes("Command")).toBeTruthy(); - expect(allText.includes("Description")).toBeTruthy(); - expect(allText.includes("npm install")).toBeTruthy(); - expect(allText.includes("Install")).toBeTruthy(); + expect(allText.includes("Command"), "Should contain 'Command'").toBeTruthy(); + expect(allText.includes("Description"), "Should contain 'Description'").toBeTruthy(); + expect(allText.includes("npm install"), "Should contain 'npm install'").toBeTruthy(); + expect(allText.includes("Install"), "Should contain 'Install'").toBeTruthy(); }); it("should wrap long cell content to multiple lines", () => { @@ -208,13 +304,13 @@ describe("Markdown component", () => { // Should have multiple data rows due to wrapping const dataRows = plainLines.filter(line => line.startsWith("|") && !line.includes("-")); - expect(dataRows.length > 2).toBeTruthy(); + expect(dataRows.length > 2, `Expected wrapped rows, got ${dataRows.length} rows`).toBeTruthy(); // All content should be preserved (may be split across lines) const allText = plainLines.join(" "); - expect(allText.includes("very long")).toBeTruthy(); - expect(allText.includes("cell content")).toBeTruthy(); - expect(allText.includes("should wrap")).toBeTruthy(); + expect(allText.includes("very long"), "Should preserve 'very long'").toBeTruthy(); + expect(allText.includes("cell content"), "Should preserve 'cell content'").toBeTruthy(); + expect(allText.includes("should wrap"), "Should preserve 'should wrap'").toBeTruthy(); }); it("should wrap long unbroken tokens inside table cells (not only at line start)", () => { @@ -235,22 +331,23 @@ describe("Markdown component", () => { for (const line of plainLines) { expect( line.length <= width, - `Line exceeds width ${width}: "${line}" (length: ${line.length}).toBeTruthy()`, - ); + `Line exceeds width ${width}: "${line}" (length: ${line.length})`, + ).toBeTruthy(); } // Borders should stay intact (exactly 2 vertical borders for a 1-col table) const tableLines = plainLines.filter(line => line.startsWith("|")); + expect(tableLines.length > 0, "Expected table rows to render").toBeTruthy(); for (const line of tableLines) { const borderCount = line.split("|").length - 1; - expect(borderCount).toBe(2); + expect(borderCount, `Expected 2 borders, got ${borderCount}: "${line}"`).toBe(2); } // Strip box drawing characters + whitespace so we can assert the URL is preserved // even if it was split across multiple wrapped lines. - const extracted = plainLines.join("").replace(/[|++-\s]/g, ""); - expect(extracted.includes("prefix")).toBeTruthy(); - expect(extracted.includes(url)).toBeTruthy(); + const extracted = plainLines.join("").replace(/[|+\-\s]/g, ""); + expect(extracted.includes("prefix"), "Should preserve 'prefix'").toBeTruthy(); + expect(extracted.includes(url), "Should preserve URL").toBeTruthy(); }); it("should wrap styled inline code inside table cells without breaking borders", () => { @@ -266,20 +363,20 @@ describe("Markdown component", () => { const width = 20; const lines = markdown.render(width); const joinedOutput = lines.join("\n"); - expect(joinedOutput.includes("\x1b[33m")).toBeTruthy(); + expect(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)").toBeTruthy(); const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); for (const line of plainLines) { expect( line.length <= width, - `Line exceeds width ${width}: "${line}" (length: ${line.length}).toBeTruthy()`, - ); + `Line exceeds width ${width}: "${line}" (length: ${line.length})`, + ).toBeTruthy(); } const tableLines = plainLines.filter(line => line.startsWith("|")); for (const line of tableLines) { const borderCount = line.split("|").length - 1; - expect(borderCount).toBe(2); + expect(borderCount, `Expected 2 borders, got ${borderCount}: "${line}"`).toBe(2); } }); @@ -298,11 +395,11 @@ describe("Markdown component", () => { const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); // Should not crash and should produce output - expect(lines.length > 0).toBeTruthy(); + expect(lines.length > 0, "Should produce output").toBeTruthy(); // Lines should not exceed width for (const line of plainLines) { - expect(line.length <= 15, `Line exceeds width 15: "${line}" (length: ${line.length}).toBeTruthy()`); + expect(line.length <= 15, `Line exceeds width 15: "${line}" (length: ${line.length})`).toBeTruthy(); } }); @@ -322,14 +419,14 @@ describe("Markdown component", () => { // Should have proper table structure const headerLine = plainLines.find(line => line.includes("A") && line.includes("B")); - expect(headerLine).toBeTruthy(); - expect(headerLine?.includes("|")).toBeTruthy(); + expect(headerLine, "Should have header row").toBeTruthy(); + expect(headerLine?.includes("|"), "Header should have borders").toBeTruthy(); - const separatorLine = plainLines.find(line => line.includes("+") && line.includes("+")); - expect(separatorLine).toBeTruthy(); + const separatorLine = plainLines.find(line => line.includes("+") && line.includes("-")); + expect(separatorLine, "Should have separator row").toBeTruthy(); const dataLine = plainLines.find(line => line.includes("1") && line.includes("2")); - expect(dataLine).toBeTruthy(); + expect(dataLine, "Should have data row").toBeTruthy(); }); it("should respect paddingX when calculating table width", () => { @@ -348,12 +445,12 @@ describe("Markdown component", () => { // All lines should respect width for (const line of plainLines) { - expect(line.length <= 40, `Line exceeds width 40: "${line}" (length: ${line.length}).toBeTruthy()`); + expect(line.length <= 40, `Line exceeds width 40: "${line}" (length: ${line.length})`).toBeTruthy(); } // Table rows should have left padding const tableRow = plainLines.find(line => line.includes("|")); - expect(tableRow?.startsWith(" ")).toBeTruthy(); + expect(tableRow?.startsWith(" "), "Table should have left padding").toBeTruthy(); }); }); @@ -409,12 +506,12 @@ describe("Markdown component", () => { expect(joinedOutput.includes("inline code")).toBeTruthy(); // The output should have ANSI codes for gray (90) and italic (3) - expect(joinedOutput.includes("\x1b[90m")).toBeTruthy(); - expect(joinedOutput.includes("\x1b[3m")).toBeTruthy(); + expect(joinedOutput.includes("\x1b[90m"), "Should have gray color code").toBeTruthy(); + expect(joinedOutput.includes("\x1b[3m"), "Should have italic code").toBeTruthy(); // Verify that inline code is styled (theme uses yellow) const hasCodeColor = joinedOutput.includes("\x1b[33m"); - expect(hasCodeColor).toBeTruthy(); + expect(hasCodeColor, "Should style inline code").toBeTruthy(); }); it("should preserve gray italic styling after bold text", () => { @@ -436,11 +533,46 @@ describe("Markdown component", () => { expect(joinedOutput.includes("bold text")).toBeTruthy(); // The output should have ANSI codes for gray (90) and italic (3) - expect(joinedOutput.includes("\x1b[90m")).toBeTruthy(); - expect(joinedOutput.includes("\x1b[3m")).toBeTruthy(); + expect(joinedOutput.includes("\x1b[90m"), "Should have gray color code").toBeTruthy(); + expect(joinedOutput.includes("\x1b[3m"), "Should have italic code").toBeTruthy(); // Should have bold codes (1 or 22 for bold on/off) - expect(joinedOutput.includes("\x1b[1m")).toBeTruthy(); + expect(joinedOutput.includes("\x1b[1m"), "Should have bold code").toBeTruthy(); + }); + + it("should not leak styles into following lines when rendered in TUI", async () => { + class MarkdownWithInput implements Component { + public markdownLineCount = 0; + + constructor(private readonly markdown: Markdown) {} + + render(width: number): string[] { + const lines = this.markdown.render(width); + this.markdownLineCount = lines.length; + return [...lines, "INPUT"]; + } + + invalidate(): void { + this.markdown.invalidate(); + } + } + + const markdown = new Markdown("This is thinking with `inline code`", 1, 0, defaultMarkdownTheme, { + color: text => chalk.gray(text), + italic: true, + }); + + const terminal = new VirtualTerminal(80, 6); + const tui = new TUI(terminal); + const component = new MarkdownWithInput(markdown); + tui.addChild(component); + tui.start(); + await terminal.flush(); + + expect(component.markdownLineCount > 0).toBeTruthy(); + const inputRow = component.markdownLineCount; + expect(getCellItalic(terminal, inputRow, 0)).toBe(0); + tui.stop(); }); }); @@ -463,12 +595,15 @@ again, hello world`, const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); const closingBackticksIndex = plainLines.indexOf("```"); - expect(closingBackticksIndex !== -1).toBeTruthy(); + expect(closingBackticksIndex !== -1, "Should have closing backticks").toBeTruthy(); const afterBackticks = plainLines.slice(closingBackticksIndex + 1); const emptyLineCount = afterBackticks.findIndex(line => line !== ""); - expect(emptyLineCount).toBe(1); + expect( + emptyLineCount, + `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`, + ).toBe(1); }); }); @@ -488,13 +623,16 @@ again, hello world`, const lines = markdown.render(80); const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); - const dividerIndex = plainLines.findIndex(line => line.includes("-")); - expect(dividerIndex !== -1).toBeTruthy(); + const dividerIndex = plainLines.findIndex(line => /^-+$/.test(line.trim())); + expect(dividerIndex !== -1, "Should have divider").toBeTruthy(); const afterDivider = plainLines.slice(dividerIndex + 1); const emptyLineCount = afterDivider.findIndex(line => line !== ""); - expect(emptyLineCount).toBe(1); + expect( + emptyLineCount, + `Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`, + ).toBe(1); }); }); @@ -513,12 +651,15 @@ This is a paragraph`, const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); const headingIndex = plainLines.findIndex(line => line.includes("Hello")); - expect(headingIndex !== -1).toBeTruthy(); + expect(headingIndex !== -1, "Should have heading").toBeTruthy(); const afterHeading = plainLines.slice(headingIndex + 1); const emptyLineCount = afterHeading.findIndex(line => line !== ""); - expect(emptyLineCount).toBe(1); + expect( + emptyLineCount, + `Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`, + ).toBe(1); }); }); @@ -539,12 +680,68 @@ again, hello world`, const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); const quoteIndex = plainLines.findIndex(line => line.includes("This is a quote")); - expect(quoteIndex !== -1).toBeTruthy(); + expect(quoteIndex !== -1, "Should have blockquote").toBeTruthy(); const afterQuote = plainLines.slice(quoteIndex + 1); const emptyLineCount = afterQuote.findIndex(line => line !== ""); - expect(emptyLineCount).toBe(1); + expect( + emptyLineCount, + `Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`, + ).toBe(1); + }); + }); + + describe("Links", () => { + it("should not duplicate URL for autolinked emails", () => { + const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should contain the email once, not duplicated with mailto: + expect(joinedPlain.includes("user@example.com"), "Should contain email").toBeTruthy(); + expect(!joinedPlain.includes("mailto:"), "Should not show mailto: prefix for autolinked emails").toBeTruthy(); + }); + + it("should not duplicate URL for bare URLs", () => { + const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // URL should appear only once + const urlCount = (joinedPlain.match(/https:\/\/example\.com/g) || []).length; + expect(urlCount, "URL should appear exactly once").toBe(1); + }); + + it("should show URL for explicit markdown links with different text", () => { + const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should show both link text and URL + expect(joinedPlain.includes("click here"), "Should contain link text").toBeTruthy(); + expect(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses").toBeTruthy(); + }); + + it("should show URL for explicit mailto links with different text", () => { + const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should show both link text and mailto URL + expect(joinedPlain.includes("Email me"), "Should contain link text").toBeTruthy(); + expect( + joinedPlain.includes("(mailto:test@example.com)"), + "Should show mailto URL in parentheses", + ).toBeTruthy(); }); }); @@ -564,7 +761,10 @@ again, hello world`, const joinedPlain = plainLines.join(" "); // The content inside the tags should be visible - expect(joinedPlain.includes("hidden content") || joinedPlain.includes("")).toBeTruthy(); + expect( + joinedPlain.includes("hidden content") || joinedPlain.includes(""), + "Should render HTML-like tags or their content as text, not hide them", + ).toBeTruthy(); }); it("should render HTML tags in code blocks correctly", () => { @@ -575,7 +775,10 @@ again, hello world`, const joinedPlain = plainLines.join("\n"); // HTML in code blocks should be visible - expect(joinedPlain.includes("
") && joinedPlain.includes("
")).toBeTruthy(); + expect( + joinedPlain.includes("
") && joinedPlain.includes("
"), + "Should render HTML in code blocks", + ).toBeTruthy(); }); }); }); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 0d2586405..8800ebe93 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils"; describe("wrapTextWithAnsi", () => { describe("underline styling", () => { @@ -11,29 +11,22 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 40); - const prefix = "read this thread "; - expect(wrapped[0].startsWith(prefix)).toBe(true); - const underlineIndex = wrapped[0].indexOf(underlineOn); - if (underlineIndex !== -1) { - expect(underlineIndex).toBeGreaterThanOrEqual(prefix.length); - expect(wrapped[0].endsWith(underlineOff)).toBe(true); - } + // First line should NOT contain underline code - it's just "read this thread" + expect(wrapped[0]).toBe("read this thread"); - // Second line should start with underline - expect(wrapped[1].startsWith(underlineOn)).toBe(true); - - const plain = wrapped.join("").replace(/\x1b\[[0-9;]*m/g, ""); - expect(plain.includes(url)).toBe(true); + // Second line should start with underline, have URL content + expect(wrapped[1].startsWith(underlineOn)).toStrictEqual(true); + expect(wrapped[1].includes("https://")).toBe(true); }); - it("should preserve whitespace before underline reset code", () => { + it("should not have whitespace before underline reset code", () => { const underlineOn = "\x1b[4m"; const underlineOff = "\x1b[24m"; const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`; const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18); - expect(wrapped[1].includes(` ${underlineOff}`)).toBe(true); + expect(wrapped[0].includes(` ${underlineOff}`)).toBe(false); }); it("should not bleed underline to padding - each line should end with reset for underline only", () => { @@ -67,7 +60,7 @@ describe("wrapTextWithAnsi", () => { // Each line should have background color for (const line of wrapped) { - expect(line.includes(bgBlue)).toBeTruthy(); + expect(line.includes(bgBlue)).toBe(true); } // Middle lines should NOT end with full reset (kills background for padding) @@ -76,7 +69,7 @@ describe("wrapTextWithAnsi", () => { } }); - it("should reset underline without preserving background after wrap", () => { + it("should reset underline but preserve background when wrapping underlined text inside background", () => { const underlineOn = "\x1b[4m"; const underlineOff = "\x1b[24m"; const reset = "\x1b[0m"; @@ -85,11 +78,11 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 20); - const lineHasBg = (line: string) => line.includes("[41m") || line.includes(";41m") || line.includes("[41;"); - - expect(lineHasBg(wrapped[0])).toBeTruthy(); - expect(lineHasBg(wrapped[1])).toBeFalsy(); - expect(lineHasBg(wrapped[2])).toBeFalsy(); + // All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m) + for (const line of wrapped) { + const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;"); + expect(hasBgColor).toBe(true); + } // Lines with underlined content should use underline-off at end, not full reset for (let i = 0; i < wrapped.length - 1; i++) { @@ -111,15 +104,15 @@ describe("wrapTextWithAnsi", () => { const text = "hello world this is a test"; const wrapped = wrapTextWithAnsi(text, 10); - expect(wrapped.length > 1).toBeTruthy(); + expect(wrapped.length > 1).toBe(true); for (const line of wrapped) { - expect(visibleWidth(line) <= 10).toBeTruthy(); + expect(visibleWidth(line) <= 10).toBe(true); } }); it("should truncate trailing whitespace that exceeds width", () => { const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1); - expect(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1).toBeTruthy(); + expect(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1).toBe(true); }); it("should preserve color codes across wraps", () => { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 776484a22..791918c0d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,3 @@ [toolchain] channel = "nightly" components = ["rustfmt", "clippy", "rust-analyzer"] -targets = ["wasm32-unknown-unknown"]