diff --git a/Cargo.lock b/Cargo.lock index f761d7c..67197c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,9 +24,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -109,7 +109,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -126,9 +126,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -136,14 +136,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -152,6 +153,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -169,9 +176,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -208,15 +215,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -226,15 +233,26 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -251,9 +269,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -261,9 +279,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -273,21 +291,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clipboard-win" @@ -321,9 +339,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -361,6 +379,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -372,9 +396,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -420,9 +444,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "digest" @@ -460,13 +484,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -483,11 +507,16 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -514,21 +543,21 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fnv" @@ -568,53 +597,53 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -656,11 +685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -670,15 +697,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" @@ -731,9 +761,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -741,9 +771,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -751,9 +781,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -770,18 +800,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -800,9 +830,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -831,16 +861,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -880,9 +911,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -894,9 +925,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -907,9 +938,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -921,16 +952,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -941,15 +973,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -983,9 +1015,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -993,9 +1025,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1036,7 +1068,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1055,24 +1087,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1081,15 +1113,15 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -1099,9 +1131,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1114,27 +1146,27 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "md5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1144,15 +1176,21 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -1226,9 +1264,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "ollama-rs" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22839a9841aacb7bcd70a3f73575945b89ffaa7a5532c8a85cf2fee85d919b8c" +checksum = "fed8739c37da731774bd5b39209bbd9b770f0c1d9a000c4b859c824badac3e8e" dependencies = [ "async-stream", "log", @@ -1277,7 +1315,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1335,15 +1373,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1359,9 +1397,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1376,7 +1414,7 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1393,9 +1431,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1413,15 +1451,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1435,23 +1474,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1480,12 +1519,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1495,7 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1507,13 +1557,28 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1527,29 +1592,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1559,9 +1624,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1580,7 +1645,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1615,11 +1680,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -1672,9 +1737,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1687,9 +1752,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1700,9 +1765,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -1726,9 +1791,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1736,9 +1801,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -1757,15 +1822,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1775,9 +1840,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -1793,9 +1858,9 @@ dependencies = [ [[package]] name = "rustyline" -version = "18.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ "bitflags", "cfg-if", @@ -1821,7 +1886,7 @@ checksum = "64e5587417a3c4e16a4415e8d7d07f80998ed835ade621d19dfbe9fbe3205b0f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1850,9 +1915,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -1863,14 +1928,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -1910,9 +1975,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1920,40 +1985,40 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -1977,9 +2042,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1993,7 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2024,9 +2089,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2046,15 +2111,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2086,9 +2151,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2097,9 +2162,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2117,13 +2182,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2162,22 +2227,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2200,7 +2265,7 @@ dependencies = [ "md5", "ollama-rs", "regex", - "reqwest 0.13.4", + "reqwest 0.13.5", "schemars", "serde", "serde_json", @@ -2226,9 +2291,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2236,24 +2301,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2268,13 +2324,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2289,9 +2345,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2299,9 +2355,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2322,13 +2378,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2397,7 +2454,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2452,7 +2509,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.5", "sha1", "thiserror", ] @@ -2471,9 +2528,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -2519,9 +2576,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2591,9 +2648,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2604,19 +2661,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2624,22 +2682,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2672,9 +2730,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2692,9 +2750,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -2729,7 +2787,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2740,7 +2798,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2784,16 +2842,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2811,31 +2860,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2844,96 +2876,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -2942,9 +2926,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -2959,34 +2943,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3000,13 +2984,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] @@ -3018,9 +3002,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3029,9 +3013,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3040,17 +3024,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 516781b..6d17b98 100644 --- a/README.md +++ b/README.md @@ -384,15 +384,36 @@ Terminal UI abstractions — reusable output formatting, diff display, and confi tinyharness-ui/src/ ├── lib.rs Module declarations ├── output.rs Structured output writer (stdout/stderr abstraction) -├── style.rs ANSI color constants (BOLD, CYAN, RED, BG_TOOL, SPINNER_FRAMES, etc.) +├── style.rs ANSI color constants (BOLD, CYAN, the tool-frame palette, SPINNER_FRAMES) └── ui/ ├── mod.rs Module declarations ├── confirm.rs Tool call confirmation prompts (Yes/No/Auto-accept) ├── diff.rs Unified diff display + ├── frame.rs Shared layout primitives for the tool-frame family ├── input.rs CommandHelper for rustyline tab-completion + ├── markdown.rs Incremental markdown renderer for streamed assistant output + ├── tool_result.rs Tool execution cards (status band + capped body) └── wrap.rs Word-wrapped output with ANSI-aware line filling ``` +Tool I/O shares one visual language: a filled **title band** names each call +(`● run · cargo test --workspace · 2.3s`), and an outlined **gutter** carries +its body, closing with an elbow so every block reads as one unit. + +``` + ● read · tinyharness-ui/src/ui/frame.rs · 42ms + │ (180 lines) + │ use std::io::{self, Write}; + ╰ +176 more lines + + ✕ run · cargo clippy --workspace · 1.5s + ╰ error[E0308]: mismatched types + + ▲ Allow? · run — execute a shell command + │ $ rm -rf ./target + ▲ [y]es [n]o [a]uto-accept the rest of this turn › +``` + ### Binary crate (`src/`) CLI application — argument parsing, agent loop, slash commands, tool dispatch, safety checking. diff --git a/docs/safety.md b/docs/safety.md index af22f1b..631a1ec 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -168,14 +168,19 @@ Mixed chains work: ### Destructive Tools -`write`, `edit`, and `run` always show a confirmation prompt: +`write`, `edit`, and `run` always show a confirmation prompt — a title band +naming the tool and its effect, the command or diff it applies to, then the +choices: ``` - Write to /path/to/file.rs (4194 bytes) - -Confirm? (Y)es / (N)o / (A)uto-accept future + ▲ Allow? · run — execute a shell command + │ $ cargo clippy --workspace -- -D warnings + ▲ [y]es [n]o [a]uto-accept the rest of this turn › ``` +Declining renders a `⊘ … · denied` card, so the transcript records what was +refused rather than nothing at all. + ### Auto-Accept Mode Toggle with `/autoaccept off`, `/autoaccept safe`, or `/autoaccept all`. diff --git a/src/agent/display.rs b/src/agent/display.rs index 5f721ff..0d1b2f9 100644 --- a/src/agent/display.rs +++ b/src/agent/display.rs @@ -7,6 +7,7 @@ use tinyharness_lib::{ }; use tinyharness_ui::style::*; +use tinyharness_ui::ui::tool_result::{ToolStatus, tool_subject}; /// Print a warning if the loaded session's conversation has many messages. /// @@ -96,6 +97,16 @@ pub fn print_context_load_warning( Ok(()) } +/// Separate assistant output from user input without coloring the prose. +pub fn write_assistant_header(stdout: &mut W) -> std::io::Result<()> { + writeln!( + stdout, + "\n{RESET}{DIM}──{RESET} {BOLD}{CYAN}Assistant{RESET} {DIM}{}{RESET}\n", + "─".repeat(28) + )?; + stdout.flush() +} + /// Print the conversation history from loaded messages so the user can see /// what was discussed in the resumed session. pub fn print_conversation_history( @@ -106,58 +117,83 @@ pub fn print_conversation_history( return Ok(()); } + // tool_call_id → tool name, populated from Assistant messages so Tool + // result messages can be rendered with the right tool-specific body. + let mut tool_names: std::collections::HashMap = + std::collections::HashMap::new(); + // tool_call_id → (tool name, subject), so the result card can name what + // the call acted on — arguments are not persisted on the Tool message. + let mut pending_cards: std::collections::HashMap)> = + std::collections::HashMap::new(); + for msg in messages { match msg.role { Role::System => {} Role::User => { - writeln!(stdout, "{}> {}{}", BLUE, msg.content, RESET)?; + writeln!(stdout, "{BOLD}{BLUE}You >{RESET} {}", msg.content)?; } Role::Assistant => { + if !msg.content.is_empty() || !msg.tool_calls.is_empty() { + write_assistant_header(stdout)?; + } if !msg.content.is_empty() { - write!(stdout, "{}", ORANGE)?; - stdout.write_all(msg.content.as_bytes())?; - writeln!(stdout, "{}", RESET)?; + // Render prose with the same markdown styling as live + // streaming so a reloaded session looks identical to the + // original run. + write!(stdout, "{ASSISTANT_TEXT}")?; + let rendered = tinyharness_ui::ui::markdown::render_markdown(&msg.content); + stdout + .write_all(rendered.strip_suffix('\n').unwrap_or(&rendered).as_bytes())?; + writeln!(stdout, "{RESET}")?; + // Blank line after prose. Tool-only turns skip it: the + // assistant header already ends on one, and a doubled gap + // would separate the header from its own cards. + writeln!(stdout)?; } if !msg.tool_calls.is_empty() { for tc in &msg.tool_calls { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM}{FILL_EOL}{RESET}", - name = tc.function.name - )?; + // Remember tool names so the following Tool messages + // can render with the correct tool-specific body. + if let Some(id) = &tc.id { + tool_names.insert(id.clone(), tc.function.name.clone()); + } + // Replay shows the call and its result as one card: + // the stored Tool message carries the outcome, so the + // header is emitted there rather than as a separate + // "▶ name" line. + pending_cards.insert( + tc.id.clone().unwrap_or_default(), + ( + tc.function.name.clone(), + tool_subject(&tc.function.arguments), + ), + ); } } - writeln!(stdout)?; } Role::Tool => { - let tool_name = msg.content.split('\'').nth(1).unwrap_or("tool"); - let result_body = extract_tool_result_body(&msg.content, tool_name); - - if tool_name == "read" { - let summary = result_body.lines().next().unwrap_or("(empty result)"); - writeln!( - stdout, - "{BG_DIM} {DIM}{summary}{FILL_EOL}{RESET}", - summary = summary - )?; - } else if matches!(tool_name, "ls" | "grep" | "glob") { - let summary = summarize_listing_result(result_body, tool_name); - writeln!( - stdout, - "{BG_DIM} {DIM}{summary}{FILL_EOL}{RESET}", - summary = summary - )?; - } else { - tinyharness_ui::ui::wrap::write_wrapped_lines( - stdout, - result_body, - &format!("{BG_DIM} "), - &format!(" {BG_DIM}{DIM}"), - tinyharness_ui::ui::wrap::MAX_LINE_WIDTH, - true, - )?; - } - writeln!(stdout, "{RESET}")?; + let (tool_name, result_body) = + split_tool_result(&msg.content, &tool_names, &msg.tool_call_id); + let subject = msg + .tool_call_id + .as_deref() + .and_then(|id| pending_cards.get(id)) + .and_then(|(_, subject)| subject.clone()); + let denied = result_body.starts_with("[Tool denied]"); + tinyharness_ui::ui::tool_result::write_tool_card( + stdout, + &tool_name, + subject.as_deref(), + if denied { + ToolStatus::Denied + } else if result_body.starts_with("Error:") { + ToolStatus::Error + } else { + ToolStatus::Ok + }, + None, + result_body, + )?; } } } @@ -286,43 +322,29 @@ pub fn display_context_status( Ok(()) } -/// Extract the result body from a tool result message. -fn extract_tool_result_body<'a>(content: &'a str, tool_name: &str) -> &'a str { - let prefix = format!("Tool '{tool_name}' result:\n"); - content - .strip_prefix(&prefix) - .or_else(|| content.strip_prefix(&prefix)) - .unwrap_or(content) -} - -/// Produce a one-line summary for listing tools (ls, grep, glob). -pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { - if result.starts_with("Error:") || result.starts_with("No ") || result == "Directory is empty" { - return result.to_string(); - } - - let lines: Vec<&str> = result.lines().collect(); - let count = lines.len(); - let label = match tool_name { - "ls" => "entries", - "grep" => "matches", - "glob" => "files", - _ => "results", - }; - - const PREVIEW: usize = 3; - if count <= PREVIEW { - format!("{} {} — {}", count, label, result) - } else { - let preview: Vec<&str> = lines.iter().take(PREVIEW).copied().collect(); - format!( - "{} {} — {} ... ({} more)", - count, - label, - preview.join(", "), - count - PREVIEW - ) +/// Split a stored tool result into `(tool_name, result_body)`. +/// +/// Tool results are persisted as `### {tool}\n\n{result}`. When the tool name +/// can't be recovered from the prefix, falls back to the assistant's +/// `tool_calls` map (id → name) or the message's own `tool_call_id`, so older +/// sessions with different prefixes still resolve the tool name. +fn split_tool_result<'a>( + content: &'a str, + tool_names: &std::collections::HashMap, + tool_call_id: &Option, +) -> (String, &'a str) { + if let Some(rest) = content.strip_prefix("### ") + && let Some((name, body)) = rest.split_once("\n\n") + { + return (name.trim().to_string(), body); } + // Fallbacks: the originating call's name, looked up by id. + let fallback = tool_call_id + .as_deref() + .and_then(|id| tool_names.get(id)) + .cloned() + .unwrap_or_else(|| "tool".to_string()); + (fallback, content) } /// Format tool call arguments as a compact single-line summary. @@ -359,72 +381,14 @@ fn format_args_summary_impl(arguments: &serde_json::Value, truncate: bool) -> St #[cfg(test)] mod tests { - use super::*; + use std::collections::HashMap; - #[test] - fn test_summarize_ls_error_passthrough() { - let result = summarize_listing_result("Error: Path '/nope' does not exist", "ls"); - assert_eq!(result, "Error: Path '/nope' does not exist"); - } - - #[test] - fn test_summarize_ls_empty_dir() { - let result = summarize_listing_result("Directory is empty", "ls"); - assert_eq!(result, "Directory is empty"); - } - - #[test] - fn test_summarize_ls_no_matches() { - let result = summarize_listing_result("No matches found for pattern 'xyz'", "grep"); - assert_eq!(result, "No matches found for pattern 'xyz'"); - } - - #[test] - fn test_summarize_ls_few_entries() { - let result = summarize_listing_result("Cargo.toml\nCargo.lock\nsrc", "ls"); - assert_eq!(result, "3 entries — Cargo.toml\nCargo.lock\nsrc"); - } - - #[test] - fn test_summarize_ls_many_entries() { - let entries: Vec = (0..10).map(|i| format!("file{i}")).collect(); - let input = entries.join("\n"); - let result = summarize_listing_result(&input, "ls"); - assert_eq!(result, "10 entries — file0, file1, file2 ... (7 more)"); - } - - #[test] - fn test_summarize_grep_many_matches() { - let matches: Vec = (1..=5).map(|i| format!("src/main.rs:{}:foo", i)).collect(); - let input = matches.join("\n"); - let result = summarize_listing_result(&input, "grep"); - assert_eq!( - result, - "5 matches — src/main.rs:1:foo, src/main.rs:2:foo, src/main.rs:3:foo ... (2 more)" - ); - } - - #[test] - fn test_summarize_glob_no_files() { - let result = summarize_listing_result("No files found matching pattern '*.xyz'", "glob"); - assert_eq!(result, "No files found matching pattern '*.xyz'"); - } - - #[test] - fn test_summarize_glob_many_files() { - let files: Vec = (0..6).map(|i| format!("src/file{i}.rs")).collect(); - let input = files.join("\n"); - let result = summarize_listing_result(&input, "glob"); - assert_eq!( - result, - "6 files — src/file0.rs, src/file1.rs, src/file2.rs ... (3 more)" - ); - } + use super::*; - #[test] - fn test_summarize_single_entry() { - let result = summarize_listing_result("Cargo.toml", "ls"); - assert_eq!(result, "1 entries — Cargo.toml"); + /// Strip ANSI escape sequences so assertions can match rendered text. + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); + re.replace_all(s, "").to_string() } #[test] @@ -536,4 +500,188 @@ mod tests { // Should have dim gray color for the token part assert!(result.contains(GRAY)); } + + #[test] + fn test_history_labels_separate_user_and_assistant() { + let messages = vec![ + Message::simple(Role::User, "question"), + Message::simple(Role::Assistant, "answer"), + ]; + let mut out = Vec::new(); + print_conversation_history(&messages, &mut out).unwrap(); + let out = String::from_utf8(out).unwrap(); + assert!(out.contains(&format!("You >{RESET} question\n"))); + assert!(out.contains("Assistant")); + assert!(out.find("question").unwrap() < out.find("Assistant").unwrap()); + assert!(out.find("Assistant").unwrap() < out.find("answer").unwrap()); + assert!(out.ends_with(&format!("answer{RESET}\n\n"))); + } + + #[test] + fn test_history_assistant_message_exact_newlines_without_trailing() { + // Content WITHOUT a trailing newline: the markdown renderer adds one, + // and the final writeln! adds the message-terminating newline. The + // result must be exactly one blank line after the message — never two. + let msgs = vec![Message::simple(Role::Assistant, "hello world")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!("{header}{ASSISTANT_TEXT}hello world{RESET}\n\n") + ); + } + + #[test] + fn test_history_assistant_message_exact_newlines_with_trailing() { + // Content WITH a trailing newline must yield the same output as + // without it — no doubled blank line between history messages. + let msgs = vec![Message::simple(Role::Assistant, "hello world\n")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!("{header}{ASSISTANT_TEXT}hello world{RESET}\n\n") + ); + } + + #[test] + fn test_history_multi_paragraph_no_doubled_blanks() { + // Two assistant messages in a row: exactly one blank line separates + // each message block. + let msgs = vec![ + Message::simple(Role::Assistant, "first"), + Message::simple(Role::Assistant, "second"), + ]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!( + "{header}{ASSISTANT_TEXT}first{RESET}\n\n{header}{ASSISTANT_TEXT}second{RESET}\n\n" + ) + ); + } + + #[test] + fn test_history_multiline_content_rendered_as_markdown() { + // Headings get markdown-styled on reload, matching live streaming. + let msgs = vec![Message::simple(Role::Assistant, "# Title\nbody")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains(BOLD), "heading should be bold: {out:?}"); + assert!(out.contains("body")); + assert!(out.ends_with(&format!("{RESET}\n\n"))); + } + + #[test] + fn test_split_tool_result_stored_prefix() { + let names = HashMap::new(); + let content = "### read\n\n(2 lines)\nfoo\nbar"; + let (name, body) = split_tool_result(content, &names, &None); + assert_eq!(name, "read"); + assert_eq!(body, "(2 lines)\nfoo\nbar"); + } + + #[test] + fn test_split_tool_result_falls_back_to_call_id() { + let mut names = HashMap::new(); + names.insert("call_0".to_string(), "run".to_string()); + let content = "plain output without prefix"; + let (name, body) = split_tool_result(content, &names, &Some("call_0".to_string())); + assert_eq!(name, "run"); + assert_eq!(body, "plain output without prefix"); + } + + #[test] + fn test_history_tool_result_uses_call_name_and_caps_body() { + use tinyharness_lib::provider::{ToolCall, ToolCallFunction}; + let assistant = Message { + role: Role::Assistant, + content: String::new(), + tool_calls: vec![ToolCall { + id: Some("call_0".to_string()), + function: ToolCallFunction { + name: "read".to_string(), + arguments: serde_json::json!({"path": "src/lib.rs"}), + thought_signature: None, + }, + }], + tool_call_id: None, + images: vec![], + thinking: None, + }; + let mut tool = Message::simple(Role::Tool, "### read\n\n(5 lines)\nl1\nl2\nl3\nl4\nl5"); + tool.tool_call_id = Some("call_0".to_string()); + let msgs = vec![assistant, tool]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("read"), "tool name should render: {out:?}"); + assert!( + out.contains("src/lib.rs"), + "the call's subject should carry over from the assistant message: {out:?}" + ); + assert!(out.contains("(5 lines)"), "meta line should render"); + let plain = strip_ansi(&out); + assert!( + plain.contains("╰ +2 more lines"), + "body should cap at 3: {plain:?}" + ); + assert!(!plain.contains("l4"), "hidden lines must not render"); + } + + #[test] + fn test_history_denied_call_renders_as_denied_card() { + use tinyharness_lib::provider::{ToolCall, ToolCallFunction}; + let assistant = Message { + role: Role::Assistant, + content: String::new(), + tool_calls: vec![ToolCall { + id: Some("call_0".to_string()), + function: ToolCallFunction { + name: "run".to_string(), + arguments: serde_json::json!({"command": "rm -rf /"}), + thought_signature: None, + }, + }], + tool_call_id: None, + images: vec![], + thinking: None, + }; + let mut tool = Message::simple( + Role::Tool, + "### run\n\n[Tool denied] The user denied the 'run' tool call", + ); + tool.tool_call_id = Some("call_0".to_string()); + let mut buf = Vec::new(); + print_conversation_history(&[assistant, tool], &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!( + out.contains('⊘'), + "denied calls use the deny glyph: {out:?}" + ); + assert!(out.contains("rm -rf /"), "subject should render"); + assert!(!out.contains('●'), "a denial is not a success"); + } + + #[test] + fn test_split_tool_result_unknown_defaults_to_tool() { + let names = HashMap::new(); + let (name, body) = split_tool_result("hello", &names, &None); + assert_eq!(name, "tool"); + assert_eq!(body, "hello"); + } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 4c6f6b6..a44a2c4 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -37,7 +37,7 @@ use tinyharness_ui::ui::input::CommandHelper; pub use display::{ format_args_summary, format_context_status, print_context_load_warning, - print_conversation_history, summarize_listing_result, + print_conversation_history, }; pub use input::read_multiline_input; pub use safety::{is_safe_command, strip_safe_descriptor_redirections}; @@ -58,29 +58,11 @@ pub async fn run_agent_loop( let mut stdout = Output::stdout(); stdout.write_all( format!( - "{}╔════════════════════════════════════════════════════════╗{}\n", - BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}║{} {}TinyHarness AI Assistant{} {}║{}\n", - BOX_COLOR, RESET, BOLD, TITLE_COLOR, BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}╚════════════════════════════════════════════════════════╝{}\n\n", - BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}Tip:{} Type {} to see available commands\n\n", - GRAY, RESET, "/help" + "{BOLD}{TITLE_COLOR}TinyHarness{RESET}{BOLD} AI Assistant{RESET} {DIM}v{}{RESET}\n\ + {DIM}{}{RESET}\n\ + {DIM}Tip:{RESET} {CYAN}/help{RESET} {DIM}shows all commands{RESET}\n\n", + env!("CARGO_PKG_VERSION"), + "─".repeat(44) ) .as_bytes(), )?; @@ -199,7 +181,7 @@ pub async fn run_agent_loop( }; let prompt = format!( - "{}{}{}\n{}[{}]{}{}> {}{}", + "{}{}{}\n{}[{}]{}{} {BOLD}{BLUE}You >{RESET} {}{}", status_line, session_suffix, RESET, @@ -322,6 +304,9 @@ pub async fn run_agent_loop( // auto_accept persists across all agent iterations within this user turn, let mut auto_accept = false; + // One visible boundary per user turn; tool iterations remain within it. + display::write_assistant_header(&mut stdout)?; + loop { // Filter tools based on current mode and settings let (_, _, merged) = load_merged_settings(); @@ -366,7 +351,12 @@ pub async fn run_agent_loop( // debug metric fires about once per second instead of per chunk. let mut backpressure_log_ticks: u32 = 0; - stdout.write_all(ORANGE.as_bytes())?; + // Line-buffered markdown renderer for the assistant's reply: emits + // each line (styled) as soon as its newline arrives, and always + // starts from a clean ANSI state — the previous code relied on an + // ambient orange color that the spinner/thinking RESET could tear + // down mid-stream ("random orange" bug). + let mut md_stream = tinyharness_ui::ui::markdown::MarkdownStream::new(); loop { tokio::select! { @@ -438,7 +428,7 @@ pub async fn run_agent_loop( } response_content.push_str(&msg.message.content); - stdout.write_all(msg.message.content.as_bytes())?; + md_stream.push(&mut stdout, &msg.message.content)?; stdout.flush()?; } @@ -501,6 +491,9 @@ pub async fn run_agent_loop( stdout.flush()?; } + // Flush the markdown renderer's trailing partial line. + md_stream.finish(&mut stdout)?; + // Close thinking styling if stream ended while still in thinking mode if thinking_header_shown { writeln!(stdout, "{RESET}")?; @@ -632,8 +625,8 @@ pub async fn run_agent_loop( break; } - // Blank line after agent response for visual separation - writeln!(stdout)?; + // End a partial output line and leave space before the next prompt. + writeln!(stdout, "{RESET}\n")?; } // Save history on exit diff --git a/src/agent/tools.rs b/src/agent/tools.rs index 58b3fae..121289a 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -14,6 +14,8 @@ use tinyharness_lib::{ use crate::commands::CommandContext; use tinyharness_ui::style::*; use tinyharness_ui::ui::confirm::Confirmation; +use tinyharness_ui::ui::frame::status_line; +use tinyharness_ui::ui::tool_result::ToolStatus; use super::confirm::ConfirmationDecision; use super::signal::{self, SignalResult}; @@ -55,11 +57,10 @@ pub async fn handle_tool_calls( let tool_count = tool_calls.len(); // Track cumulative tool calls for session stats. session.add_tool_calls(tool_count as u64); - writeln!( - stdout, - "\n{BG_TOOL} {WHITE}{count} tool call(s){FILL_EOL}{RESET}", - count = tool_count - )?; + // Blank line separating the assistant's prose from the first card of the + // batch. Cards within the batch stay tight together — they read as one + // group. + writeln!(stdout)?; messages.push(Message { role: Role::Assistant, @@ -140,18 +141,24 @@ pub async fn handle_tool_calls( ConfirmationDecision::NeedsConfirmation => { match tinyharness_ui::ui::confirm::prompt_tool_confirmation(stdout, &call)? { Confirmation::No => { - stdout.write_all( - format!(" {}Skipped{}{}\n", ORANGE, RESET, BOLD).as_bytes(), + // Denials get a card of their own so the transcript + // shows what was refused, rather than a bare word. + tinyharness_ui::ui::tool_result::write_tool_card( + stdout, + &call.function.name, + tinyharness_ui::ui::tool_result::tool_subject(&call.function.arguments) + .as_deref(), + ToolStatus::Denied, + None, + "", )?; - stdout.flush()?; (false, false) } Confirmation::AutoAccept => { *auto_accept = true; writeln!( stdout, - " {}Auto-accept enabled for the rest of this turn{}", - GREEN, RESET + " {FG_OK}●{RESET} {DIM}auto-accepting the rest of this turn{RESET}" )?; (true, true) } @@ -178,7 +185,7 @@ pub async fn handle_tool_calls( } // Generic tool execution — collect result for batching - let result = execute_generic_tool(&call, tool_manager, stdout, auto_accepted).await; + let result = execute_generic_tool(&call, tool_manager, stdout).await; // Log to audit if this was an auditable tool (run/write/edit) log_tool_audit( @@ -220,39 +227,36 @@ fn handle_question_cli( return Ok(()); } - // Display the question and options + // Display the question and options inside the same frame language as the + // tool cards: a band for the ask, a gutter for the options. + writeln!(stdout)?; + tinyharness_ui::ui::frame::write_band( + stdout, + &[("?", FG_ACCENT), (" ", FG_FAINT), ("Question", FG_MUTED)], + )?; writeln!( stdout, - "\n{} ┌─── {}❓ Question {}─────{}", - BOLD, CYAN, BOLD, RESET + " {FG_FAINT}│{RESET} {BOLD}{question}{RESET}", + question = question )?; - writeln!(stdout, " │ {}{}{}", BOLD, question, RESET)?; - writeln!(stdout, " │")?; for (i, answer) in answers.iter().enumerate() { writeln!( stdout, - " │ {}{}.{}) {} {}{}", - GREEN, - i + 1, - RESET, - BOLD, - answer, - RESET + " {FG_FAINT}│{RESET} {FG_OK}{n}{RESET}{FG_FAINT}){RESET} {BOLD}{answer}{RESET}", + n = i + 1, + answer = answer )?; } - writeln!(stdout, " │")?; writeln!( stdout, - " │ {}Enter anything else to skip with a custom answer{}", - DIM, RESET + " {FG_FAINT}╰{RESET} {FG_FAINT}anything else skips with a custom answer{RESET}" )?; - writeln!(stdout, " └{}──────────────────────────────{}", BOLD, RESET)?; let answer_count = answers.len(); write!( stdout, - " {}Your choice (1-{} or type to skip): {}", - BOLD, answer_count, RESET + " {BOLD}Your choice{RESET} {FG_FAINT}(1-{answer_count} or type to skip){RESET}{FG_FAINT} ›{RESET} ", + answer_count = answer_count )?; stdout.flush()?; @@ -282,14 +286,14 @@ fn handle_question_cli( if is_skip { writeln!( stdout, - " {}⊘{} Skipped with: {}{}{}", - ORANGE, RESET, BOLD, selected_answer, RESET + " {FG_WARN}⊘{RESET} {FG_FAINT}skipped with{RESET} {BOLD}{answer}{RESET}", + answer = selected_answer )?; } else { writeln!( stdout, - " {}✓{} Selected: {}{}{}", - GREEN, RESET, BOLD, selected_answer, RESET + " {FG_OK}●{RESET} {FG_FAINT}answered{RESET} {BOLD}{answer}{RESET}", + answer = selected_answer )?; } stdout.flush()?; @@ -316,16 +320,22 @@ fn render_signal_result_cli( new_mode, already_in, } => { + // One-line outcomes use the card glyphs so the whole transcript + // speaks the same visual language: `●` something happened, + // `⊘` nothing did, `✕` it failed. if *already_in { - writeln!( + status_line( stdout, - " {ORANGE}Already in '{new_mode}' mode. No change was made.{RESET}", + "⊘", + FG_WARN, + &format!("already in '{new_mode}' mode — no change"), )?; } else { - writeln!( + status_line( stdout, - "\n{}{}🔄 Mode switched: {} → {}{}", - BOLD, BLUE, old_mode, new_mode, RESET + "●", + FG_ACCENT, + &format!("mode switched: {old_mode} → {new_mode}"), )?; } stdout.flush()?; @@ -336,13 +346,9 @@ fn render_signal_result_cli( error, } => { if *success { - writeln!( - stdout, - "\n{} {}▶ auto_compact{} Compacting conversation history...", - DIM, CYAN, RESET - )?; + status_line(stdout, "●", FG_ACCENT, "compacting conversation history…")?; } else if let Some(e) = error { - writeln!(stdout, "\n{}⚠ Auto-compact failed: {}{}", RED, e, RESET)?; + status_line(stdout, "✕", FG_ERR, &format!("auto-compact failed: {e}"))?; } stdout.flush()?; } @@ -353,22 +359,25 @@ fn render_signal_result_cli( found, } => { if *already_active { - writeln!( + status_line( stdout, - "\n{}⚠ Skill '{}' is already active.{}", - ORANGE, name, RESET + "⊘", + FG_WARN, + &format!("skill '{name}' already active"), )?; } else if *found { - writeln!( + status_line( stdout, - "\n{}{}⚡ Skill activated: {}{}{} — {}{}", - BOLD, CYAN, BOLD, name, RESET, description, RESET + "●", + FG_ACCENT, + &format!("skill activated: {name} — {description}"), )?; } else { - writeln!( + status_line( stdout, - "\n{}⚠ Skill '{}' not found — it may have been removed.{}", - RED, name, RESET + "✕", + FG_ERR, + &format!("skill '{name}' not found — it may have been removed"), )?; } stdout.flush()?; @@ -377,84 +386,29 @@ fn render_signal_result_cli( // Question is handled separately via handle_question_cli } SignalResult::ParseError { tool_name } => { - writeln!( + status_line( stdout, - "\n{}⚠ Could not parse arguments for signal tool '{}'.{}", - RED, tool_name, RESET + "✕", + FG_ERR, + &format!("could not parse arguments for signal tool '{tool_name}'"), )?; } } Ok(()) } -/// Format a duration in milliseconds as a human-readable string. -/// Under 1 second: "42ms", 1-59 seconds: "1.2s", 60+ seconds: "1m 23s" -fn format_duration(ms: u64) -> String { - if ms < 1000 { - format!("{}ms ", ms) - } else if ms < 60_000 { - format!("{:.1}s ", ms as f64 / 1000.0) - } else { - let mins = ms / 60_000; - let secs = (ms % 60_000) / 1000; - format!("{}m {}s ", mins, secs) - } -} - async fn execute_generic_tool( call: &ToolCall, tool_manager: &ToolManager, stdout: &mut W, - auto_accepted: bool, ) -> GenericToolResult { - // Show the "Executing..." header line - if auto_accepted { - if call.function.name == "run" { - if let Some(cmd) = call - .function - .arguments - .get("command") - .and_then(|v| v.as_str()) - { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - writeln!( - stdout, - "{BG_DIM} {BRIGHT_CYAN}{cmd}{FILL_EOL}{RESET}", - cmd = cmd - ) - .unwrap(); - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} Executing...{FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - stdout.flush().unwrap(); + use tinyharness_ui::ui::tool_result::{ToolStatus, tool_subject, write_tool_card}; - // Spinner state for tool execution animation + let subject = tool_subject(&call.function.arguments); + // The progress line is drawn lazily: instant calls never show it, so the + // common case stays one card per call with no flicker. It is erased once + // the real card is printed, which is why the tool/subject are not repeated + // there. let mut spinner_idx: usize = 0; let mut has_shown_spinner = false; @@ -468,7 +422,7 @@ async fn execute_generic_tool( let result = loop { tokio::select! { result = &mut tool_fut => { - // Tool finished — clear spinner line if we showed one + // Tool finished — clear the progress line if we showed one. if has_shown_spinner { write!(stdout, "\r{CLEAR_LINE}{RESET}").unwrap(); stdout.flush().unwrap(); @@ -476,114 +430,51 @@ async fn execute_generic_tool( break result; } _ = tokio::time::sleep(std::time::Duration::from_millis(80)) => { - // Animate spinner + // Animate: redraw the whole progress line each tick so a + // shrinking frame never leaves stale text behind. let frame = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()]; spinner_idx += 1; if has_shown_spinner { - write!(stdout, "\r{DIM}{frame} {RESET}").unwrap(); + write!(stdout, "\r{CLEAR_LINE}").unwrap(); } else { - write!(stdout, "{DIM}{frame} {RESET}").unwrap(); has_shown_spinner = true; } + write!( + stdout, + " {FG_ACCENT}{frame}{RESET} {FG_MUTED}{name}{RESET}{rest}", + name = call.function.name, + rest = subject + .as_deref() + .map(|s| format!(" {FG_FAINT}·{RESET} {FG_FAINT}{s}{RESET}")) + .unwrap_or_default(), + ) + .unwrap(); stdout.flush().unwrap(); } } }; let duration_ms = start_time.elapsed().as_millis() as u64; + let is_error = result.starts_with("Error:"); - // For tools that return potentially large listings, show only a summary - match call.function.name.as_str() { - "read" => { - let is_error = result.starts_with("Error:"); - let summary = result.lines().next().unwrap_or("(empty result)"); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - let summary_color = if is_error { RED } else { DIM }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{summary_color}{summary}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - summary_color = summary_color, - summary = summary - ) - .unwrap(); - } - "ls" | "grep" | "glob" => { - let is_error = result.starts_with("Error:"); - let summary = super::display::summarize_listing_result(&result, &call.function.name); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - let summary_color = if is_error { RED } else { DIM }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{summary_color}{summary}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - summary_color = summary_color, - summary = summary - ) - .unwrap(); - } - _ => { - let is_error = result.starts_with("Error:"); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - - if is_error { - // Compact single-line error: truncate to fit one line - let error_msg = result.lines().next().unwrap_or("Error"); - // Truncate at 80 chars to keep the line compact - let max_err_len = 80; - let truncated = if error_msg.len() > max_err_len { - let cut = error_msg.floor_char_boundary(max_err_len - 1); - format!("{}…", &error_msg[..cut]) - } else { - error_msg.to_string() - }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{RED}{truncated}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - truncated = truncated, - ) - .unwrap(); - } else { - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - ) - .unwrap(); - tinyharness_ui::ui::wrap::write_wrapped_lines( - stdout, - &result, - &format!("{BG_DIM} "), - &format!(" {BG_DIM}{DIM}"), - tinyharness_ui::ui::wrap::MAX_LINE_WIDTH, - true, // fill background to end of line - ) - .unwrap(); - } - } - } - writeln!(stdout, "{RESET}").unwrap(); - stdout.flush().unwrap(); + // Render the card through the shared renderer (same styling as + // session-history replay), so live and replayed transcripts match. + write_tool_card( + stdout, + &call.function.name, + subject.as_deref(), + if is_error { + ToolStatus::Error + } else { + ToolStatus::Ok + }, + Some(duration_ms), + &result, + ) + .unwrap(); // Capture audit-relevant info before returning let (audit_tool_name, audit_detail) = audit_info_for_tool(call); - let is_error = result.starts_with("Error:"); // For read tool on image files, load the image data for the model to view. // The read tool prefixes image results with "[IMAGE] path" so we can detect them. diff --git a/tinyharness-ui/src/style.rs b/tinyharness-ui/src/style.rs index 2c31901..cc362b0 100644 --- a/tinyharness-ui/src/style.rs +++ b/tinyharness-ui/src/style.rs @@ -18,18 +18,54 @@ pub const WHITE: &str = "\x1b[37m"; // Bright / extended colors pub const GRAY: &str = "\x1b[90m"; -pub const ORANGE: &str = "\x1b[38;5;208m"; +pub const ORANGE: &str = "\x1b[38;5;208m"; // Warning / notice accent (matches `Output::warning`) pub const BRIGHT_YELLOW: &str = "\x1b[93m"; pub const BRIGHT_CYAN: &str = "\x1b[96m"; +// ── Tool frame palette ───────────────────────────────────────────────────── +// +// The tool-call family (execution cards, result blocks, diffs and the +// confirmation prompt) shares one visual language: a single filled *title +// band* per block, an outlined gutter body, and status conveyed by a colored +// glyph rather than by tinting whole lines. +// +// 256-color SGR is the common denominator for the terminals this CLI targets +// (the UI crate deliberately has no terminal-capability dependency), and the +// indices below are chosen from the neutral/flat ramps that survive both +// light and dark themes: they are either background shades or saturated +// mid-lightness hues, never the pure primary 1..4 which theme authors remap +// aggressively. +pub const BG_BAND: &str = "\x1b[48;5;236m"; // Card title band (neutral charcoal) +pub const BG_PROMPT: &str = "\x1b[48;5;237m"; // Confirmation prompt band (one step lighter) + +pub const FG_FAINT: &str = "\x1b[38;5;240m"; // Gutters, timestamps, "more lines" footers +pub const FG_MUTED: &str = "\x1b[38;5;245m"; // Secondary text (argument values, snippets) +pub const FG_BAND: &str = "\x1b[38;5;252m"; // Tool name on a band (needs contrast vs BG_BAND) +pub const FG_ACCENT: &str = "\x1b[38;5;39m"; // Tool identity, paths, result titles +pub const FG_CMD: &str = "\x1b[38;5;45m"; // Shell command text +pub const FG_OK: &str = "\x1b[38;5;41m"; // Success status / additions +pub const FG_ERR: &str = "\x1b[38;5;197m"; // Failure status / removals +pub const FG_WARN: &str = "\x1b[38;5;214m"; // Denied calls, warnings, the approval prompt + +// Assistant prose: rendered in the terminal's *default* foreground so it stays +// legible against any user theme (the modern CLI convention). Emitted once, +// before the first streamed content chunk, to clear any residual styling left +// behind by the spinner, the thinking block, or ANSI sequences that happened to +// be present in the model's own output. This is the single hook for theming +// assistant text later. +pub const ASSISTANT_TEXT: &str = RESET; + // Thinking/reasoning chain colors pub const THINK_COLOR: &str = "\x1b[35m"; // Magenta for thinking text pub const THINK_COLOR_DIM: &str = "\x1b[38;5;97m"; // Dimmer magenta // Background colors (subtle, for tool call highlighting) -pub const BG_DIM: &str = "\x1b[48;5;236m"; // Dark gray bg — tool call results -pub const BG_TOOL: &str = "\x1b[48;5;237m"; // Slightly lighter gray bg — tool headers -pub const BG_WARN: &str = "\x1b[48;5;17m"; // Dark navy bg — confirmation/warning headers +#[deprecated(note = "renamed to BG_BAND — the tool card title band")] +pub const BG_DIM: &str = "\x1b[48;5;236m"; +#[deprecated(note = "tool batch headers use the outlined frame in ui::frame")] +pub const BG_TOOL: &str = "\x1b[48;5;237m"; +#[deprecated(note = "renamed to BG_PROMPT — the confirmation prompt band")] +pub const BG_WARN: &str = "\x1b[48;5;17m"; // Line fill: clears from cursor to end of line, filling with current background color pub const FILL_EOL: &str = "\x1b[K"; @@ -37,7 +73,7 @@ pub const FILL_EOL: &str = "\x1b[K"; // UI styling presets pub const TITLE_COLOR: &str = CYAN; // For titles and headers pub const BOX_COLOR: &str = BLUE; // For box borders and frames -pub const WARNING_COLOR: &str = YELLOW; // For warnings and alerts +pub const WARNING_COLOR: &str = ORANGE; // For warnings and alerts (matches `Output::warning`) pub const ACCENT_COLOR: &str = MAGENTA; // For highlights and emphasis // Special escape sequences diff --git a/tinyharness-ui/src/ui/confirm.rs b/tinyharness-ui/src/ui/confirm.rs index 76dc785..0954f49 100644 --- a/tinyharness-ui/src/ui/confirm.rs +++ b/tinyharness-ui/src/ui/confirm.rs @@ -1,72 +1,65 @@ +//! The approval prompt: the one place the CLI interrupts the user. +//! +//! Rendered in the same visual language as the tool cards (see +//! [`super::frame`]): a filled band that states what is being asked and why, +//! the arguments/preview underneath, then a single-line choice list. The band +//! is deliberately *lighter* than a card band ([`BG_PROMPT`] vs [`BG_BAND`]) +//! so a pending question never reads as a completed action. + use std::{ error::Error, io::{self, Write}, }; use super::diff::{show_edit_diff, show_write_preview}; -use super::wrap::MAX_LINE_WIDTH; +use super::frame::{ + GUTTER, GUTTER_WIDTH, gutter_line, title_case, truncate_ellipsis, write_band_on, +}; +use super::wrap::{MAX_LINE_WIDTH, wrap_line}; use tinyharness_lib::provider::ToolCall; use crate::style::*; -/// Maximum width available for the command text on the first line, -/// after the ` ` prefix (4 chars) and `$ ` (2 chars). -const CMD_FIRST_AVAIL: usize = MAX_LINE_WIDTH - 6; -/// Maximum width available for continuation lines, -/// after the ` ` prefix (4 chars) and `> ` (2 chars). -const CMD_CONT_AVAIL: usize = MAX_LINE_WIDTH - 6; +/// What the prompt is asking the user to bless, phrased as a sentence. +/// +/// Naming the *effect* (not just the tool identifier) is what makes an +/// approval prompt useful: `run` means nothing on its own, "execute a shell +/// command" tells you what could happen. +fn describe(name: &str) -> &'static str { + match name { + "run" => "execute a shell command", + "write" => "create or overwrite a file", + "edit" => "modify a file in place", + "read" => "read a file", + "ls" => "list a directory", + "grep" => "search file contents", + "glob" => "match files by pattern", + "web_search" => "search the web", + "web_fetch" => "fetch a web page", + "screenshot" => "capture a screenshot", + _ => "", + } +} + +/// Columns left for command text after the gutter and `$ ` prefix. +const CMD_PREFIX_WIDTH: usize = GUTTER_WIDTH + 2; /// Display a shell command, splitting it across multiple lines at word /// boundaries when it exceeds the available terminal width. /// -/// Each line has BG_WARN background filling the full terminal width. +/// Drawn inside the frame's gutter with a `$` prompt prefix, so a wrapped +/// multi-line command still reads as one quoted statement. Rows stay open +/// ([`GUTTER`], never an elbow): the prompt's closing `▲` line ends the +/// block, and nested diff cards close themselves. fn write_command_lines(stdout: &mut W, cmd: &str) -> Result<(), Box> { - // BG_WARN starts at column 0, no RESET until end of line. - let prefix_first = format!("{BG_WARN} {BOLD}{WHITE}$ "); - let prefix_cont = format!("{BG_WARN} {DIM}>{WHITE} "); - - // Split at spaces for word-wrapping - let mut remaining = cmd; - let mut first = true; - while !remaining.is_empty() { - let (prefix, avail) = if first { - first = false; - (prefix_first.as_str(), CMD_FIRST_AVAIL) - } else { - (prefix_cont.as_str(), CMD_CONT_AVAIL) - }; - - if remaining.len() <= avail { - writeln!( - stdout, - "{prefix}{BRIGHT_CYAN}{remaining}{FILL_EOL}{RESET}", - remaining = remaining - )?; - break; - } - - // Find the last space within `avail` characters - let chunk_end = remaining.floor_char_boundary(avail); - let chunk = &remaining[..chunk_end]; - let split_at = match chunk.rfind(' ') { - Some(pos) if pos > 0 => pos, - _ => { - // No space found — hard-break at width limit - writeln!( - stdout, - "{prefix}{BRIGHT_CYAN}{chunk}{FILL_EOL}{RESET}", - chunk = &remaining[..chunk_end] - )?; - remaining = remaining[chunk_end..].trim_start(); - continue; - } - }; - writeln!( + let avail = MAX_LINE_WIDTH - CMD_PREFIX_WIDTH; + for (i, row) in wrap_line(cmd, avail, avail).into_iter().enumerate() { + let prefix = if i == 0 { "$ " } else { "> " }; + gutter_line( stdout, - "{prefix}{BRIGHT_CYAN}{chunk}{FILL_EOL}{RESET}", - chunk = &chunk[..split_at] + GUTTER, + &format!("{FG_FAINT}{prefix}{RESET}{FG_CMD}{row}{RESET}", row = row), )?; - remaining = remaining[chunk[..split_at].len()..].trim_start(); } Ok(()) } @@ -81,10 +74,10 @@ pub enum Confirmation { AutoAccept, } -/// Display a tool confirmation header and prompt the user. +/// Display a tool confirmation prompt and ask the user to confirm. /// -/// Shows a bordered box with the tool name, relevant arguments, and optional -/// diff/preview content, then asks the user to confirm. +/// Shows a band naming the tool and its target, the remaining arguments and +/// any diff/command preview, then a one-line choice list. pub fn prompt_tool_confirmation( stdout: &mut W, call: &ToolCall, @@ -92,12 +85,27 @@ pub fn prompt_tool_confirmation( let name = &call.function.name; let args = &call.function.arguments; - // ── Header ── - writeln!( - stdout, - "\n{BG_WARN} {WHITE}─── {BRIGHT_YELLOW}⚠ {WHITE}{name}{WHITE} ───{FILL_EOL}{RESET}", - name = name - )?; + // ── Title band ── + // Glyph + question + tool + effect, so the reason for the interruption is + // on the same line as the thing being interrupted. + let effect = describe(name); + let mut segments: Vec<(&str, &str)> = vec![ + ("▲", FG_WARN), + (" ", FG_FAINT), + ("Allow?", FG_WARN), + (" · ", FG_FAINT), + ]; + // Known tools get their effect spelled out; custom tools fall back to a + // humanized form of their identifier rather than the raw wire name. + let label = title_case(name); + if effect.is_empty() { + segments.push((label.as_str(), FG_BAND)); + } else { + segments.push((name.as_str(), FG_BAND)); + segments.push((" — ", FG_FAINT)); + segments.push((effect, FG_MUTED)); + } + write_band_on(stdout, BG_PROMPT, &segments)?; // ── Arguments (skip large fields already shown in diff/preview) ── let skip_keys: &[&str] = match name.as_str() { @@ -113,20 +121,20 @@ pub fn prompt_tool_confirmation( continue; } let val_str = match val { - serde_json::Value::String(s) => { - if s.len() > 100 { - format!("{}... ({} chars)", &s[..97], s.len()) - } else { - s.clone() - } - } + serde_json::Value::String(s) => truncate_ellipsis( + s.lines().next().unwrap_or_default(), + MAX_LINE_WIDTH - GUTTER_WIDTH - key.len() - 2, + ), other => other.to_string(), }; - writeln!( + gutter_line( stdout, - "{BG_WARN} {BRIGHT_CYAN}{key}: {WHITE}{val}{FILL_EOL}{RESET}", - key = key, - val = val_str + GUTTER, + &format!( + "{FG_MUTED}{key}:{RESET} {FG_ACCENT}{val}{RESET}", + key = key, + val = val_str + ), )?; } } @@ -151,7 +159,7 @@ pub fn prompt_tool_confirmation( } } - // ── Diff / preview for write (shown after the box) ── + // ── Diff / preview for write (shown after the band) ── if let serde_json::Value::Object(map) = args { let path = map.get("path").and_then(|v| v.as_str()).unwrap_or(""); if !path.trim().is_empty() && name == "write" { @@ -162,12 +170,16 @@ pub fn prompt_tool_confirmation( } } - // ── Footer with prompt ── - writeln!( + // ── Choice list ── + // Part of the same frame as the band above: the question was asked there, + // the details are between, so this line only offers the answers. Brackets + // mark the accepted keystroke, and every option is spelled out so the + // user never has to remember what `a` meant three prompts ago. + write!( stdout, - "{BG_WARN} {DIM}───────────────────────────────{FILL_EOL}{RESET}" + " {FG_WARN}▲{RESET} {BOLD}[y]{RESET}{FG_FAINT}es{RESET} {BOLD}[n]{RESET}{FG_FAINT}o{RESET} \ + {BOLD}[a]{RESET}{FG_FAINT}uto-accept the rest of this turn{RESET} {FG_FAINT}›{RESET} " )?; - write!(stdout, " {BOLD}Allow? {GREEN}y{BOLD}/n/a{RESET}: ")?; stdout.flush()?; let mut input = String::new(); @@ -194,57 +206,57 @@ mod tests { re.replace_all(s, "").to_string() } + // ── command rendering ── + #[test] fn test_short_command_single_line() { let mut buf = Vec::new(); write_command_lines(&mut buf, "ls -la").unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); - assert!( - output.contains("$ ls -la\n"), - "short command should be on a single line, got:\n{output}" - ); - assert!( - !output.contains(">"), - "short command should not have continuation lines, got:\n{output}" - ); + assert_eq!(output, " │ $ ls -la\n", "rows stay open until the ▲ line"); } #[test] fn test_long_command_wraps() { - // Build a command that exceeds MAX_LINE_WIDTH - 4 chars - let long_cmd: Vec = (0..50).map(|i| format!("arg{i}")).collect(); + // Build a command that exceeds the line budget + let long_cmd: Vec = (0..60).map(|i| format!("argument_number_{i}")).collect(); let cmd = long_cmd.join(" "); - assert!( - cmd.len() > CMD_FIRST_AVAIL, - "test command must exceed first-line limit" - ); let mut buf = Vec::new(); write_command_lines(&mut buf, &cmd).unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); let lines: Vec<&str> = output.lines().collect(); + assert!(lines.len() > 1, "long command should wrap: {output}"); + assert!(lines[0].starts_with(" │ $ "), "first line has $ prompt"); assert!( - lines.len() > 1, - "long command should wrap to multiple lines, got:\n{output}" - ); - assert!(lines[0].contains("$ "), "first line should have $ prompt"); - assert!( - lines[1].contains("> "), - "continuation lines should have > prompt" + lines[1..].iter().all(|l| l.starts_with(" │ > ")), + "continuation lines use > and stay in the gutter" ); + for line in &lines { + assert!(line.chars().count() <= MAX_LINE_WIDTH, "overflow: {line}"); + } } #[test] fn test_command_no_spaces_hard_breaks() { // A very long string with no spaces should hard-break - let cmd = "a".repeat(CMD_FIRST_AVAIL + 50); + let cmd = "a".repeat(MAX_LINE_WIDTH); let mut buf = Vec::new(); write_command_lines(&mut buf, &cmd).unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); - let lines: Vec<&str> = output.lines().collect(); assert!( - lines.len() > 1, - "no-space command should hard-break, got:\n{output}" + output.lines().count() > 1, + "no-space command should hard-break" ); } + + // ── description ── + + #[test] + fn known_tools_describe_their_effect() { + assert_eq!(describe("run"), "execute a shell command"); + assert_eq!(describe("write"), "create or overwrite a file"); + // Unknown/custom tools get no invented description. + assert!(describe("my_tool").is_empty()); + } } diff --git a/tinyharness-ui/src/ui/diff.rs b/tinyharness-ui/src/ui/diff.rs index b1b1e6c..799657e 100644 --- a/tinyharness-ui/src/ui/diff.rs +++ b/tinyharness-ui/src/ui/diff.rs @@ -1,5 +1,10 @@ -use std::{error::Error, io::Write}; +use std::{ + error::Error, + io::{self, Write}, +}; +use super::frame::{FOOT, GAP, GUTTER, GUTTER_WIDTH, gutter_line, truncate_ellipsis, write_band}; +use super::wrap::MAX_LINE_WIDTH; use crate::style::*; // ── Diff computation (LCS-based algorithm) ───────────────────────────────── @@ -86,157 +91,217 @@ pub fn compute_diff<'a>(old: &'a [&str], new: &'a [&str]) -> Vec> { /// Context lines to show around each change in unified diff output. const DIFF_CONTEXT_LINES: usize = 3; -/// Render a [`DiffLine`] sequence into unified-diff-style output with line -/// numbers, `+`/`-`/` ` prefixes, and configurable context around changes. -#[allow(unused_assignments)] -fn render_diff_lines( - stdout: &mut W, - diff: &[DiffLine], - old_lines: &[&str], - new_lines: &[&str], - show_line_numbers: bool, -) -> Result<(), Box> { - if diff.is_empty() { - return Ok(()); +// ── Public API ───────────────────────────────────────────────────────────── + +/// Diff-card glyph — "not equal", marking the block as a change preview +/// rather than a completed tool result. +const DIFF_GLYPH: &str = "≠"; + +/// Which side of the diff a rendered row belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Keep, + Remove, + Add, +} + +impl Kind { + /// One-character change indicator, in the column before the line number. + fn sign(self) -> char { + match self { + Kind::Keep => ' ', + Kind::Remove => '-', + Kind::Add => '+', + } } - // Compute line number width - let max_line = old_lines.len().max(new_lines.len()).max(1); - let num_width = if show_line_numbers { - max_line.to_string().len().max(2) - } else { - 0 - }; + fn color(self) -> &'static str { + match self { + Kind::Keep => FG_FAINT, + Kind::Remove => FG_ERR, + Kind::Add => FG_OK, + } + } +} - // Find hunks: groups of changes with context around them. - let change_indices: Vec = diff +/// One row of a diff card. +enum DiffRow { + /// A source line, with the number of the side it belongs to. + Line { + kind: Kind, + num: usize, + text: String, + }, + /// Hunk boundary: how many unchanged rows were skipped. + Gap(usize), +} + +/// Number every row of a computed diff with the line number of its own side, +/// so a reader can jump straight to the location in their editor. +fn numbered(diff: &[DiffLine]) -> Vec<(Kind, usize, String)> { + let (mut old, mut new) = (0usize, 0usize); + diff.iter() + .map(|item| match item { + DiffLine::Keep(t) => { + old += 1; + new += 1; + (Kind::Keep, old, (*t).to_string()) + } + DiffLine::Remove(t) => { + old += 1; + (Kind::Remove, old, (*t).to_string()) + } + DiffLine::Add(t) => { + new += 1; + (Kind::Add, new, (*t).to_string()) + } + }) + .collect() +} + +/// Collapse numbered rows into hunks around the changes, keeping `context` +/// unchanged lines on each side and replacing everything else with a +/// [`DiffRow::Gap`] that says how much was hidden. +/// +/// Never hides a change — only context — so the preview always shows every +/// line the tool would touch. +fn hunked(rows: &[(Kind, usize, String)], context: usize) -> Vec { + let changed: Vec = rows .iter() .enumerate() - .filter_map(|(i, l)| matches!(l, DiffLine::Remove(_) | DiffLine::Add(_)).then_some(i)) + .filter(|(_, (k, ..))| *k != Kind::Keep) + .map(|(i, _)| i) .collect(); - - if change_indices.is_empty() { - return Ok(()); + if changed.is_empty() { + return vec![]; } - // Merge overlapping/adjacent hunks - let mut hunk_ranges: Vec<(usize, usize)> = Vec::new(); - let mut hunk_start = change_indices[0].saturating_sub(DIFF_CONTEXT_LINES); - let mut hunk_end = (change_indices[0] + DIFF_CONTEXT_LINES + 1).min(diff.len()); - - for &idx in &change_indices[1..] { - let new_start = idx.saturating_sub(DIFF_CONTEXT_LINES); - let new_end = (idx + DIFF_CONTEXT_LINES + 1).min(diff.len()); - - if new_start <= hunk_end { - hunk_end = hunk_end.max(new_end); + // Merge overlapping windows into hunk ranges. + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut start = changed[0].saturating_sub(context); + let mut end = (changed[0] + context + 1).min(rows.len()); + for &i in &changed[1..] { + let lo = i.saturating_sub(context); + let hi = (i + context + 1).min(rows.len()); + if lo <= end { + end = end.max(hi); } else { - hunk_ranges.push((hunk_start, hunk_end)); - hunk_start = new_start; - hunk_end = new_end; + ranges.push((start, end)); + start = lo; + end = hi; } } - hunk_ranges.push((hunk_start, hunk_end)); + ranges.push((start, end)); - // Render each hunk - for (hunk_idx, &(start, end)) in hunk_ranges.iter().enumerate() { - // Add separator between hunks - if hunk_idx > 0 { - let sep_line = format!( - "{} {}{}", - DIM, - BOX_COLOR, - "·".repeat(if show_line_numbers { - num_width + 1 + 3 + 60 - } else { - 3 + 60 - }) - ); - writeln!(stdout, "{}{}", sep_line, RESET)?; + let mut out: Vec = Vec::new(); + let mut cursor = 0usize; + for (lo, hi) in ranges { + if lo > cursor { + out.push(DiffRow::Gap(lo - cursor)); } - - // Count line numbers up to the start of this hunk - let mut old_num: usize = 0; - #[allow(unused_variables)] - let mut new_num: usize = 0; - for item in diff.iter().take(start) { - match item { - DiffLine::Keep(_) => { - old_num += 1; - new_num += 1; - } - DiffLine::Remove(_) => { - old_num += 1; - } - DiffLine::Add(_) => { - new_num += 1; - } - } + for (kind, num, text) in &rows[lo..hi] { + out.push(DiffRow::Line { + kind: *kind, + num: *num, + text: text.clone(), + }); } + cursor = hi; + } + out +} - for i in start..end { - if i >= diff.len() { - break; +/// Draw a diff card: a `≠` title band naming the operation and target, then +/// gutter-outlined rows carrying line numbers and `-`/`+` signs. +/// +/// `stat` is a pre-styled trailing segment for the band (change counts, or a +/// note like `new file`); pass an empty string to omit it. When `rows` is +/// empty the card is just the band — the "nothing to change" case. +fn write_diff_card( + w: &mut W, + op: &str, + path: &str, + stat: &str, + rows: &[DiffRow], +) -> io::Result<()> { + // `stat` may carry its own inline colors (e.g. `-3 +7`), so it is passed + // through as an unstyled segment. + let mut segments: Vec<(&str, &str)> = vec![ + (DIFF_GLYPH, FG_ACCENT), + (" ", FG_FAINT), + (op, FG_BAND), + (" · ", FG_FAINT), + (path, FG_ACCENT), + ]; + if !stat.is_empty() { + segments.push((" · ", FG_FAINT)); + segments.push((stat, "")); + } + write_band(w, &segments)?; + if rows.is_empty() { + return Ok(()); + } + + let num_width = rows + .iter() + .map(|r| match r { + DiffRow::Line { num, .. } => *num, + DiffRow::Gap(_) => 0, + }) + .max() + .unwrap_or(1) + .to_string() + .len() + .max(2); + // Columns left for source text after the gutter, sign, number and space. + let text_width = MAX_LINE_WIDTH.saturating_sub(GUTTER_WIDTH + num_width + 3); + let last = rows.len() - 1; + + for (i, row) in rows.iter().enumerate() { + match row { + DiffRow::Line { kind, num, text } => { + let glyph = if i == last { FOOT } else { GUTTER }; + let color = kind.color(); + gutter_line( + w, + glyph, + &format!( + "{color}{sign}{RESET}{FG_FAINT}{num:>width$}{RESET} {color}{text}{RESET}", + sign = kind.sign(), + num = num, + width = num_width, + text = truncate_ellipsis(text, text_width) + ), + )?; } - match &diff[i] { - DiffLine::Keep(line) => { - old_num += 1; - new_num += 1; - if show_line_numbers { - writeln!( - stdout, - " {:>width$} {} {}", - old_num, - DIM, - line, - width = num_width, - )?; - } else { - writeln!(stdout, " {} {}{}", DIM, line, RESET)?; - } - } - DiffLine::Remove(line) => { - old_num += 1; - if show_line_numbers { - writeln!( - stdout, - "{} {: { - new_num += 1; - if show_line_numbers { - writeln!( - stdout, - "{} {: { + gutter_line( + w, + GAP, + &format!( + "{FG_FAINT}{skipped} unchanged lines{RESET}", + skipped = skipped + ), + )?; } } } - Ok(()) } -// ── Public API ───────────────────────────────────────────────────────────── +/// Count removed/added rows and format them as a band stat (`-3 +7`). +fn stat_of(diff: &[DiffLine]) -> String { + let removals = diff + .iter() + .filter(|l| matches!(l, DiffLine::Remove(_))) + .count(); + let additions = diff + .iter() + .filter(|l| matches!(l, DiffLine::Add(_))) + .count(); + format!("{FG_ERR}-{removals}{RESET} {FG_OK}+{additions}{RESET}") +} /// Show a unified-diff-style view of a write operation (full file). /// If the file already exists, reads it and shows a line-by-line diff @@ -247,94 +312,37 @@ pub fn show_write_preview( path: &str, new_content: &str, ) -> Result<(), Box> { - let existing = std::fs::read_to_string(path); + let new_lines: Vec<&str> = new_content.lines().collect(); - let old_lines: Vec<&str> = match &existing { - Ok(content) => content.lines().collect(), - Err(_) => { - // File doesn't exist — show a green preview - writeln!( - stdout, - "\n{} ── {}New file: {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; - writeln!( - stdout, - "{} {}{} {}", - DIM, - BOX_COLOR, - "┄".repeat(60), - RESET - )?; - for line in new_content.lines() { - writeln!(stdout, "{} + {}{}", GREEN, RESET, line)?; - } - writeln!(stdout, "{} {} {}", DIM, "┄".repeat(60), RESET)?; - return Ok(()); - } + let Ok(existing) = std::fs::read_to_string(path) else { + // File doesn't exist — every line is an addition. + let rows: Vec = new_lines + .iter() + .enumerate() + .map(|(i, t)| DiffRow::Line { + kind: Kind::Add, + num: i + 1, + text: (*t).to_string(), + }) + .collect(); + let stat = format!( + "{FG_OK}new file{RESET} {FG_FAINT}({} lines)", + new_lines.len() + ); + write_diff_card(stdout, "write", path, &stat, &rows)?; + return Ok(()); }; - let new_lines: Vec<&str> = new_content.lines().collect(); - - // Compute the diff + let old_lines: Vec<&str> = existing.lines().collect(); let diff = compute_diff(&old_lines, &new_lines); - - // If the file is identical, show a note - let has_changes = diff.iter().any(|l| !matches!(l, DiffLine::Keep(_))); - - if !has_changes { - writeln!( - stdout, - "\n{} ── {}No changes in {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; + if !diff.iter().any(|l| !matches!(l, DiffLine::Keep(_))) { + write_diff_card(stdout, "write", path, "no changes", &[])?; return Ok(()); } - // Count additions and removals for the header - let removals = diff - .iter() - .filter(|l| matches!(l, DiffLine::Remove(_))) - .count(); - let additions = diff - .iter() - .filter(|l| matches!(l, DiffLine::Add(_))) - .count(); - - writeln!( - stdout, - "\n{} ── {}Diff for {}{} ── {}{}-{}{}+{}{}", - BOLD, BOX_COLOR, path, BOLD, RED, removals, GREEN, additions, RESET, RESET, - )?; - - let max_line = old_lines.len().max(new_lines.len()).max(1); - let num_width = max_line.to_string().len().max(2); - - // Separator line - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(num_width), - "┄".repeat(60), - RESET - )?; - - // Render the diff with line numbers - render_diff_lines(stdout, &diff, &old_lines, &new_lines, true)?; - - // Ending separator - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(num_width), - "┄".repeat(60), - RESET - )?; - + let rows = hunked(&numbered(&diff), DIFF_CONTEXT_LINES); + let stat = stat_of(&diff); + write_diff_card(stdout, "write", path, &stat, &rows)?; Ok(()) } @@ -347,120 +355,84 @@ pub fn show_edit_diff( old_str: &str, new_str: &str, ) -> Result<(), Box> { - let content = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read '{}': {}", path, e))?; + let content = std::fs::read_to_string(path) + .map_err(|e| -> Box { format!("Failed to read '{path}': {e}").into() })?; // Find the byte offset of old_str in the content - let offset = match content.find(old_str) { - Some(o) => o, - None => { - writeln!( - stdout, - " {}[diff error: 'old_str' not found in file]{}", - RED, RESET - )?; - return Ok(()); - } + let Some(offset) = content.find(old_str) else { + let rows = vec![DiffRow::Line { + kind: Kind::Remove, + num: 0, + text: "'old_str' not found in file — the edit would fail".to_string(), + }]; + return write_diff_card(stdout, "edit", path, "match failed", &rows).map_err(Into::into); }; // Count newlines before the match to get the line number (0-based) let line_number = content[..offset].matches('\n').count(); let lines: Vec<&str> = content.lines().collect(); - - // Split old_str and new_str into lines let old_lines: Vec<&str> = old_str.lines().collect(); let new_lines: Vec<&str> = new_str.lines().collect(); - // Determine context window (show up to 2 lines before and after) + // Show a couple of lines of context around the replacement so the change + // has a location, not just a content. let before_ctx = 2usize; let after_ctx = 2usize; let start_line = line_number.saturating_sub(before_ctx); - let end_line = (line_number + old_lines.len() + after_ctx).min(lines.len()); - let line_num_width = (end_line + 1).to_string().len().max(2); - - writeln!( - stdout, - "\n{} ── {}Diff for {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; - - // Separator line - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(line_num_width), - "┄".repeat(60), - RESET - )?; - - // Lines before the change - for (i, line) in lines.iter().enumerate().take(line_number).skip(start_line) { - writeln!( - stdout, - " {:>width$} {} {}", - i + 1, - DIM, - line, - width = line_num_width, - )?; - } - // Removed lines (old_str) – shown in red with '-' - for line in old_lines.iter() { - writeln!( - stdout, - "{} {: = Vec::new(); + if start_line > 0 { + rows.push(DiffRow::Gap(start_line)); } - - // Added lines (new_str) – shown in green with '+' - for line in new_lines.iter() { - writeln!( - stdout, - "{} {:width$} {} {}", - i + 1, - DIM, - lines[i], - width = line_num_width, - )?; - } - } - - // Ending separator - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(line_num_width), - "┄".repeat(60), - RESET - )?; - + let end_line = (after_start + after_ctx).min(lines.len()); + for (i, line) in lines.iter().enumerate().take(end_line).skip(after_start) { + rows.push(DiffRow::Line { + kind: Kind::Keep, + num: i + 1, + text: line.to_string(), + }); + } + let tail = lines.len().saturating_sub(end_line); + if tail > 0 { + rows.push(DiffRow::Gap(tail)); + } + + let stat = format!( + "{FG_ERR}-{n}{RESET} {FG_OK}+{m}{RESET}", + n = old_lines.len(), + m = new_lines.len() + ); + write_diff_card(stdout, "edit", path, &stat, &rows)?; Ok(()) } diff --git a/tinyharness-ui/src/ui/frame.rs b/tinyharness-ui/src/ui/frame.rs new file mode 100644 index 0000000..3914ce9 --- /dev/null +++ b/tinyharness-ui/src/ui/frame.rs @@ -0,0 +1,197 @@ +//! Shared visual primitives for the *tool frame* family. +//! +//! Every piece of tool I/O — the execution card, the diff previews and the +//! confirmation prompt — is drawn from these parts so all three read as one +//! language: +//! +//! ```text +//! ● run · cargo test --all · 1.2s ← filled title band (`write_band`) +//! │ running 199 tests ← outlined gutter (`gutter_line`) +//! ╰ +40 more lines ← closing elbow marks the last row +//! ``` +//! +//! Rules the family follows: +//! - **One filled band per block, and only for the title.** Whole-line +//! background fills on body rows are loud and make output hard to scan; +//! the band anchors the eye, the gutter carries the structure. +//! - **The gutter always closes with `╰`.** The elbow tells you where a block +//! ends without needing a blank line or a rule. +//! - **Status lives in the glyph, not the body.** `●`/`✕`/`⊘` in +//! [`FG_OK`]/[`FG_ERR`]/[`FG_WARN`] on the title band; body rows stay muted +//! so the exception (an error line) is the only thing that can be loud. + +use std::io::{self, Write}; + +use crate::style::*; + +/// Left margin of every tool frame line. Two columns, matching the assistant +/// prose indent, so tool output nests visually under the message that asked +/// for it. +pub const INDENT: &str = " "; +/// Gutter glyph for body rows. +pub const GUTTER: &str = "│"; +/// Gutter glyph marking a gap between two hunks of the same block (skipped +/// context lines in a diff, hidden middle rows). +pub const GAP: &str = "┆"; +/// Gutter glyph closing the last row of a block. +pub const FOOT: &str = "╰"; + +/// Columns consumed by `INDENT + GUTTER + " "` — subtract from the line budget +/// to get the space available for body text. +pub const GUTTER_WIDTH: usize = 4; + +/// Truncate `s` to at most `max` *characters*, appending `…` when truncation +/// occurred. +/// +/// Character-based (not byte-based) so a multibyte string is cut to the number +/// of columns it will actually occupy. +pub fn truncate_ellipsis(s: &str, max: usize) -> String { + if s.char_indices().nth(max).is_none() { + return s.to_string(); + } + let kept: String = s.chars().take(max.saturating_sub(1)).collect(); + kept + "…" +} + +/// Write one filled title band from pre-composed styled content, on a +/// specific background. +/// +/// `styled` may contain its own escape sequences; any embedded [`RESET`] +/// clears the background mid-line, so the background is re-opened before the +/// fill to guarantee the band always reaches the margin. +fn band_line_on(w: &mut W, bg: &str, styled: &str) -> io::Result<()> { + writeln!(w, "{bg}{INDENT}{styled}{bg}{FILL_EOL}{RESET}") +} + +/// Write a filled title band from `(text, style)` segments, on a specific +/// background. +/// +/// The background is restored between segments so the band is always +/// continuous — an embedded [`RESET`] clears the background as well as the +/// color, which is the easy thing to get wrong when hand-building bands. +pub fn write_band_on(w: &mut W, bg: &str, segments: &[(&str, &str)]) -> io::Result<()> { + let mut styled = String::new(); + for (text, style) in segments { + styled.push_str(&format!("{style}{text}{RESET}{bg}")); + } + band_line_on(w, bg, &styled) +} + +/// Write a filled title band on the standard card background. +pub fn write_band(w: &mut W, segments: &[(&str, &str)]) -> io::Result<()> { + write_band_on(w, BG_BAND, segments) +} + +/// Write one gutter row of a tool frame. +/// +/// Pass [`GUTTER`] for every row except the last, and [`FOOT`] for the last, +/// so the block reads as a closed shape. `styled` is the row content, already +/// carrying whatever inline styling it needs. +pub fn gutter_line(w: &mut W, glyph: &str, styled: &str) -> io::Result<()> { + writeln!(w, "{INDENT}{FG_FAINT}{glyph}{RESET} {styled}{RESET}") +} + +/// Write a single status line (no card body), for signal tools and other +/// one-line outcomes that share the card glyph vocabulary. +pub fn status_line(w: &mut W, glyph: &str, color: &str, text: &str) -> io::Result<()> { + writeln!(w, "{INDENT}{color}{glyph}{RESET} {FG_MUTED}{text}{RESET}") +} + +/// `deploy_to_staging` → `Deploy to staging`. +/// +/// Human-facing labels (the confirmation prompt's description of an unknown +/// tool) should read like a sentence, not like a wire-format identifier: +/// words separated, only the first letter capitalized. +pub fn title_case(name: &str) -> String { + let sentence = name + .split(['_', '-']) + .filter(|w| !w.is_empty()) + .map(str::to_lowercase) + .collect::>() + .join(" "); + let mut chars = sentence.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); + re.replace_all(s, "").to_string() + } + + fn render_gutter(glyph: &str, text: &str) -> String { + let mut buf = Vec::new(); + gutter_line(&mut buf, glyph, &format!("{FG_FAINT}{text}{RESET}")).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn gutter_row_is_indented_and_reset() { + let out = render_gutter(GUTTER, "hello"); + assert_eq!(strip_ansi(&out), " │ hello\n"); + assert!(out.ends_with(&format!("{RESET}\n"))); + } + + #[test] + fn gutter_width_matches_the_rendered_prefix() { + let out = render_gutter(GUTTER, ""); + let prefix = strip_ansi(&out); + assert_eq!(prefix.chars().count(), GUTTER_WIDTH + 1); + } + + #[test] + fn band_fills_to_end_of_line() { + let mut buf = Vec::new(); + write_band(&mut buf, &[("title", FG_BAND)]).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(strip_ansi(&out), " title\n"); + assert!(out.contains(BG_BAND)); + assert!(out.contains(FILL_EOL)); + } + + #[test] + fn segmented_band_keeps_the_background_continuous() { + let mut buf = Vec::new(); + write_band(&mut buf, &[("●", FG_OK), (" run", FG_MUTED)]).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(strip_ansi(&out), " ● run\n"); + // Every RESET that ends a segment must be followed by the background + // again (or the line-ending fill), or the band would show holes. The + // final RESET legitimately ends the line, so it is not inspected. + let chunks: Vec<&str> = out.split("\x1b[0m").collect(); + for chunk in &chunks[1..chunks.len() - 1] { + assert!( + chunk.starts_with(BG_BAND) || chunk.starts_with(FILL_EOL), + "background must reopen after each segment, got {chunk:?}" + ); + } + } + + #[test] + fn title_case_humanizes_identifiers() { + assert_eq!(title_case("deploy_to_staging"), "Deploy to staging"); + assert_eq!(title_case("run"), "Run"); + assert_eq!(title_case("my-tool"), "My tool"); + assert_eq!(title_case("__"), ""); + } + + #[test] + fn truncate_ellipsis_is_char_boundary_safe() { + let s = "żółw".repeat(50); + let t = truncate_ellipsis(&s, 10); + assert!(t.ends_with('…')); + assert_eq!(t.chars().count(), 10); + } + + #[test] + fn truncate_ellipsis_leaves_short_text_alone() { + assert_eq!(truncate_ellipsis("short", 10), "short"); + assert_eq!(truncate_ellipsis("exact", 5), "exact"); + } +} diff --git a/tinyharness-ui/src/ui/markdown.rs b/tinyharness-ui/src/ui/markdown.rs new file mode 100644 index 0000000..3cbd40a --- /dev/null +++ b/tinyharness-ui/src/ui/markdown.rs @@ -0,0 +1,670 @@ +//! A conservative, dependency-free markdown renderer for terminal output. +//! +//! Model responses are streamed token-by-token, which makes full re-rendering +//! impossible — styling markers (`` ` ``, `**`, fences) arrive split across +//! chunks. This module solves that by buffering **lines**: text is emitted as +//! soon as a newline finalizes it, so output still feels live while constructs +//! like fenced code blocks, headings, and lists are styled correctly. +//! +//! Design rules: +//! - **Never lose text.** If a construct can't be completed (unmatched `**`, +//! unterminated link, etc.) the raw characters are emitted untouched. +//! - **Styling can't leak.** If the model emits raw ANSI sequences in its +//! prose, each affected line is closed with `RESET` so stray styling never +//! bleeds into subsequent lines or UI output. +//! - Single-`*` italics and `_underscores_` are deliberately *not* styled — +//! they appear too often in code/glob/math contexts to detect safely. +//! - ANSI is only emitted for constructs that are actually detected, so plain +//! prose streams through with zero escape-sequence noise. +//! +//! [`MarkdownStream`] is the incremental (live) API; [`render_markdown`] is the +//! one-shot API used for re-printing saved history. + +use std::io::{self, Write}; + +use crate::style::*; + +/// Incremental markdown renderer for streamed content. +/// +/// Feed deltas with [`push`](Self::push); call [`finish`](Self::finish) when +/// the generation ends (including on interrupt/error) to flush the trailing +/// partial line. +#[derive(Debug, Default)] +pub struct MarkdownStream { + core: MdCore, + /// Whether the initial [`ASSISTANT_TEXT`] style has been emitted. + started: bool, +} + +impl MarkdownStream { + pub fn new() -> Self { + Self::default() + } + + /// Consume a streamed content delta, emitting finished lines. + pub fn push(&mut self, w: &mut W, delta: &str) -> io::Result<()> { + if delta.is_empty() { + return Ok(()); + } + if !self.started { + // Reset any styling left over from the spinner / thinking block / + // model-emitted escapes before the first visible content. + w.write_all(ASSISTANT_TEXT.as_bytes())?; + self.started = true; + } + for ch in delta.chars() { + if ch == '\n' { + let line = std::mem::take(&mut self.core.line_buf); + let styled = self.core.finalize_line(&line); + w.write_all(styled.as_bytes())?; + w.write_all(b"\n")?; + } else if ch != '\r' { + self.core.line_buf.push(ch); + } + } + Ok(()) + } + + /// Flush the trailing partial line (if any) without adding a newline. + /// + /// Note: the instance is *not* reusable after `finish` in practice — + /// `started` stays `true`, so no redundant second reset is emitted. Use a + /// fresh [`MarkdownStream`] per generation. + pub fn finish(&mut self, w: &mut W) -> io::Result<()> { + if !self.core.line_buf.is_empty() { + let line = std::mem::take(&mut self.core.line_buf); + let styled = self.core.finalize_line(&line); + w.write_all(styled.as_bytes())?; + } + self.core.fence = None; + Ok(()) + } +} + +/// One-shot renderer for complete texts (e.g. re-printing a saved session). +pub fn render_markdown(text: &str) -> String { + let mut core = MdCore::default(); + let mut out = String::with_capacity(text.len()); + for line in text.split('\n') { + out.push_str(&core.finalize_line(line.trim_end_matches('\r'))); + out.push('\n'); + } + // `split` on a trailing '\n' yields a final empty fragment that must not + // become an extra blank line. + if text.ends_with('\n') && out.ends_with("\n\n") { + out.truncate(out.len() - 1); + } + out +} + +#[derive(Debug, Default)] +struct MdCore { + line_buf: String, + /// Opening fence marker of an open code block (``` or ~~~). + fence: Option, +} + +impl MdCore { + /// Style one complete line. May return an empty string (consumed lines, + /// e.g. code-fence delimiters). + /// + /// Wraps [`Self::style_line`] with ANSI containment: if the model emitted raw + /// escape sequences in its prose, they must not leak styling into subsequent + /// lines. Paragraph and list-item tails don't otherwise end with `RESET`, so + /// append one when the raw line contained ESC and the styled output doesn't + /// already end in a reset (idempotent, so this is always safe). + fn finalize_line(&mut self, line: &str) -> String { + let styled = self.style_line(line); + if line.contains('\x1b') && !styled.ends_with(RESET) { + styled + RESET + } else { + styled + } + } + + /// Core line styler — see [`Self::finalize_line`]. + fn style_line(&mut self, line: &str) -> String { + let trimmed = line.trim(); + + // ── Inside a fenced code block ── + if let Some(marker) = &self.fence { + if trimmed.starts_with(marker.as_str()) { + // Closing fence: hide it. + self.fence = None; + return String::new(); + } + let mut out = String::with_capacity(line.len() + 16); + out.push_str(GRAY); + out.push_str(" "); + out.push_str(line.trim_start()); + out.push_str(RESET); + return out; + } + if let Some(lang) = fence_opening(trimmed) { + self.fence = Some(lang.0); + let mut out = String::new(); + if !lang.1.is_empty() { + out.push_str(DIM); + out.push_str(ITALIC); + out.push_str(&lang.1); + out.push_str(RESET); + } + return out; + } + + // ── Block constructs (matched on trimmed text) ── + if let Some((level, text)) = heading(trimmed) { + let color = if level <= 2 { TITLE_COLOR } else { GRAY }; + return format!("{BOLD}{color}{}{RESET}", inline(text)); + } + if is_rule(trimmed) { + return format!("{DIM}{}{RESET}", "─".repeat(40)); + } + if let Some(rest) = trimmed.strip_prefix("> ") { + return format!("{DIM}│ {RESET}{GRAY}{}{RESET}", inline(rest)); + } + if trimmed == ">" { + return format!("{DIM}│{RESET}"); + } + if let Some((indent, marker, rest)) = list_item(line) { + let pad = " ".repeat(indent); + return format!("{pad}{GRAY}{marker}{RESET} {}", inline(rest)); + } + + // ── Plain paragraph line ── + inline(line) + } +} + +/// Returns `Some((marker, language))` if the line opens a code fence. +fn fence_opening(trimmed: &str) -> Option<(String, String)> { + for marker in ["```", "~~~"] { + if let Some(rest) = trimmed.strip_prefix(marker) { + return Some((marker.to_string(), rest.trim().to_string())); + } + } + None +} + +/// `# Heading` .. `###### Heading` → `(level, text)`. +fn heading(trimmed: &str) -> Option<(u8, &str)> { + let hashes = trimmed.chars().take_while(|&c| c == '#').count(); + if (1..=6).contains(&hashes) + && let Some(rest) = trimmed[hashes..].strip_prefix(' ') + { + let text = rest.trim_end(); + if !text.is_empty() { + return Some((hashes as u8, text)); + } + } + None +} + +/// `---`, `***`, `___` (3+) → horizontal rule. Allocation-free: bails out at +/// the first character that can't belong to a rule. +fn is_rule(trimmed: &str) -> bool { + let mut first: Option = None; + let mut count = 0usize; + for c in trimmed.chars() { + match first { + None => { + if !matches!(c, '-' | '*' | '_') { + return false; + } + first = Some(c); + count = 1; + } + Some(f) if c == f => count += 1, + Some(_) if c != ' ' => return false, + _ => {} + } + } + count >= 3 +} + +/// Detect `- item`, `* item`, `+ item` or `1. item`. Returns +/// `(indent width, display marker, item text)`. `-`/`*` render as `•`. +fn list_item(line: &str) -> Option<(usize, String, &str)> { + let indent = line.len() - line.trim_start().len(); + let rest = line.trim_start(); + if let Some(after) = rest.strip_prefix(['-', '+']) + && let Some(text) = after.strip_prefix(' ') + { + let text = text.trim_start(); + if !text.is_empty() { + return Some((indent, "•".to_string(), text)); + } + } + if let Some(after) = rest.strip_prefix('*') + && let Some(text) = after.strip_prefix(' ') + { + let text = text.trim_start(); + // Guard: `** bold **`-style lines are not lists. + if !text.is_empty() && !text.starts_with('*') { + return Some((indent, "•".to_string(), text)); + } + } + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() + && let Some(after) = rest[digits.len()..].strip_prefix(". ") + { + let text = after.trim_start(); + if !text.is_empty() { + return Some((indent, format!("{digits}."), text)); + } + } + None +} + +/// Inline styling: `**bold**`, `` `code` ``, `[text](url)`. +/// +/// Unmatched markers are emitted verbatim — text is never dropped. +fn inline(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut i = 0usize; + while i < s.len() { + let c = s[i..].chars().next().unwrap(); + match c { + '*' => { + if let Some(inner) = s[i..].strip_prefix("**") { + match inner.find("**") { + Some(end) if end > 0 => { + out.push_str(BOLD); + out.push_str(&inner[..end]); + out.push_str(RESET); + // 2 (opening **) + content + 2 (closing **) + i += end + 4; + continue; + } + _ => {} + } + } + out.push('*'); + i += 1; + } + '`' => { + if let Some(end) = s[i + 1..].find('`') + && end > 0 + { + out.push_str(GRAY); + out.push('`'); + out.push_str(&s[i + 1..i + 1 + end]); + out.push('`'); + out.push_str(RESET); + i += end + 2; + continue; + } + out.push('`'); + i += 1; + } + '[' | '!' => { + // [text](url) or ![alt](url) + let is_image = c == '!'; + let open = if is_image { i + 1 } else { i }; + if s.get(open..).and_then(|r| r.strip_prefix('[')).is_some() { + let after_bracket = &s[open + 1..]; + if let Some(text_end) = after_bracket.find("](") + && text_end > 0 + { + let tail = &after_bracket[text_end + 2..]; + if let Some(url_end) = tail.find(')') { + let text = &after_bracket[..text_end]; + let url = &tail[..url_end]; + if is_image { + out.push('!'); + } + out.push_str(CYAN); + out.push_str(text); + out.push_str(RESET); + out.push_str(DIM); + out.push('('); + out.push_str(url); + out.push(')'); + out.push_str(RESET); + i = open + 1 + text_end + 2 + url_end + 1; + continue; + } + } + } + out.push(c); + i += c.len_utf8(); + } + _ => { + out.push(c); + i += c.len_utf8(); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + re.replace_all(s, "").to_string() + } + + // ── Block constructs ── + + #[test] + fn heading_is_bold_and_styled() { + let out = render_markdown("# Title\n"); + assert!(out.contains(BOLD)); + assert!(out.contains("Title")); + assert_eq!(strip_ansi(&out).trim_end(), "Title"); + assert!(!strip_ansi(&out).contains('#')); + } + + #[test] + fn h3_heading_uses_dim_color_variant() { + let out = render_markdown("### Sub\n"); + assert!(out.contains(GRAY)); + assert_eq!(strip_ansi(&out).trim_end(), "Sub"); + } + + #[test] + fn heading_without_space_not_styled() { + // `#hashtag` is not a heading — must pass through untouched. + let out = render_markdown("#hashtag\n"); + assert_eq!(out, "#hashtag\n"); + } + + #[test] + fn bullet_points_rendered() { + let out = render_markdown("- one\n* two\n"); + assert!(out.contains('•')); + assert_eq!(strip_ansi(&out), "• one\n• two\n"); + } + + #[test] + fn nested_list_keeps_indent() { + let out = render_markdown(" - child\n"); + assert!(out.starts_with(" ")); + assert_eq!(strip_ansi(&out), " • child\n"); + } + + #[test] + fn numbered_list_keeps_number() { + let out = render_markdown("3. third\n"); + assert_eq!(strip_ansi(&out), "3. third\n"); + } + + #[test] + fn bold_stars_line_is_not_a_list() { + let out = render_markdown("**Important** note\n"); + assert!(out.contains(BOLD)); + assert_eq!(strip_ansi(&out), "Important note\n"); + } + + #[test] + fn code_fence_block_styled_and_markers_hidden() { + let out = render_markdown("```rust\nfn main() {}\n```\n"); + let plain = strip_ansi(&out); + assert!(!plain.contains("```")); + assert!(plain.contains(" fn main() {}")); + assert!(out.contains(GRAY)); + } + + #[test] + fn code_fence_shows_language_tag_dim() { + let out = render_markdown("```rust\nx\n```\n"); + assert!(out.contains(ITALIC)); + assert!(out.contains("rust")); + } + + #[test] + fn code_fence_content_never_styled() { + // Markdown inside a code block must not be interpreted. + let out = render_markdown("```\n# not a heading\n**not bold**\n```\n"); + let plain = strip_ansi(&out); + assert!(plain.contains("# not a heading")); + assert!(plain.contains("**not bold**")); + assert!(!out.contains(BOLD)); + } + + #[test] + fn unclosed_fence_streams_content_as_code() { + let mut core = MdCore::default(); + assert_eq!(core.finalize_line("```"), ""); + assert!(core.fence.is_some()); + let line = core.finalize_line("code here"); + assert!(line.contains(GRAY)); + assert!(line.contains("code here")); + } + + #[test] + fn blockquote_renders_bar() { + let out = render_markdown("> quoted text\n"); + assert!(out.contains('│')); + assert_eq!(strip_ansi(&out), "│ quoted text\n"); + } + + #[test] + fn horizontal_rule_converted() { + let out = render_markdown("---\n"); + assert!(out.contains("─")); + assert!(!strip_ansi(&out).contains("---")); + } + + #[test] + fn dashes_inside_text_untouched() { + let out = render_markdown("a - - b\n"); + assert_eq!(out, "a - - b\n"); + } + + // ── Inline constructs ── + + #[test] + fn inline_bold() { + let out = render_markdown("this is **bold** text\n"); + assert!(out.contains(BOLD)); + assert_eq!(strip_ansi(&out), "this is bold text\n"); + } + + #[test] + fn unmatched_bold_marker_preserved() { + let out = render_markdown("2 * 3 ** 4\n"); + assert_eq!(strip_ansi(&out), "2 * 3 ** 4\n"); + } + + #[test] + fn inline_code_span() { + let out = render_markdown("call `foo()` now\n"); + assert!(out.contains(GRAY)); + assert_eq!(strip_ansi(&out), "call `foo()` now\n"); + } + + #[test] + fn unmatched_backtick_preserved() { + let out = render_markdown("one ` tick\n"); + assert_eq!(strip_ansi(&out), "one ` tick\n"); + } + + #[test] + fn link_shows_text_and_url() { + let out = render_markdown("see [docs](https://x.dev) please\n"); + assert!(out.contains(CYAN)); + assert!(out.contains("docs")); + assert!(out.contains("https://x.dev")); + } + + #[test] + fn broken_link_left_raw() { + let out = render_markdown("a [b c\n"); + assert_eq!(strip_ansi(&out), "a [b c\n"); + } + + #[test] + fn plain_text_gets_no_ansi() { + let out = render_markdown("just a normal sentence\n"); + assert_eq!(out, "just a normal sentence\n"); + } + + #[test] + fn unicode_safe() { + let out = render_markdown("żółw **gruby** 🐢\n"); + assert_eq!(strip_ansi(&out), "żółw gruby 🐢\n"); + } + + // ── Raw ANSI containment (model-emitted escapes) ── + + #[test] + fn model_emitted_ansi_is_contained_on_plain_line() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("hello {red}red world\n")); + // Styling is neutralized before the next line begins. + assert!(out.ends_with(&format!("{RESET}\n"))); + assert!( + out.contains(red), + "raw sequence must pass through, not be dropped" + ); + assert_eq!(strip_ansi(&out), "hello red world\n"); + } + + #[test] + fn model_emitted_ansi_does_not_leak_to_next_line() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("line one {red}colored\nline two\n")); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 2); + // The second line carries no residual color: it is plain text with + // zero escape sequences of its own. + assert_eq!(lines[1], "line two"); + } + + #[test] + fn styled_line_ending_in_reset_is_not_double_reset() { + // A construct-styled line already ends with RESET — containment must + // be idempotent (no doubled escape). + let out = render_markdown("already **styled**\n"); + let suffix = format!("{BOLD}styled{RESET}"); + assert!(out.contains(&suffix)); + // Exactly one RESET at end-of-line. + let after_suffix = &out[out.find(&suffix).unwrap() + suffix.len()..]; + assert!(!after_suffix.starts_with(RESET)); + } + + #[test] + fn fence_content_with_ansi_is_contained() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("```\n{red}colored code\n```\n")); + // Fence delimiters are hidden (empty lines); only the content line is + // non-empty. Fence styling already ends with RESET, so containment is + // idempotent and no raw escape can leak past it. + let non_empty: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(non_empty.len(), 1); + assert!(non_empty[0].contains("colored code")); + assert!(non_empty[0].ends_with(RESET)); + } + + #[test] + fn stream_containment_matches_oneshot() { + // The streaming path goes through the same finalize_line wrapper, so + // its containment behavior must match the one-shot renderer. + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "a \x1b[32mb\n").unwrap(); + md.finish(&mut buf).unwrap(); + let streamed = String::from_utf8(buf).unwrap(); + let oneshot = render_markdown("a \x1b[32mb\n"); + let streamed_body = streamed.strip_prefix(ASSISTANT_TEXT).unwrap_or(&streamed); + assert_eq!(streamed_body, oneshot); + } + + // ── Horizontal-rule edge cases ── + + #[test] + fn two_dashes_is_not_a_rule() { + let out = render_markdown("--\n"); + assert_eq!(strip_ansi(&out), "--\n"); + } + + #[test] + fn mixed_rule_chars_not_a_rule() { + let out = render_markdown("-*- \n"); + assert_eq!(strip_ansi(&out), "-*- \n"); + } + + #[test] + fn spaced_rule_is_a_rule() { + let out = render_markdown("- - -\n"); + assert!(strip_ansi(&out).contains('─')); + } + + #[test] + fn long_underscore_rule_converted() { + let out = render_markdown("_____\n"); + assert!(strip_ansi(&out).contains('─')); + } + + // ── Streaming behavior ── + + #[test] + fn stream_emits_line_only_on_newline() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "# He").unwrap(); + // No newline yet: nothing visible except the initial reset. + assert_eq!(String::from_utf8_lossy(&buf), ASSISTANT_TEXT); + md.push(&mut buf, "llo world\nnext\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("Hello world")); + assert!(s.contains("next")); + } + + #[test] + fn stream_marker_split_across_deltas_is_styled() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "**bo").unwrap(); + md.push(&mut buf, "ld**\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains(BOLD)); + assert_eq!(strip_ansi(&s[ASSISTANT_TEXT.len()..]), "bold\n"); + } + + #[test] + fn stream_fence_spans_multiple_chunks() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "```\n").unwrap(); + md.push(&mut buf, "let x = 1;\n").unwrap(); + md.push(&mut buf, "```\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + let plain = strip_ansi(&s); + assert!(!plain.contains("```")); + assert!(plain.contains("let x = 1;")); + } + + #[test] + fn finish_flushes_partial_line_without_newline() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "complete\npartial tail").unwrap(); + md.finish(&mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.ends_with("partial tail")); + assert!(!s.ends_with("partial tail\n")); + } + + #[test] + fn crlf_input_is_normalized() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "line one\r\nline two\r\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert_eq!(s, format!("{ASSISTANT_TEXT}line one\nline two\n")); + } + + #[test] + fn empty_push_is_noop() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "").unwrap(); + md.finish(&mut buf).unwrap(); + assert!(buf.is_empty()); + } +} diff --git a/tinyharness-ui/src/ui/mod.rs b/tinyharness-ui/src/ui/mod.rs index 2f0f51d..97228f9 100644 --- a/tinyharness-ui/src/ui/mod.rs +++ b/tinyharness-ui/src/ui/mod.rs @@ -1,4 +1,7 @@ pub mod confirm; pub mod diff; +pub mod frame; pub mod input; +pub mod markdown; +pub mod tool_result; pub mod wrap; diff --git a/tinyharness-ui/src/ui/tool_result.rs b/tinyharness-ui/src/ui/tool_result.rs new file mode 100644 index 0000000..4cd1e1c --- /dev/null +++ b/tinyharness-ui/src/ui/tool_result.rs @@ -0,0 +1,905 @@ +//! Compact, styled rendering of tool execution cards. +//! +//! Shared by the live agent loop (after a tool finishes executing) and the +//! session-history replay (when a saved session is printed), so both paths +//! render identically. +//! +//! A card is one filled title band naming the call — status glyph, tool, +//! subject, duration — followed by a gutter-outlined body: +//! +//! ```text +//! ● run · cargo test --all · 1.2s +//! │ running 199 tests +//! ╰ +40 more lines +//! ``` +//! +//! Tool results are *plain text*, not markdown — they are raw command output, +//! file listings and API responses emitted verbatim by the model. Interpreting +//! them as markdown would corrupt `**` in test output, `-` in diffs and `#` in +//! shell comments, so this module uses a small structural renderer instead, +//! following the same design rules as [`super::markdown`] and the layout +//! primitives in [`super::frame`]: +//! +//! - **Never lose the summary.** Long output is capped, but the footer states +//! exactly how many lines were hidden (`╰ +N more lines`). +//! - **The title band always says what was acted on**, so a reader scrolling +//! back learns which file or command a card refers to without opening the +//! body. +//! - **Errors are always visible in full prominence**, on a single red line. +//! - **Only the status glyph carries strong color** in the header; body rows +//! stay muted, so an error line is the one thing allowed to be loud. + +use std::io::{self, Write}; + +use super::frame::{FOOT, GUTTER, GUTTER_WIDTH, gutter_line, truncate_ellipsis, write_band}; +use super::wrap::{MAX_LINE_WIDTH, wrap_line}; +use crate::style::*; + +/// Maximum body lines shown for free-form output (`run`, tool dumps). +const MAX_OUTPUT_LINES: usize = 10; +/// Maximum content lines shown after a `read` meta line. +const MAX_READ_LINES: usize = 3; +/// Maximum entries shown for parsed `web_search` output. +const MAX_SEARCH_RESULTS: usize = 5; +/// Columns left for body text inside a gutter row. +const BODY_WIDTH: usize = MAX_LINE_WIDTH - GUTTER_WIDTH; +/// Maximum characters for the one-line subject on the title band. The band +/// fills the terminal, so it is not wrapped and only needs a sanity cap. +const MAX_SUBJECT_CHARS: usize = 120; + +/// Signal tools produce conversational meta-results ("User answered …"), +/// not raw output — always render as a single dim line. +const SIGNAL_TOOLS: [&str; 4] = ["question", "switch_mode", "invoke_skill", "auto_compact"]; + +/// Prefix of the denial message the agent loop records for a refused call +/// (see `src/agent/tools.rs`). Its content is fully implied by the card's +/// title band, so it is not repeated in the body. +const DENIED_BOILERPLATE: &str = "[Tool denied] The user denied the"; + +/// Where a tool call is in its lifecycle, and how it ended. +/// +/// The status decides the title band's glyph and color — the only strongly +/// colored element on the line — so a user scanning a scrolled-back transcript +/// can tell success from failure from refusal at a glance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + /// Completed without error. + Ok, + /// Returned an error. + Error, + /// The user refused it — nothing ran. + Denied, +} + +impl ToolStatus { + /// Status glyph drawn on the title band. + /// + /// The braille spinner frames are deliberately not reused here: the live + /// spinner is drawn on this same line while the call runs and would fight + /// a static glyph for the same cell. + fn glyph(self) -> &'static str { + match self { + ToolStatus::Ok => "●", + ToolStatus::Error => "✕", + ToolStatus::Denied => "⊘", + } + } + + fn color(self) -> &'static str { + match self { + ToolStatus::Ok => FG_OK, + ToolStatus::Error => FG_ERR, + ToolStatus::Denied => FG_WARN, + } + } + + /// Trailing label for terminal states that have no duration to show. + fn label(self) -> &'static str { + match self { + ToolStatus::Error => "failed", + ToolStatus::Denied => "denied", + _ => "", + } + } +} + +/// Format a duration in milliseconds as a human-readable string. +/// +/// Under 1 second: `42ms` — 1–59 seconds: `1.2s` — 60+: `1m 23s`. +pub fn format_duration(ms: u64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + let mins = ms / 60_000; + let secs = (ms % 60_000) / 1000; + format!("{mins}m {secs}s") + } +} + +/// Render one tool card: a status title band plus a capped, tool-specific +/// gutter body. +/// +/// - `tool`: tool name (e.g. `read`, `run`, `web_search`). +/// - `subject`: what the call acted on — the command, the path, the query. +/// Pass `None` when it can't be determined (history replay, argument-less +/// tools) and the band degrades to just the tool name. +/// - `status`: see [`ToolStatus`]. +/// - `duration_ms`: execution duration, when known (`None` for history +/// replay, where durations are not persisted). +/// - `body`: the raw tool result, without any `### {tool}` prefix. +pub fn write_tool_card( + w: &mut W, + tool: &str, + subject: Option<&str>, + status: ToolStatus, + duration_ms: Option, + body: &str, +) -> io::Result<()> { + write_card_header(w, tool, subject, status, duration_ms)?; + if body.trim().is_empty() { + return Ok(()); + } + write_body_lines(w, &card_lines(tool, body, status)) +} + +/// The call's primary target — command, path or query — for a card's title +/// band. +/// +/// Recognized argument names are checked in order of specificity, then any +/// first string value, so custom tools still say what they were pointed at. +/// Returns `None` when the call has no string arguments. +pub fn tool_subject(arguments: &serde_json::Value) -> Option { + let args = arguments.as_object()?; + for key in ["command", "path", "file_path", "query", "url", "pattern"] { + if let Some(value) = args.get(key).and_then(|v| v.as_str()) + && !value.trim().is_empty() + { + return Some(value.lines().next().unwrap_or_default().to_string()); + } + } + args.values() + .find_map(|v| v.as_str()) + .map(|v| v.lines().next().unwrap_or_default().to_string()) + .filter(|v| !v.trim().is_empty()) +} + +/// Write a card's title band: status glyph, tool name, subject, duration. +fn write_card_header( + w: &mut W, + tool: &str, + subject: Option<&str>, + status: ToolStatus, + duration_ms: Option, +) -> io::Result<()> { + // Compose the optional trailing pieces before assembling the band, so the + // segments can all borrow from values that outlive the call. + let subject_text = subject + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| truncate_ellipsis(s.lines().next().unwrap_or_default(), MAX_SUBJECT_CHARS)); + + let tail = match (status, duration_ms) { + (_, Some(ms)) => Some((format!(" · {}", format_duration(ms)), FG_FAINT)), + // Without a duration (history replay) the terminal states still need + // to say how they ended. + (ToolStatus::Error | ToolStatus::Denied, None) => { + Some((format!(" · {}", status.label()), status.color())) + } + (ToolStatus::Ok, None) => None, + }; + + let mut segments: Vec<(&str, &str)> = vec![(status.glyph(), status.color()), (" ", FG_FAINT)]; + segments.push((tool, FG_BAND)); + if let Some(subject) = &subject_text { + segments.push((" · ", FG_FAINT)); + segments.push((subject.as_str(), FG_ACCENT)); + } + if let Some((text, color)) = &tail { + segments.push((text.as_str(), *color)); + } + write_band(w, &segments) +} + +/// One physical line of a card body, before framing. +enum BodyLine { + /// Plain text, painted in a single color. + Text(String, &'static str), + /// Pre-styled content with its own inline colors (search entries). + Rich(String), +} + +/// Frame and write body lines: every line but the last uses the [`GUTTER`] +/// bar, the last closes the card with the [`FOOT`] elbow. +fn write_body_lines(w: &mut W, lines: &[BodyLine]) -> io::Result<()> { + let last = lines.len().saturating_sub(1); + for (i, line) in lines.iter().enumerate() { + let glyph = if i == last { FOOT } else { GUTTER }; + match line { + BodyLine::Text(text, color) => gutter_line(w, glyph, &format!("{color}{text}{RESET}"))?, + BodyLine::Rich(styled) => gutter_line(w, glyph, styled)?, + } + } + Ok(()) +} + +/// Append `text` to `lines` as however many physical rows it needs at the +/// body width. +/// +/// Embedded newlines (a summary joining several entries, say) become separate +/// rows rather than one row that would silently break the gutter framing. +fn push_wrapped(lines: &mut Vec, text: &str, color: &'static str) { + for segment in text.split('\n') { + for row in wrap_line(segment, BODY_WIDTH, BODY_WIDTH) { + lines.push(BodyLine::Text(row, color)); + } + } +} + +/// Append the hidden-rows footer. +fn push_more(lines: &mut Vec, hidden: usize, noun: &'static str) { + if hidden > 0 { + let noun = if hidden == 1 { singular(noun) } else { noun }; + lines.push(BodyLine::Text(format!("+{hidden} more {noun}"), FG_FAINT)); + } +} + +/// `"lines"` → `"line"`, `"results"` → `"result"`. +fn singular(noun: &str) -> &'static str { + match noun { + "lines" => "line", + "results" => "result", + _ => "line", + } +} + +/// Build the body lines of a card for `tool`/`body`. +fn card_lines(tool: &str, body: &str, status: ToolStatus) -> Vec { + let mut lines: Vec = Vec::new(); + + if status == ToolStatus::Error { + // The header already says `failed`; the body carries the message. + push_wrapped( + &mut lines, + &truncate_ellipsis(body.lines().next().unwrap_or("Error"), BODY_WIDTH), + FG_ERR, + ); + return lines; + } + if body.starts_with("[Tool denied]") { + // The canonical denial template only restates what the band already + // says (tool, subject, `denied`), so it renders as a band alone. Any + // other message is shown with the marker stripped. + if body.starts_with(DENIED_BOILERPLATE) { + return lines; + } + let msg = body.trim_start_matches("[Tool denied]").trim(); + push_wrapped(&mut lines, &truncate_ellipsis(msg, BODY_WIDTH), FG_WARN); + return lines; + } + + match tool { + // Listings collapse to a single summary line. + "ls" | "grep" | "glob" => push_wrapped( + &mut lines, + &truncate_ellipsis(&summarize_listing_result(body, tool), BODY_WIDTH), + FG_MUTED, + ), + // Structured search results render as a link-styled list. + "web_search" => match parse_web_search(body) { + Some(entries) => search_lines(&mut lines, &entries), + None => capped_lines(&mut lines, body.lines(), MAX_OUTPUT_LINES), + }, + // File reads show the line-count meta line, then a small preview. + "read" => read_lines(&mut lines, body), + // Conversational meta-results collapse to one dim line. + t if SIGNAL_TOOLS.contains(&t) => push_wrapped( + &mut lines, + &truncate_ellipsis(body.lines().next().unwrap_or(body), BODY_WIDTH), + FG_MUTED, + ), + // Everything else: capped raw output. + _ => capped_lines(&mut lines, body.lines(), MAX_OUTPUT_LINES), + } + lines +} + +/// Show up to `max` of `body_lines`, noting how many were hidden. +fn capped_lines<'a>( + out: &mut Vec, + body_lines: impl Iterator, + max: usize, +) { + let all: Vec<&str> = body_lines.collect(); + for line in all.iter().take(max) { + push_wrapped(out, line, FG_MUTED); + } + push_more(out, all.len().saturating_sub(max), "lines"); +} + +/// Meta line, then up to [`MAX_READ_LINES`] content lines and a footer. +fn read_lines(out: &mut Vec, body: &str) { + let mut lines = body.lines(); + let first = lines.next().unwrap_or(""); + let is_meta = first.starts_with('(') && first.contains(" lines") && first.ends_with(')'); + + let (meta, content): (Option<&str>, Vec<&str>) = if is_meta { + (Some(first), lines.collect()) + } else { + (None, std::iter::once(first).chain(lines).collect()) + }; + + if let Some(meta) = meta { + out.push(BodyLine::Text(meta.to_string(), FG_FAINT)); + } + for line in content.iter().take(MAX_READ_LINES) { + push_wrapped(out, &truncate_ellipsis(line, BODY_WIDTH), FG_MUTED); + } + push_more(out, content.len().saturating_sub(MAX_READ_LINES), "lines"); +} + +/// Render parsed search entries as a link-styled list (matching the markdown +/// renderer's link treatment: accent title, faint URL). +fn search_lines(out: &mut Vec, entries: &[SearchEntry]) { + for entry in entries.iter().take(MAX_SEARCH_RESULTS) { + out.push(BodyLine::Rich(format!( + "{FG_FAINT}•{RESET} {FG_ACCENT}{}{RESET} {FG_FAINT}{}{RESET}", + truncate_ellipsis(&entry.title, 60), + truncate_ellipsis(&format!("({})", entry.url), 60), + ))); + let snippet = entry.content.lines().next().unwrap_or(""); + if !snippet.is_empty() { + push_wrapped( + out, + &format!(" {}", truncate_ellipsis(snippet, 100)), + FG_FAINT, + ); + } + } + push_more( + out, + entries.len().saturating_sub(MAX_SEARCH_RESULTS), + "results", + ); +} + +/// One parsed `web_search` entry. +struct SearchEntry { + title: String, + url: String, + content: String, +} + +/// Parse the `web_search` tool's textual output format: +/// +/// ```text +/// [1] Title +/// URL: https://… +/// content… +/// ``` +/// +/// Returns `None` when the body doesn't match the format at all, so callers +/// can fall back to raw capped output. +fn parse_web_search(body: &str) -> Option> { + let mut entries: Vec = Vec::new(); + + for line in body.lines() { + if let Some(rest) = line.strip_prefix(" URL: ") { + // URL line completes the most recently started entry. + let last = entries.last_mut()?; + last.url = rest.trim().to_string(); + } else if line.starts_with('[') + && let Some(close) = line.find("] ") + && close > 1 + && line[1..close].chars().all(|c| c.is_ascii_digit()) + { + entries.push(SearchEntry { + title: line[close + 2..].trim().to_string(), + url: String::new(), + content: String::new(), + }); + } else if let Some(last) = entries.last_mut() { + // Continuation (snippet) lines accumulate into the open entry. + // Blank separator lines between entries are skipped — otherwise + // they leave a trailing space on the previous snippet. + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !last.content.is_empty() { + last.content.push(' '); + } + last.content.push_str(trimmed); + } + } + + if entries.is_empty() || entries.iter().any(|e| e.url.is_empty()) { + return None; + } + Some(entries) +} + +/// Produce a one-line summary for listing tools (ls, grep, glob). +/// +/// `5 entries — Cargo.toml, src, target ... (2 more)`. Always a single line: +/// the whole point is to collapse a listing that would otherwise bury the +/// cards around it. +pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { + if result.starts_with("Error:") || result.starts_with("No ") || result == "Directory is empty" { + return result.to_string(); + } + + let lines: Vec<&str> = result.lines().collect(); + let count = lines.len(); + let label = match (tool_name, count == 1) { + ("ls", true) => "entry", + ("ls", false) => "entries", + ("grep", true) => "match", + ("grep", false) => "matches", + ("glob", true) => "file", + ("glob", false) => "files", + (_, true) => "result", + (_, false) => "results", + }; + + const PREVIEW: usize = 3; + let preview = lines + .iter() + .take(PREVIEW) + .copied() + .collect::>() + .join(", "); + if count <= PREVIEW { + format!("{count} {label} — {preview}") + } else { + format!("{count} {label} — {preview} ... ({} more)", count - PREVIEW) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); + re.replace_all(s, "").to_string() + } + + /// Render a finished card (the common case in tests). + fn render(tool: &str, body: &str, is_error: bool, ms: Option) -> String { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + tool, + None, + if is_error { + ToolStatus::Error + } else { + ToolStatus::Ok + }, + ms, + body, + ) + .unwrap(); + String::from_utf8(buf).unwrap() + } + + fn plain(s: &str) -> String { + strip_ansi(s) + } + + fn body_rows(out: &str) -> Vec { + plain(out).lines().skip(1).map(str::to_string).collect() + } + + // ── Header ── + + #[test] + fn success_header_shows_status_glyph_and_duration() { + let out = render("read", "(1 lines)\nhi", false, Some(42)); + assert!(out.contains('●')); + assert!(out.contains("read")); + assert!(out.contains("42ms")); + assert!(!out.contains('✕')); + assert!(out.contains(FG_OK), "status color must be applied"); + } + + #[test] + fn error_header_shows_cross_and_failure_label() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + None, + ToolStatus::Error, + None, + "Error: boom", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains('✕')); + assert!(out.contains(FG_ERR)); + assert!(plain(&out).contains("failed"), "no duration ⇒ label"); + } + + #[test] + fn error_with_duration_omits_the_label() { + let out = render("run", "Error: boom", true, Some(1500)); + assert!(out.contains('✕')); + assert!(out.contains("1.5s")); + assert!(!plain(&out).contains("failed")); + } + + #[test] + fn denied_header_shows_slash_circle_and_denied_label() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("rm -rf /"), + ToolStatus::Denied, + None, + "", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains('⊘')); + assert!(plain(&out).contains("denied")); + assert!(plain(&out).contains("rm -rf /")); + } + + #[test] + fn subject_appears_on_the_title_band() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "read", + Some("src/main.rs"), + ToolStatus::Ok, + None, + "", + ) + .unwrap(); + assert!(plain(&String::from_utf8(buf).unwrap()).contains("src/main.rs")); + } + + #[test] + fn multiline_subject_collapses_to_its_first_line() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("echo one\necho two"), + ToolStatus::Ok, + None, + "", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.lines().count(), 1, "band must stay a single line"); + assert!(plain(&out).contains("echo one")); + assert!(!plain(&out).contains("echo two")); + } + + #[test] + fn blank_subject_is_omitted() { + let mut buf = Vec::new(); + write_tool_card(&mut buf, "ls", Some(" "), ToolStatus::Ok, None, "").unwrap(); + assert_eq!(plain(&String::from_utf8(buf).unwrap()).trim(), "● ls"); + } + + #[test] + fn history_omits_duration() { + let out = render("ls", "a\nb", false, None); + assert!(!plain(&out).contains('·'), "no separator: {out:?}"); + assert!(out.contains("ls")); + } + + #[test] + fn empty_body_is_header_only() { + let out = render("run", "", false, Some(5)); + assert_eq!(out.lines().count(), 1); + } + + #[test] + fn whitespace_only_body_is_header_only() { + let out = render("run", " \n ", false, None); + assert_eq!(out.lines().count(), 1); + } + + // ── Body framing ── + + #[test] + fn body_rows_are_gutter_outlined_and_close_with_an_elbow() { + let out = render("run", "one\ntwo", false, None); + let rows = body_rows(&out); + assert_eq!(rows.len(), 2, "band + 2 body rows"); + assert!(rows[0].starts_with(" │ "), "body rows use the gutter"); + assert!(rows[1].starts_with(" ╰ "), "last row closes the card"); + assert!(rows[0].contains("one")); + assert!(rows[1].contains("two")); + } + + #[test] + fn single_row_body_closes_with_the_elbow() { + let out = render("ls", "a\nb\nc", false, None); + let rows = body_rows(&out); + assert_eq!(rows.len(), 1); + assert!(rows[0].starts_with(" ╰ ")); + } + + #[test] + fn elbow_lands_on_the_final_physical_row_after_wrapping() { + let line = "word ".repeat(80); + let out = render("run", line.trim(), false, None); + let rows = body_rows(&out); + assert!(rows.len() > 2, "long line must wrap onto several rows"); + assert!(rows.last().unwrap().starts_with(" ╰ ")); + for row in &rows[..rows.len() - 1] { + assert!(row.starts_with(" │ "), "no early elbow: {row:?}"); + } + } + + #[test] + fn no_body_row_exceeds_the_line_budget() { + let out = render("run", &"word ".repeat(60), false, None); + for row in body_rows(&out) { + assert!(row.chars().count() <= MAX_LINE_WIDTH, "overflow: {row:?}"); + } + } + + // ── Error / denied bodies ── + + #[test] + fn error_body_is_a_single_red_line_truncated() { + let long = format!("Error: {}", "x".repeat(300)); + let out = render("run", &long, true, None); + let rows = body_rows(&out); + assert_eq!(rows.len(), 1, "error collapses to one row: {rows:?}"); + assert!(out.contains(FG_ERR)); + assert!(plain(&rows[0]).ends_with('…')); + assert!(!plain(&out).contains(&"x".repeat(200))); + } + + #[test] + fn error_body_shows_only_the_first_line() { + let out = render("run", "Error: boom\nstack trace\nmore", true, None); + let p = plain(&out); + assert!(p.contains("Error: boom")); + assert!(!p.contains("stack trace"), "trace stays hidden: {p:?}"); + } + + #[test] + fn canonical_denial_renders_band_only() { + // The boilerplate message only restates the band (tool, subject, + // `denied`), so repeating it in the body would be pure noise. + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("rm -rf /"), + ToolStatus::Denied, + None, + "[Tool denied] The user denied the 'run' tool call with arguments: command=\"rm -rf /\"", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.lines().count(), 1, "no body row: {out:?}"); + assert!(out.contains('⊘')); + assert!(out.contains(FG_WARN)); + } + + #[test] + fn non_boilerplate_denial_shows_the_message_without_the_marker() { + let out = render( + "run", + "[Tool denied] sandbox policy refuses writes outside the workspace", + false, + None, + ); + let p = plain(&out); + assert!(out.contains(FG_WARN)); + assert!(p.contains("sandbox policy"), "{p:?}"); + assert!(!p.contains("[Tool denied]"), "marker is redundant: {p:?}"); + } + + // ── Listing tools ── + + #[test] + fn listing_summary_line() { + let out = render("ls", "Cargo.toml\nsrc\ntarget\nx\ny", false, None); + let p = plain(&out); + assert!(p.contains("5 entries — Cargo.toml, src, target ... (2 more)")); + assert_eq!(body_rows(&out).len(), 1); + } + + #[test] + fn listing_error_passes_through() { + let out = render("grep", "Error: bad pattern", true, None); + assert!(plain(&out).contains("Error: bad pattern")); + } + + // ── read ── + + #[test] + fn read_shows_meta_then_capped_preview() { + let content: Vec = (1..=20).map(|i| format!("line{i}")).collect(); + let body = format!("(20 lines)\n{}", content.join("\n")); + let out = render("read", &body, false, None); + let p = plain(&out); + assert!(p.contains("(20 lines)")); + assert!(p.contains("line1")); + assert!(p.contains("line3")); + assert!(!p.contains("line4")); + assert!(p.contains("╰ +17 more lines")); + } + + #[test] + fn read_without_meta_line_renders_all_as_content() { + let out = render("read", "just one line", false, None); + let p = plain(&out); + assert!(p.contains("just one line")); + assert!(!p.contains("more lines")); + assert_eq!(body_rows(&out).len(), 1); + } + + // ── run / default capping ── + + #[test] + fn run_output_capped_with_footer() { + let content: Vec = (1..=50).map(|i| format!("out{i}")).collect(); + let out = render("run", &content.join("\n"), false, Some(2300)); + let p = plain(&out); + assert!(p.contains("out1")); + assert!(p.contains("out10")); + assert!(!p.contains("out11")); + assert!(p.contains("╰ +40 more lines")); + assert!(out.contains("2.3s")); + } + + #[test] + fn short_output_not_capped() { + let out = render("run", "one\ntwo", false, None); + assert!(!plain(&out).contains("more lines")); + } + + // ── web_search ── + + fn search_body(n: usize) -> String { + (1..=n) + .map(|i| format!("[{i}] Title {i}\n URL: https://x.dev/{i}\n Snippet {i} text\n")) + .collect::>() + .join("\n") + } + + #[test] + fn web_search_renders_link_styled_list() { + let out = render("web_search", &search_body(3), false, None); + let p = plain(&out); + assert!(p.contains("• Title 1")); + assert!(p.contains("(https://x.dev/1)")); + assert!(p.contains("Snippet 1 text")); + assert!(out.contains(FG_ACCENT), "titles should be link-colored"); + assert!(!p.contains("more results"), "3 entries fit under the cap"); + } + + #[test] + fn web_search_caps_results_with_footer() { + let out = render("web_search", &search_body(8), false, None); + let p = plain(&out); + assert!(p.contains("Title 1")); + assert!(p.contains("Title 5")); + assert!(!p.contains("Title 6")); + assert!(p.contains("╰ +3 more results")); + } + + #[test] + fn web_search_unparseable_falls_back_to_raw() { + let out = render("web_search", "No results found.", false, None); + assert!(plain(&out).contains("No results found.")); + } + + #[test] + fn web_search_prose_starting_with_bracket_falls_back() { + // `[1] x` without a following URL line is prose, not a result list. + let out = render( + "web_search", + "[1] see section one of the manual", + false, + None, + ); + assert!(plain(&out).contains("see section one")); + } + + // ── signal tools ── + + #[test] + fn signal_tool_result_is_single_dim_line() { + let out = render( + "question", + "User answered the question 'Deploy?' with: 'yes'.", + false, + None, + ); + assert_eq!(body_rows(&out).len(), 1); + assert!(plain(&out).contains("User answered the question")); + } + + // ── tool_subject ── + + #[test] + fn subject_prefers_the_command_over_other_arguments() { + assert_eq!( + tool_subject(&json!({"command": "cargo build", "timeout": 30})).as_deref(), + Some("cargo build") + ); + } + + #[test] + fn subject_prefers_the_path_for_file_tools() { + assert_eq!( + tool_subject(&json!({"path": "src/main.rs", "old_str": "a"})).as_deref(), + Some("src/main.rs") + ); + } + + #[test] + fn subject_falls_back_to_the_first_string_argument() { + assert_eq!( + tool_subject(&json!({"count": 3, "target": "prod"})).as_deref(), + Some("prod") + ); + } + + #[test] + fn subject_collapses_multiline_arguments_to_one_line() { + assert_eq!( + tool_subject(&json!({"command": "echo one\necho two"})).as_deref(), + Some("echo one") + ); + } + + #[test] + fn subject_is_none_without_string_arguments() { + assert_eq!(tool_subject(&json!({})), None); + assert_eq!(tool_subject(&json!({"n": 1})), None); + assert_eq!(tool_subject(&json!([])), None); + } + + // ── format_duration ── + + #[test] + fn duration_formatting() { + assert_eq!(format_duration(42), "42ms"); + assert_eq!(format_duration(1200), "1.2s"); + assert_eq!(format_duration(83_000), "1m 23s"); + } + + // ── summarize_listing_result ── + + #[test] + fn summarize_labels_per_tool() { + assert_eq!( + summarize_listing_result("a\nb\nc", "grep"), + "3 matches — a, b, c" + ); + assert_eq!(summarize_listing_result("a", "ls"), "1 entry — a"); + assert_eq!(summarize_listing_result("a\nb", "glob"), "2 files — a, b"); + } + + #[test] + fn summarize_is_always_a_single_line() { + // A multi-entry summary containing newlines would break the gutter + // framing of the card body, so the join is always ", ". + let out = summarize_listing_result("a\nb\nc\nd\ne", "ls"); + assert!(!out.contains('\n'), "{out:?}"); + assert_eq!(out, "5 entries — a, b, c ... (2 more)"); + } + + #[test] + fn summarize_error_passthrough() { + assert_eq!(summarize_listing_result("Error: nope", "ls"), "Error: nope"); + } +} diff --git a/tinyharness-ui/src/ui/wrap.rs b/tinyharness-ui/src/ui/wrap.rs index abdd747..7956b7b 100644 --- a/tinyharness-ui/src/ui/wrap.rs +++ b/tinyharness-ui/src/ui/wrap.rs @@ -5,6 +5,50 @@ use crate::style::FILL_EOL; /// Maximum line width for word-wrapped output. pub const MAX_LINE_WIDTH: usize = 120; +/// Break a single logical `line` into display rows that fit the available +/// width, splitting at word boundaries where possible and hard-breaking only +/// when a single word exceeds the budget. +/// +/// `first_avail` / `rest_avail` are the columns left for text on the first row +/// and on continuation rows (i.e. `max_width - indent.len()`), which lets +/// hanging-indent layouts wrap without the continuation rows overflowing. +/// +/// Always returns at least one row for a non-empty line, and exactly one empty +/// row for an empty line, so callers can round-trip blank lines. +pub fn wrap_line(line: &str, first_avail: usize, rest_avail: usize) -> Vec { + if line.is_empty() { + return vec![String::new()]; + } + if line.chars().count() <= first_avail { + return vec![line.to_string()]; + } + + let mut rows = Vec::new(); + let mut remaining = line; + let mut avail = first_avail; + loop { + if remaining.chars().count() <= avail { + rows.push(remaining.to_string()); + break; + } + let chunk_end = remaining.floor_char_boundary(avail); + let chunk = &remaining[..chunk_end]; + match chunk.rfind(' ') { + Some(pos) if pos > 0 => { + rows.push(chunk[..pos].to_string()); + remaining = remaining[pos..].trim_start(); + } + // No break point inside the budget — hard-break at the limit. + _ => { + rows.push(chunk.to_string()); + remaining = remaining[chunk_end..].trim_start(); + } + } + avail = rest_avail; + } + rows +} + /// Write lines to stdout with word-wrapping at `max_width` characters. /// Lines shorter than `max_width - indent.len()` are written as-is. /// Empty lines produce just a newline. @@ -19,38 +63,84 @@ pub fn write_wrapped_lines( fill_bg: bool, ) -> Result<(), Box> { let suffix = if fill_bg { FILL_EOL } else { "" }; + let first_avail = max_width.saturating_sub(base_indent.chars().count()); + let rest_avail = max_width.saturating_sub(cont_indent.chars().count()); for line in content.lines() { - if line.is_empty() { - writeln!(stdout, "{}", suffix)?; - continue; - } - if line.len() <= max_width - base_indent.len() { - writeln!(stdout, "{}{}{}", base_indent, line, suffix)?; - continue; - } - // Word-wrap long lines - let mut remaining = line; - let mut first = true; - while !remaining.is_empty() { - let indent = if first { base_indent } else { cont_indent }; - let avail = max_width - indent.len(); - if remaining.len() <= avail { - writeln!(stdout, "{}{}{}", indent, remaining, suffix)?; - break; - } - let chunk_end = remaining.floor_char_boundary(avail); - let chunk = &remaining[..chunk_end]; - let split_at = chunk.rfind(' ').unwrap_or(chunk_end); - if split_at == 0 { - // No space found at all — just break at the width limit - writeln!(stdout, "{}{}{}", indent, &remaining[..chunk_end], suffix)?; - remaining = remaining[chunk_end..].trim_start(); - } else { - writeln!(stdout, "{}{}{}", indent, &chunk[..split_at], suffix)?; - remaining = remaining[chunk[..split_at].len()..].trim_start(); - } - first = false; + for (i, row) in wrap_line(line, first_avail, rest_avail) + .into_iter() + .enumerate() + { + let indent = if i == 0 { base_indent } else { cont_indent }; + writeln!(stdout, "{indent}{row}{suffix}")?; } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(line: &str, avail: usize) -> Vec { + wrap_line(line, avail, avail) + } + + #[test] + fn short_line_is_one_row() { + assert_eq!(rows("hello", 10), vec!["hello"]); + } + + #[test] + fn empty_line_yields_one_empty_row() { + assert_eq!(rows("", 10), vec![String::new()]); + } + + #[test] + fn wraps_at_word_boundaries() { + assert_eq!(rows("alpha beta gamma", 12), vec!["alpha beta", "gamma"]); + } + + #[test] + fn hard_breaks_when_a_word_exceeds_the_budget() { + let out = rows(&"x".repeat(25), 10); + assert_eq!(out.len(), 3); + assert_eq!(out[0], "x".repeat(10)); + assert_eq!(out.concat(), "x".repeat(25)); + } + + #[test] + fn continuation_budget_is_honoured() { + // First row is narrow, continuations wide: nothing may exceed its own + // budget, and no text may be lost. + let line = "aaa bbb ccc ddd eee"; + let out = wrap_line(line, 7, 12); + assert!(out[0].len() <= 7); + for rest in &out[1..] { + assert!(rest.len() <= 12); + } + assert_eq!(out.join(" ").replace(' ', ""), line.replace(' ', "")); + } + + #[test] + fn multibyte_input_never_panics_and_loses_nothing() { + let line = "🎉 ".repeat(10); + let out = wrap_line(&line, 6, 6); + assert_eq!(out.join(" ").replace(' ', ""), line.replace(' ', "")); + } + + #[test] + fn writer_pads_every_row_when_requested() { + let mut buf = Vec::new(); + write_wrapped_lines(&mut buf, "one two three", " ", " ", 9, true).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.matches(FILL_EOL).count(), out.lines().count()); + assert!(out.lines().all(|l| l.len() <= 12), "{out:?}"); + } + + #[test] + fn writer_keeps_blank_lines_blank() { + let mut buf = Vec::new(); + write_wrapped_lines(&mut buf, "a\n\nb", "", "", 80, false).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "a\n\nb\n"); + } +}