diff --git a/.gitignore b/.gitignore index 0a0c8259..1a72c8ec 100644 --- a/.gitignore +++ b/.gitignore @@ -56,5 +56,11 @@ models.gen.json .alchemy +.freebuff +opencode.json + # Rust build output (packages/tui binary; the launcher loads the built artifact). target + +# testing +videos diff --git a/packages/tui/Cargo.lock b/packages/tui/Cargo.lock index e6f0f666..dd5a65d5 100644 --- a/packages/tui/Cargo.lock +++ b/packages/tui/Cargo.lock @@ -17,30 +17,12 @@ dependencies = [ "libc", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -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 = "bitflags" version = "2.13.2" @@ -105,23 +87,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" version = "0.4.45" @@ -141,10 +106,8 @@ version = "0.0.1" dependencies = [ "chrono", "crossterm", - "dirs", "futures-util", "ratatui", - "reqwest", "serde", "serde_json", "tokio", @@ -191,15 +154,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - [[package]] name = "crossterm" version = "0.28.1" @@ -208,7 +162,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "futures-core", "mio", "parking_lot", "rustix", @@ -286,38 +239,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", -] - [[package]] name = "either" version = "1.19.0" @@ -352,24 +273,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -430,24 +333,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -477,94 +364,12 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "hyper" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" -dependencies = [ - "atomic-waker", - "bytes", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" -dependencies = [ - "base64 0.23.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -589,116 +394,12 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indoc" version = "2.0.8" @@ -721,12 +422,6 @@ dependencies = [ "syn 3.0.8", ] -[[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - [[package]] name = "itertools" version = "0.13.0" @@ -759,27 +454,12 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" -[[package]] -name = "libredox" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" -dependencies = [ - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - [[package]] name = "lock_api" version = "0.4.14" @@ -804,12 +484,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "lru-slab" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" - [[package]] name = "memchr" version = "2.8.3" @@ -849,12 +523,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "parking_lot" version = "0.12.5" @@ -884,27 +552,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -923,62 +576,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.21", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.3", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.21", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -988,12 +585,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.8.8" @@ -1002,18 +593,7 @@ checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[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", + "rand_core", ] [[package]] @@ -1023,7 +603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1032,22 +612,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.17", -] - -[[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", + "getrandom", ] [[package]] @@ -1080,54 +645,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror 2.0.21", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - [[package]] name = "ring" version = "0.17.14" @@ -1136,18 +653,12 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustix" version = "0.38.44" @@ -1168,7 +679,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1193,7 +703,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] @@ -1301,18 +810,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "sha1" version = "0.10.7" @@ -1320,7 +817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -1383,12 +880,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -1451,42 +942,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", -] - [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl 2.0.21", + "thiserror-impl", ] [[package]] @@ -1500,33 +962,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - [[package]] name = "tokio" version = "1.53.2" @@ -1581,76 +1016,6 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "tungstenite" version = "0.24.0" @@ -1663,11 +1028,11 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.8", + "rand", "rustls", "rustls-pki-types", "sha1", - "thiserror 1.0.69", + "thiserror", "utf-8", ] @@ -1718,45 +1083,18 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1776,17 +1114,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" -dependencies = [ - "js-sys", - "tokio", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1819,35 +1146,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2020,35 +1318,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.62" @@ -2069,66 +1338,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", - "synstructure", -] - [[package]] name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.8", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/tui/Cargo.toml b/packages/tui/Cargo.toml index a7d3333c..fadc1e8c 100644 --- a/packages/tui/Cargo.toml +++ b/packages/tui/Cargo.toml @@ -12,13 +12,16 @@ path = "src/main.rs" [dependencies] ratatui = { version = "0.29", features = ["crossterm"] } -crossterm = { version = "0.28", features = ["event-stream"] } +# Defaults include bracketed paste; `event-stream` is not needed because input is +# read on its own thread. +crossterm = "0.28" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -unicode-width = "0.2" -dirs = "6.0" chrono = "0.4" +# Width of a character in cells, for the prompt line's caret and clipping. Already in the +# tree through ratatui, so it costs no extra build to depend on it directly rather than +# assume every character is one column wide. +unicode-width = "0.2" diff --git a/packages/tui/README.md b/packages/tui/README.md index f2aaf9a4..0b90a84f 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -2,21 +2,47 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh), written in **Rust**. -`codework-tui` lets you chat with your AI coding agent directly from your terminal. Watch it write code, run tools, and edit files in real time, all while keeping track of token usage and costs. +The TUI is an early skeleton. Today it opens one screen: the landing site's hero — its wordmark over +the same hairline lattice the site draws it on, with a prompt line under it that takes typing. A +leading slash on an empty line opens a menu of the app's commands, as a chat prompt does; as this is +written there are two, `theme`, which lists every palette the site ships, and `exit`. The prompt +cannot be sent, and model selection and the session view are not built yet; the launcher and the RPC +transport they will need are already in place. --- -## Features - -- **Fast and Lightweight** — Starts up instantly and uses minimal memory. -- **Live Streaming** — Watch the AI think, stream answers, and run tools (like running terminal commands or editing files) as it happens. -- **Side-by-Side View** — - - **Chat screen:** See your conversation, AI replies, tool results, and response speed (`tok/s`). - - **Sidebar:** Keep an eye on your current model, context window usage, tokens used, cost, and active Git branch. -- **Easy Model Picker (`/model`)** — Browse models (Anthropic, OpenAI, Google, OpenRouter, Groq), check pricing, and enter API keys directly in the terminal. -- **Neat Markdown Formatting** — Code blocks, lists, bold text, links, and tables look clean and readable in the terminal. -- **Cancel Anytime** — Press `Esc` or `Ctrl+C` while the AI is responding to safely stop it without losing your conversation or quitting the app. -- **Auto-Connect** — `codework tui` starts or reuses the CodeWork background server, then connects the UI to it. +## Architecture + +The TUI is a thin client. It draws the screen and reads keystrokes, and a CodeWork server +owns the agent, the tools and the model calls. The two talk over a WebSocket carrying +newline-delimited JSON-RPC. + +| Path | Role | +| :------------------- | :-------------------------------------------------------------------- | +| `src/main.rs` | Wiring: build the app, terminal and event loop, then run them. | +| `src/tui.rs` | Terminal lifecycle: raw mode, alternate screen, input modes, restore. | +| `src/event.rs` | Input: the reader thread, and the app events it produces. | +| `src/action.rs` | Named actions and the keymap, with the `` prefix. | +| `src/app.rs` | UI state and `update`, the one place state changes. | +| `src/prompt.rs` | The prompt line's text and caret, and the keys that edit it. | +| `src/menu.rs` | A menu: its entries, its previews and where the cursor is. | +| `src/ui.rs` | Rendering: reads state and draws, nothing else. | +| `src/ui/glyphs.rs` | Pixel glyphs, generated from the landing site's wordmark. | +| `src/ui/wordmark.rs` | Drawing the wordmark, and the mark it falls back to, in half-blocks. | +| `src/ui/field.rs` | The lattice behind the wordmark, on the wordmark's own grid. | +| `src/ui/input.rs` | The prompt box and its line, which reports where the caret goes. | +| `src/ui/menu.rs` | Drawing a menu: its rows, its scrolling and what a row previews. | +| `src/ui/badge.rs` | The tag a box clips to its top border. | +| `src/ui/text.rs` | Measuring and cutting text by columns, which both of the above need. | +| `src/theme.rs` | The palette, generated from the landing site's themes. | +| `src/rpc/` | The transport: WebSocket client and wire protocol. | +| `src/types.rs` | The DTOs the transport speaks in. | +| `src/index.ts` | Thin TypeScript launcher: resolves the platform binary for the CLI. | + +Two files are generated from the landing site instead of written by hand: `src/theme/palettes.rs` +from its theme tokens — including the lattice ink its canvas derives from two of them — and +`src/ui/glyphs.rs` from its pixel wordmark. Run `node scripts/themes.mjs` or +`node scripts/wordmark.mjs` after the site moves; each takes `--check` and fails on drift. --- @@ -29,9 +55,11 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh ### Running the App -`codework tui` is the normal way in: the CLI starts or reuses the background server (registered in `/service.json`) and launches this binary with `CODEWORK_RPC_SERVER` set. From a checkout it picks up a `target/{release,debug}` build. +`codework tui` is the normal way in: the CLI starts or reuses the background server +(registered in `/service.json`) and launches this binary with `CODEWORK_RPC_SERVER` +set. From a checkout it picks up a `target/{release,debug}` build. -To run the binary directly against a server you started with `codework serve`, use Cargo: +To run the binary directly, use Cargo: ```bash # Run in development mode @@ -47,74 +75,26 @@ Or using pnpm: ```bash # From packages/tui pnpm start - -# Or from the project root -pnpm --filter @codeworksh/tui start ``` --- -## Commands - -Type `/` in the prompt input to open the command menu: - -| Command | What it does | -| :--------- | :------------------------------------------ | -| `/help` | Show available commands and shortcuts | -| `/model` | Pick an AI model or enter API keys | -| `/session` | View, switch, or resume past sessions | -| `/compact` | Shorten conversation history to save tokens | -| `/clear` | Clear the current chat screen | -| `/exit` | Exit the app | - ---- - ## Keyboard Shortcuts -### General & Chat - -| Shortcut | Action | -| :-------------------- | :---------------------------------------------------------------- | -| `Enter` | Send message / run command | -| `Esc` | Close popup, cancel current action, or stop the AI | -| `Ctrl+C` | Stop the AI response / exit app when idle | -| `PageUp` / `PageDown` | Scroll chat up or down by a full page | -| `Ctrl+U` / `Ctrl+D` | Scroll chat up or down by half a page | -| `Home` / `End` | Jump to the very top or bottom of the chat | -| `Up` / `Down` | Move through slash commands (or scroll lines when input is empty) | - -### Model Picker (`/model`) - -| Shortcut | Action | -| :--------------------------- | :-------------------------------------------------- | -| `Tab` / `Shift+Tab` | Move between Providers, Models, and Details columns | -| `Up` / `Down` (or `j` / `k`) | Move through the list | -| `/` | Search models or providers | -| `s` | Change sort order (by name, context size, cost) | -| `c` / `y` | Copy the selected model ID to clipboard | -| `o` | Open provider website in your browser | -| `Enter` | Select the model (asks for an API key if needed) | - ---- - -## API Keys & Settings - -You can provide API keys in two simple ways: - -1. **Inside the app:** Type `/model`, choose a provider, and enter your API key when prompted. -2. **Environment variables:** Export the key in your terminal before running: - ```bash - export ANTHROPIC_API_KEY="your-key-here" - export OPENAI_API_KEY="your-key-here" - export GEMINI_API_KEY="your-key-here" - export OPENROUTER_API_KEY="your-key-here" - export GROQ_API_KEY="your-key-here" - ``` - -Your configuration and keys are saved in your home folder: - -- **`~/.codework/config.json`** — Your selected model and provider. -- **`~/.codework/credentials.json`** — Your securely stored API keys. +| Shortcut | Action | +| :------------------- | :--------------------------------------------- | +| _any character_ | Type it into the prompt | +| `←` `→` | Move the prompt's caret | +| `Home` `End` | Jump to the start or the end of the prompt | +| `Backspace` `Delete` | Erase a character | +| _paste_ | Insert the clipboard at the caret, on one line | +| `/` | Ask for the command menu | +| `↑` `↓` | Move through a menu | +| `Enter` | Take the entry a menu is on | +| `Esc` | Close the menu, or exit the app | +| `Ctrl+C` | Exit the app | +| `Ctrl+D` | Exit the app | +| `q` | Exit the app (`Ctrl+X`, then `q`) | --- diff --git a/packages/tui/scripts/themes.mjs b/packages/tui/scripts/themes.mjs new file mode 100644 index 00000000..1a80446b --- /dev/null +++ b/packages/tui/scripts/themes.mjs @@ -0,0 +1,186 @@ +// Generates `src/theme/palettes.rs` from the landing site's palettes, which are the source of +// truth for the TUI's 16 tokens — plus the one ink the landing canvas derives from two of them. +// The output is committed, so building the TUI never needs this script; run it with `--check` to +// fail when the site has moved and the file is stale. +// +// node scripts/themes.mjs # rewrite src/theme/palettes.rs +// node scripts/themes.mjs --check # exit 1 if it would change +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const site = (path) => fileURLToPath(new URL(`../../../sites/www/src/${path}`, import.meta.url)); +const output = fileURLToPath(new URL("../src/theme/palettes.rs", import.meta.url)); + +const CSS = site("styles/themes.css"); +const REGISTRY = site("components/landing/theme.ts"); +const FIELD = site("components/landing/field.tsx"); + +/** The site token -> the Rust field that carries it, in the order the design doc lists them. */ +const TOKENS = [ + ["bg", "bg"], + ["surface", "surface"], + ["surface-2", "surface_2"], + ["border-subtle", "border_subtle"], + ["border-strong", "border_strong"], + ["text", "text"], + ["text-secondary", "text_secondary"], + ["text-muted", "text_muted"], + ["brand", "brand"], + ["brand-ink", "brand_ink"], + ["field-bg", "field_bg"], + ["field-dim", "field_dim"], + ["field-mid", "field_mid"], + ["field-lit", "field_lit"], + ["field-hover", "field_hover"], + ["field-crest", "field_crest"], +]; + +const problems = []; +const note = (message) => problems.push(message); + +/** + * The one colour the canvas derives rather than stores: its lattice, a `mixHex` of two of its own + * tokens. The recipe is read out of the canvas instead of restated here, so a change there is a + * drift failure rather than a silent difference. + */ +function gridRecipe() { + const source = readFileSync(FIELD, "utf8"); + const found = source.match(/grid:\s*mixHex\(token\("([a-z-]+)"\),\s*token\("([a-z-]+)"\),\s*([0-9.]+)\)/); + if (found === null) { + note("field.tsx: no `grid: mixHex(...)` recipe"); + return null; + } + // The canvas names its inks `token("dim")` for `--t-field-dim`. + const token = (name) => `field-${name}`; + for (const mixed of [found[1], found[2]]) { + if (!TOKENS.some(([name]) => name === token(mixed))) + note(`field.tsx: grid mixes --t-${token(mixed)}, which is not a token`); + } + return { from: token(found[1]), to: token(found[2]), t: Number(found[3]) }; +} + +/** Mixes two #rrggbb colours, `t` of the way from a to b, as the canvas does. */ +const mix = (a, b, t) => + `#${[1, 3, 5] + .map((i) => { + const [from, to] = [parseInt(a.slice(i, i + 2), 16), parseInt(b.slice(i, i + 2), 16)]; + return Math.round(from + (to - from) * t); + }) + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}`; + +/** `[data-theme="id"] { --t-x: #hex; … color-scheme: dark|light }` blocks. */ +function palettes() { + const css = readFileSync(CSS, "utf8"); + const found = new Map(); + for (const block of css.matchAll(/\[data-theme="([a-z0-9-]+)"\]\s*\{([^}]*)\}/g)) { + const [, id, body] = block; + const tokens = {}; + for (const token of body.matchAll(/--t-([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})/g)) tokens[token[1]] = token[2]; + const scheme = body.match(/color-scheme:\s*(dark|light)/)?.[1]; + + if (Object.keys(tokens).length !== TOKENS.length) { + note(`${id}: ${Object.keys(tokens).length} tokens, expected ${TOKENS.length}`); + } + for (const [token] of TOKENS) if (tokens[token] === undefined) note(`${id}: missing --t-${token}`); + if (scheme === undefined) note(`${id}: no color-scheme`); + if (found.has(id)) note(`${id}: appears twice`); + found.set(id, { tokens, light: scheme === "light" }); + } + return found; +} + +/** The `THEMES` list and `DEFAULT_THEME`, which own the ids, labels, order and scheme. */ +function registry() { + const source = readFileSync(REGISTRY, "utf8"); + const themes = []; + for (const entry of source.matchAll(/\{\s*id:\s*"([^"]+)",\s*name:\s*"([^"]+)"(,\s*light:\s*true)?\s*\}/g)) { + themes.push({ id: entry[1], name: entry[2], light: entry[3] !== undefined }); + } + const fallback = source.match(/DEFAULT_THEME\s*=\s*"([^"]+)"/)?.[1]; + if (themes.length === 0) note("registry: no themes parsed"); + if (fallback === undefined) note("registry: no DEFAULT_THEME"); + return { themes, fallback }; +} + +const found = palettes(); +const { themes, fallback } = registry(); +const grid = gridRecipe(); + +// The two files have to agree both ways, or a theme would render as nothing. +for (const theme of themes) if (!found.has(theme.id)) note(`registry has ${theme.id}, no palette`); +for (const id of found.keys()) if (!themes.some((t) => t.id === id)) note(`palette ${id} is not in the registry`); +if (fallback !== undefined && !themes.some((t) => t.id === fallback)) + note(`DEFAULT_THEME ${fallback} is not in the registry`); + +for (const theme of themes) { + const palette = found.get(theme.id); + if (palette !== undefined && palette.light !== theme.light) { + note(`${theme.id}: registry says light=${theme.light}, color-scheme says light=${palette.light}`); + } +} + +if (problems.length > 0) { + console.error(`themes.mjs: the site's palettes and registry disagree:\n ${problems.join("\n ")}`); + process.exit(1); +} + +const rgb = (hex) => { + const [r, g, b] = [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)]; + return `Color::Rgb(0x${r.toLowerCase()}, 0x${g.toLowerCase()}, 0x${b.toLowerCase()})`; +}; + +const body = themes + .map((theme) => { + const { tokens } = found.get(theme.id); + const fields = TOKENS.map(([token, field]) => ` ${field}: ${rgb(tokens[token])},`); + // Declared next to `field-bg`, which is where the canvas lists it. + fields.splice( + TOKENS.findIndex(([, field]) => field === "field_bg") + 1, + 0, + ` field_grid: ${rgb(mix(tokens[grid.from], tokens[grid.to], grid.t))},`, + ); + return ` Palette { + id: "${theme.id}", + name: ${JSON.stringify(theme.name)}, + scheme: Scheme::${theme.light ? "Light" : "Dark"}, +${fields.join("\n")} + },`; + }) + .join("\n"); + +const header = [ + "// @generated by scripts/themes.mjs from the landing site's palettes. Do not edit.", + "//", + "// Source: sites/www/src/styles/themes.css (the 16 tokens),", + "// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default) and", + "// sites/www/src/components/landing/field.tsx (the lattice ink it derives from two tokens).", + "// Re-run `node scripts/themes.mjs` after any of them moves; `--check` fails on drift.", +].join("\n"); + +const defaultLine = `/// The site's \`DEFAULT_THEME\`, so the two do not drift.\npub const DEFAULT: &str = "${fallback}";`; + +const generated = `${header} + +use ratatui::style::Color; + +use super::{Palette, Scheme}; + +pub static PALETTES: &[Palette] = &[ +${body} +]; + +${defaultLine} +`; + +if (process.argv.includes("--check")) { + if (readFileSync(output, "utf8") !== generated) { + console.error("themes.mjs: src/theme/palettes.rs is stale — run `node scripts/themes.mjs`"); + process.exit(1); + } + console.log(`themes.mjs: ${themes.length} palettes are up to date`); + process.exit(0); +} + +writeFileSync(output, generated); +console.log(`themes.mjs: wrote ${themes.length} palettes to src/theme/palettes.rs`); diff --git a/packages/tui/scripts/wordmark.mjs b/packages/tui/scripts/wordmark.mjs new file mode 100644 index 00000000..253fe099 --- /dev/null +++ b/packages/tui/scripts/wordmark.mjs @@ -0,0 +1,123 @@ +// Generates `src/ui/glyphs.rs` from the landing site's pixel glyphs, which are the source of truth +// for the wordmark and the compact mark. The output is committed, so building the TUI never needs +// this script; run it with `--check` to fail when the glyphs have moved and the file is stale. +// +// node scripts/wordmark.mjs # rewrite src/ui/glyphs.rs +// node scripts/wordmark.mjs --check # exit 1 if it would change +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const source = fileURLToPath(new URL("../../../sites/www/src/components/landing/wordmark.ts", import.meta.url)); +const output = fileURLToPath(new URL("../src/ui/glyphs.rs", import.meta.url)); + +const problems = []; +const note = (message) => problems.push(message); + +const ts = readFileSync(source, "utf8"); + +/** The per-letter pixel grids that `compose` lays side by side. */ +function letters() { + const block = ts.slice(ts.indexOf("const LETTERS"), ts.indexOf("function compose")); + const found = new Map(); + for (const entry of block.matchAll(/^\t([A-Z]): \[([\s\S]*?)\n\t\],/gm)) { + found.set( + entry[1], + [...entry[2].matchAll(/"([#.]+)"/g)].map((row) => row[1]), + ); + } + if (found.size === 0) note("no letters parsed"); + return found; +} + +/** `compose` from wordmark.ts, kept identical so the two agree cell for cell. */ +function compose(word, glyphs) { + const parts = word.split("").map((char) => { + const rows = glyphs.get(char); + if (rows === undefined) note(`no glyph for ${char}`); + return rows; + }); + const width = parts.reduce((sum, rows) => sum + rows[0].length + 1, 0); + const rows = Array.from( + { length: parts[0].length }, + (_, row) => parts.map((letter) => letter[row].replaceAll("#", "1").replaceAll(".", "0")).join("0") + "0", + ); + // One empty cell on the right and bottom leaves room for the outline echoes. + return { rows: [...rows, "0".repeat(width)], width, height: parts[0].length + 1 }; +} + +/** A standalone `{ width, height, rows }` glyph, such as `MARK`. */ +function standalone(name) { + const at = ts.indexOf(`export const ${name}`); + if (at < 0) { + note(`no ${name} in wordmark.ts`); + return undefined; + } + const block = ts.slice(at, ts.indexOf("};", at)); + const rows = [...block.matchAll(/"([01]+)"/g)].map((row) => row[1]); + const width = Number(block.match(/width:\s*(\d+)/)?.[1]); + const height = Number(block.match(/height:\s*(\d+)/)?.[1]); + return { rows, width, height }; +} + +const glyphs = letters(); +const wordmark = compose("CODEWORK", glyphs); +const mark = standalone("MARK"); + +// Both glyphs feed a cell grid, so a ragged or mistyped grid has to fail here rather than draw +// something subtly wrong. +function check(name, glyph) { + if (glyph === undefined) return; + if (glyph.rows.length !== glyph.height) note(`${name}: ${glyph.rows.length} rows, height says ${glyph.height}`); + for (const [i, row] of glyph.rows.entries()) { + if (row.length !== glyph.width) note(`${name}: row ${i} is ${row.length} wide, width says ${glyph.width}`); + if (!/^[01]+$/.test(row)) note(`${name}: row ${i} is not binary`); + } +} + +check("CODEWORK", wordmark); +check("MARK", mark); + +if (problems.length > 0) { + console.error(`wordmark.mjs: the site's glyphs are not what this expects:\n ${problems.join("\n ")}`); + process.exit(1); +} + +const rows = (glyph) => glyph.rows.map((row) => ` "${row}",`).join("\n"); +const glyph = (name, doc, value) => + `/// ${doc}\npub static ${name}: Glyph = Glyph { + width: ${value.width}, + height: ${value.height}, + rows: &[ +${rows(value)} + ], +};`; + +const header = [ + "// @generated by scripts/wordmark.mjs from the landing site's pixel glyphs. Do not edit.", + "//", + "// Source: sites/www/src/components/landing/wordmark.ts. Re-run `node scripts/wordmark.mjs`", + "// after it moves; `--check` fails on drift.", +].join("\n"); + +const generated = `${header} + +use super::wordmark::Glyph; + +${glyph("WORDMARK", "CODEWORK, laid out on the site's pixel grid.", wordmark)} + +${glyph("MARK", "The C-and-cursor logo, on the same grid and small enough to draw where the wordmark cannot fit.", mark)} +`; + +if (process.argv.includes("--check")) { + if (readFileSync(output, "utf8") !== generated) { + console.error("wordmark.mjs: src/ui/glyphs.rs is stale — run `node scripts/wordmark.mjs`"); + process.exit(1); + } + console.log(`wordmark.mjs: the wordmark and mark are up to date`); + process.exit(0); +} + +writeFileSync(output, generated); +console.log( + `wordmark.mjs: wrote a ${wordmark.width}x${wordmark.height} wordmark and a ${mark.width}x${mark.height} mark`, +); diff --git a/packages/tui/src/action.rs b/packages/tui/src/action.rs new file mode 100644 index 00000000..8b19f0a4 --- /dev/null +++ b/packages/tui/src/action.rs @@ -0,0 +1,80 @@ +//! Named actions and the keymap. +//! +//! Key presses are resolved to *named* actions rather than matched at each call +//! site, so a binding is one edit instead of six, and the names can line up with +//! OpenCode's (`app.exit`, `command.palette.show`, …). Only actions the TUI can +//! currently perform are listed — each new screen adds its own, instead of this +//! table binding behaviour that does not exist yet. +//! +//! These are the keys that mean one thing everywhere. A key whose meaning depends on what is on +//! screen — escape, which closes a menu or leaves — stays with the screen, because a keymap can +//! only hold one answer per key. + +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +/// How long the leader stays armed while waiting for the key after it. +const LEADER_TIMEOUT: Duration = Duration::from_secs(2); + +/// The leader key that every `` binding is prefixed with. OpenCode +/// defaults to ctrl+x, and the leader is what keeps the rest of the keyboard +/// free for plain typing. +const LEADER: (KeyCode, KeyModifiers) = (KeyCode::Char('x'), KeyModifiers::CONTROL); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// `app.exit` — leave the TUI and hand the terminal back. + Exit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Resolution { + /// The key triggered a named action. + Action(Action), + /// The key armed or spent the `` prefix without triggering an action, so it must + /// not fall through to the prompt. + Consumed, + /// The key is not bound here. + Unbound, +} + +/// Resolves key presses to actions, including the `` prefix. +#[derive(Debug, Default)] +pub struct Keymap { + leader_armed: Option, +} + +impl Keymap { + pub fn new() -> Self { + Self::default() + } + + /// What this key press means to the keymap. + /// + /// Takes `&mut self` because a key can be consumed without triggering + /// anything: the leader key arms, and any key pressed while it is armed + /// spends the prefix rather than typing into the prompt. + pub fn resolve(&mut self, key: KeyEvent) -> Resolution { + if (key.code, key.modifiers) == LEADER { + if key.kind != KeyEventKind::Repeat { + self.leader_armed = Some(Instant::now()); + } + return Resolution::Consumed; + } + let leader = self + .leader_armed + .take() + .is_some_and(|at| at.elapsed() < LEADER_TIMEOUT); + match (key.code, key.modifiers) { + // OpenCode's defaults for `app.exit`: ctrl+c, ctrl+d, q. + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Resolution::Action(Action::Exit), + (KeyCode::Char('d'), KeyModifiers::CONTROL) => Resolution::Action(Action::Exit), + (KeyCode::Char('q'), KeyModifiers::NONE) if leader => Resolution::Action(Action::Exit), + _ if leader => Resolution::Consumed, + // Escape is deliberately not bound here, though it is what leaves the app: a menu + // claims it first now, so what it means depends on the screen and `App` decides. + _ => Resolution::Unbound, + } + } +} diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 835b2a44..a6ac5060 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -1,175 +1,528 @@ -use std::sync::Arc; +//! Application state, and the only place that mutates it. +//! +//! `update` is the single entry point for change: it takes an event and reports +//! whether the screen needs redrawing. That report is the whole redraw policy — +//! there is no tick, so an idle TUI does no work and no repaint. + +use std::cell::Cell; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::style::Color; use tokio::sync::mpsc; -use crate::catalog::{ - copy_to_clipboard, open_browser_url, validate_api_key, -}; -use crate::config::ConfigManager; -use crate::credentials::CredentialStore; +use crate::action::{Action, Keymap, Resolution}; +use crate::event::{AppEvent, RpcEvent}; +use crate::menu::{Entry, Menu, Preview}; +use crate::prompt::Prompt; use crate::rpc::{RpcClient, StreamEvent}; -use crate::tool; -use crate::types::{ - ConversationTurn, FocusedPanel, ModelConfig, ModelSortMode, SessionInfo, SessionStats, -}; -use crate::ui::model::ModelBrowserState; -use crate::ui::spinner::Spinner; -use crate::ui::welcome::commands; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ActiveScreen { +use crate::theme::{self, Palette}; +use crate::tool::ToolCall; +use crate::types::{HistoryEntry, Part, SessionInfo, ToolResultContent, UsageReport}; + +/// What a leading slash offers. Only commands the app can actually perform are listed, and an +/// entry's name is what choosing it matches on. +const COMMANDS: &[Entry] = &[ + Entry::item("model", "pick a model"), + Entry::item("thinking", "set thinking effort"), + Entry::item("session", "switch or resume a session"), + Entry::item("new", "start a new session"), + Entry::item("reload", "reload server plugins"), + Entry::item("theme", "pick a palette"), + Entry::item("exit", "close the app"), +]; + +/// Thinking effort options, matching `packages/codework/src/acp/config.ts`. +const THINKING_LEVELS: &[Entry] = &[ + Entry::item("off", "Off"), + Entry::item("minimal", "Minimal"), + Entry::item("low", "Low"), + Entry::item("medium", "Medium"), + Entry::item("high", "High"), + Entry::item("xhigh", "Extra High"), + Entry::item("max", "Max"), +]; + +/// Common `provider/model` presets across supported providers; typing any custom `provider/model` +/// in the search box also offers that exact identifier. +const MODELS: &[Entry] = &[ + Entry::item("google/gemini-4-argon", "Google · Gemini 4 Argon"), + Entry::item("google/gemini-3.8-flash", "Google · Gemini 3.8 Flash"), + Entry::item("google/gemini-3.7-flash", "Google · Gemini 3.7 Flash"), + Entry::item("google/gemini-3.6-flash", "Google · Gemini 3.6 Flash"), + Entry::item("google/gemini-3.5-flash", "Google · Gemini 3.5 Flash"), + Entry::item( + "google/gemini-3.5-flash-lite", + "Google · Gemini 3.5 Flash Lite", + ), + Entry::item( + "google/gemini-3.1-pro-preview", + "Google · Gemini 3.1 Pro Preview", + ), + Entry::item( + "google/gemini-3.1-flash-lite", + "Google · Gemini 3.1 Flash Lite", + ), + Entry::item( + "google/gemini-3-flash-preview", + "Google · Gemini 3 Flash Preview", + ), + Entry::item("google/gemini-flash-latest", "Google · Gemini Flash Latest"), + Entry::item( + "google/gemini-flash-lite-latest", + "Google · Gemini Flash-Lite Latest", + ), + Entry::item("google/gemini-2.5-pro", "Google · Gemini 2.5 Pro"), + Entry::item("google/gemini-2.5-flash", "Google · Gemini 2.5 Flash"), + Entry::item( + "google/gemini-2.5-flash-lite", + "Google · Gemini 2.5 Flash-Lite", + ), + Entry::item("google/gemma-4-31b-it", "Google · Gemma 4 31B IT"), + Entry::item("google/gemma-4-26b-a4b-it", "Google · Gemma 4 26B A4B IT"), + Entry::item( + "anthropic/claude-sonnet-4-5", + "Anthropic · Claude Sonnet 4.5", + ), + Entry::item("anthropic/claude-opus-4-5", "Anthropic · Claude Opus 4.5"), + Entry::item("anthropic/claude-haiku-4-5", "Anthropic · Claude Haiku 4.5"), + Entry::item("openai/gpt-5", "OpenAI · GPT-5"), + Entry::item("openai/gpt-4.1", "OpenAI · GPT-4.1"), + Entry::item("openai/o3", "OpenAI · o3"), + Entry::item( + "openai-codex/codex-mini-latest", + "OpenAI Codex · Codex Mini", + ), + Entry::item( + "github-copilot/claude-sonnet-4", + "GitHub Copilot · Claude Sonnet 4", + ), + Entry::item("xai/grok-4", "xAI · Grok 4"), + Entry::item("deepseek/deepseek-reasoner", "DeepSeek · Reasoner"), + Entry::item("openrouter/auto", "OpenRouter · Auto"), +]; + +/// The screens the TUI can show. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { Welcome, - ModelFlow, Session, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TurnState { + Running, + Succeeded, + Failed(String), + Interrupted(String), +} + +#[derive(Debug, Clone)] +pub struct Turn { + pub prompt: String, + pub thinking: String, + pub text: String, + pub tools: Vec, + pub notices: Vec, + pub state: TurnState, +} + +/// A menu open over the active screen, taking navigation keys while it is there. +pub enum Popup { + /// What a leading slash asks for. + Commands(Menu), + /// Selectable `provider/model` identifiers. + Models { search: Prompt, menu: Menu }, + /// Selectable thinking effort levels. + Thinking(Menu), + /// Sessions on the server (`session.list`). + Sessions { + search: Prompt, + menu: Menu, + sessions: Vec, + }, + /// The palettes the site ships, filtered by `search`, and the one that was on screen before + /// the picker opened so Escape can put it back. + Themes { + search: Prompt, + menu: Menu, + initial: &'static Palette, + }, +} + +impl Popup { + pub fn menu(&self) -> &Menu { + match self { + Popup::Commands(menu) + | Popup::Thinking(menu) + | Popup::Models { menu, .. } + | Popup::Sessions { menu, .. } + | Popup::Themes { menu, .. } => menu, + } + } + + fn menu_mut(&mut self) -> &mut Menu { + match self { + Popup::Commands(menu) + | Popup::Thinking(menu) + | Popup::Models { menu, .. } + | Popup::Sessions { menu, .. } + | Popup::Themes { menu, .. } => menu, + } + } + + /// The menu's own search prompt, when it has one. + pub fn search(&self) -> Option<&Prompt> { + match self { + Popup::Commands(_) | Popup::Thinking(_) => None, + Popup::Models { search, .. } + | Popup::Sessions { search, .. } + | Popup::Themes { search, .. } => Some(search), + } + } + + /// What the menu calls itself, on its border. + pub fn title(&self) -> &'static str { + match self { + Popup::Commands(_) => "commands", + Popup::Models { .. } => "models", + Popup::Thinking(_) => "thinking", + Popup::Sessions { .. } => "sessions", + Popup::Themes { .. } => "themes", + } + } +} + pub struct App { - pub screen: ActiveScreen, - pub should_quit: bool, - pub config_manager: ConfigManager, - pub active_config: Option, - pub spinner: Spinner, - - // Welcome screen state - pub welcome_input: String, - pub welcome_cursor: usize, - pub welcome_dropdown_index: usize, - pub welcome_status_message: Option, - - // Model browser state - pub model_browser: ModelBrowserState, - - // Session state - pub session_info: Option, - pub turns: Vec, - pub session_stats: SessionStats, - pub session_input: String, - pub session_cursor: usize, - pub session_scroll_offset: usize, - pub session_dropdown_index: usize, - pub is_streaming: bool, - /// The socket dropped mid-turn and a reconnect is in flight. Distinct from an - /// error: the turn is still running server-side, so the UI shows recovery - /// rather than a failure. + pub screen: Screen, + /// The palette every screen draws with. Held here rather than read from a global, so a frame + /// is a pure function of the app and can be reproduced from a test backend. + pub theme: &'static Palette, + /// The prompt line: its text and where the caret is in it. + pub prompt: Prompt, + /// The menu on screen, if there is one. + pub popup: Option, + /// A line of plain truth about the prompt or RPC status, shown in place of the key hints. + pub notice: Option, + /// Active server session, once created or attached. + pub session: Option, + /// Selected `(provider, model)` override; when `None`, the server uses its configured default. + pub model: Option<(String, String)>, + /// Selected thinking effort level. + pub thinking: Option, + /// Cumulative token and cost usage for the active session. + pub usage: UsageReport, + /// Conversation turns in the active session. + pub turns: Vec, + /// Whether a prompt turn is currently streaming from the server. + pub busy: bool, + /// Whether the stream socket dropped and is reconnecting. pub reconnecting: bool, + /// Scroll offset in lines from the bottom of the session transcript. + pub scroll: usize, + /// Maximum scroll offset computed by the last transcript render pass. + pub max_scroll: Cell, + pub should_quit: bool, + keymap: Keymap, + tx: mpsc::UnboundedSender, +} - // Theme picker state - pub theme_picker_open: bool, - pub theme_picker_index: usize, - - // RPC Client - pub rpc_client: Arc, - pub stream_tx: mpsc::UnboundedSender, - pub stream_rx: mpsc::UnboundedReceiver, +impl Default for App { + fn default() -> Self { + let (tx, _) = mpsc::unbounded_channel(); + Self::new(tx) + } } impl App { - pub fn new() -> Self { - let config_manager = ConfigManager::new(); - let active_config = config_manager.load(); - let (stream_tx, stream_rx) = mpsc::unbounded_channel(); - + pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { - screen: ActiveScreen::Welcome, + screen: Screen::Welcome, + theme: theme::default_palette(), + prompt: Prompt::default(), + popup: None, + notice: None, + session: None, + model: None, + thinking: None, + usage: UsageReport::default(), + turns: Vec::new(), + busy: false, + reconnecting: false, + scroll: 0, + max_scroll: Cell::new(0), should_quit: false, - config_manager, - active_config, - spinner: Spinner::new(), + keymap: Keymap::new(), + tx, + } + } - welcome_input: String::new(), - welcome_cursor: 0, - welcome_dropdown_index: 0, - welcome_status_message: None, + /// The terminal background colour for the active screen. + pub fn background(&self) -> Color { + match self.screen { + Screen::Welcome => self.theme.field_bg, + Screen::Session => self.theme.bg, + } + } - model_browser: ModelBrowserState::new(), + fn apply_session(&mut self, info: SessionInfo) { + self.model = info + .model + .as_ref() + .map(|m| (m.provider.clone(), m.id.clone())); + self.session = Some(info); + } - session_info: None, - turns: Vec::new(), - session_stats: SessionStats::default(), - session_input: String::new(), - session_cursor: 0, - session_scroll_offset: 0, - session_dropdown_index: 0, - is_streaming: false, - reconnecting: false, + /// Display label for the active model. + pub fn model_label(&self) -> String { + if let Some((provider, id)) = &self.model { + return format!("{provider}/{id}"); + } + if let Some(model) = &self.usage.model { + return model.clone(); + } + "default model".to_string() + } - theme_picker_open: false, - theme_picker_index: 0, + /// Apply one event. Returns whether the screen needs to be drawn again. + pub fn update(&mut self, event: AppEvent) -> bool { + match event { + AppEvent::Key(key) => self.handle_key(key), + AppEvent::Scroll(delta) => self.handle_scroll(delta), + AppEvent::Paste(text) => self.handle_paste(&text), + AppEvent::Resize => true, + AppEvent::Rpc(rpc) => self.handle_rpc(rpc), + } + } - rpc_client: Arc::new(RpcClient::default()), - stream_tx, - stream_rx, + fn handle_scroll(&mut self, delta: isize) -> bool { + if self.popup.is_some() { + let step = if delta > 0 { -1 } else { 1 }; + return self.step_menu(step); } + if self.screen == Screen::Session { + return self.scroll_by(delta); + } + false } - pub fn on_tick(&mut self) { - self.spinner.tick(); + fn scroll_by(&mut self, delta: isize) -> bool { + let cap = self.max_scroll.get(); + let current = self.scroll.min(cap); + let next = if delta >= 0 { + current.saturating_add(delta as usize).min(cap) + } else { + current.saturating_sub((-delta) as usize) + }; + if next == self.scroll { + return false; + } + self.scroll = next; + true + } - // Process any pending stream events from background RPC tasks - while let Ok(event) = self.stream_rx.try_recv() { - self.handle_stream_event(event); + fn handle_paste(&mut self, text: &str) -> bool { + if let Some(popup) = &mut self.popup { + match popup { + Popup::Themes { + search, + menu, + initial, + } => { + if !search.insert_text(text) { + return false; + } + self.theme = filter_themes(search.text(), menu, *initial); + return true; + } + Popup::Models { search, menu } => { + if !search.insert_text(text) { + return false; + } + *menu = filter_models(search.text(), menu.selected().map(|e| e.id.as_ref())); + return true; + } + Popup::Sessions { + search, + menu, + sessions, + } => { + if !search.insert_text(text) { + return false; + } + *menu = filter_sessions( + search.text(), + sessions, + menu.selected().map(|e| e.id.as_ref()), + ); + return true; + } + Popup::Thinking(_) => return false, + Popup::Commands(_) => {} + } } + let changed = self.prompt.insert_text(text); + if changed { + self.notice = None; + self.sync_commands(); + } + changed } - pub fn handle_stream_event(&mut self, event: StreamEvent) { - if self.screen == ActiveScreen::ModelFlow { - match event { - StreamEvent::Succeeded => { - self.model_browser.validating = false; - self.model_browser.validation_error = None; - self.model_browser.api_key_modal_open = false; - self.active_config = self.config_manager.load(); - if let Some(cfg) = &self.active_config { - self.welcome_status_message = Some(format!("Model configured: {} ({})", cfg.model, cfg.provider)); + fn handle_key(&mut self, key: KeyEvent) -> bool { + // Named actions first, so a key bound to the app is never typed into the prompt. + match self.keymap.resolve(key) { + Resolution::Action(Action::Exit) => { + self.should_quit = true; + return false; + } + Resolution::Consumed => return false, + Resolution::Unbound => {} + } + + if key.code == KeyCode::Esc { + return self.escape(); + } + + if self.popup.is_some() { + if self.menu_key(key) { + return true; + } + match &mut self.popup { + Some(Popup::Themes { + search, + menu, + initial, + }) => { + if !search.edit(key) { + return false; } - self.screen = ActiveScreen::Welcome; + self.theme = filter_themes(search.text(), menu, *initial); + return true; } - StreamEvent::Failed(err) => { - self.model_browser.validating = false; - self.model_browser.validation_error = Some(err); + Some(Popup::Models { search, menu }) => { + if !search.edit(key) { + return false; + } + *menu = filter_models(search.text(), menu.selected().map(|e| e.id.as_ref())); + return true; + } + Some(Popup::Sessions { + search, + menu, + sessions, + }) => { + if !search.edit(key) { + return false; + } + *menu = filter_sessions( + search.text(), + sessions, + menu.selected().map(|e| e.id.as_ref()), + ); + return true; + } + Some(Popup::Thinking(_)) => return false, + Some(Popup::Commands(_)) | None => {} + } + } + + if self.screen == Screen::Session { + match key.code { + KeyCode::PageUp => return self.scroll_by(10), + KeyCode::PageDown => return self.scroll_by(-10), + KeyCode::Up + if key.modifiers.contains(KeyModifiers::SHIFT) + || self.prompt.text().is_empty() => + { + return self.scroll_by(1); + } + KeyCode::Down + if key.modifiers.contains(KeyModifiers::SHIFT) + || self.prompt.text().is_empty() => + { + return self.scroll_by(-1); } _ => {} } - return; } - // Connection state is not tied to a turn: a drop and its recovery can - // arrive whether or not the transcript has produced its first turn yet. - match &event { - StreamEvent::Disconnected => { - self.reconnecting = true; - return; + if key.code == KeyCode::Enter { + return self.submit_prompt(); + } + + let edited = self.prompt.edit(key); + if edited { + self.notice = None; + self.sync_commands(); + } + edited + } + + fn handle_rpc(&mut self, event: RpcEvent) -> bool { + match event { + RpcEvent::SessionsLoaded(Ok(mut sessions)) => { + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let current_id = self.session.as_ref().map(|s| s.id.as_str()); + let menu = filter_sessions("", &sessions, current_id); + self.popup = Some(Popup::Sessions { + search: Prompt::default(), + menu, + sessions, + }); + true } - StreamEvent::Reconnected => { - self.reconnecting = false; - return; + RpcEvent::SessionsLoaded(Err(err)) => { + self.notice = Some(err); + true } - _ => {} + RpcEvent::SessionHistoryLoaded(Ok(entries)) => { + self.turns = turns_from_history(&entries); + self.busy = self + .turns + .last() + .is_some_and(|turn| turn.state == TurnState::Running); + self.scroll = 0; + true + } + RpcEvent::SessionHistoryLoaded(Err(err)) => { + self.notice = Some(err); + true + } + RpcEvent::SessionConfigured(Ok(info)) => { + self.apply_session(info); + true + } + RpcEvent::SessionConfigured(Err(err)) => { + self.notice = Some(err); + true + } + RpcEvent::PluginsReloaded(Ok((count, failure))) => { + self.notice = Some(match failure { + Some(err) => format!("plugin reload failed: {err}"), + None => format!("reloaded {count} plugin(s)"), + }); + true + } + RpcEvent::PluginsReloaded(Err(err)) => { + self.notice = Some(err); + true + } + RpcEvent::Stream(stream) => self.handle_stream(stream), } + } - let last_turn_idx = if self.turns.is_empty() { - return; - } else { - self.turns.len() - 1 - }; - + fn handle_stream(&mut self, event: StreamEvent) -> bool { match event { StreamEvent::SessionCreated(info) => { - self.session_info = Some(info); + self.apply_session(info); } StreamEvent::TextDelta(delta) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.response.push_str(&delta); - let approx_tokens = (delta.len() / 4).max(1) as u64; - turn.tokens += approx_tokens; - let elapsed_sec = (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0; - if elapsed_sec > 0.1 { - turn.tokens_per_sec = turn.tokens as f64 / elapsed_sec; - } + if let Some(turn) = self.turns.last_mut() { + turn.text.push_str(&delta); } } StreamEvent::ThinkingDelta(delta) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { + if let Some(turn) = self.turns.last_mut() { turn.thinking.push_str(&delta); } } @@ -179,11 +532,15 @@ impl App { label, arguments, } => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - // Title the running line from the call's arguments, so an in-flight tool - // reads `bash npm test` rather than just `bash`. - turn.active_tool = Some(tool::derive(&name, label.as_deref(), &arguments, None, false)); - turn.active_call_id = Some(call_id); + if let Some(turn) = self.turns.last_mut() { + let started = ToolCall::start(call_id.clone(), name, label, &arguments); + if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == call_id) { + existing.name = started.name; + existing.target = started.target; + existing.label = started.label; + } else { + turn.tools.push(started); + } } } StreamEvent::ToolSettled { @@ -191,1481 +548,921 @@ impl App { name, is_error, arguments, + output, details, } => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - // Pair by callID: parallel tool calls settle out of order, so a settle that - // is not the current active call must not clear or steal its line. - let live = if turn.active_call_id.as_deref() == Some(call_id.as_str()) { - turn.active_call_id = None; - turn.active_tool.take() + if let Some(turn) = self.turns.last_mut() { + if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == call_id) { + existing.apply_settlement( + name, + &arguments, + output, + details.as_ref(), + is_error, + ); } else { - None - }; - let tool_name = name.clone().unwrap_or_else(|| call_id.clone()); - let settled = - tool::derive(&tool_name, None, &arguments, details.as_ref(), is_error); - // The running call already resolved its own identity, including the - // tool's declared label, which the terminal part does not carry. - let result = match live { - Some(running) => tool::settle(running, settled), - None => settled, - }; - turn.completed_tools.push(result); + turn.tools.push(ToolCall::settled( + call_id, + name, + &arguments, + output, + details.as_ref(), + is_error, + )); + } + } + } + StreamEvent::Notice(notice) => { + if let Some(turn) = self.turns.last_mut() { + turn.notices.push(notice); } } StreamEvent::Usage(usage) => { - self.session_stats.total_tokens += usage.total_tokens; - self.session_stats.cost += usage.cost; - self.session_stats.turns_count += 1; - - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.tokens = usage.output_tokens.max(turn.tokens); - let elapsed_sec = (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0; - if elapsed_sec > 0.1 { - turn.tokens_per_sec = turn.tokens as f64 / elapsed_sec; - } + self.usage.total_tokens += usage.total_tokens; + self.usage.input_tokens += usage.input_tokens; + self.usage.output_tokens += usage.output_tokens; + self.usage.cost += usage.cost; + if usage.model.is_some() { + self.usage.model = usage.model; } } + StreamEvent::Disconnected => { + self.reconnecting = true; + } + StreamEvent::Reconnected => { + self.reconnecting = false; + } StreamEvent::Succeeded => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.streaming = false; - turn.end_time = Some(chrono::Utc::now().timestamp_millis()); - // A call that never settled keeps the line it was titled with. - if let Some(running) = turn.active_tool.take() { - turn.active_call_id = None; - turn.completed_tools.push(running); - } + if let Some(turn) = self.turns.last_mut() { + turn.state = TurnState::Succeeded; } - self.is_streaming = false; + self.busy = false; + self.reconnecting = false; } StreamEvent::Failed(err) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.streaming = false; - turn.error = Some(err); - turn.active_tool = None; - turn.active_call_id = None; + if let Some(turn) = self.turns.last_mut() { + turn.state = TurnState::Failed(err); } - self.is_streaming = false; + self.busy = false; self.reconnecting = false; } StreamEvent::Interrupted(reason) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.streaming = false; - turn.error = Some(format!("Interrupted: {}", reason)); - turn.active_tool = None; - turn.active_call_id = None; + if let Some(turn) = self.turns.last_mut() { + turn.state = TurnState::Interrupted(reason); } - self.is_streaming = false; + self.busy = false; self.reconnecting = false; } - // Handled before the transcript: they are connection state, not turns. - StreamEvent::Disconnected | StreamEvent::Reconnected => {} } + true } - pub fn get_session_input_width(&self) -> usize { - let (term_w, _) = crossterm::terminal::size().unwrap_or((80, 24)); - let sidebar_width = (term_w / 4).clamp(24, 32); - let main_width = term_w.saturating_sub(sidebar_width + 1); - (main_width as usize).saturating_sub(6).max(10) - } - - pub fn get_welcome_input_width(&self) -> usize { - let (term_w, _) = crossterm::terminal::size().unwrap_or((80, 24)); - let card_width = 78u16.min(term_w.saturating_sub(2)); - (card_width as usize).saturating_sub(6).max(10) - } - - pub fn handle_key(&mut self, key: KeyEvent) { - self.spinner.reset_blink(); - - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - if self.is_streaming { - // Interrupt active session - if let Some(sess) = &self.session_info { - let rpc = self.rpc_client.clone(); - let s_id = sess.id.clone(); - tokio::spawn(async move { - let _ = rpc.interrupt_session(&s_id).await; - }); - } - self.is_streaming = false; - return; - } - self.should_quit = true; - return; + fn submit_prompt(&mut self) -> bool { + let text = self.prompt.text().trim().to_string(); + if text.is_empty() { + return false; } - - if self.theme_picker_open && self.screen != ActiveScreen::ModelFlow { - return self.handle_theme_picker_key(key); + if self.busy { + self.notice = Some("a turn is already running · press esc to interrupt".to_string()); + return true; } - match self.screen { - ActiveScreen::Welcome => self.handle_welcome_key(key), - ActiveScreen::ModelFlow => self.handle_model_flow_key(key), - ActiveScreen::Session => self.handle_session_key(key), - } - } + self.prompt.clear(); + self.notice = None; + self.screen = Screen::Session; + self.scroll = 0; + self.busy = true; + self.reconnecting = false; + self.turns.push(Turn { + prompt: text.clone(), + thinking: String::new(), + text: String::new(), + tools: Vec::new(), + notices: Vec::new(), + state: TurnState::Running, + }); - /// Live-preview navigation inside the `/theme` picker: moving the cursor - /// switches the palette immediately, so the whole UI recolors as you move. - /// Esc dismisses back to the palette you came from; Enter keeps it. - fn handle_theme_picker_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - let _ = crate::ui::theme::set_active(self.theme_picker_index); - self.theme_picker_open = false; - } - KeyCode::Up | KeyCode::Left => { - let count = crate::ui::theme::PALETTES.len(); - self.theme_picker_index = if self.theme_picker_index == 0 { - count - 1 - } else { - self.theme_picker_index - 1 + let tx = self.tx.clone(); + let existing_session_id = self.session.as_ref().map(|s| s.id.clone()); + let model = self.model.clone(); + let thinking = self.thinking.clone(); + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let client = RpcClient::default(); + let session_id = match existing_session_id { + Some(id) => id, + None => match client.create_session(None, model, thinking).await { + Ok(info) => { + let id = info.id.clone(); + let _ = tx.send(AppEvent::Rpc(RpcEvent::Stream( + StreamEvent::SessionCreated(info), + ))); + id + } + Err(err) => { + let _ = tx + .send(AppEvent::Rpc(RpcEvent::Stream(StreamEvent::Failed(err)))); + return; + } + }, }; - let _ = crate::ui::theme::set_active(self.theme_picker_index); - } - KeyCode::Down | KeyCode::Right => { - self.theme_picker_index = (self.theme_picker_index + 1) % crate::ui::theme::PALETTES.len(); - let _ = crate::ui::theme::set_active(self.theme_picker_index); - } - KeyCode::Enter => { - self.theme_picker_open = false; - self.persist_theme(); - } - _ => {} - } - } - - /// Save the active theme id alongside the model config, so the choice - /// survives restarts. Without a configured model it stays session-only. - fn persist_theme(&self) { - if let Some(cfg) = &self.active_config { - let cfg = ModelConfig { - provider: cfg.provider.clone(), - model: cfg.model.clone(), - updated_at: cfg.updated_at.clone(), - theme: Some(crate::ui::theme::active().id.to_string()), - }; - let _ = self.config_manager.save(&cfg); - } - } - fn handle_welcome_key(&mut self, key: KeyEvent) { - let is_dropdown_open = self.welcome_input.starts_with('/') && !self.welcome_input.contains(' '); - let all_commands = commands(); - let matching_cmds = if is_dropdown_open { - all_commands - .iter() - .filter(|cmd| cmd.name.starts_with(&self.welcome_input)) - .collect::>() - } else { - Vec::new() - }; - - match key.code { - KeyCode::Esc => { - if is_dropdown_open { - self.welcome_input.clear(); - self.welcome_cursor = 0; - self.welcome_dropdown_index = 0; - } else { - self.should_quit = true; - } - } - KeyCode::Up => { - if is_dropdown_open && !matching_cmds.is_empty() { - if self.welcome_dropdown_index == 0 { - self.welcome_dropdown_index = matching_cmds.len() - 1; - } else { - self.welcome_dropdown_index -= 1; - } - } else if !is_dropdown_open { - let w = self.get_welcome_input_width(); - if let Some(new_cur) = crate::ui::input::cursor_up_in_input(&self.welcome_input, self.welcome_cursor, w) { - self.welcome_cursor = new_cur; - } - } - } - KeyCode::Down => { - if is_dropdown_open && !matching_cmds.is_empty() { - if self.welcome_dropdown_index + 1 >= matching_cmds.len() { - self.welcome_dropdown_index = 0; - } else { - self.welcome_dropdown_index += 1; - } - } else if !is_dropdown_open { - let w = self.get_welcome_input_width(); - if let Some(new_cur) = crate::ui::input::cursor_down_in_input(&self.welcome_input, self.welcome_cursor, w) { - self.welcome_cursor = new_cur; - } - } - } - KeyCode::Left => { - self.welcome_cursor = self.welcome_cursor.saturating_sub(1); - } - KeyCode::Right => { - let char_count = self.welcome_input.chars().count(); - if self.welcome_cursor < char_count { - self.welcome_cursor += 1; - } - } - KeyCode::Home => { - self.welcome_cursor = 0; - } - KeyCode::End => { - self.welcome_cursor = self.welcome_input.chars().count(); - } - KeyCode::Delete => { - delete_at(&mut self.welcome_input, self.welcome_cursor); - } - KeyCode::Tab => { - if is_dropdown_open && !matching_cmds.is_empty() { - let cmd_name = matching_cmds[self.welcome_dropdown_index].name; - self.welcome_input = format!("{} ", cmd_name); - self.welcome_cursor = self.welcome_input.chars().count(); - } - } - KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::ALT) => { - insert_char_at(&mut self.welcome_input, &mut self.welcome_cursor, '\n'); - self.welcome_dropdown_index = 0; - } - KeyCode::Enter => { - if is_dropdown_open && !matching_cmds.is_empty() { - let cmd_name = matching_cmds[self.welcome_dropdown_index.min(matching_cmds.len() - 1)].name; - self.welcome_input.clear(); - self.welcome_cursor = 0; - self.welcome_dropdown_index = 0; - if self.execute_command(cmd_name) { - return; - } else { - self.welcome_input = format!("{} ", cmd_name); - self.welcome_cursor = self.welcome_input.chars().count(); - return; + let (stream_tx, mut stream_rx) = mpsc::unbounded_channel(); + let forward_tx = tx.clone(); + let forward = tokio::spawn(async move { + while let Some(evt) = stream_rx.recv().await { + if forward_tx + .send(AppEvent::Rpc(RpcEvent::Stream(evt))) + .is_err() + { + break; + } } - } + }); - let trimmed = self.welcome_input.trim().to_string(); - if trimmed.is_empty() { - return; - } - - if self.execute_command(&trimmed) { - self.welcome_input.clear(); - self.welcome_cursor = 0; - self.welcome_dropdown_index = 0; - return; - } - - if self.active_config.is_none() { - self.welcome_status_message = Some("Please connect a model first (/model)".to_string()); - self.open_model_flow(); - return; - } - - // Start Session - self.welcome_input.clear(); - self.welcome_cursor = 0; - self.welcome_dropdown_index = 0; - self.start_session(trimmed); - } - KeyCode::Backspace => { - backspace_at(&mut self.welcome_input, &mut self.welcome_cursor); - self.welcome_dropdown_index = 0; - } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.welcome_cursor = 0; - } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.welcome_cursor = self.welcome_input.chars().count(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - clear_to_start(&mut self.welcome_input, &mut self.welcome_cursor); - } - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - delete_word_backward(&mut self.welcome_input, &mut self.welcome_cursor); - } - KeyCode::Char(c) => { - insert_char_at(&mut self.welcome_input, &mut self.welcome_cursor, c); - self.welcome_dropdown_index = 0; - self.welcome_status_message = None; - } - _ => {} + let _ = client.prompt_session(session_id, text, stream_tx).await; + let _ = forward.await; + }); } - } - pub fn execute_command(&mut self, cmd: &str) -> bool { - let trimmed = cmd.trim(); - let cmd_name = trimmed.split_whitespace().next().unwrap_or(trimmed); + true + } - match cmd_name { - "/model" => { - self.open_model_flow(); - true - } - "/theme" => { - self.theme_picker_open = true; - self.theme_picker_index = crate::ui::theme::active_index(); - true - } - "/exit" => { - self.should_quit = true; - true - } - "/clear" => { - self.turns.clear(); - self.session_scroll_offset = 0; - true - } - "/session" => { - self.screen = ActiveScreen::Welcome; - true - } - "/help" => { - let mut help_turn = ConversationTurn::new("help".to_string(), "/help".to_string(), "system".to_string()); - help_turn.streaming = false; - help_turn.end_time = Some(chrono::Utc::now().timestamp_millis()); - help_turn.response = "### CodeWork Help & Commands\n\n| Command | Description |\n| :--- | :--- |\n| `/help` | Show available commands and shortcuts | -| `/theme` | Change the color theme |\n| `/clear` | Clear conversation history and screen |\n| `/model` | Switch or view active LLM model |\n| `/session` | List or resume recent sessions |\n| `/compact` | Compact current conversation context |\n| `/exit` | Exit CodeWork TUI |\n\n**Shortcuts:**\n- `↑` / `↓` : Scroll chat history (or navigate commands when typing `/`)\n- `Home` / `End` : Jump to top / bottom of chat\n- `Ctrl+C` : Interrupt streaming response / Exit".to_string(); - self.turns.push(help_turn); - true - } - "/compact" => { - if self.turns.len() > 1 { - let old_count = self.turns.len(); - let last_turn = self.turns.pop().unwrap(); - self.turns.clear(); - let mut compact_turn = ConversationTurn::new("compact".to_string(), "[Context Compacted]".to_string(), "system".to_string()); - compact_turn.streaming = false; - compact_turn.end_time = Some(chrono::Utc::now().timestamp_millis()); - compact_turn.response = format!("Previous {} turns compacted into session context summary.", old_count - 1); - self.turns.push(compact_turn); - self.turns.push(last_turn); - self.session_scroll_offset = 0; - } - true + /// Open, filter, or close the command menu to match a leading slash on the prompt line. + fn sync_commands(&mut self) { + let Some(query) = self.prompt.text().strip_prefix('/') else { + if matches!(self.popup, Some(Popup::Commands(_))) { + self.popup = None; } - _ => false, + return; + }; + let query = query.to_ascii_lowercase(); + let mut menu = match self.popup.take() { + Some(Popup::Commands(menu)) => menu, + _ => Menu::new(COMMANDS.to_vec()), + }; + menu.filter(|entry| entry.name.starts_with(&query)); + if !menu.entries().is_empty() { + self.popup = Some(Popup::Commands(menu)); } } - pub fn open_model_flow(&mut self) { - self.model_browser = ModelBrowserState::new(); - self.screen = ActiveScreen::ModelFlow; + /// A key for the menu on screen: the arrows walk it and Enter takes what it is on. + fn menu_key(&mut self, key: KeyEvent) -> bool { + let step = match key.code { + KeyCode::Up => -1, + KeyCode::Down => 1, + KeyCode::Enter => return self.choose(), + _ => return false, + }; + self.step_menu(step) } - fn handle_model_flow_key(&mut self, key: KeyEvent) { - // Case 1: Help Modal is Open - if self.model_browser.show_help { - self.model_browser.show_help = false; - return; + fn step_menu(&mut self, step: isize) -> bool { + let Some(popup) = &mut self.popup else { + return false; + }; + if !popup.menu_mut().step(step) { + return false; } - - // Case 2: API Key Modal is Open - if self.model_browser.api_key_modal_open { - if self.model_browser.validating { - return; + // Walking the theme picker previews each palette on the whole screen as the cursor lands + // on it; Enter keeps it and Escape puts the initial one back. + if let Popup::Themes { menu, .. } = popup { + if let Some(palette) = menu.selected().and_then(|entry| theme_named(&entry.name)) { + self.theme = palette; } - match key.code { - KeyCode::Esc => { - self.model_browser.api_key_modal_open = false; - self.model_browser.validation_error = None; - } - KeyCode::Backspace => { - self.model_browser.api_key_input.pop(); - } - KeyCode::Char(c) => { - self.model_browser.api_key_input.push(c); - } - KeyCode::Enter => { - let api_key = self.model_browser.api_key_input.trim().to_string(); - let pending = match &self.model_browser.pending_model { - Some(m) => m.clone(), - None => return, - }; - - if api_key.is_empty() && pending.provider_id != "ollama" { - self.model_browser.validation_error = Some("API key cannot be empty".to_string()); - return; - } - - self.model_browser.validating = true; - self.model_browser.validation_error = None; - - let tx = self.stream_tx.clone(); - tokio::spawn(async move { - let val_res = validate_api_key(&pending.provider_id, &api_key).await; - match val_res { - Ok(_) => { - if !api_key.is_empty() { - let cred_store = CredentialStore::new(); - let _ = cred_store.set_api_key(&pending.provider_id, &api_key); - } - - let cfg_mgr = ConfigManager::new(); - let _ = cfg_mgr.save(&ModelConfig { - provider: pending.provider_id.clone(), - model: pending.id.clone(), - updated_at: Some(chrono::Utc::now().to_rfc3339()), - theme: Some(crate::ui::theme::active().id.to_string()), - }); - - let _ = tx.send(StreamEvent::Succeeded); - } - Err(err) => { - let _ = tx.send(StreamEvent::Failed(err)); - } - } - }); - } - _ => {} - } - return; } + true + } - // Case 3: Search mode is active - if self.model_browser.is_searching { - match self.model_browser.focused_panel { - FocusedPanel::Providers => match key.code { - KeyCode::Esc => { - self.model_browser.provider_search.clear(); - self.model_browser.is_searching = false; - self.model_browser.provider_index = 0; - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - KeyCode::Enter => { - self.model_browser.is_searching = false; - } - KeyCode::Backspace => { - self.model_browser.provider_search.pop(); - let filtered = self.model_browser.filtered_providers(); - if self.model_browser.provider_search.is_empty() { - self.model_browser.provider_index = 0; - } else if !filtered.is_empty() { - self.model_browser.provider_index = 1; - } else { - self.model_browser.provider_index = 0; - } - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; + /// Take the entry the cursor is on. + fn choose(&mut self) -> bool { + let Some(chosen) = self + .popup + .as_ref() + .and_then(|popup| popup.menu().selected()) + .cloned() + else { + return false; + }; + let Some(popup) = self.popup.take() else { + return false; + }; + match popup { + Popup::Commands(_) => { + self.prompt.clear(); + match chosen.id.as_ref() { + "model" => { + let current = self.model_label(); + self.popup = Some(Popup::Models { + search: Prompt::default(), + menu: filter_models("", Some(¤t)), + }); } - KeyCode::Char(c) => { - self.model_browser.provider_search.push(c); - let filtered = self.model_browser.filtered_providers(); - if !filtered.is_empty() { - self.model_browser.provider_index = 1; - } else { - self.model_browser.provider_index = 0; - } - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; + "thinking" => { + let current = self.thinking.as_deref().unwrap_or("off"); + self.popup = Some(Popup::Thinking(Menu::at( + THINKING_LEVELS.to_vec(), + current, + ))); } - _ => {} - }, - FocusedPanel::Models => match key.code { - KeyCode::Esc => { - self.model_browser.model_search.clear(); - self.model_browser.search_query.clear(); - self.model_browser.is_searching = false; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; + "session" => { + self.notice = Some("loading sessions...".to_string()); + self.spawn_list_sessions(); } - KeyCode::Enter => { - self.model_browser.is_searching = false; - self.model_browser.focused_panel = FocusedPanel::Models; + "new" => { + self.session = None; + self.turns.clear(); + self.usage = UsageReport::default(); + self.busy = false; + self.reconnecting = false; + self.scroll = 0; + self.notice = Some("started a new session".to_string()); } - KeyCode::Backspace => { - self.model_browser.model_search.pop(); - self.model_browser.search_query.pop(); - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; + "reload" => { + self.notice = Some("reloading plugins...".to_string()); + self.spawn_reload_plugins(); } - KeyCode::Char(c) => { - self.model_browser.model_search.push(c); - self.model_browser.search_query.push(c); - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; + "theme" => { + self.popup = Some(Popup::Themes { + search: Prompt::default(), + menu: themes(self.theme.name), + initial: self.theme, + }); } + "exit" => self.should_quit = true, _ => {} - }, - } - return; - } - - // Case 4: Normal browser dashboard navigation - match key.code { - KeyCode::Char('q') => { - self.screen = ActiveScreen::Welcome; - } - KeyCode::Esc => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - if !self.model_browser.provider_search.is_empty() { - self.model_browser.provider_search.clear(); - self.model_browser.provider_index = 0; - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } else if !self.model_browser.model_search.is_empty() || !self.model_browser.search_query.is_empty() { - self.model_browser.model_search.clear(); - self.model_browser.search_query.clear(); - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } else { - self.screen = ActiveScreen::Welcome; - } - } - FocusedPanel::Models => { - if !self.model_browser.model_search.is_empty() || !self.model_browser.search_query.is_empty() { - self.model_browser.model_search.clear(); - self.model_browser.search_query.clear(); - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } else if !self.model_browser.provider_search.is_empty() { - self.model_browser.provider_search.clear(); - self.model_browser.provider_index = 0; - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } else { - self.screen = ActiveScreen::Welcome; - } - } - } - } - KeyCode::Tab | KeyCode::BackTab => { - self.model_browser.focused_panel = match self.model_browser.focused_panel { - FocusedPanel::Providers => FocusedPanel::Models, - FocusedPanel::Models => FocusedPanel::Providers, - }; - } - KeyCode::Char('/') => { - self.model_browser.is_searching = true; - } - KeyCode::Char('s') => { - self.model_browser.sort_mode = match self.model_browser.sort_mode { - ModelSortMode::Id => ModelSortMode::Provider, - ModelSortMode::Provider => ModelSortMode::Context, - ModelSortMode::Context => ModelSortMode::Cost, - ModelSortMode::Cost => ModelSortMode::Id, - }; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - let sort_label = match self.model_browser.sort_mode { - ModelSortMode::Id => "Model ID", - ModelSortMode::Provider => "Provider", - ModelSortMode::Context => "Context Window", - ModelSortMode::Cost => "Cost", - }; - self.model_browser.set_notification(format!("Sorted by {}", sort_label)); - } - KeyCode::Char('c') => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - if let Some(prov) = self.model_browser.provider_for_details() { - let val = prov.env_keys.first().cloned().unwrap_or_else(|| prov.id.clone()); - copy_to_clipboard(&val); - self.model_browser.set_notification(format!("Copied \"{}\"", val)); - } - } - FocusedPanel::Models => { - if let Some(m) = self.model_browser.current_model() { - copy_to_clipboard(&m.id); - self.model_browser.set_notification(format!("Copied \"{}\"", m.id)); - } - } - } - } - KeyCode::Char('o') => { - if let Some(prov) = self.model_browser.provider_for_details() { - if let Some(url) = &prov.docs_url { - open_browser_url(url); - self.model_browser.set_notification(format!("Opening {}", url)); - } } } - KeyCode::Char('?') => { - self.model_browser.show_help = true; - } - KeyCode::Up | KeyCode::Char('k') => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - let total_provs = 1 + self.model_browser.filtered_providers().len(); - if self.model_browser.provider_index > 0 { - self.model_browser.provider_index -= 1; - } else { - self.model_browser.provider_index = total_provs.saturating_sub(1); - } - if self.model_browser.provider_index < self.model_browser.provider_scroll_offset { - self.model_browser.provider_scroll_offset = self.model_browser.provider_index; - } - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - if self.model_browser.model_index > 0 { - self.model_browser.model_index -= 1; - if self.model_browser.model_index < self.model_browser.model_scroll_offset { - self.model_browser.model_scroll_offset = self.model_browser.model_index; - } - } - } - } - } - KeyCode::Down | KeyCode::Char('j') => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - let total_provs = 1 + self.model_browser.filtered_providers().len(); - if self.model_browser.provider_index + 1 < total_provs { - self.model_browser.provider_index += 1; - } else { - self.model_browser.provider_index = 0; - } - let visible = 14usize; - if self.model_browser.provider_index >= self.model_browser.provider_scroll_offset + visible { - self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; - } - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - let models = self.model_browser.filtered_models(); - if !models.is_empty() && self.model_browser.model_index + 1 < models.len() { - self.model_browser.model_index += 1; - let visible = 14usize; - if self.model_browser.model_index >= self.model_browser.model_scroll_offset + visible { - self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; - } - } - } - } - } - KeyCode::PageUp => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - self.model_browser.provider_index = self.model_browser.provider_index.saturating_sub(12); - self.model_browser.provider_scroll_offset = self.model_browser.provider_index; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - self.model_browser.model_index = self.model_browser.model_index.saturating_sub(12); - if self.model_browser.model_index < self.model_browser.model_scroll_offset { - self.model_browser.model_scroll_offset = self.model_browser.model_index; - } - } - } - } - KeyCode::PageDown => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - let total_provs = 1 + self.model_browser.filtered_providers().len(); - self.model_browser.provider_index = (self.model_browser.provider_index + 12).min(total_provs.saturating_sub(1)); - let visible = 14usize; - if self.model_browser.provider_index >= self.model_browser.provider_scroll_offset + visible { - self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; - } - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - let models = self.model_browser.filtered_models(); - if !models.is_empty() { - self.model_browser.model_index = (self.model_browser.model_index + 12).min(models.len() - 1); - let visible = 14usize; - if self.model_browser.model_index >= self.model_browser.model_scroll_offset + visible { - self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; - } - } - } - } - } - KeyCode::Home => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - self.model_browser.provider_index = 0; - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } + Popup::Models { .. } => { + if let Some((provider, id)) = parse_model(&chosen.id) { + self.model = Some((provider.clone(), id.clone())); + self.notice = Some(format!("model set to {provider}/{id}")); + self.spawn_configure_session(); } } - KeyCode::End => { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - let total_provs = 1 + self.model_browser.filtered_providers().len(); - self.model_browser.provider_index = total_provs.saturating_sub(1); - let visible = 14usize; - if self.model_browser.provider_index >= visible { - self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; - } - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - let models = self.model_browser.filtered_models(); - if !models.is_empty() { - self.model_browser.model_index = models.len() - 1; - let visible = 14usize; - if self.model_browser.model_index >= visible { - self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; - } - } - } + Popup::Thinking(_) => { + let level = chosen.id.to_string(); + self.thinking = Some(level.clone()); + self.notice = Some(format!("thinking effort set to {level}")); + self.spawn_configure_session(); + } + Popup::Sessions { sessions, .. } => { + if let Some(info) = sessions.into_iter().find(|s| s.id == chosen.id.as_ref()) { + let session_id = info.id.clone(); + let title = info.title.clone(); + self.apply_session(info); + self.turns.clear(); + self.usage = UsageReport::default(); + self.busy = false; + self.reconnecting = false; + self.scroll = 0; + self.screen = Screen::Session; + self.notice = Some(format!("switched to session {title}")); + self.spawn_session_history(&session_id); } } - KeyCode::Enter => { - if let Some(m) = self.model_browser.current_model() { - let m_clone = m.clone(); - let has_key = m.provider_id == "ollama" || { - let mut found = false; - for env_k in &m.env_keys { - if let Ok(v) = std::env::var(env_k) { - if !v.trim().is_empty() { - found = true; - break; - } - } - } - if !found { - let cred = CredentialStore::new(); - if let Some(k) = cred.get_api_key(&m.provider_id) { - if !k.trim().is_empty() { - found = true; - } - } - } - found - }; - - if has_key { - let _ = self.config_manager.save(&ModelConfig { - provider: m_clone.provider_id.clone(), - model: m_clone.id.clone(), - updated_at: Some(chrono::Utc::now().to_rfc3339()), - theme: Some(crate::ui::theme::active().id.to_string()), - }); - self.active_config = self.config_manager.load(); - self.welcome_status_message = Some(format!("Connected to {} ({})", m_clone.name, m_clone.id)); - self.screen = ActiveScreen::Welcome; - } else { - self.model_browser.pending_model = Some(m_clone); - self.model_browser.api_key_input.clear(); - self.model_browser.validation_error = None; - self.model_browser.validating = false; - self.model_browser.api_key_modal_open = true; - } + Popup::Themes { .. } => { + if let Some(palette) = theme_named(&chosen.name) { + self.theme = palette; } } - _ => {} } + true } - pub fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) { - if self.screen == ActiveScreen::Session { - match mouse.kind { - crossterm::event::MouseEventKind::ScrollUp => { - self.session_scroll_offset += 2; - } - crossterm::event::MouseEventKind::ScrollDown => { - self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); - } - _ => {} + /// Close what is open, interrupt a running turn, or leave when idle. + fn escape(&mut self) -> bool { + if let Some(popup) = self.popup.take() { + match popup { + Popup::Commands(_) => self.prompt.clear(), + Popup::Themes { initial, .. } => self.theme = initial, + Popup::Models { .. } | Popup::Thinking(_) | Popup::Sessions { .. } => {} } + return true; + } + if self.busy { + self.spawn_interrupt(); + self.notice = Some("interrupting turn...".to_string()); + return true; } + if !self.prompt.text().is_empty() { + self.prompt.clear(); + return true; + } + self.should_quit = true; + false } - fn handle_session_key(&mut self, key: KeyEvent) { - if self.is_streaming { - match key.code { - KeyCode::Up => { - self.session_scroll_offset += 2; - } - KeyCode::Down => { - self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); - } - KeyCode::PageUp => { - self.session_scroll_offset += 15; - } - KeyCode::PageDown => { - self.session_scroll_offset = self.session_scroll_offset.saturating_sub(15); - } - KeyCode::Home => { - self.session_scroll_offset = usize::MAX / 2; - } - KeyCode::End => { - self.session_scroll_offset = 0; - } - _ => {} - } - return; + fn spawn_list_sessions(&self) { + let tx = self.tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = RpcClient::default().list_sessions().await; + let _ = tx.send(AppEvent::Rpc(RpcEvent::SessionsLoaded(res))); + }); } + } - let is_dropdown_open = self.session_input.starts_with('/') && !self.session_input.contains(' '); - let all_commands = commands(); - let matching_cmds = if is_dropdown_open { - all_commands - .iter() - .filter(|cmd| cmd.name.starts_with(&self.session_input)) - .collect::>() - } else { - Vec::new() - }; - - if is_dropdown_open { - match key.code { - KeyCode::Esc => { - self.session_input.clear(); - self.session_cursor = 0; - self.session_dropdown_index = 0; - return; - } - KeyCode::Up => { - if !matching_cmds.is_empty() { - if self.session_dropdown_index == 0 { - self.session_dropdown_index = matching_cmds.len() - 1; - } else { - self.session_dropdown_index -= 1; - } - } - return; - } - KeyCode::Down => { - if !matching_cmds.is_empty() { - if self.session_dropdown_index + 1 >= matching_cmds.len() { - self.session_dropdown_index = 0; - } else { - self.session_dropdown_index += 1; - } - } - return; - } - KeyCode::Tab => { - if !matching_cmds.is_empty() { - let cmd_name = matching_cmds[self.session_dropdown_index.min(matching_cmds.len() - 1)].name; - self.session_input = format!("{} ", cmd_name); - self.session_cursor = self.session_input.chars().count(); - } - return; - } - KeyCode::Enter => { - if !matching_cmds.is_empty() { - let cmd_name = matching_cmds[self.session_dropdown_index.min(matching_cmds.len() - 1)].name; - self.session_input.clear(); - self.session_cursor = 0; - self.session_dropdown_index = 0; - if self.execute_command(cmd_name) { - return; - } else { - self.session_input = format!("{} ", cmd_name); - self.session_cursor = self.session_input.chars().count(); - return; - } - } - } - _ => {} - } + fn spawn_session_history(&self, session_id: &str) { + let session_id = session_id.to_string(); + let tx = self.tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = RpcClient::default().session_history(&session_id).await; + let _ = tx.send(AppEvent::Rpc(RpcEvent::SessionHistoryLoaded(res))); + }); } + } - match key.code { - KeyCode::Esc => { - self.screen = ActiveScreen::Welcome; - } - KeyCode::Up => { - let w = self.get_session_input_width(); - if let Some(new_cur) = crate::ui::input::cursor_up_in_input(&self.session_input, self.session_cursor, w) { - self.session_cursor = new_cur; - } else { - self.session_scroll_offset += 2; - } - } - KeyCode::Down => { - let w = self.get_session_input_width(); - if let Some(new_cur) = crate::ui::input::cursor_down_in_input(&self.session_input, self.session_cursor, w) { - self.session_cursor = new_cur; - } else { - self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); - } - } - KeyCode::PageUp => { - self.session_scroll_offset += 15; - } - KeyCode::PageDown => { - self.session_scroll_offset = self.session_scroll_offset.saturating_sub(15); - } - KeyCode::Left => { - self.session_cursor = self.session_cursor.saturating_sub(1); - } - KeyCode::Right => { - let char_count = self.session_input.chars().count(); - if self.session_cursor < char_count { - self.session_cursor += 1; - } - } - KeyCode::Home => { - if self.session_input.is_empty() { - self.session_scroll_offset = usize::MAX / 2; - } else { - self.session_cursor = 0; - } - } - KeyCode::End => { - if self.session_input.is_empty() { - self.session_scroll_offset = 0; - } else { - self.session_cursor = self.session_input.chars().count(); - } - } - KeyCode::Delete => { - delete_at(&mut self.session_input, self.session_cursor); - self.session_dropdown_index = 0; - } - KeyCode::Backspace => { - backspace_at(&mut self.session_input, &mut self.session_cursor); - self.session_dropdown_index = 0; - } - KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::ALT) => { - insert_char_at(&mut self.session_input, &mut self.session_cursor, '\n'); - self.session_dropdown_index = 0; - } - KeyCode::Enter => { - let prompt = self.session_input.trim().to_string(); - if prompt.is_empty() { - return; - } - self.session_input.clear(); - self.session_cursor = 0; - self.session_dropdown_index = 0; - if self.execute_command(&prompt) { - return; - } - self.dispatch_turn(prompt); - } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.session_cursor = 0; - } - KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.session_cursor = self.session_input.chars().count(); - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - clear_to_start(&mut self.session_input, &mut self.session_cursor); - self.session_dropdown_index = 0; - } - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - delete_word_backward(&mut self.session_input, &mut self.session_cursor); - self.session_dropdown_index = 0; - } - KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let byte_pos = self.session_input.char_indices().nth(self.session_cursor).map(|(i, _)| i).unwrap_or(self.session_input.len()); - self.session_input.truncate(byte_pos); - self.session_dropdown_index = 0; - } - KeyCode::Char(c) => { - insert_char_at(&mut self.session_input, &mut self.session_cursor, c); - self.session_dropdown_index = 0; - } - _ => {} + fn spawn_configure_session(&self) { + let Some(session) = &self.session else { + return; + }; + let session_id = session.id.clone(); + let model = self.model.clone(); + let thinking = self.thinking.clone(); + let tx = self.tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = RpcClient::default() + .configure_session(&session_id, model, thinking) + .await; + let _ = tx.send(AppEvent::Rpc(RpcEvent::SessionConfigured(res))); + }); } } - pub fn handle_paste(&mut self, text: &str) { - self.spinner.reset_blink(); - match self.screen { - ActiveScreen::Welcome => { - insert_str_at(&mut self.welcome_input, &mut self.welcome_cursor, text); - self.welcome_dropdown_index = 0; - self.welcome_status_message = None; - } - ActiveScreen::ModelFlow => { - if self.model_browser.api_key_modal_open { - self.model_browser.api_key_input.push_str(text); - } else if self.model_browser.is_searching { - match self.model_browser.focused_panel { - FocusedPanel::Providers => { - self.model_browser.provider_search.push_str(text); - let filtered = self.model_browser.filtered_providers(); - if !filtered.is_empty() { - self.model_browser.provider_index = 1; - } else { - self.model_browser.provider_index = 0; - } - self.model_browser.provider_scroll_offset = 0; - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - FocusedPanel::Models => { - self.model_browser.model_search.push_str(text); - self.model_browser.search_query.push_str(text); - self.model_browser.model_index = 0; - self.model_browser.model_scroll_offset = 0; - } - } - } - } - ActiveScreen::Session => { - if !self.is_streaming { - insert_str_at(&mut self.session_input, &mut self.session_cursor, text); - self.session_dropdown_index = 0; - } - } + fn spawn_reload_plugins(&self) { + let tx = self.tx.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = RpcClient::default().reload_plugins().await; + let _ = tx.send(AppEvent::Rpc(RpcEvent::PluginsReloaded(res))); + }); } } - pub fn start_session(&mut self, initial_prompt: String) { - self.screen = ActiveScreen::Session; - self.session_cursor = 0; - self.session_scroll_offset = 0; - self.session_dropdown_index = 0; - self.reconnecting = false; - - let cfg = match &self.active_config { - Some(c) => c.clone(), - None => return, + fn spawn_interrupt(&self) { + let Some(session) = &self.session else { + return; }; + let session_id = session.id.clone(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = RpcClient::default().interrupt_session(&session_id).await; + }); + } + } +} - let rpc = self.rpc_client.clone(); - let tx = self.stream_tx.clone(); - let prompt_clone = initial_prompt.clone(); +fn theme_named(name: &str) -> Option<&'static Palette> { + theme::PALETTES.iter().find(|palette| palette.name == name) +} - let model_name = cfg.model.clone(); - let new_turn = ConversationTurn::new( - format!("turn_{}", chrono::Utc::now().timestamp_millis()), - initial_prompt, - model_name, - ); - self.turns.push(new_turn); - self.is_streaming = true; - - tokio::spawn(async move { - // The CLI has already made a server ready; this only guards the instant - // between it registering and the socket actually accepting. Starting a - // server is the CLI's job, never this one's. - if !rpc.wait_for_server().await { - let _ = tx.send(StreamEvent::Failed(format!( - "Could not connect to the CodeWork server at {}", - rpc.url() - ))); - return; - } +/// Splits a `provider/model` identifier on its first slash (matching `acp/config.ts:parseModel`). +fn parse_model(value: &str) -> Option<(String, String)> { + let slash = value.find('/')?; + if slash == 0 || slash + 1 >= value.len() { + return None; + } + Some(( + value[..slash].to_string(), + value[slash + 1..].to_string(), + )) +} - match rpc.create_session(&cfg.provider, &cfg.model, None, Some("high".to_string())).await { - Ok(info) => { - let s_id = info.id.clone(); - let _ = tx.send(StreamEvent::SessionCreated(info)); - let _ = rpc.prompt_session(s_id, prompt_clone, tx).await; - } - Err(err) => { - let _ = tx.send(StreamEvent::Failed(err)); - } - } - }); +/// Filter the theme picker's entries by `query` (matching palette name or scheme) and return the +/// palette to preview on screen — the selected match when there is one, else `initial`. +fn filter_themes(query: &str, menu: &mut Menu, initial: &'static Palette) -> &'static Palette { + let query = query.trim().to_ascii_lowercase(); + menu.filter(|entry| { + query.is_empty() + || entry.name.to_ascii_lowercase().contains(&query) + || entry.detail.to_ascii_lowercase().contains(&query) + }); + menu.selected() + .and_then(|entry| theme_named(&entry.name)) + .unwrap_or(initial) +} + +/// Build the model picker's menu, including any custom `provider/model` typed in the search box. +fn filter_models(query: &str, active: Option<&str>) -> Menu { + let trimmed = query.trim(); + let needle = trimmed.to_ascii_lowercase(); + let mut entries = MODELS.to_vec(); + if parse_model(trimmed).is_some() && !entries.iter().any(|e| e.id.eq_ignore_ascii_case(trimmed)) + { + entries.insert(0, Entry::owned(trimmed, trimmed, "custom")); } + let mut menu = Menu::at(entries, active.unwrap_or("")); + menu.filter(|entry| { + needle.is_empty() + || entry.name.to_ascii_lowercase().contains(&needle) + || entry.detail.to_ascii_lowercase().contains(&needle) + }); + menu +} - pub fn dispatch_turn(&mut self, prompt: String) { - let cfg = match &self.active_config { - Some(c) => c.clone(), - None => return, - }; +/// Reconstruct conversation turns from the server's `session.history` entries. +pub fn turns_from_history(entries: &[HistoryEntry]) -> Vec { + let mut turns: Vec = Vec::new(); - let model_name = cfg.model.clone(); - let new_turn = ConversationTurn::new( - format!("turn_{}", chrono::Utc::now().timestamp_millis()), - prompt.clone(), - model_name, - ); - self.turns.push(new_turn); - self.is_streaming = true; - self.session_scroll_offset = 0; - self.reconnecting = false; + for entry in entries { + if entry.role == "user" { + let prompt = entry + .parts + .iter() + .filter_map(|part| match part { + Part::Text { text } if !text.is_empty() => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + turns.push(Turn { + prompt, + thinking: String::new(), + text: String::new(), + tools: Vec::new(), + notices: Vec::new(), + state: entry_turn_state(&entry.state), + }); + continue; + } + + if turns.is_empty() { + turns.push(Turn { + prompt: String::new(), + thinking: String::new(), + text: String::new(), + tools: Vec::new(), + notices: Vec::new(), + state: TurnState::Succeeded, + }); + } - let rpc = self.rpc_client.clone(); - let tx = self.stream_tx.clone(); - let existing_sess_id = self.session_info.as_ref().map(|s| s.id.clone()); - - tokio::spawn(async move { - let sess_id = match existing_sess_id { - Some(id) => id, - None => match rpc.create_session(&cfg.provider, &cfg.model, None, Some("high".to_string())).await { - Ok(info) => { - let id = info.id.clone(); - let _ = tx.send(StreamEvent::SessionCreated(info)); - id + let Some(turn) = turns.last_mut() else { + continue; + }; + turn.state = entry_turn_state(&entry.state); + + for part in &entry.parts { + match part { + Part::Text { text } if !text.is_empty() => { + if !turn.text.is_empty() + && !turn.text.ends_with('\n') + && !text.starts_with('\n') + { + turn.text.push_str("\n\n"); } - Err(e) => { - let _ = tx.send(StreamEvent::Failed(e)); - return; + turn.text.push_str(text); + } + Part::Thinking { thinking, redacted } + if redacted != &Some(true) && !thinking.is_empty() => + { + if !turn.thinking.is_empty() + && !turn.thinking.ends_with('\n') + && !thinking.starts_with('\n') + { + turn.thinking.push('\n'); } - }, - }; - let _ = rpc.prompt_session(sess_id, prompt, tx).await; - }); + turn.thinking.push_str(thinking); + } + Part::ToolCall { + call_id, + name, + arguments, + status, + result, + .. + } => { + let tool_call = + if matches!(status.as_str(), "pending" | "running") && result.is_none() { + ToolCall::start(call_id.clone(), name.clone(), None, arguments) + } else { + let is_error = status != "completed" + || result.as_ref().is_some_and(|r| r.is_error); + let output = result.as_ref().and_then(|r| { + let text = r + .content + .iter() + .filter_map(|block| match block { + ToolResultContent::Text { text } => Some(text.as_str()), + ToolResultContent::Image { .. } => None, + }) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) + }); + let details = result.as_ref().and_then(|r| r.details.as_ref()); + ToolCall::settled( + call_id.clone(), + Some(name.clone()), + arguments, + output, + details, + is_error, + ) + }; + + if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == *call_id) { + *existing = tool_call; + } else { + turn.tools.push(tool_call); + } + } + Part::Text { .. } | Part::Thinking { .. } | Part::Image { .. } => {} + } + } } + + turns } -fn insert_char_at(s: &mut String, cursor: &mut usize, c: char) { - let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); - s.insert(byte_pos, c); - *cursor += 1; +fn entry_turn_state(state: &str) -> TurnState { + match state { + "draft" => TurnState::Running, + "aborted" => TurnState::Interrupted("aborted".to_string()), + _ => TurnState::Succeeded, + } } -fn insert_str_at(s: &mut String, cursor: &mut usize, text: &str) { - let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); - s.insert_str(byte_pos, text); - *cursor += text.chars().count(); +/// Build the session picker's menu from the server's `session.list` response, sorted by +/// `updated_at` (newest first). +fn filter_sessions(query: &str, sessions: &[SessionInfo], active: Option<&str>) -> Menu { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + filter_sessions_at(query, sessions, active, now_ms) } -fn backspace_at(s: &mut String, cursor: &mut usize) { - if *cursor > 0 { - *cursor -= 1; - let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); - if let Some((_, ch)) = s[byte_pos..].char_indices().next() { - s.drain(byte_pos..byte_pos + ch.len_utf8()); - } - } +fn filter_sessions_at( + query: &str, + sessions: &[SessionInfo], + active: Option<&str>, + now_ms: u64, +) -> Menu { + let needle = query.trim().to_ascii_lowercase(); + let mut ordered: Vec<&SessionInfo> = sessions.iter().collect(); + ordered.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + let entries: Vec = ordered + .into_iter() + .map(|s| { + let title = if s.title.is_empty() { + s.id.clone() + } else { + s.title.clone() + }; + Entry::owned(s.id.clone(), title, session_detail(s, now_ms)) + }) + .collect(); + let mut menu = Menu::at(entries, active.unwrap_or("")); + menu.filter(|entry| { + needle.is_empty() + || entry.name.to_ascii_lowercase().contains(&needle) + || entry.detail.to_ascii_lowercase().contains(&needle) + || entry.id.to_ascii_lowercase().contains(&needle) + }); + menu } -fn delete_at(s: &mut String, cursor: usize) { - let char_count = s.chars().count(); - if cursor < char_count { - let byte_pos = s.char_indices().nth(cursor).map(|(i, _)| i).unwrap_or(s.len()); - if let Some((_, ch)) = s[byte_pos..].char_indices().next() { - s.drain(byte_pos..byte_pos + ch.len_utf8()); - } +fn session_detail(session: &SessionInfo, now_ms: u64) -> String { + let location = session + .project + .as_ref() + .map(|p| p.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| session.host_dir.as_deref().unwrap_or(&session.directory)); + match format_ago(session.updated_at, now_ms) { + Some(age) if !location.is_empty() => format!("{location} · {age}"), + Some(age) => age, + None => location.to_string(), } } -fn delete_word_backward(s: &mut String, cursor: &mut usize) { - if *cursor == 0 { - return; - } - let chars: Vec = s.chars().collect(); - let mut new_cursor = *cursor; - while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() { - new_cursor -= 1; - } - while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() { - new_cursor -= 1; +fn format_ago(updated_at: u64, now_ms: u64) -> Option { + if updated_at == 0 { + return None; } - let remove_count = *cursor - new_cursor; - let start_byte = s.char_indices().nth(new_cursor).map(|(i, _)| i).unwrap_or(s.len()); - let end_byte = start_byte - + chars[new_cursor..new_cursor + remove_count] - .iter() - .map(|c| c.len_utf8()) - .sum::(); - s.drain(start_byte..end_byte); - *cursor = new_cursor; + let elapsed = now_ms.saturating_sub(updated_at); + const MINUTE: u64 = 60_000; + const HOUR: u64 = 60 * MINUTE; + const DAY: u64 = 24 * HOUR; + Some(if elapsed >= DAY { + format!("{}d", elapsed / DAY) + } else if elapsed >= HOUR { + format!("{}h", elapsed / HOUR) + } else if elapsed >= MINUTE { + format!("{}m", elapsed / MINUTE) + } else { + "now".to_string() + }) } -fn clear_to_start(s: &mut String, cursor: &mut usize) { - let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); - s.drain(0..byte_pos); - *cursor = 0; +/// The theme picker: one entry per palette the site ships, its cursor on `active`. +fn themes(active: &str) -> Menu { + Menu::at( + theme::PALETTES + .iter() + .map(|palette| { + Entry::with_preview( + palette.name, + palette.scheme.name(), + Preview { + bg: palette.field_bg, + fg: palette.text, + }, + ) + }) + .collect(), + active, + ) } #[cfg(test)] mod tests { use super::*; + use crate::tool::ToolStatus; + use crate::types::{ModelRef, ProjectInfo, ToolResult, ToolTime}; + use serde_json::json; #[test] - fn test_text_editing_operations() { - let mut s = String::new(); - let mut cursor = 0; - - // Typing fast - for c in "hello world".chars() { - insert_char_at(&mut s, &mut cursor, c); - } - assert_eq!(s, "hello world"); - assert_eq!(cursor, 11); - - // Move cursor back - cursor = 5; - insert_char_at(&mut s, &mut cursor, ','); - assert_eq!(s, "hello, world"); - assert_eq!(cursor, 6); - - // Backspace - backspace_at(&mut s, &mut cursor); - assert_eq!(s, "hello world"); - assert_eq!(cursor, 5); - - // Delete char at cursor (' ') - delete_at(&mut s, cursor); - assert_eq!(s, "helloworld"); - assert_eq!(cursor, 5); - - // Paste - insert_str_at(&mut s, &mut cursor, " beautiful "); - assert_eq!(s, "hello beautiful world"); - assert_eq!(cursor, 16); - - // Delete word backward - delete_word_backward(&mut s, &mut cursor); - assert_eq!(s, "hello world"); - assert_eq!(cursor, 6); - - // Clear to start - clear_to_start(&mut s, &mut cursor); - assert_eq!(s, "world"); - assert_eq!(cursor, 0); + fn reconstructs_turns_from_history_entries() { + let entries = vec![ + HistoryEntry { + id: "u1".to_string(), + seq: 1, + role: "user".to_string(), + state: "committed".to_string(), + created_at: 1_000, + parts: vec![Part::Text { + text: "Read Cargo.toml and run cargo check".to_string(), + }], + }, + HistoryEntry { + id: "a1".to_string(), + seq: 2, + role: "assistant".to_string(), + state: "committed".to_string(), + created_at: 1_100, + parts: vec![ + Part::Thinking { + thinking: "Checking Cargo.toml first.".to_string(), + redacted: None, + }, + Part::Thinking { + thinking: "secret".to_string(), + redacted: Some(true), + }, + Part::ToolCall { + call_id: "call_1".to_string(), + name: "read".to_string(), + arguments: json!({ "path": "Cargo.toml" }), + status: "completed".to_string(), + result: Some(ToolResult { + content: vec![ToolResultContent::Text { + text: "[package]\nname = \"codework-tui\"".to_string(), + }], + is_error: false, + details: None, + }), + time: Some(ToolTime { start: 10, end: 20 }), + }, + ], + }, + HistoryEntry { + id: "a2".to_string(), + seq: 3, + role: "assistant".to_string(), + state: "committed".to_string(), + created_at: 1_200, + parts: vec![ + Part::ToolCall { + call_id: "call_2".to_string(), + name: "bash".to_string(), + arguments: json!({ "command": "cargo check" }), + status: "error".to_string(), + result: Some(ToolResult { + content: vec![ToolResultContent::Text { + text: "compile error".to_string(), + }], + is_error: true, + details: None, + }), + time: None, + }, + Part::Text { + text: "The check failed.".to_string(), + }, + ], + }, + HistoryEntry { + id: "u2".to_string(), + seq: 4, + role: "user".to_string(), + state: "committed".to_string(), + created_at: 2_000, + parts: vec![Part::Text { + text: "Fix it".to_string(), + }], + }, + HistoryEntry { + id: "a3".to_string(), + seq: 5, + role: "assistant".to_string(), + state: "draft".to_string(), + created_at: 2_100, + parts: vec![Part::ToolCall { + call_id: "call_3".to_string(), + name: "edit".to_string(), + arguments: json!({ "path": "src/app.rs" }), + status: "running".to_string(), + result: None, + time: None, + }], + }, + HistoryEntry { + id: "u3".to_string(), + seq: 6, + role: "user".to_string(), + state: "committed".to_string(), + created_at: 3_000, + parts: vec![Part::Text { + text: "Stop".to_string(), + }], + }, + HistoryEntry { + id: "a4".to_string(), + seq: 7, + role: "assistant".to_string(), + state: "aborted".to_string(), + created_at: 3_100, + parts: vec![Part::Text { + text: "Partial reply".to_string(), + }], + }, + ]; + + let turns = turns_from_history(&entries); + assert_eq!(turns.len(), 3); + + assert_eq!(turns[0].prompt, "Read Cargo.toml and run cargo check"); + assert_eq!(turns[0].thinking, "Checking Cargo.toml first."); + assert_eq!(turns[0].text, "The check failed."); + assert_eq!(turns[0].state, TurnState::Succeeded); + assert_eq!(turns[0].tools.len(), 2); + assert_eq!(turns[0].tools[0].call_id, "call_1"); + assert_eq!(turns[0].tools[0].target.as_deref(), Some("Cargo.toml")); + assert_eq!(turns[0].tools[0].status, ToolStatus::Completed); + assert_eq!(turns[0].tools[1].call_id, "call_2"); + assert_eq!(turns[0].tools[1].target.as_deref(), Some("cargo check")); + assert_eq!(turns[0].tools[1].status, ToolStatus::Failed); + assert_eq!(turns[0].tools[1].output.as_deref(), Some("compile error")); + + assert_eq!(turns[1].prompt, "Fix it"); + assert_eq!(turns[1].state, TurnState::Running); + assert_eq!(turns[1].tools.len(), 1); + assert_eq!(turns[1].tools[0].status, ToolStatus::Running); + assert_eq!(turns[1].tools[0].target.as_deref(), Some("src/app.rs")); + + assert_eq!(turns[2].prompt, "Stop"); + assert_eq!(turns[2].text, "Partial reply"); + assert_eq!( + turns[2].state, + TurnState::Interrupted("aborted".to_string()) + ); } #[test] - fn test_unicode_text_editing() { - let mut s = String::new(); - let mut cursor = 0; - - insert_char_at(&mut s, &mut cursor, '🚀'); - insert_char_at(&mut s, &mut cursor, ' '); - insert_char_at(&mut s, &mut cursor, 'h'); - insert_char_at(&mut s, &mut cursor, 'i'); - assert_eq!(s, "🚀 hi"); - assert_eq!(cursor, 4); - - // Backspace 'i' - backspace_at(&mut s, &mut cursor); - assert_eq!(s, "🚀 h"); - assert_eq!(cursor, 3); - - // Backspace 'h', ' ', '🚀' - backspace_at(&mut s, &mut cursor); - backspace_at(&mut s, &mut cursor); - backspace_at(&mut s, &mut cursor); - assert_eq!(s, ""); - assert_eq!(cursor, 0); - } - - #[tokio::test] - async fn test_model_browser_full_flow() { - let mut app = App::new(); - assert_eq!(app.screen, ActiveScreen::Welcome); - - // Type /model and press Enter - app.welcome_input = "/model".to_string(); - app.handle_key(KeyEvent::from(KeyCode::Enter)); - assert_eq!(app.screen, ActiveScreen::ModelFlow); - - // Check initial state: 6001 models, focused on Models panel - assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); - assert_eq!(app.model_browser.filtered_models().len(), 6001); - - // Tab switches to Providers panel - app.handle_key(KeyEvent::from(KeyCode::Tab)); - assert_eq!(app.model_browser.focused_panel, FocusedPanel::Providers); - - // Arrow down moves to first provider - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.model_browser.provider_index, 1); - let models_for_p1 = app.model_browser.filtered_models(); - assert!(!models_for_p1.is_empty() && models_for_p1.len() < 6001); - - // Tab back to Models panel - app.handle_key(KeyEvent::from(KeyCode::Tab)); - assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); - - // Press / to search - app.handle_key(KeyEvent::from(KeyCode::Char('/'))); - assert!(app.model_browser.is_searching); - - for c in "claude".chars() { - app.handle_key(KeyEvent::from(KeyCode::Char(c))); - } - assert_eq!(app.model_browser.search_query, "claude"); - - // Enter finishes typing and focuses Models panel - app.handle_key(KeyEvent::from(KeyCode::Enter)); - assert!(!app.model_browser.is_searching); - assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); - - // Cycle sort with s - app.handle_key(KeyEvent::from(KeyCode::Char('s'))); - assert_eq!(app.model_browser.sort_mode, ModelSortMode::Provider); - - // Press Esc clears search filter - app.handle_key(KeyEvent::from(KeyCode::Esc)); - assert_eq!(app.model_browser.search_query, ""); - - // Press q exits back to Welcome screen - app.handle_key(KeyEvent::from(KeyCode::Char('q'))); - assert_eq!(app.screen, ActiveScreen::Welcome); + fn sorts_sessions_by_updated_at_and_formats_project_activity() { + let now_ms = 1_000_000_000u64; + let sessions = vec![ + SessionInfo { + id: "ses_older".to_string(), + title: "Older session".to_string(), + directory: "/workspace/alpha".to_string(), + host_dir: Some("/Users/prem/alpha".to_string()), + sandbox: None, + project: None, + updated_at: now_ms - 2 * 3_600_000, + model: None, + }, + SessionInfo { + id: "ses_newest".to_string(), + title: "Newest session".to_string(), + directory: "/workspace/codework".to_string(), + host_dir: Some("/Users/prem/codework".to_string()), + sandbox: None, + project: Some(ProjectInfo { + id: "prj_1".to_string(), + name: "codework".to_string(), + }), + updated_at: now_ms - 5 * 60_000, + model: None, + }, + SessionInfo { + id: "ses_oldest".to_string(), + title: "Oldest session".to_string(), + directory: "/workspace/notes".to_string(), + host_dir: None, + sandbox: None, + project: Some(ProjectInfo { + id: "prj_2".to_string(), + name: "notes".to_string(), + }), + updated_at: now_ms - 3 * 86_400_000, + model: None, + }, + ]; + + let menu = filter_sessions_at("", &sessions, None, now_ms); + let entries = menu.entries(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].id.as_ref(), "ses_newest"); + assert_eq!(entries[0].detail.as_ref(), "codework · 5m"); + assert_eq!(entries[1].id.as_ref(), "ses_older"); + assert_eq!(entries[1].detail.as_ref(), "/Users/prem/alpha · 2h"); + assert_eq!(entries[2].id.as_ref(), "ses_oldest"); + assert_eq!(entries[2].detail.as_ref(), "notes · 3d"); } - #[tokio::test] - async fn test_provider_search_flow() { - let mut app = App::new(); - app.welcome_input = "/model".to_string(); - app.handle_key(KeyEvent::from(KeyCode::Enter)); - assert_eq!(app.screen, ActiveScreen::ModelFlow); - - // Switch to Providers panel - app.handle_key(KeyEvent::from(KeyCode::Tab)); - assert_eq!(app.model_browser.focused_panel, FocusedPanel::Providers); - - // Press / to search provider - app.handle_key(KeyEvent::from(KeyCode::Char('/'))); - assert!(app.model_browser.is_searching); - - for c in "openai".chars() { - app.handle_key(KeyEvent::from(KeyCode::Char(c))); - } - assert_eq!(app.model_browser.provider_search, "openai"); - assert_eq!(app.model_browser.provider_index, 1); - assert_eq!(app.model_browser.selected_provider_id(), Some("openai")); - - // Models panel now filtered to openai models - let models = app.model_browser.filtered_models(); - assert!(!models.is_empty()); - for m in &models { - assert_eq!(m.provider_id, "openai"); - } - - // Enter stops search typing but keeps provider selected - app.handle_key(KeyEvent::from(KeyCode::Enter)); - assert!(!app.model_browser.is_searching); - assert_eq!(app.model_browser.provider_search, "openai"); - - // Esc clears provider filter and resets to All - app.handle_key(KeyEvent::from(KeyCode::Esc)); - assert_eq!(app.model_browser.provider_search, ""); - assert_eq!(app.model_browser.provider_index, 0); - assert_eq!(app.model_browser.filtered_models().len(), 6001); - } - - #[tokio::test] - async fn test_session_scrolling() { - let mut app = App::new(); - app.screen = ActiveScreen::Session; - assert_eq!(app.session_scroll_offset, 0); - - // Press Up scrolls up - app.handle_key(KeyEvent::from(KeyCode::Up)); - assert_eq!(app.session_scroll_offset, 2); - - // PageUp scrolls up 15 lines - app.handle_key(KeyEvent::from(KeyCode::PageUp)); - assert_eq!(app.session_scroll_offset, 17); - - // Down scrolls back down - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.session_scroll_offset, 15); - - // PageDown scrolls back down - app.handle_key(KeyEvent::from(KeyCode::PageDown)); - assert_eq!(app.session_scroll_offset, 0); - - // Down at bottom stays at 0 - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.session_scroll_offset, 0); - - // Mouse scroll - app.handle_mouse(crossterm::event::MouseEvent { - kind: crossterm::event::MouseEventKind::ScrollUp, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }); - assert_eq!(app.session_scroll_offset, 2); + #[test] + fn syncs_model_and_loads_history_on_session_switch() { + let mut app = App::default(); + + let first = SessionInfo { + id: "ses_1".to_string(), + title: "First".to_string(), + directory: "/workspace/one".to_string(), + host_dir: None, + sandbox: None, + project: None, + updated_at: 1_000, + model: Some(ModelRef { + provider: "anthropic".to_string(), + id: "claude-sonnet-4-5".to_string(), + }), + }; + let second = SessionInfo { + id: "ses_2".to_string(), + title: "Second".to_string(), + directory: "/workspace/two".to_string(), + host_dir: None, + sandbox: None, + project: None, + updated_at: 2_000, + model: None, + }; - // End resets to 0 (when input empty) - app.handle_key(KeyEvent::from(KeyCode::End)); - assert_eq!(app.session_scroll_offset, 0); - } + // Creating a session syncs `app.model` from `info.model`. + app.update(AppEvent::Rpc(RpcEvent::Stream( + StreamEvent::SessionCreated(first.clone()), + ))); + assert_eq!( + app.model, + Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string())) + ); - #[tokio::test] - async fn test_session_command_dropdown() { - let mut app = App::new(); - app.screen = ActiveScreen::Session; - assert_eq!(app.session_input, ""); - - // Typing '/' opens the command dropdown - app.handle_key(KeyEvent::from(KeyCode::Char('/'))); - assert_eq!(app.session_input, "/"); - assert_eq!(app.session_dropdown_index, 0); - - // Pressing Down navigates to next command - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.session_dropdown_index, 1); - - // Pressing Up navigates back - app.handle_key(KeyEvent::from(KeyCode::Up)); - assert_eq!(app.session_dropdown_index, 0); - - // Typing 'm' filters commands - app.handle_key(KeyEvent::from(KeyCode::Char('m'))); - assert_eq!(app.session_input, "/m"); - - // Pressing Tab auto-completes the selected command - app.handle_key(KeyEvent::from(KeyCode::Tab)); - assert_eq!(app.session_input, "/model "); - - // Pressing Esc clears the input and closes dropdown - app.session_input = "/".to_string(); - app.session_cursor = 1; - app.handle_key(KeyEvent::from(KeyCode::Esc)); - assert_eq!(app.session_input, ""); - assert_eq!(app.screen, ActiveScreen::Session); - - // Executing /clear clears turns - let mut turn = ConversationTurn::new("1".to_string(), "hello".to_string(), "model".to_string()); - turn.response = "world".to_string(); - turn.streaming = false; - app.turns.push(turn); + // Opening `/session` sorts newest (`ses_2`) first, places the cursor on the active + // session (`ses_1`), and choosing `ses_2` clears `app.model` back to `None`. + app.update(AppEvent::Rpc(RpcEvent::SessionsLoaded(Ok(vec![ + first, second, + ])))); + app.update(AppEvent::Key(KeyEvent::from(KeyCode::Up))); + app.update(AppEvent::Key(KeyEvent::from(KeyCode::Enter))); + assert_eq!(app.session.as_ref().map(|s| s.id.as_str()), Some("ses_2")); + assert_eq!(app.model, None); + assert_eq!(app.screen, Screen::Session); + + // Delivering `SessionHistoryLoaded` populates `app.turns`. + app.update(AppEvent::Rpc(RpcEvent::SessionHistoryLoaded(Ok(vec![ + HistoryEntry { + id: "m1".to_string(), + seq: 1, + role: "user".to_string(), + state: "committed".to_string(), + created_at: 2_000, + parts: vec![Part::Text { + text: "Resume me".to_string(), + }], + }, + HistoryEntry { + id: "m2".to_string(), + seq: 2, + role: "assistant".to_string(), + state: "committed".to_string(), + created_at: 2_100, + parts: vec![Part::Text { + text: "Resumed!".to_string(), + }], + }, + ])))); assert_eq!(app.turns.len(), 1); - app.session_input = "/clear".to_string(); - app.session_cursor = 6; - app.handle_key(KeyEvent::from(KeyCode::Enter)); - assert!(app.turns.is_empty()); - assert_eq!(app.session_input, ""); - } - - #[tokio::test] - async fn test_multiline_session_input_and_navigation() { - let mut app = App::new(); - app.screen = ActiveScreen::Session; - app.session_input = "hello world".to_string(); - app.session_cursor = 11; - - // Shift+Enter inserts newline - let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - app.handle_key(shift_enter); - assert_eq!(app.session_input, "hello world\n"); - assert_eq!(app.session_cursor, 12); - - // Type second line - for c in "next line".chars() { - app.handle_key(KeyEvent::from(KeyCode::Char(c))); - } - assert_eq!(app.session_input, "hello world\nnext line"); - assert_eq!(app.session_cursor, 21); - - // Press Up should move cursor to line 1 (col 9, which is 'r') instead of scrolling chat - let initial_scroll = app.session_scroll_offset; - app.handle_key(KeyEvent::from(KeyCode::Up)); - assert_eq!(app.session_scroll_offset, initial_scroll); - assert_eq!(app.session_cursor, 9); // col 9 on "hello world" - - // Press Up again from line 0 should scroll chat history - app.handle_key(KeyEvent::from(KeyCode::Up)); - assert_eq!(app.session_scroll_offset, initial_scroll + 2); - - // Press Down moves cursor to line 1 - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.session_cursor, 12 + 9); // col 9 on "next line" (12 + 9 = 21) - - // Press Down again from last line scrolls chat down - app.handle_key(KeyEvent::from(KeyCode::Down)); - assert_eq!(app.session_scroll_offset, initial_scroll); + assert_eq!(app.turns[0].prompt, "Resume me"); + assert_eq!(app.turns[0].text, "Resumed!"); + assert_eq!(app.turns[0].state, TurnState::Succeeded); + assert!(!app.busy); } } - - diff --git a/packages/tui/src/catalog.rs b/packages/tui/src/catalog.rs deleted file mode 100644 index 0b07b936..00000000 --- a/packages/tui/src/catalog.rs +++ /dev/null @@ -1,930 +0,0 @@ -use std::collections::HashSet; -use std::fs; -use std::path::PathBuf; -use std::sync::OnceLock; -use std::time::Duration; -use crate::credentials::{get_codework_dir, CredentialStore}; -use crate::types::{ModelCost, ModelDetails, ModelEntry, ProviderDefinition, ProviderSummary}; - -static CATALOG_JSON: OnceLock> = OnceLock::new(); -static ALL_MODELS: OnceLock> = OnceLock::new(); -static ALL_PROVIDERS: OnceLock> = OnceLock::new(); - -fn get_catalog_data() -> Option<&'static serde_json::Value> { - CATALOG_JSON.get_or_init(|| { - let mut candidates = Vec::new(); - if let Ok(p) = std::env::var("CODEWORK_MODELS_FILE") { - candidates.push(PathBuf::from(p)); - } - candidates.push(get_codework_dir().join("models.gen.json")); - candidates.push(PathBuf::from("packages/aikit/models.gen.json")); - candidates.push(PathBuf::from("../aikit/models.gen.json")); - candidates.push(PathBuf::from("models.gen.json")); - candidates.push(PathBuf::from("../models.gen.json")); - candidates.push(PathBuf::from("../../models.gen.json")); - candidates.push(PathBuf::from("packages/codework/.codework/models.gen.json")); - - if let Ok(cwd) = std::env::current_dir() { - candidates.push(cwd.join("packages/aikit/models.gen.json")); - candidates.push(cwd.join("models.gen.json")); - candidates.push(cwd.join("../models.gen.json")); - candidates.push(cwd.join("../../models.gen.json")); - candidates.push(cwd.join(".codework/models.gen.json")); - candidates.push(cwd.join("../packages/aikit/models.gen.json")); - } - - for path in candidates { - if path.exists() { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(val) = serde_json::from_str::(&content) { - return Some(val); - } - } - } - } - None - }).as_ref() -} - -pub fn get_all_models() -> &'static [ModelDetails] { - ALL_MODELS.get_or_init(|| { - let mut list = Vec::new(); - if let Some(root) = get_catalog_data().and_then(|v| v.as_object()) { - for (prov_id, prov_val) in root { - if let Some(models_map) = prov_val.as_object() { - for (model_key, model_val) in models_map { - let id = model_val - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or(model_key) - .to_string(); - let name = model_val - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(&id) - .to_string(); - - let (provider_id, provider_name, env_keys) = if let Some(p) = model_val.get("provider").and_then(|v| v.as_object()) { - let p_id = p.get("id").and_then(|v| v.as_str()).unwrap_or(prov_id).to_string(); - let p_name = p.get("name").and_then(|v| v.as_str()).unwrap_or(&p_id).to_string(); - let env = if let Some(arr) = p.get("env").and_then(|v| v.as_array()) { - arr.iter().filter_map(|e| e.as_str().map(|s| s.to_string())).collect() - } else { - default_env_for_provider(&p_id) - }; - (p_id, p_name, env) - } else { - (prov_id.to_string(), prov_id.to_string(), default_env_for_provider(prov_id)) - }; - - let base_url = model_val - .get("baseUrl") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - let npm = model_val - .get("npm") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - let reasoning = model_val - .get("reasoning") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let input_modalities: Vec = if let Some(arr) = model_val.get("input").and_then(|v| v.as_array()) { - arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect() - } else { - vec!["text".to_string()] - }; - - let cost = model_val.get("cost").and_then(|c| { - let input = c.get("input").and_then(|v| v.as_f64()); - let output = c.get("output").and_then(|v| v.as_f64()); - let cache_read = c.get("cacheRead").and_then(|v| v.as_f64()); - let cache_write = c.get("cacheWrite").and_then(|v| v.as_f64()); - if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() { - Some(ModelCost { input, output, cache_read, cache_write }) - } else { - None - } - }); - - let context_window = model_val.get("contextWindow").and_then(|v| v.as_u64()); - let max_tokens = model_val.get("maxTokens").and_then(|v| v.as_u64()); - let family = infer_family(&id, &name); - let docs_url = Some(docs_url_for_provider(&provider_id, base_url.as_deref())); - let released = extract_date(&id).or_else(|| extract_date(&name)); - let updated = released.clone(); - - list.push(ModelDetails { - id, - name, - provider_id, - provider_name, - env_keys, - base_url, - npm, - reasoning, - input_modalities, - cost, - context_window, - max_tokens, - family, - status: "active".to_string(), - docs_url, - released, - updated, - }); - } - } - } - } - list.sort_by(|a, b| a.id.cmp(&b.id)); - list - }) -} - -pub fn get_all_providers() -> &'static [ProviderSummary] { - ALL_PROVIDERS.get_or_init(|| { - let models = get_all_models(); - let mut map: std::collections::BTreeMap, Option, Option, Option)> = std::collections::BTreeMap::new(); - - for m in models { - let entry = map.entry(m.provider_id.clone()).or_insert_with(|| { - ( - m.provider_name.clone(), - 0, - m.env_keys.clone(), - m.base_url.clone(), - m.npm.clone(), - m.docs_url.clone(), - ) - }); - entry.1 += 1; - if entry.3.is_none() && m.base_url.is_some() { - entry.3 = m.base_url.clone(); - } - if entry.4.is_none() && m.npm.is_some() { - entry.4 = m.npm.clone(); - } - } - - let mut providers = Vec::new(); - for (id, (name, count, env_keys, base_url, npm, docs_url)) in map { - providers.push(ProviderSummary { - id, - name, - model_count: count, - env_keys, - base_url, - npm, - docs_url, - }); - } - providers.sort_by(|a, b| a.id.cmp(&b.id)); - providers - }) -} - -pub fn default_env_for_provider(provider_id: &str) -> Vec { - match provider_id { - "google" => vec!["GEMINI_API_KEY".to_string(), "GOOGLE_GENERATIVE_AI_API_KEY".to_string(), "GOOGLE_API_KEY".to_string()], - "openai" => vec!["OPENAI_API_KEY".to_string()], - "anthropic" => vec!["ANTHROPIC_API_KEY".to_string()], - "openrouter" => vec!["OPENROUTER_API_KEY".to_string()], - "groq" => vec!["GROQ_API_KEY".to_string()], - "deepseek" => vec!["DEEPSEEK_API_KEY".to_string()], - "mistral" => vec!["MISTRAL_API_KEY".to_string()], - "cohere" => vec!["COHERE_API_KEY".to_string()], - "together" | "together-ai" => vec!["TOGETHER_API_KEY".to_string()], - "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY".to_string()], - "perplexity" | "perplexity-agent" => vec!["PERPLEXITY_API_KEY".to_string()], - "cerebras" => vec!["CEREBRAS_API_KEY".to_string()], - "chutes" => vec!["CHUTES_API_KEY".to_string()], - "baseten" => vec!["BASETEN_API_KEY".to_string()], - "ai21" => vec!["AI21_API_KEY".to_string()], - "alibaba" | "alibaba-cn" => vec!["DASHSCOPE_API_KEY".to_string()], - "abacus" => vec!["ABACUS_API_KEY".to_string()], - "ollama" => vec!["OLLAMA_API_KEY".to_string()], - _ => { - let normalized = provider_id.replace('-', "_").to_uppercase(); - vec![format!("{}_API_KEY", normalized)] - } - } -} - -pub fn docs_url_for_provider(provider_id: &str, base_url: Option<&str>) -> String { - match provider_id { - "anthropic" => "https://docs.anthropic.com".to_string(), - "openai" => "https://platform.openai.com/docs".to_string(), - "google" => "https://ai.google.dev/docs".to_string(), - "abacus" => "https://abacus.ai/help/api".to_string(), - "groq" => "https://console.groq.com/docs".to_string(), - "deepseek" => "https://api-docs.deepseek.com".to_string(), - "mistral" => "https://docs.mistral.ai".to_string(), - "openrouter" => "https://openrouter.ai/docs".to_string(), - "cohere" => "https://docs.cohere.com".to_string(), - "together" | "together-ai" => "https://docs.together.ai".to_string(), - "fireworks" | "fireworks-ai" => "https://docs.fireworks.ai".to_string(), - "perplexity" | "perplexity-agent" => "https://docs.perplexity.ai".to_string(), - "cerebras" => "https://inference-docs.cerebras.net".to_string(), - "azure" | "azure-cognitive-services" => "https://learn.microsoft.com/azure/ai-services".to_string(), - "amazon-bedrock" => "https://docs.aws.amazon.com/bedrock".to_string(), - "cloudflare-workers-ai" | "cloudflare-ai-gateway" => "https://developers.cloudflare.com/workers-ai".to_string(), - "ollama" => "https://ollama.com".to_string(), - _ => { - if let Some(url) = base_url { - if let Some(host) = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")) { - let domain = host.split('/').next().unwrap_or(host); - return format!("https://{}", domain); - } - } - format!("https://{}.ai", provider_id) - } - } -} - -pub fn infer_family(id: &str, _name: &str) -> String { - let lower = id.to_lowercase(); - if lower.contains("claude-3-7") || lower.contains("claude-3.7") { - "claude-3.7".to_string() - } else if lower.contains("claude-3-5") || lower.contains("claude-3.5") { - "claude-3.5".to_string() - } else if lower.contains("claude") { - "claude".to_string() - } else if lower.contains("gpt-4o") { - "gpt-4o".to_string() - } else if lower.contains("gpt-4") { - "gpt-4".to_string() - } else if lower.contains("gpt-5") { - "gpt-5".to_string() - } else if lower.contains("o1") { - "o1".to_string() - } else if lower.contains("o3") { - "o3".to_string() - } else if lower.contains("gemini-2.5") { - "gemini-2.5".to_string() - } else if lower.contains("gemini-2.0") { - "gemini-2.0".to_string() - } else if lower.contains("gemini-1.5") { - "gemini-1.5".to_string() - } else if lower.contains("gemini") { - "gemini".to_string() - } else if lower.contains("qwen") || lower.contains("qwq") { - "qwen".to_string() - } else if lower.contains("deepseek") { - "deepseek".to_string() - } else if lower.contains("llama") { - "llama".to_string() - } else if lower.contains("mistral") || lower.contains("codestral") { - "mistral".to_string() - } else if lower.contains("command-r") || lower.contains("cohere") { - "cohere".to_string() - } else { - let first = lower.split(|c| c == '/' || c == '-' || c == ':').next().unwrap_or(&lower); - first.to_string() - } -} - -pub fn extract_date(s: &str) -> Option { - for part in s.split(|c| c == '-' || c == '_' || c == '/' || c == '.') { - if part.len() == 8 && part.starts_with("202") && part.chars().all(|c| c.is_ascii_digit()) { - return Some(format!("{}-{}-{}", &part[0..4], &part[4..6], &part[6..8])); - } - } - let bytes = s.as_bytes(); - if bytes.len() >= 10 { - for i in 0..=bytes.len() - 10 { - if bytes[i] == b'2' && bytes[i + 1] == b'0' && bytes[i + 2] == b'2' - && bytes[i + 3].is_ascii_digit() - && bytes[i + 4] == b'-' - && bytes[i + 5].is_ascii_digit() && bytes[i + 6].is_ascii_digit() - && bytes[i + 7] == b'-' - && bytes[i + 8].is_ascii_digit() && bytes[i + 9].is_ascii_digit() - { - return Some(s[i..i + 10].to_string()); - } - } - } - None -} - -pub fn format_context(tokens: Option) -> String { - match tokens { - None | Some(0) => "-".to_string(), - Some(t) if t >= 1_000_000 => { - if t % 1_000_000 == 0 { - format!("{}M", t / 1_000_000) - } else if (1_040_000..=1_050_000).contains(&t) { - "1M".to_string() - } else if (2_090_000..=2_100_000).contains(&t) { - "2M".to_string() - } else if t % 100_000 == 0 { - format!("{:.1}M", t as f64 / 1_000_000.0) - } else { - let m = (t as f64 / 1_000_000.0 * 10.0).round() / 10.0; - if m.fract() == 0.0 { - format!("{:.0}M", m) - } else { - format!("{:.1}M", m) - } - } - } - Some(t) if t >= 1_000 => { - format!("{}k", t / 1_000) - } - Some(t) => format!("{}", t), - } -} - -pub fn clean_cost(val: Option) -> String { - match val { - None => "-".to_string(), - Some(v) if v <= 0.0 => "-".to_string(), - Some(v) if v.fract() == 0.0 => format!("${:.0}", v), - Some(v) if (v * 10.0).fract() == 0.0 => format!("${:.1}", v), - Some(v) if (v * 100.0).fract() == 0.0 => format!("${:.2}", v), - Some(v) => { - let s = format!("${:.4}", v); - s.trim_end_matches('0').trim_end_matches('.').to_string() - } - } -} - -pub fn format_cost_table(cost: Option<&ModelCost>) -> String { - match cost { - None => "-/-".to_string(), - Some(c) => { - let inp = clean_cost(c.input); - let out = clean_cost(c.output); - if inp == "-" && out == "-" { - "-/-".to_string() - } else { - format!("{}/{}", inp, out) - } - } - } -} - -pub fn format_rate(cost: Option) -> String { - match cost { - Some(v) if v > 0.0 => { - let c = clean_cost(Some(v)); - format!("{}/M", c) - } - _ => "-".to_string(), - } -} - -pub fn copy_to_clipboard(text: &str) { - #[cfg(target_os = "macos")] - { - use std::io::Write; - use std::process::{Command, Stdio}; - if let Ok(mut child) = Command::new("pbcopy") - .stdin(Stdio::piped()) - .spawn() - { - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - } - } - - let base64_str = { - const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let bytes = text.as_bytes(); - let mut buf = String::with_capacity((bytes.len() + 2) / 3 * 4); - for chunk in bytes.chunks(3) { - let b0 = chunk[0]; - let b1 = if chunk.len() > 1 { chunk[1] } else { 0 }; - let b2 = if chunk.len() > 2 { chunk[2] } else { 0 }; - buf.push(B64_CHARS[(b0 >> 2) as usize] as char); - buf.push(B64_CHARS[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - if chunk.len() > 1 { - buf.push(B64_CHARS[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); - } else { - buf.push('='); - } - if chunk.len() > 2 { - buf.push(B64_CHARS[(b2 & 0x3F) as usize] as char); - } else { - buf.push('='); - } - } - buf - }; - - use std::io::Write; - let mut out = std::io::stdout(); - let _ = write!(out, "\x1b]52;c;{}\x07", base64_str); - let _ = out.flush(); -} - -pub fn open_browser_url(url: &str) { - #[cfg(target_os = "macos")] - { - let _ = std::process::Command::new("open").arg(url).spawn(); - } - #[cfg(target_os = "linux")] - { - let _ = std::process::Command::new("xdg-open").arg(url).spawn(); - } - #[cfg(target_os = "windows")] - { - let _ = std::process::Command::new("cmd").args(["/C", "start", url]).spawn(); - } -} - -pub const PROVIDERS: &[ProviderDefinition] = &[ - ProviderDefinition { - id: "google", - name: "Google", - description: "Gemini 2.5 Pro, Flash, and Gemma models", - env_keys: &["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"], - default_model: "gemini-2.5-pro", - }, - ProviderDefinition { - id: "openai", - name: "OpenAI", - description: "GPT-4o, o1, o3-mini, and GPT-4 Turbo models", - env_keys: &["OPENAI_API_KEY"], - default_model: "gpt-4o", - }, - ProviderDefinition { - id: "anthropic", - name: "Anthropic", - description: "Claude 3.7 Sonnet, 3.5 Sonnet, and Haiku models", - env_keys: &["ANTHROPIC_API_KEY"], - default_model: "claude-3-7-sonnet-latest", - }, - ProviderDefinition { - id: "openrouter", - name: "OpenRouter", - description: "Multi-provider gateway with unified access", - env_keys: &["OPENROUTER_API_KEY"], - default_model: "anthropic/claude-3.7-sonnet", - }, - ProviderDefinition { - id: "groq", - name: "Groq", - description: "Ultra-low latency Llama and Mixtral inference", - env_keys: &["GROQ_API_KEY"], - default_model: "llama-3.3-70b-versatile", - }, - ProviderDefinition { - id: "deepseek", - name: "DeepSeek", - description: "DeepSeek V3 and R1 reasoning models", - env_keys: &["DEEPSEEK_API_KEY"], - default_model: "deepseek-chat", - }, - ProviderDefinition { - id: "mistral", - name: "Mistral", - description: "Mistral Large, Codestral, and Pixtral models", - env_keys: &["MISTRAL_API_KEY"], - default_model: "codestral-latest", - }, - ProviderDefinition { - id: "ollama", - name: "Ollama (Local)", - description: "Local model execution via Ollama runner", - env_keys: &["OLLAMA_API_KEY"], - default_model: "qwen2.5-coder:latest", - }, -]; - -#[allow(dead_code)] -pub fn get_provider(id: &str) -> Option<&'static ProviderDefinition> { - PROVIDERS.iter().find(|p| p.id == id) -} - -#[allow(dead_code)] -pub fn get_models_for_provider(provider_id: &str) -> Vec { - let mut models = match provider_id { - "google" => vec![ - ModelEntry { - id: "gemini-2.5-pro".to_string(), - name: "Gemini 2.5 Pro".to_string(), - description: Some("State-of-the-art coding and reasoning".to_string()), - is_default: true, - }, - ModelEntry { - id: "gemini-2.5-flash".to_string(), - name: "Gemini 2.5 Flash".to_string(), - description: Some("High speed, balanced capability".to_string()), - is_default: false, - }, - ModelEntry { - id: "gemini-2.0-flash".to_string(), - name: "Gemini 2.0 Flash".to_string(), - description: Some("Fast production-grade model".to_string()), - is_default: false, - }, - ModelEntry { - id: "gemini-1.5-pro".to_string(), - name: "Gemini 1.5 Pro".to_string(), - description: Some("Deep context window and analysis".to_string()), - is_default: false, - }, - ModelEntry { - id: "gemini-1.5-flash".to_string(), - name: "Gemini 1.5 Flash".to_string(), - description: Some("Lightweight and cost-efficient".to_string()), - is_default: false, - }, - ], - "openai" => vec![ - ModelEntry { - id: "gpt-4o".to_string(), - name: "GPT-4o".to_string(), - description: Some("Flagship multimodal intelligence".to_string()), - is_default: true, - }, - ModelEntry { - id: "gpt-4o-mini".to_string(), - name: "GPT-4o Mini".to_string(), - description: Some("Fast, cost-efficient everyday tasks".to_string()), - is_default: false, - }, - ModelEntry { - id: "o1".to_string(), - name: "o1".to_string(), - description: Some("Advanced reasoning and deep math".to_string()), - is_default: false, - }, - ModelEntry { - id: "o3-mini".to_string(), - name: "o3-mini".to_string(), - description: Some("High-speed reasoning and code synthesis".to_string()), - is_default: false, - }, - ModelEntry { - id: "gpt-4-turbo".to_string(), - name: "GPT-4 Turbo".to_string(), - description: Some("High capability GPT-4 generation".to_string()), - is_default: false, - }, - ], - "anthropic" => vec![ - ModelEntry { - id: "claude-3-7-sonnet-latest".to_string(), - name: "Claude 3.7 Sonnet".to_string(), - description: Some("Hybrid reasoning and coding champion".to_string()), - is_default: true, - }, - ModelEntry { - id: "claude-3-5-sonnet-latest".to_string(), - name: "Claude 3.5 Sonnet".to_string(), - description: Some("Industry standard coding model".to_string()), - is_default: false, - }, - ModelEntry { - id: "claude-3-5-haiku-latest".to_string(), - name: "Claude 3.5 Haiku".to_string(), - description: Some("Ultra-fast intelligent responses".to_string()), - is_default: false, - }, - ModelEntry { - id: "claude-3-opus-latest".to_string(), - name: "Claude 3 Opus".to_string(), - description: Some("Deep intellectual analysis".to_string()), - is_default: false, - }, - ], - "openrouter" => vec![ - ModelEntry { - id: "anthropic/claude-3.7-sonnet".to_string(), - name: "Claude 3.7 Sonnet".to_string(), - description: Some("Anthropic via OpenRouter".to_string()), - is_default: true, - }, - ModelEntry { - id: "anthropic/claude-3.5-sonnet".to_string(), - name: "Claude 3.5 Sonnet".to_string(), - description: Some("Anthropic via OpenRouter".to_string()), - is_default: false, - }, - ModelEntry { - id: "openai/gpt-4o".to_string(), - name: "GPT-4o".to_string(), - description: Some("OpenAI via OpenRouter".to_string()), - is_default: false, - }, - ModelEntry { - id: "deepseek/deepseek-r1".to_string(), - name: "DeepSeek R1".to_string(), - description: Some("Open reasoning model".to_string()), - is_default: false, - }, - ModelEntry { - id: "meta-llama/llama-3.3-70b-instruct".to_string(), - name: "Llama 3.3 70B".to_string(), - description: Some("Meta open weights".to_string()), - is_default: false, - }, - ModelEntry { - id: "google/gemini-2.5-pro".to_string(), - name: "Gemini 2.5 Pro".to_string(), - description: Some("Google via OpenRouter".to_string()), - is_default: false, - }, - ], - "groq" => vec![ - ModelEntry { - id: "llama-3.3-70b-versatile".to_string(), - name: "Llama 3.3 70B Versatile".to_string(), - description: Some("Ultra-low latency inference".to_string()), - is_default: true, - }, - ModelEntry { - id: "llama-3.1-8b-instant".to_string(), - name: "Llama 3.1 8B Instant".to_string(), - description: Some("Fast small parameter model".to_string()), - is_default: false, - }, - ModelEntry { - id: "mixtral-8x7b-32768".to_string(), - name: "Mixtral 8x7B".to_string(), - description: Some("High throughput mixture of experts".to_string()), - is_default: false, - }, - ], - "deepseek" => vec![ - ModelEntry { - id: "deepseek-chat".to_string(), - name: "DeepSeek V3".to_string(), - description: Some("High-capability reasoning and general coding".to_string()), - is_default: true, - }, - ModelEntry { - id: "deepseek-reasoner".to_string(), - name: "DeepSeek R1".to_string(), - description: Some("Deep chain-of-thought reasoning model".to_string()), - is_default: false, - }, - ], - "mistral" => vec![ - ModelEntry { - id: "codestral-latest".to_string(), - name: "Codestral".to_string(), - description: Some("Specialized code completion and synthesis".to_string()), - is_default: true, - }, - ModelEntry { - id: "mistral-large-latest".to_string(), - name: "Mistral Large".to_string(), - description: Some("Flagship multilingual model".to_string()), - is_default: false, - }, - ], - "ollama" => vec![ - ModelEntry { - id: "qwen2.5-coder:latest".to_string(), - name: "Qwen 2.5 Coder".to_string(), - description: Some("State-of-the-art open code model".to_string()), - is_default: true, - }, - ModelEntry { - id: "llama3.3:latest".to_string(), - name: "Llama 3.3".to_string(), - description: Some("High capability open weights".to_string()), - is_default: false, - }, - ModelEntry { - id: "deepseek-r1:latest".to_string(), - name: "DeepSeek R1 (Local)".to_string(), - description: Some("Local reasoning model".to_string()), - is_default: false, - }, - ], - _ => Vec::new(), - }; - - let mut seen: HashSet = models.iter().map(|m| m.id.clone()).collect(); - - if let Some(catalog) = get_catalog_data() { - if let Some(provider_data) = catalog.get(provider_id).and_then(|v| v.as_object()) { - for (id, info) in provider_data { - if !seen.contains(id) { - let name = info - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(id) - .to_string(); - - let description = if let Some(desc) = info.get("description").and_then(|v| v.as_str()) { - Some(desc.to_string()) - } else { - let mut parts = Vec::new(); - if let Some(ctx) = info.get("contextWindow").and_then(|v| v.as_u64()) { - if ctx >= 1_000_000 { - parts.push(format!("{:.1}M context", ctx as f64 / 1_000_000.0)); - } else if ctx >= 1_000 { - parts.push(format!("{}k context", ctx / 1000)); - } - } - if info.get("reasoning").and_then(|v| v.as_bool()).unwrap_or(false) { - parts.push("reasoning".to_string()); - } - if !parts.is_empty() { - Some(parts.join(" · ")) - } else { - None - } - }; - - models.push(ModelEntry { - id: id.clone(), - name, - description, - is_default: false, - }); - seen.insert(id.clone()); - } - } - } - } - - models -} - -#[allow(dead_code)] -pub struct KeyResolution { - pub key: String, - pub source: &'static str, // "env" or "store" -} - -#[allow(dead_code)] -pub fn resolve_api_key(provider: &ProviderDefinition) -> Option { - for env_key in provider.env_keys { - if let Ok(val) = std::env::var(env_key) { - let trimmed = val.trim(); - if !trimmed.is_empty() { - return Some(KeyResolution { - key: trimmed.to_string(), - source: "env", - }); - } - } - } - - let store = CredentialStore::new(); - if let Some(key) = store.get_api_key(provider.id) { - let trimmed = key.trim(); - if !trimmed.is_empty() { - return Some(KeyResolution { - key: trimmed.to_string(), - source: "store", - }); - } - } - - None -} - -pub async fn validate_api_key(provider_id: &str, api_key: &str) -> Result<(), String> { - let key = api_key.trim(); - if key.is_empty() && provider_id != "ollama" { - return Err("API key cannot be empty".to_string()); - } - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(8)) - .build() - .map_err(|e| e.to_string())?; - - match provider_id { - "google" => { - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models?key={}", - key - ); - let res = client.get(&url).send().await.map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - let status = res.status(); - let body = res.text().await.unwrap_or_default(); - Err(format!("Validation failed (HTTP {}): {}", status, body)) - } - } - "openai" => { - let res = client - .get("https://api.openai.com/v1/models") - .header("Authorization", format!("Bearer {}", key)) - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid OpenAI API key (HTTP {})", res.status())) - } - } - "anthropic" => { - let res = client - .get("https://api.anthropic.com/v1/models") - .header("x-api-key", key) - .header("anthropic-version", "2023-06-01") - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid Anthropic API key (HTTP {})", res.status())) - } - } - "openrouter" => { - let res = client - .get("https://openrouter.ai/api/v1/auth/key") - .header("Authorization", format!("Bearer {}", key)) - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid OpenRouter API key (HTTP {})", res.status())) - } - } - "groq" => { - let res = client - .get("https://api.groq.com/openai/v1/models") - .header("Authorization", format!("Bearer {}", key)) - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid Groq API key (HTTP {})", res.status())) - } - } - "deepseek" => { - let res = client - .get("https://api.deepseek.com/models") - .header("Authorization", format!("Bearer {}", key)) - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid DeepSeek API key (HTTP {})", res.status())) - } - } - "mistral" => { - let res = client - .get("https://api.mistral.ai/v1/models") - .header("Authorization", format!("Bearer {}", key)) - .send() - .await - .map_err(|e| e.to_string())?; - if res.status().is_success() { - Ok(()) - } else { - Err(format!("Invalid Mistral API key (HTTP {})", res.status())) - } - } - "ollama" => Ok(()), - _ => Ok(()), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_load_all_models() { - let google_models = get_models_for_provider("google"); - assert!(google_models.len() >= 20, "Expected >=20 Google models, got {}", google_models.len()); - - let openai_models = get_models_for_provider("openai"); - assert!(openai_models.len() >= 40, "Expected >=40 OpenAI models, got {}", openai_models.len()); - - let openrouter_models = get_models_for_provider("openrouter"); - assert!(openrouter_models.len() >= 300, "Expected >=300 OpenRouter models, got {}", openrouter_models.len()); - - let all_models = get_all_models(); - assert_eq!(all_models.len(), 6001, "Expected 6001 models, got {}", all_models.len()); - - let all_providers = get_all_providers(); - assert_eq!(all_providers.len(), 206, "Expected 206 providers, got {}", all_providers.len()); - - // Test formatting helpers - assert_eq!(format_context(Some(32768)), "32k"); - assert_eq!(format_context(Some(1048576)), "1M"); - assert_eq!(format_context(Some(2000000)), "2M"); - assert_eq!(clean_cost(Some(0.4)), "$0.4"); - assert_eq!(clean_cost(Some(15.0)), "$15"); - assert_eq!(clean_cost(Some(0.11)), "$0.11"); - } -} diff --git a/packages/tui/src/config.rs b/packages/tui/src/config.rs deleted file mode 100644 index 482f518d..00000000 --- a/packages/tui/src/config.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use crate::credentials::get_codework_dir; -use crate::types::ModelConfig; -use crate::ui::theme; - -pub struct ConfigManager { - file_path: PathBuf, -} - -impl Default for ConfigManager { - fn default() -> Self { - Self::new() - } -} - -impl ConfigManager { - pub fn new() -> Self { - let dir = get_codework_dir(); - Self { - file_path: dir.join("config.json"), - } - } - - pub fn load(&self) -> Option { - if let Ok(content) = fs::read_to_string(&self.file_path) { - let config: Option = serde_json::from_str(&content).ok(); - if let Some(cfg) = &config { - if let Some(id) = &cfg.theme { - if let Some(idx) = theme::by_id(id) { - theme::set_active(idx); - } - } - } - config - } else { - None - } - } - - pub fn save(&self, config: &ModelConfig) -> std::io::Result<()> { - if let Some(parent) = self.file_path.parent() { - fs::create_dir_all(parent)?; - } - let data = serde_json::to_string_pretty(config).unwrap_or_else(|_| "{}".to_string()); - fs::write(&self.file_path, data)?; - Ok(()) - } -} diff --git a/packages/tui/src/credentials.rs b/packages/tui/src/credentials.rs deleted file mode 100644 index 5f9a5d80..00000000 --- a/packages/tui/src/credentials.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; - -pub fn get_codework_dir() -> PathBuf { - // `CODEWORK_HOME_DIR` is the name the harness and CLI use, so a UI launched - // against a chosen home reads the same settings and credentials as the server - // the CLI started. `CODEWORK_HOME` is kept as an earlier spelling. - if let Ok(home) = std::env::var("CODEWORK_HOME_DIR") { - PathBuf::from(home) - } else if let Ok(home) = std::env::var("CODEWORK_HOME") { - PathBuf::from(home) - } else if let Some(home) = dirs::home_dir() { - home.join(".codework") - } else { - PathBuf::from(".codework") - } -} - -pub struct CredentialStore { - file_path: PathBuf, -} - -impl Default for CredentialStore { - fn default() -> Self { - Self::new() - } -} - -impl CredentialStore { - pub fn new() -> Self { - let dir = get_codework_dir(); - Self { - file_path: dir.join("credentials.json"), - } - } - - fn ensure_dir(&self) -> std::io::Result<()> { - if let Some(parent) = self.file_path.parent() { - fs::create_dir_all(parent)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); - } - } - Ok(()) - } - - pub fn read_all(&self) -> HashMap { - if let Ok(content) = fs::read_to_string(&self.file_path) { - serde_json::from_str(&content).unwrap_or_default() - } else { - HashMap::new() - } - } - - pub fn get_api_key(&self, provider_id: &str) -> Option { - self.read_all().get(provider_id).cloned() - } - - pub fn set_api_key(&self, provider_id: &str, api_key: &str) -> std::io::Result<()> { - self.ensure_dir()?; - let mut all = self.read_all(); - all.insert(provider_id.to_string(), api_key.to_string()); - let data = serde_json::to_string_pretty(&all).unwrap_or_else(|_| "{}".to_string()); - fs::write(&self.file_path, data)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&self.file_path, fs::Permissions::from_mode(0o600)); - } - - Ok(()) - } -} diff --git a/packages/tui/src/diff.rs b/packages/tui/src/diff.rs index 9e209a6c..d0cbd73d 100644 --- a/packages/tui/src/diff.rs +++ b/packages/tui/src/diff.rs @@ -1,19 +1,28 @@ //! Renders a unified diff (the `patch` field tools such as `edit` return in //! their result `details`) into styled terminal lines. //! -//! The renderer is deliberately tag-less: it only understands the standard -//! unified-diff grammar, so any tool that emits a patch can reuse it. It is pure -//! and returns `Line`s rather than touching a `Frame`, which keeps it testable. - -use ratatui::style::{Modifier, Style}; +//! Matches OpenCode and Codex TUI diff rendering: +//! - Wide viewports (`max_width >= SPLIT_MIN_WIDTH`) render side-by-side (`old │ new`) split +//! diffs for file updates; narrower viewports (or pure file additions/deletions) render unified +//! diffs. +//! - File headers (`---` / `+++`) and raw `@@ ... @@` lines are omitted in favour of line numbers +//! on both old and new sides and a `⋮` separator between multiple hunks. +//! - Gutter line numbers carry a deeper tint (`diff_add_gutter_bg` / `diff_del_gutter_bg`) distinct +//! from the full-width line background (`diff_add_bg` / `diff_del_bg`). + +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; +use unicode_width::UnicodeWidthChar; + +use crate::theme::Palette; +use crate::ui::text::{elide, width}; -use crate::ui::text::elide; -use crate::ui::theme::Theme; +/// Default cap on rendered diff rows per tool call (`0` means unlimited). +pub const DEFAULT_MAX_DIFF_LINES: usize = 60; -/// How many diff lines are shown before the "… +N more lines" footer. -pub const DEFAULT_MAX_DIFF_LINES: usize = 12; +/// Minimum width (in columns) at which update diffs switch from unified to side-by-side split view +/// (matching OpenCode's `ctx.width > 120` threshold minus the 4-column transcript indent). +pub const SPLIT_MIN_WIDTH: usize = 116; /// Added/removed counts for the whole patch, before any truncation. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -24,91 +33,114 @@ pub struct DiffSummary { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Kind { - FileHeader, - Hunk, Added, Removed, Context, - NoNewline, } -struct ParsedLine { +#[derive(Debug, Clone)] +struct DiffLine { kind: Kind, text: String, - /// 1-based line number in the new file; `None` for removals and metadata. - line_no: Option, + old_no: Option, + new_no: Option, } -/// Parses the new-side start line out of a hunk header (`@@ -a,b +c,d @@`). -fn parse_new_start(header: &str) -> Option { - let rest = &header[header.find('+')? + 1..]; - let end = rest.find(|c| c == ' ' || c == ',').unwrap_or(rest.len()); - rest[..end].parse().ok() +#[derive(Debug, Clone, Default)] +struct Hunk { + lines: Vec, } -fn classify(line: &str) -> Kind { - // Order matters: `---`/`+++` must be checked before the `-`/`+` markers. - if line.starts_with("--- ") || line.starts_with("+++ ") { - Kind::FileHeader - } else if line.starts_with("@@") { - Kind::Hunk - } else if line.starts_with('+') { - Kind::Added - } else if line.starts_with('-') { - Kind::Removed - } else if line.starts_with('\\') { - Kind::NoNewline - } else { - Kind::Context - } +/// Parses `(old_start, new_start)` out of a unified diff hunk header (`@@ -a,b +c,d @@`). +fn parse_hunk_starts(header: &str) -> (usize, usize) { + let old_start = header + .find('-') + .and_then(|idx| { + let rest = &header[idx + 1..]; + let end = rest.find([' ', ',']).unwrap_or(rest.len()); + rest[..end].parse::().ok() + }) + .unwrap_or(1); + let new_start = header + .find('+') + .and_then(|idx| { + let rest = &header[idx + 1..]; + let end = rest.find([' ', ',']).unwrap_or(rest.len()); + rest[..end].parse::().ok() + }) + .unwrap_or(1); + (old_start, new_start) } -fn parse(patch: &str) -> Vec { - let mut parsed = Vec::new(); - let mut new_line: Option = None; +fn parse_hunks(patch: &str) -> (Vec, DiffSummary) { + let mut hunks: Vec = Vec::new(); + let mut summary = DiffSummary::default(); + let mut old_ln = 1usize; + let mut new_ln = 1usize; for raw in patch.lines() { - let kind = classify(raw); - let (line_no, text) = match kind { - Kind::FileHeader | Kind::Hunk | Kind::NoNewline => (None, raw.to_string()), - Kind::Added => { - let current = new_line; - new_line = new_line.map(|n| n + 1); - (current, raw.get(1..).unwrap_or("").to_string()) - } - Kind::Removed => (None, raw.get(1..).unwrap_or("").to_string()), - Kind::Context => { - let current = new_line; - new_line = new_line.map(|n| n + 1); - (current, raw.get(1..).unwrap_or("").to_string()) - } - }; - if kind == Kind::Hunk { - new_line = parse_new_start(raw); + if raw.starts_with("--- ") || raw.starts_with("+++ ") || raw.starts_with('\\') { + continue; + } + if raw.starts_with("@@") { + let (o, n) = parse_hunk_starts(raw); + old_ln = o; + new_ln = n; + hunks.push(Hunk::default()); + continue; + } + + if hunks.is_empty() { + hunks.push(Hunk::default()); + } + let hunk = hunks.last_mut().expect("hunk exists"); + + if let Some(rest) = raw.strip_prefix('+') { + summary.added += 1; + let current = new_ln; + new_ln += 1; + hunk.lines.push(DiffLine { + kind: Kind::Added, + text: expand_tabs(rest), + old_no: None, + new_no: Some(current), + }); + } else if let Some(rest) = raw.strip_prefix('-') { + summary.removed += 1; + let current = old_ln; + old_ln += 1; + hunk.lines.push(DiffLine { + kind: Kind::Removed, + text: expand_tabs(rest), + old_no: Some(current), + new_no: None, + }); + } else { + let rest = raw.strip_prefix(' ').unwrap_or(raw); + let cur_old = old_ln; + let cur_new = new_ln; + old_ln += 1; + new_ln += 1; + hunk.lines.push(DiffLine { + kind: Kind::Context, + text: expand_tabs(rest), + old_no: Some(cur_old), + new_no: Some(cur_new), + }); } - parsed.push(ParsedLine { kind, text, line_no }); } - parsed + hunks.retain(|h| !h.lines.is_empty()); + (hunks, summary) } -/// Added/removed counts across an already-parsed patch. -fn summarize(parsed: &[ParsedLine]) -> DiffSummary { - let mut summary = DiffSummary::default(); - for line in parsed { - match line.kind { - Kind::Added => summary.added += 1, - Kind::Removed => summary.removed += 1, - _ => {} - } - } - summary +fn expand_tabs(text: &str) -> String { + text.replace('\t', " ") } -/// Added/removed counts for the whole of `patch`, without building any styled -/// lines — for callers that only want the numbers (a tool-call summary). +/// Added/removed counts for the whole of `patch`, without building any styled lines. pub fn counts(patch: &str) -> DiffSummary { - summarize(&parse(patch)) + parse_hunks(patch).1 } /// The new-file path from a patch's `+++` header, when present. @@ -116,103 +148,333 @@ pub fn patch_path(patch: &str) -> Option { patch .lines() .find_map(|line| line.strip_prefix("+++ ")) - .map(|path| path.trim().to_string()) + .map(|path| { + let trimmed = path.trim(); + trimmed + .strip_prefix("b/") + .unwrap_or(trimmed) + .to_string() + }) } -/// Renders `patch` into at most `max_lines` styled lines (`0` means unlimited), -/// indented for a conversation pane. Returns the full added/removed summary. -pub fn render_diff(patch: &str, max_width: usize, max_lines: usize) -> (Vec>, DiffSummary) { - let parsed = parse(patch); - let summary = summarize(&parsed); +/// Renders `patch` into styled lines using split view when `max_width >= SPLIT_MIN_WIDTH` (and the +/// patch modifies existing lines) or unified view otherwise. +pub fn render_diff( + patch: &str, + max_width: usize, + max_lines: usize, + palette: &Palette, +) -> (Vec>, DiffSummary) { + let (hunks, summary) = parse_hunks(patch); + if hunks.is_empty() || max_width < 8 { + return (Vec::new(), summary); + } + + let has_old = summary.removed > 0 + || hunks + .iter() + .any(|h| h.lines.iter().any(|l| l.kind == Kind::Context)); + let use_split = max_width >= SPLIT_MIN_WIDTH && has_old && summary.added > 0; - let gutter_width = parsed - .iter() - .filter_map(|line| line.line_no) - .max() - .map(|n| n.to_string().len()) - .unwrap_or(0); - // gutter + space + marker + space - let prefix_width = gutter_width + 3; - let content_width = max_width.saturating_sub(prefix_width); - - let limit = if max_lines == 0 { - parsed.len() + let mut all_lines = if use_split { + render_split(&hunks, max_width, palette) } else { - max_lines.min(parsed.len()) + render_unified(&hunks, max_width, palette) }; - let mut lines: Vec> = Vec::new(); + if max_lines > 0 && all_lines.len() > max_lines { + let remaining = all_lines.len() - max_lines; + all_lines.truncate(max_lines); + all_lines.push(Line::from(vec![Span::styled( + format!(" ⋯ +{remaining} more diff lines"), + Style::default().fg(palette.text_muted), + )])); + } + + (all_lines, summary) +} - for line in parsed.iter().take(limit) { - match line.kind { - Kind::Hunk | Kind::FileHeader => { - let style = if line.kind == Kind::Hunk { - Style::default().fg(Theme::diff_hunk()).add_modifier(Modifier::BOLD) +fn render_unified(hunks: &[Hunk], max_width: usize, palette: &Palette) -> Vec> { + let max_line_no = hunks + .iter() + .flat_map(|h| &h.lines) + .filter_map(|l| l.old_no.max(l.new_no)) + .max() + .unwrap_or(1); + let gutter_width = max_line_no.to_string().len().max(2); + // "[gutter] [sign] " -> gutter_width + 1 + 3 = gutter_width + 4 + let prefix_width = gutter_width + 4; + let content_width = max_width.saturating_sub(prefix_width).max(1); + + let mut out: Vec> = Vec::new(); + + for (hunk_idx, hunk) in hunks.iter().enumerate() { + if hunk_idx > 0 { + out.push(Line::from(vec![Span::styled( + format!("{:>width$} ⋮", "", width = gutter_width), + Style::default().fg(palette.text_muted), + )])); + } + + for line in &hunk.lines { + let line_no = match line.kind { + Kind::Removed => line.old_no, + Kind::Added | Kind::Context => line.new_no, + }; + let (sign, fg, line_bg, gutter_fg, gutter_bg) = match line.kind { + Kind::Added => ( + " + ", + palette.text, + Some(palette.diff_add_bg()), + palette.success(), + palette.diff_add_gutter_bg(), + ), + Kind::Removed => ( + " - ", + palette.text_secondary, + Some(palette.diff_del_bg()), + palette.error(), + palette.diff_del_gutter_bg(), + ), + Kind::Context => ( + " ", + palette.text_secondary, + None, + palette.text_muted, + palette.surface, + ), + }; + + let chunks = wrap_chars(&line.text, content_width); + for (chunk_idx, chunk) in chunks.into_iter().enumerate() { + let gutter_text = if chunk_idx == 0 { + line_no + .map(|n| format!("{n:>gutter_width$} ")) + .unwrap_or_else(|| format!("{:>gutter_width$} ", "")) } else { - Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::BOLD) + format!("{:>gutter_width$} ", "") }; - lines.push(Line::from(vec![Span::styled(elide(&line.text, max_width), style)])); - } - Kind::NoNewline => { - lines.push(Line::from(vec![ - Span::raw(" ".repeat(gutter_width + 1)), + let sign_text = if chunk_idx == 0 { sign } else { " " }; + + let mut spans = vec![ Span::styled( - elide(&line.text, max_width.saturating_sub(gutter_width + 1)), - Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::ITALIC), + gutter_text, + Style::default().fg(gutter_fg).bg(gutter_bg), ), - ])); - } - Kind::Added | Kind::Removed | Kind::Context => { - let (marker, fg, bg, marker_color) = match line.kind { - Kind::Added => ("+", Theme::success(), Some(Theme::diff_add_bg()), Theme::success()), - Kind::Removed => ("-", Theme::error(), Some(Theme::diff_del_bg()), Theme::error()), - _ => (" ", Theme::text_primary(), None, Theme::text_muted()), - }; - - let mut spans: Vec> = Vec::new(); - if gutter_width > 0 { - let number = line - .line_no - .map(|n| format!("{:>width$} ", n, width = gutter_width)) - .unwrap_or_else(|| " ".repeat(gutter_width + 1)); - spans.push(Span::styled(number, Style::default().fg(Theme::text_muted()))); - } - spans.push(Span::styled(marker, Style::default().fg(marker_color))); - spans.push(Span::raw(" ")); + Span::styled( + sign_text, + match line_bg { + Some(bg) => Style::default().fg(gutter_fg).bg(bg), + None => Style::default().fg(gutter_fg), + }, + ), + ]; - let shown = elide(&line.text, content_width); - let used = prefix_width + UnicodeWidthStr::width(shown.as_str()); - let content_style = match bg { - Some(background) => Style::default().fg(fg).bg(background), + let chunk_w = width(&chunk); + let content_style = match line_bg { + Some(bg) => Style::default().fg(fg).bg(bg), None => Style::default().fg(fg), }; - spans.push(Span::styled(shown, content_style)); - if let Some(background) = bg { + spans.push(Span::styled(chunk, content_style)); + + if let Some(bg) = line_bg { + let used = prefix_width + chunk_w; if used < max_width { - spans.push(Span::styled(" ".repeat(max_width - used), Style::default().bg(background))); + spans.push(Span::styled( + " ".repeat(max_width - used), + Style::default().bg(bg), + )); } } - lines.push(Line::from(spans)); + + out.push(Line::from(spans)); } } } - let remaining = parsed.len().saturating_sub(limit); - if remaining > 0 { - lines.push(Line::from(vec![ - Span::raw(" ".repeat(gutter_width + 1)), - Span::styled(format!("⋯ +{remaining} more lines"), Style::default().fg(Theme::diff_meta())), - ])); + out +} + +fn render_split(hunks: &[Hunk], max_width: usize, palette: &Palette) -> Vec> { + let max_line_no = hunks + .iter() + .flat_map(|h| &h.lines) + .filter_map(|l| l.old_no.max(l.new_no)) + .max() + .unwrap_or(1); + let gutter_width = max_line_no.to_string().len().max(2); + + let left_width = (max_width.saturating_sub(1)) / 2; + let right_width = max_width.saturating_sub(1 + left_width); + + let mut out: Vec> = Vec::new(); + + for (hunk_idx, hunk) in hunks.iter().enumerate() { + if hunk_idx > 0 { + let left_sep = format!( + "{:gw$} ⋮", "", gw = gutter_width), + width = left_width + ); + let right_sep = format!( + "{:gw$} ⋮", "", gw = gutter_width), + width = right_width + ); + out.push(Line::from(vec![ + Span::styled(left_sep, Style::default().fg(palette.text_muted)), + Span::styled("│", Style::default().fg(palette.border_subtle)), + Span::styled(right_sep, Style::default().fg(palette.text_muted)), + ])); + } + + let mut i = 0; + let lines = &hunk.lines; + while i < lines.len() { + if lines[i].kind == Kind::Context { + let mut row_spans = + format_split_cell(Some(&lines[i]), true, left_width, gutter_width, palette); + row_spans.push(Span::styled("│", Style::default().fg(palette.border_subtle))); + row_spans.extend(format_split_cell( + Some(&lines[i]), + false, + right_width, + gutter_width, + palette, + )); + out.push(Line::from(row_spans)); + i += 1; + continue; + } + + let mut removed = Vec::new(); + let mut added = Vec::new(); + while i < lines.len() && lines[i].kind != Kind::Context { + match lines[i].kind { + Kind::Removed => removed.push(&lines[i]), + Kind::Added => added.push(&lines[i]), + Kind::Context => unreachable!(), + } + i += 1; + } + + let rows = removed.len().max(added.len()); + for r in 0..rows { + let mut row_spans = format_split_cell( + removed.get(r).copied(), + true, + left_width, + gutter_width, + palette, + ); + row_spans.push(Span::styled("│", Style::default().fg(palette.border_subtle))); + row_spans.extend(format_split_cell( + added.get(r).copied(), + false, + right_width, + gutter_width, + palette, + )); + out.push(Line::from(row_spans)); + } + } } - (lines, summary) + out +} + +fn format_split_cell( + line: Option<&DiffLine>, + is_left: bool, + col_width: usize, + gutter_width: usize, + palette: &Palette, +) -> Vec> { + let Some(line) = line else { + return vec![Span::styled( + " ".repeat(col_width), + Style::default().bg(palette.surface), + )]; + }; + + let line_no = if is_left { line.old_no } else { line.new_no }; + let (sign, fg, line_bg, gutter_fg, gutter_bg) = match line.kind { + Kind::Added => ( + " + ", + palette.text, + Some(palette.diff_add_bg()), + palette.success(), + palette.diff_add_gutter_bg(), + ), + Kind::Removed => ( + " - ", + palette.text_secondary, + Some(palette.diff_del_bg()), + palette.error(), + palette.diff_del_gutter_bg(), + ), + Kind::Context => ( + " ", + palette.text_secondary, + None, + palette.text_muted, + palette.surface, + ), + }; + + let prefix_w = gutter_width + 4; + let content_w = col_width.saturating_sub(prefix_w); + let gutter_str = line_no + .map(|n| format!("{n:>gutter_width$} ")) + .unwrap_or_else(|| format!("{:>gutter_width$} ", "")); + let shown = elide(&line.text, content_w); + let pad = content_w.saturating_sub(width(&shown)); + + let content_style = match line_bg { + Some(bg) => Style::default().fg(fg).bg(bg), + None => Style::default().fg(fg), + }; + let sign_style = match line_bg { + Some(bg) => Style::default().fg(gutter_fg).bg(bg), + None => Style::default().fg(gutter_fg), + }; + + vec![ + Span::styled(gutter_str, Style::default().fg(gutter_fg).bg(gutter_bg)), + Span::styled(sign, sign_style), + Span::styled(format!("{shown}{}", " ".repeat(pad)), content_style), + ] +} + +fn wrap_chars(text: &str, max_cols: usize) -> Vec { + if text.is_empty() || max_cols == 0 { + return vec![String::new()]; + } + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_w = 0usize; + + for ch in text.chars() { + let ch_w = ch.width().unwrap_or(0); + if current_w + ch_w > max_cols && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + current_w = 0; + } + current.push(ch); + current_w += ch_w; + } + if !current.is_empty() || chunks.is_empty() { + chunks.push(current); + } + chunks } #[cfg(test)] mod tests { use super::*; + use crate::theme::default_palette; - const PATCH: &str = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n return a + b;\n"; + const PATCH: &str = "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n return a + b;\n"; fn text_of(line: &Line) -> String { line.spans.iter().map(|span| span.content.as_ref()).collect() @@ -224,94 +486,40 @@ mod tests { #[test] fn counts_added_and_removed() { - let (_, summary) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + let summary = counts(PATCH); assert_eq!(summary.added, 1); assert_eq!(summary.removed, 1); } #[test] - fn extracts_path_from_patch() { + fn strips_b_prefix_from_patch_path() { assert_eq!(patch_path(PATCH).as_deref(), Some("src/a.ts")); - assert_eq!(patch_path(""), None); } #[test] - fn assigns_new_side_line_numbers() { - let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + fn unified_view_shows_both_old_and_new_line_numbers_and_omits_raw_headers() { + let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES, default_palette()); let text = joined(&lines); - assert!(text.contains("1 let a = 1;"), "context line numbered from hunk start:\n{text}"); - assert!(text.contains("2 + let b = 3;"), "added line numbered on the new side:\n{text}"); - assert!(text.contains("3 return a + b;"), "numbering advances across lines:\n{text}"); + assert!(!text.contains("---"), "file headers omitted:\n{text}"); + assert!(!text.contains("@@"), "raw hunk header omitted:\n{text}"); + assert!(text.contains(" 2 - let b = 2;"), "removed line has old line number:\n{text}"); + assert!(text.contains(" 2 + let b = 3;"), "added line has new line number:\n{text}"); } #[test] - fn classifies_hunk_and_file_headers() { - let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + fn multi_hunk_diff_renders_vertical_ellipsis_between_hunks() { + let multi = "--- a/f.rs\n+++ b/f.rs\n@@ -1,2 +1,2 @@\n-old1\n+new1\n@@ -10,2 +10,2 @@\n-old10\n+new10\n"; + let (lines, _) = render_diff(multi, 80, DEFAULT_MAX_DIFF_LINES, default_palette()); let text = joined(&lines); - assert!(text.contains("--- src/a.ts")); - assert!(text.contains("+++ src/a.ts")); - assert!(text.contains("@@ -1,3 +1,3 @@")); + assert!(text.contains('⋮'), "expected ⋮ separator between hunks:\n{text}"); } #[test] - fn truncates_long_patches_with_footer() { - let mut patch = String::from("--- a\n+++ a\n@@ -1,20 +1,20 @@\n"); - for i in 0..20 { - patch.push_str(&format!("+line {i}\n")); - } - let (lines, summary) = render_diff(&patch, 80, 5); - assert_eq!(summary.added, 20, "summary counts the whole patch, not the visible slice"); - assert_eq!(lines.len(), 6, "5 visible lines plus the truncation footer"); - // 23 parsed lines (2 file headers + hunk header + 20 additions) - 5 shown. - assert!(text_of(&lines[5]).contains("+18 more lines"), "{}", text_of(&lines[5])); - } - - #[test] - fn renders_no_newline_marker() { - let patch = "--- a\n+++ a\n@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n"; - let (lines, _) = render_diff(patch, 80, DEFAULT_MAX_DIFF_LINES); - assert!(joined(&lines).contains("No newline at end of file")); - } - - #[test] - fn counts_added_and_removed_without_rendering() { - let summary = counts(PATCH); - assert_eq!(summary.added, 1); - assert_eq!(summary.removed, 1); - assert_eq!(counts(""), DiffSummary::default()); - } - - #[test] - fn truncates_lines_to_max_width() { - let long = "x".repeat(200); - let patch = format!("--- a\n+++ a\n@@ -1 +1 @@\n-{long}\n+{long}\n"); - let (lines, _) = render_diff(&patch, 40, DEFAULT_MAX_DIFF_LINES); - for line in &lines { - assert!( - UnicodeWidthStr::width(text_of(line).as_str()) <= 40, - "line exceeds width: {:?}", - text_of(line) - ); - } - } - - #[test] - fn malformed_hunk_header_does_not_panic() { - let patch = "--- a\n+++ a\n@@ broken @@\n+x\n"; - let (lines, _) = render_diff(patch, 80, DEFAULT_MAX_DIFF_LINES); - assert!(joined(&lines).contains('x')); - } - - #[test] - fn empty_patch_is_empty() { - let (lines, summary) = render_diff("", 80, DEFAULT_MAX_DIFF_LINES); - assert!(lines.is_empty()); - assert_eq!(summary, DiffSummary::default()); - } - - #[test] - fn unlimited_max_lines_keeps_every_line() { - let (lines, _) = render_diff(PATCH, 80, 0); - assert!(lines.iter().all(|line| !text_of(line).contains("more lines"))); + fn wide_viewport_uses_side_by_side_split_view() { + let (lines, _) = render_diff(PATCH, 130, DEFAULT_MAX_DIFF_LINES, default_palette()); + let text = joined(&lines); + assert!(text.contains('│'), "expected split divider │ on wide viewport:\n{text}"); + // In split view, `- let b = 2;` and `+ let b = 3;` sit on the same row! + assert_eq!(lines.len(), 3, "context + paired replacement + context = 3 rows:\n{text}"); } } diff --git a/packages/tui/src/event.rs b/packages/tui/src/event.rs new file mode 100644 index 00000000..43ba3f10 --- /dev/null +++ b/packages/tui/src/event.rs @@ -0,0 +1,114 @@ +//! Input. +//! +//! A dedicated OS thread reads crossterm events and forwards them; the UI thread +//! only ever waits on this channel. Reading on the UI task instead would drop +//! events whenever the loop is cancelled or busy, and concurrent stdin reads +//! race. The thread ends when the receiver is dropped, which is how shutdown +//! stops it — there is no flag to check and nothing to join. + +use crossterm::event::{Event, KeyEvent, KeyEventKind, MouseEventKind}; +use tokio::sync::mpsc; + +use crate::rpc::StreamEvent; +use crate::types::{HistoryEntry, SessionInfo}; + +/// Asynchronous results arriving from the RPC server. +#[derive(Debug, Clone, PartialEq)] +pub enum RpcEvent { + Stream(StreamEvent), + SessionsLoaded(Result, String>), + SessionHistoryLoaded(Result, String>), + SessionConfigured(Result), + PluginsReloaded(Result<(u64, Option), String>), +} + +/// What the app reacts to: the events that actually change the screen. Anything +/// else crossterm can deliver is dropped at the boundary rather than carried +/// around unhandled. +#[derive(Debug, Clone, PartialEq)] +pub enum AppEvent { + Key(KeyEvent), + /// Mouse wheel / trackpad scroll delta (positive = up into history, negative = down toward + /// bottom). + Scroll(isize), + /// Text pasted into the app, carried whole. The terminal brackets it, so a + /// paste can never be mistaken for typing, and it arrives as one event + /// rather than as a burst of keys the app would have to reassemble. + Paste(String), + /// The terminal changed size. The payload is deliberately not carried: the + /// view reads its own dimensions from the frame it is drawing into, so a + /// stale copy in state would be one more thing to keep in sync. + Resize, + /// An event or response from the CodeWork RPC server. + Rpc(RpcEvent), +} + +pub struct EventHandler { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, +} + +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} + +impl EventHandler { + pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let reader_tx = tx.clone(); + std::thread::spawn(move || loop { + match crossterm::event::read() { + Ok(event) => { + let Some(event) = translate(event) else { + continue; + }; + if reader_tx.send(event).is_err() { + break; + } + } + Err(_) => break, + } + }); + Self { tx, rx } + } + + /// A sender for background RPC tasks to deliver events back to the UI loop. + pub fn sender(&self) -> mpsc::UnboundedSender { + self.tx.clone() + } + + /// Wait for the next event. `None` once the input thread has stopped. + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } + + /// Take everything already queued. + /// + /// Called after each `next`, so a burst of typing or a resize storm is + /// applied in one batch and costs one frame instead of one frame per event. + pub fn drain(&mut self) -> Vec { + let mut batch = Vec::new(); + while let Ok(event) = self.rx.try_recv() { + batch.push(event); + } + batch + } +} + +/// Only key presses are meaningful. Terminals that report key releases (the +/// keyboard protocol asks them to) would otherwise act on every key twice. +fn translate(event: Event) -> Option { + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => Some(AppEvent::Key(key)), + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => Some(AppEvent::Scroll(3)), + MouseEventKind::ScrollDown => Some(AppEvent::Scroll(-3)), + _ => None, + }, + Event::Paste(text) => Some(AppEvent::Paste(text)), + Event::Resize(_, _) => Some(AppEvent::Resize), + _ => None, + } +} diff --git a/packages/tui/src/git.rs b/packages/tui/src/git.rs deleted file mode 100644 index f1e90b97..00000000 --- a/packages/tui/src/git.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::path::Path; -use std::process::Command; - -#[allow(dead_code)] -pub fn get_git_branch() -> Option { - let output = Command::new("git") - .args(["branch", "--show-current"]) - .output() - .ok()?; - - if output.status.success() { - let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !branch.is_empty() { - return Some(branch); - } - } - None -} - -#[allow(dead_code)] -pub fn get_repo_name() -> String { - if let Ok(cwd) = std::env::current_dir() { - if let Some(name) = cwd.file_name() { - return name.to_string_lossy().to_string(); - } - } - "workspace".to_string() -} - -#[allow(dead_code)] -pub fn get_formatted_location() -> String { - let repo = get_repo_name(); - if let Some(branch) = get_git_branch() { - format!("{} ({})", repo, branch) - } else { - repo - } -} - -pub fn truncate_path(path: &Path, max_len: usize) -> String { - let s = path.to_string_lossy(); - if s.len() <= max_len { - s.to_string() - } else { - format!("...{}", &s[s.len().saturating_sub(max_len - 3)..]) - } -} diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index ba7c3307..69ee3b49 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -1,176 +1,47 @@ +mod action; mod app; -mod catalog; -mod config; -mod credentials; mod diff; -mod git; -mod markdown; +mod event; +mod menu; +mod prompt; mod rpc; +mod theme; mod tool; +mod tui; mod types; mod ui; -use std::io::{self, Write}; -use std::time::Duration; +use std::error::Error; -use crossterm::event::{ - self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind, -}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::backend::CrosstermBackend; -use ratatui::style::Style; -use ratatui::widgets::Block; -use ratatui::Terminal; -use tokio::time::interval; - -use app::{ActiveScreen, App}; -use ui::{render_model_browser, render_session, render_theme_picker, render_welcome}; +use app::App; +use event::EventHandler; +use tui::TerminalGuard; #[tokio::main] -async fn main() -> Result<(), Box> { - // Install panic hook to restore terminal on crash - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = disable_raw_mode(); - let mut out = io::stdout(); - let _ = execute!(out, LeaveAlternateScreen, DisableBracketedPaste); - let _ = out.write_all(b"\x1b]111\x07\x1b[0m"); - let _ = out.flush(); - original_hook(panic_info); - })); - - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - // Set terminal background to #1E1E2E (OSC 11), clear screen, and enter alternate screen - let _ = stdout.write_all(b"\x1b]11;#1E1E2E\x07\x1b[48;2;30;30;46m\x1b[2J\x1b[H"); - let _ = stdout.flush(); - execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - - let mut app = App::new(); - let mut ticker = interval(Duration::from_millis(80)); - - // Dedicated OS thread reading events from standard input. - // Reading crossterm events on a dedicated thread avoids task cancellation drops - // during tokio::select!, eliminates multi-thread races on stdin, and ensures fast typing - // never loses characters. - let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - std::thread::spawn(move || { - loop { - match event::read() { - Ok(ev) => { - if event_tx.send(ev).is_err() { - break; - } - } - Err(_) => break, - } +async fn main() -> Result<(), Box> { + let mut events = EventHandler::new(); + // The app first: it owns the palette, and the terminal's own background is set from it so the + // ground beneath the frames matches the ground inside them. + let mut app = App::new(events.sender()); + // Dropping the guard restores the terminal, including on an early return. + let mut terminal = TerminalGuard::init(app.background())?; + + terminal.draw(app.background(), |frame| ui::view(&app, frame))?; + + while let Some(event) = events.next().await { + let mut redraw = app.update(event); + // Apply everything already queued before painting, so a burst of typing + // or a resize storm costs one frame rather than one frame per event. + for event in events.drain() { + redraw |= app.update(event); } - }); - - loop { - terminal.draw(|f| { - let area = f.area(); - // Fill the entire terminal viewport with Deep Black (#0B0B0F) background - f.render_widget( - Block::default().style(Style::default().bg(ui::Theme::bg_app())), - area, - ); - match app.screen { - ActiveScreen::Welcome => { - render_welcome( - f, - area, - &app.welcome_input, - app.welcome_cursor, - app.welcome_dropdown_index, - app.active_config.as_ref(), - app.welcome_status_message.as_deref(), - &app.spinner, - ); - } - ActiveScreen::ModelFlow => { - render_model_browser(f, area, &app.model_browser, &app.spinner); - } - ActiveScreen::Session => { - if let Some(cfg) = &app.active_config { - render_session( - f, - area, - app.session_info.as_ref(), - &app.turns, - &app.session_stats, - cfg, - &app.session_input, - app.session_cursor, - app.session_scroll_offset, - app.session_dropdown_index, - &app.spinner, - app.is_streaming, - app.reconnecting, - ); - } - } - } - - if app.theme_picker_open { - render_theme_picker(f, area, app.theme_picker_index); - } - })?; - - tokio::select! { - _ = ticker.tick() => { - app.on_tick(); - } - Some(event) = event_rx.recv() => { - handle_event(&mut app, event); - // Drain any additional events buffered so fast typing / bursts / pastes don't lag or drop! - while let Ok(event) = event_rx.try_recv() { - handle_event(&mut app, event); - } - } - } - if app.should_quit { break; } + if redraw { + terminal.draw(app.background(), |frame| ui::view(&app, frame))?; + } } - // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableBracketedPaste - )?; - terminal.show_cursor()?; - let mut out = io::stdout(); - let _ = out.write_all(b"\x1b]111\x07\x1b[0m"); - let _ = out.flush(); - Ok(()) } - -fn handle_event(app: &mut App, event: Event) { - match event { - Event::Key(key) => { - // Ignore key release events from terminals supporting keyboard protocols - if key.kind != KeyEventKind::Release { - app.handle_key(key); - } - } - Event::Mouse(mouse) => { - app.handle_mouse(mouse); - } - Event::Paste(text) => { - app.handle_paste(&text); - } - _ => {} - } -} diff --git a/packages/tui/src/markdown/block.rs b/packages/tui/src/markdown/block.rs deleted file mode 100644 index 76d27910..00000000 --- a/packages/tui/src/markdown/block.rs +++ /dev/null @@ -1,281 +0,0 @@ -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; - -use crate::markdown::inline::parse_inline; -use crate::markdown::table::{is_table_row, render_table}; -use crate::ui::Theme; - -pub fn wrap_text(text: &str, max_width: usize) -> Vec { - if max_width == 0 || text.is_empty() { - return vec![text.to_string()]; - } - let mut lines = Vec::new(); - let words: Vec<&str> = text.split_whitespace().collect(); - if words.is_empty() { - return vec![String::new()]; - } - let mut current_line = String::new(); - let mut current_width = 0usize; - - for word in words { - let w_len = UnicodeWidthStr::width(word); - if current_line.is_empty() { - current_line.push_str(word); - current_width = w_len; - } else if current_width + 1 + w_len <= max_width { - current_line.push(' '); - current_line.push_str(word); - current_width += 1 + w_len; - } else { - lines.push(current_line); - current_line = word.to_string(); - current_width = w_len; - } - } - if !current_line.is_empty() { - lines.push(current_line); - } - lines -} - -pub fn render_markdown(text: &str, max_width: usize) -> Vec> { - let mut lines: Vec> = Vec::new(); - let raw_lines: Vec<&str> = text.lines().collect(); - let mut i = 0; - - let border_style = Style::default().fg(Theme::border()); - let code_bg = Theme::bg_surface(); - - while i < raw_lines.len() { - let line = raw_lines[i]; - let trimmed = line.trim(); - - // 1. Code Fence - if trimmed.starts_with("```") { - let lang = trimmed.trim_start_matches('`').trim(); - i += 1; - let mut code_lines = Vec::new(); - while i < raw_lines.len() { - if raw_lines[i].trim().starts_with("```") { - i += 1; - break; - } - code_lines.push(raw_lines[i]); - i += 1; - } - - // Top fence header - let lang_tag = if !lang.is_empty() { - format!("── [{}] ─", lang) - } else { - "──────".to_string() - }; - let fence_top = format!("┌{}{}", lang_tag, "─".repeat(max_width.saturating_sub(lang_tag.len() + 3))); - lines.push(Line::from(vec![Span::styled(fence_top, border_style)])); - - let max_code_w = max_width.saturating_sub(4).max(10); - for c_line in code_lines { - let display_code = if UnicodeWidthStr::width(c_line) > max_code_w { - let mut truncated = String::new(); - let mut cur = 0; - for ch in c_line.chars() { - let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if cur + ch_w <= max_code_w.saturating_sub(1) { - truncated.push(ch); - cur += ch_w; - } else { - break; - } - } - truncated.push('…'); - truncated - } else { - c_line.to_string() - }; - - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::styled( - display_code.clone(), - Style::default().fg(Theme::success()).bg(code_bg), - ), - ]; - let line_len = UnicodeWidthStr::width(display_code.as_str()) + 2; - if line_len < max_width { - spans.push(Span::styled(" ".repeat(max_width - line_len), Style::default().bg(code_bg))); - } - spans.push(Span::styled(" │", border_style)); - lines.push(Line::from(spans)); - } - - // Bottom fence footer - let fence_bot = format!("└{}┘", "─".repeat(max_width.saturating_sub(2))); - lines.push(Line::from(vec![Span::styled(fence_bot, border_style)])); - continue; - } - - // 2. Table Block - if is_table_row(trimmed) { - let mut table_rows = Vec::new(); - while i < raw_lines.len() && is_table_row(raw_lines[i]) { - table_rows.push(raw_lines[i].to_string()); - i += 1; - } - let rendered = render_table(&table_rows, max_width); - lines.extend(rendered); - continue; - } - - // 3. Headings - if trimmed.starts_with("# ") { - let content = trimmed[2..].trim(); - lines.push(Line::from(vec![ - Span::styled("┃ ", Style::default().fg(Theme::primary()).add_modifier(Modifier::BOLD)), - Span::styled( - content.to_string(), - Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), - ), - ])); - i += 1; - continue; - } - - if trimmed.starts_with("## ") { - let content = trimmed[3..].trim(); - lines.push(Line::from(vec![ - Span::styled("┃ ", Style::default().fg(Theme::primary()).add_modifier(Modifier::BOLD)), - Span::styled( - content.to_string(), - Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), - ), - ])); - i += 1; - continue; - } - - if trimmed.starts_with("### ") { - let content = trimmed[4..].trim(); - lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Theme::text_muted())), - Span::styled( - content.to_string(), - Style::default().fg(Theme::text_secondary()).add_modifier(Modifier::BOLD), - ), - ])); - i += 1; - continue; - } - - // 4. Blockquotes - if trimmed.starts_with('>') { - let content = trimmed.trim_start_matches('>').trim(); - let wrap_width = max_width.saturating_sub(3).max(10); - let wrapped = wrap_text(content, wrap_width); - for w_line in wrapped { - let mut spans = vec![Span::styled( - "│ ", - Style::default().fg(Theme::text_muted()), - )]; - spans.extend(parse_inline(&w_line)); - lines.push(Line::from(spans)); - } - i += 1; - continue; - } - - // 5. Unordered List - if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { - let indent = line.len() - line.trim_start().len(); - let content = &trimmed[2..]; - let wrap_width = max_width.saturating_sub(indent + 2).max(10); - let wrapped = wrap_text(content, wrap_width); - for (w_idx, w_line) in wrapped.iter().enumerate() { - let mut spans = Vec::new(); - spans.push(Span::raw(" ".repeat(indent))); - if w_idx == 0 { - spans.push(Span::styled("• ", Style::default().fg(Theme::primary()))); - } else { - spans.push(Span::raw(" ")); - } - spans.extend(parse_inline(w_line)); - lines.push(Line::from(spans)); - } - i += 1; - continue; - } - - // 6. Ordered List - if let Some(dot_pos) = trimmed.find(". ") { - let num_part = &trimmed[..dot_pos]; - if num_part.chars().all(|c| c.is_ascii_digit()) && !num_part.is_empty() { - let indent = line.len() - line.trim_start().len(); - let prefix = format!("{}. ", num_part); - let prefix_len = prefix.len(); - let content = &trimmed[dot_pos + 2..]; - let wrap_width = max_width.saturating_sub(indent + prefix_len).max(10); - let wrapped = wrap_text(content, wrap_width); - for (w_idx, w_line) in wrapped.iter().enumerate() { - let mut spans = Vec::new(); - spans.push(Span::raw(" ".repeat(indent))); - if w_idx == 0 { - spans.push(Span::styled(prefix.clone(), Style::default().fg(Theme::primary()))); - } else { - spans.push(Span::raw(" ".repeat(prefix_len))); - } - spans.extend(parse_inline(w_line)); - lines.push(Line::from(spans)); - } - i += 1; - continue; - } - } - - // 7. Horizontal Rule - if trimmed == "---" || trimmed == "***" || trimmed == "___" { - let hr = "─".repeat(max_width.min(60)); - lines.push(Line::from(vec![Span::styled(hr, border_style)])); - i += 1; - continue; - } - - // 8. Empty line - if trimmed.is_empty() { - lines.push(Line::raw("")); - i += 1; - continue; - } - - // 9. Normal paragraph (word wrapped) - let wrapped = wrap_text(line, max_width); - for w_line in wrapped { - lines.push(Line::from(parse_inline(&w_line))); - } - i += 1; - } - - lines -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_wrap_text() { - let text = "CodeWork is an open-source coding agent harness for the terminal."; - let wrapped = wrap_text(text, 25); - assert!(wrapped.len() > 1); - for line in &wrapped { - assert!(unicode_width::UnicodeWidthStr::width(line.as_str()) <= 25); - } - } - - #[test] - fn test_render_markdown_wrapping() { - let text = "This is a long sentence that should definitely be wrapped to fit inside the terminal viewport width."; - let lines = render_markdown(text, 30); - assert!(lines.len() >= 3); - } -} - diff --git a/packages/tui/src/markdown/inline.rs b/packages/tui/src/markdown/inline.rs deleted file mode 100644 index 60fad3e9..00000000 --- a/packages/tui/src/markdown/inline.rs +++ /dev/null @@ -1,126 +0,0 @@ -use ratatui::style::{Modifier, Style}; -use ratatui::text::Span; - -use crate::ui::Theme; - -pub fn parse_inline(text: &str) -> Vec> { - let mut spans = Vec::new(); - let mut chars = text.chars().peekable(); - let mut buffer = String::new(); - - while let Some(&ch) = chars.peek() { - if ch == '`' { - if !buffer.is_empty() { - spans.push(Span::styled( - std::mem::take(&mut buffer), - Style::default().fg(Theme::text_primary()), - )); - } - chars.next(); // consume ` - let mut code = String::new(); - while let Some(c) = chars.next() { - if c == '`' { - break; - } - code.push(c); - } - spans.push(Span::styled( - code, - Style::default().fg(Theme::success()), - )); - } else if ch == '*' { - chars.next(); - if chars.peek() == Some(&'*') { - chars.next(); // double * (bold) - if !buffer.is_empty() { - spans.push(Span::styled( - std::mem::take(&mut buffer), - Style::default().fg(Theme::text_primary()), - )); - } - let mut bold_text = String::new(); - while let Some(c) = chars.next() { - if c == '*' && chars.peek() == Some(&'*') { - chars.next(); - break; - } - bold_text.push(c); - } - spans.push(Span::styled( - bold_text, - Style::default() - .fg(Theme::warning()) - .add_modifier(Modifier::BOLD), - )); - } else { - // single * (italic) - if !buffer.is_empty() { - spans.push(Span::styled( - std::mem::take(&mut buffer), - Style::default().fg(Theme::text_primary()), - )); - } - let mut italic_text = String::new(); - while let Some(c) = chars.next() { - if c == '*' { - break; - } - italic_text.push(c); - } - spans.push(Span::styled( - italic_text, - Style::default() - .fg(Theme::text_muted()) - .add_modifier(Modifier::ITALIC), - )); - } - } else if ch == '[' { - if !buffer.is_empty() { - spans.push(Span::styled( - std::mem::take(&mut buffer), - Style::default().fg(Theme::text_primary()), - )); - } - chars.next(); // consume [ - let mut link_text = String::new(); - while let Some(c) = chars.next() { - if c == ']' { - break; - } - link_text.push(c); - } - if chars.peek() == Some(&'(') { - chars.next(); // consume ( - let mut _url = String::new(); - while let Some(c) = chars.next() { - if c == ')' { - break; - } - _url.push(c); - } - spans.push(Span::styled( - link_text, - Style::default() - .fg(Theme::primary()) - .add_modifier(Modifier::UNDERLINED), - )); - } else { - buffer.push('['); - buffer.push_str(&link_text); - buffer.push(']'); - } - } else { - buffer.push(ch); - chars.next(); - } - } - - if !buffer.is_empty() { - spans.push(Span::styled( - buffer, - Style::default().fg(Theme::text_primary()), - )); - } - - spans -} diff --git a/packages/tui/src/markdown/mod.rs b/packages/tui/src/markdown/mod.rs deleted file mode 100644 index b19b5b2b..00000000 --- a/packages/tui/src/markdown/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod block; -pub mod inline; -pub mod table; - -pub use block::{render_markdown, wrap_text}; diff --git a/packages/tui/src/markdown/table.rs b/packages/tui/src/markdown/table.rs deleted file mode 100644 index f8f2d6ad..00000000 --- a/packages/tui/src/markdown/table.rs +++ /dev/null @@ -1,237 +0,0 @@ -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; - -use crate::markdown::inline::parse_inline; -use crate::ui::Theme; - -pub fn is_table_row(line: &str) -> bool { - let trimmed = line.trim(); - trimmed.starts_with('|') || (trimmed.contains('|') && trimmed.ends_with('|')) -} - -pub fn is_delimiter_row(line: &str) -> bool { - let trimmed = line.trim(); - if !is_table_row(trimmed) { - return false; - } - let cells = parse_cells(trimmed); - !cells.is_empty() - && cells.iter().all(|c| { - let s = c.trim(); - !s.is_empty() && s.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ') - }) -} - -fn parse_cells(line: &str) -> Vec { - let mut trimmed = line.trim(); - if trimmed.starts_with('|') { - trimmed = &trimmed[1..]; - } - if trimmed.ends_with('|') { - trimmed = &trimmed[..trimmed.len().saturating_sub(1)]; - } - trimmed.split('|').map(|c| c.trim().to_string()).collect() -} - -fn spans_visual_width(spans: &[Span]) -> usize { - spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum() -} - -fn truncate_spans(spans: Vec>, target_w: usize) -> Vec> { - if target_w == 0 { - return Vec::new(); - } - let max_avail = target_w.saturating_sub(1); // leave 1 col for '…' - let mut result = Vec::new(); - let mut current_w = 0; - - for span in spans { - let span_w = UnicodeWidthStr::width(span.content.as_ref()); - if current_w + span_w <= max_avail { - current_w += span_w; - result.push(span); - } else { - let mut partial = String::new(); - for ch in span.content.chars() { - let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if current_w + ch_w <= max_avail { - partial.push(ch); - current_w += ch_w; - } else { - break; - } - } - if !partial.is_empty() { - result.push(Span::styled(partial, span.style)); - } - result.push(Span::styled("…", span.style)); - return result; - } - } - result.push(Span::raw("…")); - result -} - -pub fn render_table(rows: &[String], max_width: usize) -> Vec> { - if rows.is_empty() { - return Vec::new(); - } - - let parsed_rows: Vec> = rows.iter().map(|r| parse_cells(r)).collect(); - if parsed_rows.is_empty() { - return Vec::new(); - } - - let col_count = parsed_rows.iter().map(|r| r.len()).max().unwrap_or(0); - if col_count == 0 { - return Vec::new(); - } - - // Measure raw max width of each column based on visual text length - let mut col_widths = vec![4usize; col_count]; - for row in &parsed_rows { - for (i, cell) in row.iter().enumerate() { - if i < col_count { - let parsed = parse_inline(cell); - let w = spans_visual_width(&parsed); - if w > col_widths[i] { - col_widths[i] = w; - } - } - } - } - - // Overhead: Each column is " cell ", with border on each side: col_count * 3 + 1 - // E.g. for 2 columns: "│ col1 │ col2 │" has 2 * 3 + 1 = 7 border/padding chars. - let border_overhead = col_count * 3 + 1; - let avail_for_cells = max_width.saturating_sub(border_overhead).max(col_count * 6); - let total_cell_w: usize = col_widths.iter().sum(); - - if total_cell_w > avail_for_cells { - // Proportionally scale columns to fit within avail_for_cells - let mut new_widths = Vec::new(); - for &w in &col_widths { - let scaled = (w * avail_for_cells / total_cell_w).max(6); - new_widths.push(scaled); - } - col_widths = new_widths; - // Fine-tune if sum exceeds avail_for_cells - while col_widths.iter().sum::() > avail_for_cells { - if let Some(max_col) = col_widths.iter_mut().max() { - if *max_col > 6 { - *max_col -= 1; - } else { - break; - } - } - } - } - - let border_style = Style::default().fg(Theme::border()); - let header_style = Style::default() - .fg(Theme::primary()) - .add_modifier(Modifier::BOLD); - - let mut lines = Vec::new(); - - // Top border: ┌───┬───┐ - let mut top_border = String::from("┌"); - for (i, w) in col_widths.iter().enumerate() { - top_border.push_str(&"─".repeat(*w + 2)); - if i + 1 < col_count { - top_border.push('┬'); - } else { - top_border.push('┐'); - } - } - lines.push(Line::from(vec![Span::styled(top_border, border_style)])); - - let mut has_header = false; - for (row_idx, row) in parsed_rows.iter().enumerate() { - // Skip delimiter row - if row_idx == 1 && rows.get(1).map(|s| is_delimiter_row(s)).unwrap_or(false) { - // Render middle divider: ├───┼───┤ - let mut mid_border = String::from("├"); - for (i, w) in col_widths.iter().enumerate() { - mid_border.push_str(&"─".repeat(*w + 2)); - if i + 1 < col_count { - mid_border.push('┼'); - } else { - mid_border.push('┤'); - } - } - lines.push(Line::from(vec![Span::styled(mid_border, border_style)])); - has_header = true; - continue; - } - - let mut row_spans = Vec::new(); - row_spans.push(Span::styled("│", border_style)); - - for (col_idx, &w) in col_widths.iter().enumerate() { - let cell = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); - let is_header_row = row_idx == 0 && !has_header; - - let cell_spans = if is_header_row { - vec![Span::styled(cell.to_string(), header_style)] - } else { - parse_inline(cell) - }; - - let visual_w = spans_visual_width(&cell_spans); - let final_spans = if visual_w > w { - truncate_spans(cell_spans, w) - } else { - cell_spans - }; - - let final_w = spans_visual_width(&final_spans); - let pad = w.saturating_sub(final_w); - - row_spans.push(Span::raw(" ")); - row_spans.extend(final_spans); - row_spans.push(Span::raw(" ".repeat(pad + 1))); - row_spans.push(Span::styled("│", border_style)); - } - - lines.push(Line::from(row_spans)); - } - - // Bottom border: └───┴───┘ - let mut bottom_border = String::from("└"); - for (i, w) in col_widths.iter().enumerate() { - bottom_border.push_str(&"─".repeat(*w + 2)); - if i + 1 < col_count { - bottom_border.push('┴'); - } else { - bottom_border.push('┘'); - } - } - lines.push(Line::from(vec![Span::styled(bottom_border, border_style)])); - - lines -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_render_table_alignment() { - let rows = vec![ - "| Package | Role |".to_string(), - "|---|---|".to_string(), - "| `@codeworksh/cli` (packages/codework) | The user-facing command-line tool (codework). |".to_string(), - "| `@codeworksh/aikit` (packages/aikit) | Unified multi-provider LLM client SDK. |".to_string(), - ]; - let lines = render_table(&rows, 60); - assert!(!lines.is_empty()); - let expected_w = spans_visual_width(&lines[0].spans); - for line in &lines { - let line_w = spans_visual_width(&line.spans); - assert_eq!(line_w, expected_w, "All table lines must have identical visual width!"); - } - } -} - diff --git a/packages/tui/src/menu.rs b/packages/tui/src/menu.rs new file mode 100644 index 00000000..0a9b5c75 --- /dev/null +++ b/packages/tui/src/menu.rs @@ -0,0 +1,152 @@ +//! A menu: the choices on offer and the one the cursor is on. +//! +//! State, not drawing: `ui::menu` reads this and paints it. A menu is handed its entries and +//! reports which one is under the cursor; what choosing that entry *means* belongs to the app. The +//! command menu and the theme picker are both one of these, and so are the model and session +//! pickers that come next — the only difference between them is the entries they are given. + +use std::borrow::Cow; + +use ratatui::style::Color; + +/// One choice in a menu. +#[derive(Clone, PartialEq, Eq)] +pub struct Entry { + /// What choosing this entry identifies (for a session, its id; otherwise its name). + pub id: Cow<'static, str>, + /// What the entry is called, and the first thing its row shows. + pub name: Cow<'static, str>, + /// A short note beside the name: what choosing it does, or what it is. + pub detail: Cow<'static, str>, + /// The colours the entry is about, when it is about colour. + pub preview: Option, +} + +impl Entry { + pub const fn item(name: &'static str, detail: &'static str) -> Self { + Self { + id: Cow::Borrowed(name), + name: Cow::Borrowed(name), + detail: Cow::Borrowed(detail), + preview: None, + } + } + + pub const fn with_preview(name: &'static str, detail: &'static str, preview: Preview) -> Self { + Self { + id: Cow::Borrowed(name), + name: Cow::Borrowed(name), + detail: Cow::Borrowed(detail), + preview: Some(preview), + } + } + + pub fn owned( + id: impl Into, + name: impl Into, + detail: impl Into, + ) -> Self { + Self { + id: Cow::Owned(id.into()), + name: Cow::Owned(name.into()), + detail: Cow::Owned(detail.into()), + preview: None, + } + } +} + +/// A ground and an ink an entry wears on its own name. +/// +/// A list of themes is then a list of themes, rather than a list of their names: the site's picker +/// previews each one too, in its name's colour and its card's screenshot. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Preview { + /// What the name is drawn on: for a palette, its own canvas. + pub bg: Color, + /// What it is drawn in: for a palette, its primary ink. + pub fg: Color, +} + +/// The entries, and where the cursor is among them. +pub struct Menu { + all: Vec, + entries: Vec, + cursor: usize, +} + +impl Menu { + /// A menu over `entries`, its cursor on the first of them. + pub fn new(entries: Vec) -> Self { + Self { + all: entries.clone(), + entries, + cursor: 0, + } + } + + /// A menu over `entries`, its cursor on the entry named `name` (or matching `id`). + /// + /// That is how a picker opens on what is already in use, as the site's does. When the name is + /// not there the cursor starts at the top rather than nowhere. + pub fn at(entries: Vec, name: &str) -> Self { + let cursor = entries + .iter() + .position(|entry| entry.name == name || entry.id == name) + .unwrap_or(0); + Self { + all: entries.clone(), + entries, + cursor, + } + } + + /// Every entry the menu was built with, before filtering. Used to measure column widths so the + /// box does not narrow and widen as a search query changes. + pub fn all(&self) -> &[Entry] { + &self.all + } + + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + /// The entry under the cursor, when the menu has any. + pub fn selected(&self) -> Option<&Entry> { + self.entries.get(self.cursor) + } + + /// Keep only the entries from [`Self::all`] that match `keep`, preserving the selected entry + /// when it still matches and otherwise starting at the top. + pub fn filter(&mut self, mut keep: impl FnMut(&Entry) -> bool) { + let active = self.selected().map(|entry| entry.id.clone()); + self.entries = self + .all + .iter() + .filter(|entry| keep(entry)) + .cloned() + .collect(); + self.cursor = active + .and_then(|id| self.entries.iter().position(|entry| entry.id == id)) + .unwrap_or(0); + } + + /// Move the cursor `step` entries, wrapping at both ends, so the last entry is one key from the + /// first however long the list is — as the site's picker wraps its deck. Reports whether the + /// cursor moved. + pub fn step(&mut self, step: isize) -> bool { + let len = self.entries.len() as isize; + if len == 0 { + return false; + } + let at = (self.cursor as isize + step).rem_euclid(len) as usize; + if at == self.cursor { + return false; + } + self.cursor = at; + true + } +} diff --git a/packages/tui/src/prompt.rs b/packages/tui/src/prompt.rs new file mode 100644 index 00000000..dc69ae5f --- /dev/null +++ b/packages/tui/src/prompt.rs @@ -0,0 +1,169 @@ +//! The prompt line's text and caret. +//! +//! State, not drawing: `ui::input` reads this and reports the cell the caret lands in. The app +//! resolves named actions first, so an exit key never reaches here — every key that does either +//! moves the caret, changes the text, or is not ours. + +use std::ops::Range; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// The prompt's text and caret. +/// +/// The caret is a UTF-8 byte offset into `text`, kept on a character boundary by every operation +/// that moves it so slicing before or after the caret is direct. +#[derive(Default)] +pub struct Prompt { + text: String, + caret: usize, +} + +impl Prompt { + pub fn text(&self) -> &str { + &self.text + } + + /// The text before the caret, which is what the widget measures to scroll a long line. + pub fn before_caret(&self) -> &str { + &self.text[..self.caret] + } + + /// Empty the line. Escape does this when it takes back the slash that asked for the command + /// menu, so the line is what it was before the command started. + pub fn clear(&mut self) { + self.text.clear(); + self.caret = 0; + } + + /// Apply one key. Reports whether the prompt changed, which is the redraw. + pub fn edit(&mut self, key: KeyEvent) -> bool { + match (key.code, key.modifiers) { + (KeyCode::Char('a'), KeyModifiers::CONTROL) | (KeyCode::Home, _) => self.jump(0), + (KeyCode::Char('e'), KeyModifiers::CONTROL) | (KeyCode::End, _) => { + self.jump(self.text.len()) + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => self.remove_range(0..self.caret), + (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + self.remove_range(self.caret..self.text.len()) + } + (KeyCode::Char('w'), KeyModifiers::CONTROL) + | (KeyCode::Backspace, KeyModifiers::ALT) => { + self.remove_range(self.prev_word()..self.caret) + } + (KeyCode::Left, m) if m.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => { + self.jump(self.prev_word()) + } + (KeyCode::Right, m) if m.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => { + self.jump(self.next_word()) + } + // Only a plain or shifted key types: ctrl belongs to the app (exit, the leader) and alt + // to the terminal. + (KeyCode::Char(ch), m) if m.difference(KeyModifiers::SHIFT).is_empty() => { + // Some terminals report a control key as a character (`\x01` for ctrl+a). Those are + // never text, so they are dropped rather than drawn as a box. + if ch.is_control() { + return false; + } + let mut buf = [0u8; 4]; + self.insert_text(ch.encode_utf8(&mut buf)) + } + (KeyCode::Backspace, _) => match self.prev_char() { + Some(prev) => self.remove_range(prev..self.caret), + None => false, + }, + (KeyCode::Delete, _) => match self.next_char() { + Some(next) => self.remove_range(self.caret..next), + None => false, + }, + (KeyCode::Left, _) => match self.prev_char() { + Some(prev) => self.jump(prev), + None => false, + }, + (KeyCode::Right, _) => match self.next_char() { + Some(next) => self.jump(next), + None => false, + }, + _ => false, + } + } + + /// Insert text at the caret, as a paste arrives. + /// + /// Line breaks and tabs are turned into spaces so pasted lines do not run together, and any + /// other control characters are dropped. + pub fn insert_text(&mut self, text: &str) -> bool { + let mut clean = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + clean.push(' '); + } + '\n' | '\t' => clean.push(' '), + ch if !ch.is_control() => clean.push(ch), + _ => {} + } + } + if clean.is_empty() { + return false; + } + self.text.insert_str(self.caret, &clean); + self.caret += clean.len(); + true + } + + fn remove_range(&mut self, range: Range) -> bool { + if range.is_empty() { + return false; + } + let start = range.start; + self.text.replace_range(range, ""); + self.caret = start; + true + } + + fn jump(&mut self, caret: usize) -> bool { + if caret == self.caret { + return false; + } + self.caret = caret; + true + } + + fn prev_char(&self) -> Option { + self.text[..self.caret] + .char_indices() + .next_back() + .map(|(idx, _)| idx) + } + + fn next_char(&self) -> Option { + self.text[self.caret..] + .chars() + .next() + .map(|ch| self.caret + ch.len_utf8()) + } + + fn prev_word(&self) -> usize { + let before = &self.text[..self.caret]; + let trimmed = before.trim_end_matches(char::is_whitespace); + trimmed + .char_indices() + .rev() + .find(|(_, ch)| ch.is_whitespace()) + .map_or(0, |(idx, ch)| idx + ch.len_utf8()) + } + + fn next_word(&self) -> usize { + let after = &self.text[self.caret..]; + let mut chars = after.char_indices().peekable(); + while chars.next_if(|(_, ch)| ch.is_whitespace()).is_some() {} + while chars.next_if(|(_, ch)| !ch.is_whitespace()).is_some() {} + chars + .peek() + .map_or(self.text.len(), |(idx, _)| self.caret + idx) + } +} diff --git a/packages/tui/src/rpc/client.rs b/packages/tui/src/rpc/client.rs index 87686d45..6d0bafd9 100644 --- a/packages/tui/src/rpc/client.rs +++ b/packages/tui/src/rpc/client.rs @@ -1,7 +1,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; use serde_json::Value; use tokio::sync::mpsc; use tokio::time::sleep; @@ -9,10 +10,11 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use crate::rpc::protocol::{ - CreateSessionPayload, ExitPayload, InterruptSessionPayload, ModelRefPayload, + ConfigureSessionPayload, CreateSessionPayload, ExitPayload, ModelRefPayload, PromptSessionPayload, RpcAck, RpcRequest, RuntimePayload, ServerMessage, + SessionHistoryResponse, SessionIdPayload, }; -use crate::types::{SessionInfo, UsageReport}; +use crate::types::{HistoryEntry, SessionInfo, UsageReport}; pub const DEFAULT_SERVER_URL: &str = "ws://127.0.0.1:7433/rpc/v1"; @@ -31,7 +33,7 @@ pub fn server_url() -> String { std::env::var("CODEWORK_RPC_SERVER").unwrap_or_else(|_| DEFAULT_SERVER_URL.to_string()) } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum StreamEvent { SessionCreated(SessionInfo), TextDelta(String), @@ -49,10 +51,14 @@ pub enum StreamEvent { is_error: bool, /// The arguments the terminal part carries, repeated from `started`. arguments: Value, + /// Text output returned by the tool in `result.content`. + output: Option, /// The tool's own success/failure struct. Arbitrary JSON: the shape is the /// tool's to define, so it is interpreted downstream rather than here. details: Option, }, + /// A plugin or runtime warning surfaced during the turn (as ACP's `Feed.notice` does). + Notice(String), Usage(UsageReport), Succeeded, Failed(String), @@ -84,70 +90,19 @@ impl RpcClient { } } - /// The endpoint this client connects to. - pub fn url(&self) -> &str { - &self.url - } - fn next_request_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::SeqCst) } - /// Whether this endpoint currently accepts a WebSocket connection. - pub async fn is_server_running(&self) -> bool { - match tokio::time::timeout(Duration::from_millis(1500), connect_async(&self.url)).await { - Ok(Ok(_)) => true, - _ => false, - } - } - - /// Wait, briefly, for the endpoint to answer. - /// - /// The CLI has already ensured a server before launching the UI, so this is - /// only a guard against connecting in the same instant it finishes binding. - /// It never starts a server: process startup belongs to the CLI alone. - pub async fn wait_for_server(&self) -> bool { - for _ in 0..25 { - if self.is_server_running().await { - return true; - } - sleep(Duration::from_millis(200)).await; - } - self.is_server_running().await - } - - pub async fn create_session( - &self, - provider: &str, - model_id: &str, - host_dir: Option, - thinking_level: Option, - ) -> Result { + /// Perform one request-response RPC call over the WebSocket endpoint. + async fn call(&self, tag: &str, payload: P) -> Result { let (ws_stream, _) = connect_async(&self.url) .await .map_err(|e| format!("Failed to connect to {}: {}", self.url, e))?; let (mut write, mut read) = ws_stream.split(); let req_id = self.next_request_id(); - - let dir = host_dir.unwrap_or_else(|| { - std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| ".".to_string()) - }); - - let payload = CreateSessionPayload { - host_dir: dir, - runtime: RuntimePayload { - model: ModelRefPayload { - provider: provider.to_string(), - id: model_id.to_string(), - }, - thinking_level, - }, - }; - - let req = RpcRequest::new(req_id, "session.create", payload); + let req = RpcRequest::new(req_id, tag, payload); let mut text = serde_json::to_string(&req).map_err(|e| e.to_string())?; text.push('\n'); @@ -164,31 +119,22 @@ impl RpcClient { if line.is_empty() { continue; } - let parsed: ServerMessage = match serde_json::from_str(line) { - Ok(m) => m, - Err(_) => continue, + let Ok(parsed) = serde_json::from_str::(line) else { + continue; }; - - if let ServerMessage::Exit { request_id, exit } = parsed { - let matches = match request_id { - serde_json::Value::Number(n) => n.as_u64() == Some(req_id), - serde_json::Value::String(s) => s.parse::().ok() == Some(req_id), - _ => false, - }; - - if matches { - match exit { - ExitPayload::Success { value } => { - let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let title = value.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let directory = value.get("directory").and_then(|v| v.as_str()).unwrap_or("").to_string(); - return Ok(SessionInfo { id, title, directory }); - } - ExitPayload::Failure { cause } => { - return Err(format!("RPC Failure: {:?}", cause)); - } - } + match parsed { + ServerMessage::Exit { request_id, exit } + if as_request_id(&request_id) == Some(req_id) => + { + return match exit { + ExitPayload::Success { value } => Ok(value), + ExitPayload::Failure { cause } => Err(format_cause(cause.as_ref())), + }; } + ServerMessage::Defect { defect } => { + return Err(format_cause(defect.as_ref())); + } + _ => {} } } } @@ -197,47 +143,133 @@ impl RpcClient { Err("Connection closed before response received".to_string()) } - pub async fn interrupt_session(&self, session_id: &str) -> Result { - let (ws_stream, _) = connect_async(&self.url) - .await - .map_err(|e| format!("Failed to connect: {}", e))?; + /// Whether this endpoint currently accepts a WebSocket connection. + #[cfg(test)] + pub async fn is_server_running(&self) -> bool { + matches!( + tokio::time::timeout(Duration::from_millis(1500), connect_async(&self.url)).await, + Ok(Ok(_)) + ) + } - let (mut write, mut read) = ws_stream.split(); - let req_id = self.next_request_id(); + /// Create a session, letting the server pick the default model from settings when `model` is + /// `None` (matching ACP's `newSession`). + pub async fn create_session( + &self, + host_dir: Option, + model: Option<(String, String)>, + thinking_level: Option, + ) -> Result { + let dir = host_dir.or_else(|| { + std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().to_string()) + }); + let runtime = if model.is_some() || thinking_level.is_some() { + Some(RuntimePayload { + model: model.map(|(provider, id)| ModelRefPayload { provider, id }), + thinking_level, + }) + } else { + None + }; + let value = self + .call( + "session.create", + CreateSessionPayload { + host_dir: dir, + runtime, + }, + ) + .await?; + parse_session_info(&value) + } - let req = RpcRequest::new( - req_id, - "session.interrupt", - InterruptSessionPayload { - session_id: session_id.to_string(), - }, - ); - let mut text = serde_json::to_string(&req).map_err(|e| e.to_string())?; - text.push('\n'); + /// List all sessions on the server (`session.list`). + pub async fn list_sessions(&self) -> Result, String> { + let value = self.call("session.list", serde_json::json!({})).await?; + let Some(items) = value.as_array() else { + return Ok(Vec::new()); + }; + Ok(items + .iter() + .filter_map(|item| parse_session_info(item).ok()) + .collect()) + } - write - .send(Message::Text(text.into())) - .await - .map_err(|e| e.to_string())?; + /// Fetch a session's current info (`session.info`), used to pick up auto-generated titles. + pub async fn session_info(&self, session_id: &str) -> Result { + let value = self + .call( + "session.info", + SessionIdPayload { + session_id: session_id.to_string(), + }, + ) + .await?; + parse_session_info(&value) + } - while let Some(msg_result) = read.next().await { - let msg = msg_result.map_err(|e| e.to_string())?; - if let Message::Text(txt) = msg { - for line in txt.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if let Ok(ServerMessage::Exit { exit, .. }) = serde_json::from_str::(line) { - if let ExitPayload::Success { value } = exit { - let interrupted = value.get("interrupted").and_then(|v| v.as_bool()).unwrap_or(false); - return Ok(interrupted); - } - } - } - } - } - Ok(false) + /// Fetch a session's active-branch conversation history (`session.history`). + pub async fn session_history(&self, session_id: &str) -> Result, String> { + let value = self + .call( + "session.history", + SessionIdPayload { + session_id: session_id.to_string(), + }, + ) + .await?; + parse_session_history(&value) + } + + /// Update a session's model or thinking effort (`session.configure`, matching ACP's + /// `setConfigOption`). + pub async fn configure_session( + &self, + session_id: &str, + model: Option<(String, String)>, + thinking_level: Option, + ) -> Result { + let value = self + .call( + "session.configure", + ConfigureSessionPayload { + session_id: session_id.to_string(), + runtime: RuntimePayload { + model: model.map(|(provider, id)| ModelRefPayload { provider, id }), + thinking_level, + }, + }, + ) + .await?; + parse_session_info(&value) + } + + pub async fn interrupt_session(&self, session_id: &str) -> Result { + let value = self + .call( + "session.interrupt", + SessionIdPayload { + session_id: session_id.to_string(), + }, + ) + .await?; + Ok(value + .get("interrupted") + .and_then(|v| v.as_bool()) + .unwrap_or(false)) + } + + /// Reload server plugins (`plugin.reload`). + pub async fn reload_plugins(&self) -> Result<(u64, Option), String> { + let value = self.call("plugin.reload", serde_json::json!({})).await?; + let count = value.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0); + let failure = value + .get("failure") + .and_then(|v| v.as_str()) + .map(str::to_string); + Ok((count, failure)) } /// Stream one prompt, reconnecting the socket when the transport drops. @@ -253,13 +285,36 @@ impl RpcClient { prompt_text: String, event_tx: mpsc::UnboundedSender, ) -> Result<(), String> { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + // One stable id regardless of retries, so a prompt re-admitted on a new + // connection is recognized as the same message rather than a duplicate. + let prompt_message_id = format!("msg_{}_{}", millis, self.next_request_id()); + let mut prompt_dispatched = false; let mut attempt = 0u32; + loop { match self - .stream_session_once(&session_id, &prompt_text, attempt > 0, &event_tx) + .stream_session_once( + &session_id, + &prompt_text, + &prompt_message_id, + &mut prompt_dispatched, + attempt > 0, + &event_tx, + ) .await { - Ok(true) => return Ok(()), + Ok(true) => { + // Refresh session metadata so an auto-generated title from the first prompt + // reaches the UI, matching ACP's `session_info_update`. + if let Ok(info) = self.session_info(&session_id).await { + let _ = event_tx.send(StreamEvent::SessionCreated(info)); + } + return Ok(()); + } Ok(false) => { attempt += 1; if attempt > RECONNECT_ATTEMPTS { @@ -287,6 +342,8 @@ impl RpcClient { &self, session_id: &str, prompt_text: &str, + prompt_message_id: &str, + prompt_dispatched: &mut bool, is_retry: bool, event_tx: &mpsc::UnboundedSender, ) -> Result { @@ -308,15 +365,10 @@ impl RpcClient { .await .map_err(|e| format!("Failed to subscribe to events: {}", e))?; - // One stable id regardless of retries, so a prompt re-admitted on a new - // connection is recognized as the same message rather than a duplicate. - let prompt_message_id = format!("msg_{}", chrono::Utc::now().timestamp_millis()); - // A retry must not re-admit the prompt: it may still be running server-side, - // and re-sending it would collide. The subscription alone re-attaches us to - // the events for the work already in flight. - let mut prompt_dispatched = is_retry; + let mut prompt_req_id: Option = None; // On a retry, the first matching event is proof the socket is back. let mut reconnected = is_retry; + let mut notices = std::collections::HashSet::new(); while let Some(msg_res) = read.next().await { let msg = match msg_res { @@ -339,8 +391,7 @@ impl RpcClient { match parsed { ServerMessage::Chunk { request_id, values } => { - // Send Ack for chunk with \n - if let Some(id_num) = request_id.as_u64() { + if let Some(id_num) = as_request_id(&request_id) { let ack = RpcAck::new(id_num); if let Ok(mut ack_json) = serde_json::to_string(&ack) { ack_json.push('\n'); @@ -349,10 +400,11 @@ impl RpcClient { } for env in values { - // On server.connected, dispatch prompt - if env.event_type == "server.connected" && !prompt_dispatched { - prompt_dispatched = true; + // On server.connected, dispatch prompt if not yet dispatched + if env.event_type == "server.connected" && !*prompt_dispatched { + *prompt_dispatched = true; let prompt_id = self.next_request_id(); + prompt_req_id = Some(prompt_id); let prompt_req = RpcRequest::new( prompt_id, "session.prompt", @@ -360,7 +412,7 @@ impl RpcClient { session_id: session_id.to_string(), text: prompt_text.to_string(), delivery: "followUp", - id: prompt_message_id.clone(), + id: prompt_message_id.to_string(), }, ); if let Ok(mut p_json) = serde_json::to_string(&prompt_req) { @@ -370,12 +422,35 @@ impl RpcClient { continue; } - // Verify sessionId matches let data = match &env.data { Some(d) => d, None => continue, }; + // Plugin failure notices can be process-wide or session-scoped, + // matching ACP's `Feed.notice`. + if env.event_type == "plugin.updated" { + let matches_session = data + .get("sessionId") + .and_then(|v| v.as_str()) + .is_none_or(|s| s == session_id); + if matches_session + && data.get("status").and_then(|v| v.as_str()) + == Some("failed") + { + if let Some(err) = + data.get("error").and_then(|v| v.as_str()) + { + if notices.insert(err.to_string()) { + let _ = event_tx + .send(StreamEvent::Notice(err.to_string())); + } + } + } + continue; + } + + // Verify sessionId matches let s_id = data.get("sessionId").and_then(|v| v.as_str()); if s_id != Some(session_id) { continue; @@ -390,13 +465,20 @@ impl RpcClient { match env.event_type.as_str() { "session.llm.text.delta" | "session.llm.text-delta" => { - if let Some(delta) = data.get("delta").and_then(|v| v.as_str()) { - let _ = event_tx.send(StreamEvent::TextDelta(delta.to_string())); + if let Some(delta) = + data.get("delta").and_then(|v| v.as_str()) + { + let _ = event_tx + .send(StreamEvent::TextDelta(delta.to_string())); } } "session.llm.thinking.delta" | "session.llm.thinking-delta" => { - if let Some(delta) = data.get("delta").and_then(|v| v.as_str()) { - let _ = event_tx.send(StreamEvent::ThinkingDelta(delta.to_string())); + if let Some(delta) = + data.get("delta").and_then(|v| v.as_str()) + { + let _ = event_tx.send(StreamEvent::ThinkingDelta( + delta.to_string(), + )); } } "session.tool.started" => { @@ -408,19 +490,38 @@ impl RpcClient { "session.llm.ended" => { if let Some(msg) = data.get("message") { if let Some(usage) = msg.get("usage") { - let total_tokens = usage.get("totalTokens").and_then(|v| v.as_u64()).unwrap_or(0); - let input_tokens = usage.get("input").and_then(|v| v.as_u64()).unwrap_or(0); - let output_tokens = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0); - let cost = usage.get("cost").and_then(|c| c.get("total")).and_then(|v| v.as_f64()).unwrap_or(0.0); - let model = msg.get("responseModel").or_else(|| msg.get("model")).and_then(|v| v.as_str()).map(|s| s.to_string()); - - let _ = event_tx.send(StreamEvent::Usage(UsageReport { - total_tokens, - input_tokens, - output_tokens, - cost, - model, - })); + let total_tokens = usage + .get("totalTokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let input_tokens = usage + .get("input") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let output_tokens = usage + .get("output") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let cost = usage + .get("cost") + .and_then(|c| c.get("total")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let model = msg + .get("responseModel") + .or_else(|| msg.get("model")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let _ = event_tx.send(StreamEvent::Usage( + UsageReport { + total_tokens, + input_tokens, + output_tokens, + cost, + model, + }, + )); } } } @@ -429,20 +530,41 @@ impl RpcClient { return Ok(true); } "session.execution.failed" => { - let err_msg = data.get("error").and_then(|e| e.get("message")).and_then(|v| v.as_str()).unwrap_or("Execution failed"); - let _ = event_tx.send(StreamEvent::Failed(err_msg.to_string())); + let err_msg = data + .get("error") + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("Execution failed"); + let _ = + event_tx.send(StreamEvent::Failed(err_msg.to_string())); return Ok(true); } "session.execution.interrupted" => { - let reason = data.get("reason").and_then(|v| v.as_str()).unwrap_or("Interrupted"); - let _ = event_tx.send(StreamEvent::Interrupted(reason.to_string())); + let reason = data + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("Interrupted"); + let _ = event_tx + .send(StreamEvent::Interrupted(reason.to_string())); return Ok(true); } _ => {} } } } - ServerMessage::Exit { .. } => {} + ServerMessage::Exit { + request_id, + exit: ExitPayload::Failure { cause }, + } => { + let id = as_request_id(&request_id); + if id == Some(sub_id) || (prompt_req_id.is_some() && id == prompt_req_id) + { + return Err(format_cause(cause.as_ref())); + } + } + ServerMessage::Defect { defect } => { + return Err(format_cause(defect.as_ref())); + } _ => {} } } @@ -454,6 +576,40 @@ impl RpcClient { } } +fn as_request_id(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.parse::().ok(), + _ => None, + } +} + +fn parse_session_info(value: &Value) -> Result { + serde_json::from_value::(value.clone()) + .map_err(|e| format!("Invalid session response: {e}")) +} + +fn parse_session_history(value: &Value) -> Result, String> { + serde_json::from_value::(value.clone()) + .map(|res| res.entries) + .map_err(|e| format!("Invalid session history response: {e}")) +} + +fn format_cause(cause: Option<&Value>) -> String { + let Some(cause) = cause else { + return "RPC request failed".to_string(); + }; + if let Some(msg) = cause.get("message").and_then(|v| v.as_str()) { + return msg.to_string(); + } + if let Some(failure) = cause.get("failure") { + if let Some(msg) = failure.get("message").and_then(|v| v.as_str()) { + return msg.to_string(); + } + } + cause.to_string() +} + /// Reads a `session.tool.started` payload. Every field is best-effort: a missing /// or malformed one yields a plain `tool` line rather than an error. fn started_event(data: &Value) -> StreamEvent { @@ -474,7 +630,11 @@ fn started_event(data: &Value) -> StreamEvent { /// (the `edit` tool's `patch` and `firstChangedLine`, a `read`'s line window, a /// `bash`'s `exitCode`), so it is carried through opaquely. fn settled_event(data: &Value) -> StreamEvent { - let call_id = data.get("callID").and_then(|v| v.as_str()).unwrap_or("tool").to_string(); + let call_id = data + .get("callID") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(); let part = data.get("part"); let name = part .and_then(|p| p.get("name")) @@ -489,12 +649,30 @@ fn settled_event(data: &Value) -> StreamEvent { .and_then(|r| r.get("isError")) .and_then(|v| v.as_bool()) .unwrap_or(false); + let output = result + .and_then(|r| r.get("content")) + .and_then(|c| c.as_array()) + .map(|blocks| { + blocks + .iter() + .filter_map(|block| { + if block.get("type").and_then(|v| v.as_str()) == Some("text") { + block.get("text").and_then(|v| v.as_str()) + } else { + None + } + }) + .collect::>() + .join("\n") + }) + .filter(|text| !text.is_empty()); let details = result.and_then(|r| r.get("details")).cloned(); StreamEvent::ToolSettled { call_id, name, is_error, arguments, + output, details, } } @@ -546,16 +724,33 @@ mod tests { }, }); match settled_event(&data) { - StreamEvent::ToolSettled { call_id, name, is_error, arguments, details } => { + StreamEvent::ToolSettled { + call_id, + name, + is_error, + arguments, + output, + details, + } => { assert_eq!(call_id, "call_1"); assert_eq!(name.as_deref(), Some("edit")); assert!(!is_error); + assert_eq!(output.as_deref(), Some("edited")); // The arguments ride on the terminal part, so a settled call is titled // from them even when no started event arrived. - assert_eq!(arguments.get("path").and_then(Value::as_str), Some("src/a.ts")); + assert_eq!( + arguments.get("path").and_then(Value::as_str), + Some("src/a.ts") + ); let details = details.expect("details"); - assert_eq!(details.get("patch").and_then(Value::as_str), Some("--- src/a.ts")); - assert_eq!(details.get("firstChangedLine").and_then(Value::as_u64), Some(3)); + assert_eq!( + details.get("patch").and_then(Value::as_str), + Some("--- src/a.ts") + ); + assert_eq!( + details.get("firstChangedLine").and_then(Value::as_u64), + Some(3) + ); } other => panic!("expected ToolSettled, got {other:?}"), } @@ -601,8 +796,16 @@ mod tests { "part": { "name": "acme_echo", "status": "completed" }, }); match settled_event(&settled) { - StreamEvent::ToolSettled { arguments, details, is_error, name, .. } => { + StreamEvent::ToolSettled { + arguments, + output, + details, + is_error, + name, + .. + } => { assert!(arguments.is_null()); + assert!(output.is_none()); assert!(details.is_none()); assert!(!is_error); assert_eq!(name.as_deref(), Some("acme_echo")); @@ -611,6 +814,200 @@ mod tests { } } + #[test] + fn parses_full_session_info_metadata() { + let payload = json!({ + "id": "ses_abc", + "title": "Refactor TUI", + "directory": "/workspace/codework", + "hostDir": "/Users/prem/codework", + "updatedAt": 1_760_614_701_143u64, + "project": { + "id": "prj_1", + "name": "codework" + }, + "model": { + "provider": "anthropic", + "id": "claude-sonnet-4-5" + }, + "sandbox": { + "id": "sbx_1", + "driver": "memory", + "kind": "virtual", + "providerResourceId": null, + "ownership": "managed", + "status": "online", + "usage": "busy", + "refCount": 2, + "createdAt": "2026-10-16T11:00:00.000Z", + "updatedAt": "2026-10-16T11:30:00.000Z" + } + }); + + let info = parse_session_info(&payload).expect("valid SessionInfo"); + assert_eq!(info.id, "ses_abc"); + assert_eq!(info.title, "Refactor TUI"); + assert_eq!(info.directory, "/workspace/codework"); + assert_eq!(info.host_dir.as_deref(), Some("/Users/prem/codework")); + assert_eq!(info.updated_at, 1_760_614_701_143); + let project = info.project.expect("project"); + assert_eq!(project.id, "prj_1"); + assert_eq!(project.name, "codework"); + let model = info.model.expect("model"); + assert_eq!(model.provider, "anthropic"); + assert_eq!(model.id, "claude-sonnet-4-5"); + let sandbox = info.sandbox.expect("sandbox"); + assert_eq!(sandbox.id, "sbx_1"); + assert_eq!(sandbox.driver, "memory"); + assert_eq!(sandbox.kind, "virtual"); + assert_eq!(sandbox.provider_resource_id, None); + assert_eq!(sandbox.ownership, "managed"); + assert_eq!(sandbox.status, "online"); + assert_eq!(sandbox.usage, "busy"); + assert_eq!(sandbox.ref_count, 2); + } + + #[test] + fn parses_session_history_entries_and_parts() { + use crate::types::{Part, ToolResultContent, ToolTime}; + + let payload = json!({ + "entries": [ + { + "id": "msg_u1", + "seq": 1, + "role": "user", + "state": "committed", + "createdAt": 1_760_614_700_000u64, + "parts": [ + { "type": "text", "text": "Inspect Cargo.toml" }, + { "type": "image", "data": "aGVsbG8=", "mimeType": "image/png" } + ] + }, + { + "id": "msg_a1", + "seq": 2, + "role": "assistant", + "state": "committed", + "createdAt": 1_760_614_701_000u64, + "parts": [ + { "type": "thinking", "thinking": "Reading Cargo.toml first." }, + { + "type": "toolCall", + "callID": "call_read_1", + "name": "read", + "arguments": { "path": "packages/tui/Cargo.toml" }, + "status": "completed", + "result": { + "content": [{ "type": "text", "text": "[package]\nname = \"codework-tui\"" }], + "isError": false + }, + "time": { "start": 100, "end": 150 } + }, + { "type": "text", "text": "Here is the package manifest." } + ] + } + ] + }); + + let entries = parse_session_history(&payload).expect("valid history"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, "msg_u1"); + assert_eq!(entries[0].role, "user"); + assert_eq!(entries[0].state, "committed"); + assert_eq!(entries[0].parts.len(), 2); + assert_eq!( + entries[0].parts[1], + Part::Image { + data: "aGVsbG8=".to_string(), + mime_type: "image/png".to_string(), + } + ); + + assert_eq!(entries[1].id, "msg_a1"); + assert_eq!(entries[1].role, "assistant"); + assert_eq!(entries[1].parts.len(), 3); + match &entries[1].parts[1] { + Part::ToolCall { + call_id, + name, + status, + result, + time, + .. + } => { + assert_eq!(call_id, "call_read_1"); + assert_eq!(name, "read"); + assert_eq!(status, "completed"); + assert_eq!(time, &Some(ToolTime { start: 100, end: 150 })); + let result = result.as_ref().expect("result"); + assert!(!result.is_error); + assert_eq!( + result.content, + vec![ToolResultContent::Text { + text: "[package]\nname = \"codework-tui\"".to_string(), + }] + ); + } + other => panic!("expected ToolCall part, got {other:?}"), + } + } + + #[tokio::test] + async fn session_history_round_trips_over_websocket() { + use tokio::net::TcpListener; + use tokio_tungstenite::accept_async; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("ws handshake"); + let Some(Ok(Message::Text(req_text))) = ws.next().await else { + panic!("expected text request"); + }; + let req: Value = serde_json::from_str(req_text.trim()).expect("json request"); + assert_eq!(req.get("tag").and_then(Value::as_str), Some("session.history")); + assert_eq!( + req.get("payload") + .and_then(|p| p.get("sessionId")) + .and_then(Value::as_str), + Some("ses_42") + ); + let req_id = req.get("id").cloned().unwrap_or(json!(1)); + let reply = json!({ + "_tag": "Exit", + "requestId": req_id, + "exit": { + "_tag": "Success", + "value": { + "entries": [ + { + "id": "msg_1", + "seq": 1, + "role": "user", + "state": "committed", + "createdAt": 1000, + "parts": [{ "type": "text", "text": "hello" }] + } + ] + } + } + }); + ws.send(Message::Text(format!("{reply}\n").into())) + .await + .expect("send reply"); + }); + + let client = RpcClient::new(format!("ws://{addr}/rpc/v1")); + let entries = client.session_history("ses_42").await.expect("history"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "msg_1"); + assert_eq!(entries[0].role, "user"); + server.await.expect("server task"); + } + #[tokio::test] #[ignore] async fn test_rpc_flow() { @@ -621,7 +1018,13 @@ mod tests { } println!("Creating session..."); - let session = client.create_session("google", "gemini-2.5-flash", None, None).await; + let session = client + .create_session( + None, + Some(("google".to_string(), "gemini-2.5-flash".to_string())), + None, + ) + .await; println!("Session result: {:?}", session); let session = match session { Ok(s) => s, diff --git a/packages/tui/src/rpc/protocol.rs b/packages/tui/src/rpc/protocol.rs index f1905a03..ac9f7210 100644 --- a/packages/tui/src/rpc/protocol.rs +++ b/packages/tui/src/rpc/protocol.rs @@ -1,5 +1,8 @@ use serde::{Deserialize, Serialize}; +#[allow(unused_imports)] +pub use crate::types::{HistoryEntry, Part, ToolResult, ToolResultContent, ToolTime}; + #[derive(Debug, Serialize)] pub struct RpcRequest { pub _tag: &'static str, @@ -98,19 +101,28 @@ pub struct EventEnvelope { #[derive(Debug, Serialize)] pub struct CreateSessionPayload { - #[serde(rename = "hostDir")] - pub host_dir: String, + #[serde(rename = "hostDir", skip_serializing_if = "Option::is_none")] + pub host_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, +} + +#[derive(Debug, Serialize)] +pub struct ConfigureSessionPayload { + #[serde(rename = "sessionId")] + pub session_id: String, pub runtime: RuntimePayload, } #[derive(Debug, Serialize)] pub struct RuntimePayload { - pub model: ModelRefPayload, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, #[serde(rename = "thinkingLevel", skip_serializing_if = "Option::is_none")] pub thinking_level: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct ModelRefPayload { pub provider: String, pub id: String, @@ -126,7 +138,13 @@ pub struct PromptSessionPayload { } #[derive(Debug, Serialize)] -pub struct InterruptSessionPayload { +pub struct SessionIdPayload { #[serde(rename = "sessionId")] pub session_id: String, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionHistoryResponse { + #[serde(default)] + pub entries: Vec, +} diff --git a/packages/tui/src/theme.rs b/packages/tui/src/theme.rs new file mode 100644 index 00000000..df61b99f --- /dev/null +++ b/packages/tui/src/theme.rs @@ -0,0 +1,147 @@ +//! The active palette. +//! +//! The landing site owns the colours. It ships 22 palettes that each fill the same 16 tokens, and +//! its canvas derives one more ink from two of them; `scripts/themes.mjs` turns both into +//! [`palettes`] so the two surfaces cannot drift. Widgets read +//! roles off these tokens; the semantic layer — feedback colours, diff colours, syntax hues — needs +//! colours the site does not have, and arrives with the widgets that draw them. + +mod palettes; + +pub use palettes::{DEFAULT, PALETTES}; + +use ratatui::style::Color; + +/// Whether a palette is drawn for a light or a dark terminal. The landing canvas leans on this to +/// decide how it is lit, and the TUI will need it the first time a role has to be nudged for +/// contrast. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Dark, + Light, +} + +impl Scheme { + /// What the scheme is called, as the theme picker's note beside a palette's name. + pub fn name(self) -> &'static str { + match self { + Scheme::Dark => "dark", + Scheme::Light => "light", + } + } +} + +/// One palette: the site's 16 tokens plus the ink its canvas derives, and how it is named. +/// +/// The tokens are the whole contract, so a palette carries all of them even while the UI draws +/// with a few — that is what keeps a new widget from having to re-pull every theme. Every id here +/// is generated from the site and cross-checked against its registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Palette { + pub id: &'static str, + pub name: &'static str, + pub scheme: Scheme, + /// The page background: the app's own ground. + pub bg: Color, + /// Cards, popovers, prose blocks. + pub surface: Color, + /// A muted or secondary surface; also the hover and accent fill. + pub surface_2: Color, + /// Hairlines, card borders, dividers. + pub border_subtle: Color, + /// Emphasised borders. + pub border_strong: Color, + /// Primary text. + pub text: Color, + /// Secondary text. + pub text_secondary: Color, + /// Muted text and captions. + pub text_muted: Color, + /// The accent: links, the primary action, the focus ring. + pub brand: Color, + /// Ink drawn on top of a `brand` fill. + pub brand_ink: Color, + /// The accent ramp: five steps through `brand`, plus the ground they sit on. The landing + /// canvas reads these at runtime, and they are the natural source for a banner or a spinner. + pub field_bg: Color, + /// The landing canvas's hairline lattice, mixed 22% from `field_bg` toward `field_dim`. The + /// site derives it at runtime rather than storing it; the generator bakes the same mix in. + pub field_grid: Color, + pub field_dim: Color, + pub field_mid: Color, + pub field_lit: Color, + pub field_hover: Color, + pub field_crest: Color, +} + +/// The palette the TUI opens on, which is the site's own default. +pub fn default_palette() -> &'static Palette { + // `DEFAULT` is generated from the site and checked against `PALETTES` by `scripts/themes.mjs`, + // so a miss here means the generated file is out of date. + by_id(DEFAULT).expect("DEFAULT is a generated palette id") +} + +/// The palette with this id, if it is one of the site's. +pub fn by_id(id: &str) -> Option<&'static Palette> { + PALETTES.iter().find(|palette| palette.id == id) +} + +impl Palette { + /// Positive feedback / added diff lines. + pub fn success(&self) -> Color { + match self.scheme { + Scheme::Dark => Color::Rgb(158, 206, 106), + Scheme::Light => Color::Rgb(30, 127, 62), + } + } + + /// Negative feedback / removed diff lines / errors. + pub fn error(&self) -> Color { + match self.scheme { + Scheme::Dark => Color::Rgb(247, 118, 142), + Scheme::Light => Color::Rgb(209, 36, 47), + } + } + + /// Warning / notice indicator. + pub fn warning(&self) -> Color { + match self.scheme { + Scheme::Dark => Color::Rgb(224, 175, 104), + Scheme::Light => Color::Rgb(176, 109, 15), + } + } + + /// Subtle background fill for added diff lines, blended onto `self.bg`. + pub fn diff_add_bg(&self) -> Color { + mix(self.bg, self.success(), 14) + } + + /// Subtle background fill for removed diff lines, blended onto `self.bg`. + pub fn diff_del_bg(&self) -> Color { + mix(self.bg, self.error(), 14) + } + + /// Gutter (line-number column) background for added diff lines. + pub fn diff_add_gutter_bg(&self) -> Color { + mix(self.bg, self.success(), 26) + } + + /// Gutter (line-number column) background for removed diff lines. + pub fn diff_del_gutter_bg(&self) -> Color { + mix(self.bg, self.error(), 26) + } +} + +fn mix(base: Color, tint: Color, pct: u16) -> Color { + match (base, tint) { + (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => { + let keep = 100u16.saturating_sub(pct); + Color::Rgb( + ((br as u16 * keep + tr as u16 * pct) / 100) as u8, + ((bg as u16 * keep + tg as u16 * pct) / 100) as u8, + ((bb as u16 * keep + tb as u16 * pct) / 100) as u8, + ) + } + _ => base, + } +} diff --git a/packages/tui/src/theme/palettes.rs b/packages/tui/src/theme/palettes.rs new file mode 100644 index 00000000..f5a35a22 --- /dev/null +++ b/packages/tui/src/theme/palettes.rs @@ -0,0 +1,500 @@ +// @generated by scripts/themes.mjs from the landing site's palettes. Do not edit. +// +// Source: sites/www/src/styles/themes.css (the 16 tokens), +// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default) and +// sites/www/src/components/landing/field.tsx (the lattice ink it derives from two tokens). +// Re-run `node scripts/themes.mjs` after any of them moves; `--check` fails on drift. + +use ratatui::style::Color; + +use super::{Palette, Scheme}; + +pub static PALETTES: &[Palette] = &[ + Palette { + id: "catppuccin", + name: "Catppuccin", + scheme: Scheme::Dark, + bg: Color::Rgb(0x1e, 0x1e, 0x2e), + surface: Color::Rgb(0x28, 0x28, 0x39), + surface_2: Color::Rgb(0x31, 0x32, 0x44), + border_subtle: Color::Rgb(0x31, 0x32, 0x44), + border_strong: Color::Rgb(0x58, 0x5b, 0x70), + text: Color::Rgb(0xcd, 0xd6, 0xf4), + text_secondary: Color::Rgb(0xcd, 0xd6, 0xf4), + text_muted: Color::Rgb(0x93, 0x99, 0xb2), + brand: Color::Rgb(0x89, 0xb4, 0xfa), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x10, 0x10, 0x19), + field_grid: Color::Rgb(0x18, 0x1b, 0x28), + field_dim: Color::Rgb(0x34, 0x41, 0x5c), + field_mid: Color::Rgb(0x5b, 0x76, 0xa4), + field_lit: Color::Rgb(0x89, 0xb4, 0xfa), + field_hover: Color::Rgb(0xac, 0xca, 0xfc), + field_crest: Color::Rgb(0xd2, 0xe2, 0xfd), + }, + Palette { + id: "catppuccin-latte", + name: "Catppuccin Latte", + scheme: Scheme::Light, + bg: Color::Rgb(0xef, 0xf1, 0xf5), + surface: Color::Rgb(0xf8, 0xf9, 0xfb), + surface_2: Color::Rgb(0xe3, 0xe4, 0xe8), + border_subtle: Color::Rgb(0xd7, 0xd8, 0xdc), + border_strong: Color::Rgb(0xac, 0xb0, 0xbe), + text: Color::Rgb(0x4c, 0x4f, 0x69), + text_secondary: Color::Rgb(0x53, 0x55, 0x6f), + text_muted: Color::Rgb(0x5c, 0x5f, 0x77), + brand: Color::Rgb(0x1e, 0x66, 0xf5), + brand_ink: Color::Rgb(0xff, 0xff, 0xff), + field_bg: Color::Rgb(0xd7, 0xd8, 0xdc), + field_grid: Color::Rgb(0xcb, 0xd1, 0xde), + field_dim: Color::Rgb(0xa0, 0xb6, 0xe4), + field_mid: Color::Rgb(0x64, 0x91, 0xec), + field_lit: Color::Rgb(0x1e, 0x66, 0xf5), + field_hover: Color::Rgb(0x17, 0x50, 0xbf), + field_crest: Color::Rgb(0x10, 0x38, 0x87), + }, + Palette { + id: "ethereal", + name: "Ethereal", + scheme: Scheme::Dark, + bg: Color::Rgb(0x06, 0x0b, 0x1e), + surface: Color::Rgb(0x0c, 0x12, 0x2c), + surface_2: Color::Rgb(0x13, 0x1a, 0x3a), + border_subtle: Color::Rgb(0x13, 0x1a, 0x3a), + border_strong: Color::Rgb(0x6d, 0x7d, 0xb6), + text: Color::Rgb(0xff, 0xce, 0xad), + text_secondary: Color::Rgb(0xff, 0xce, 0xad), + text_muted: Color::Rgb(0xb6, 0xa6, 0xb2), + brand: Color::Rgb(0x7d, 0x82, 0xd9), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x03, 0x06, 0x10), + field_grid: Color::Rgb(0x0b, 0x0e, 0x1d), + field_dim: Color::Rgb(0x28, 0x2b, 0x4c), + field_mid: Color::Rgb(0x4f, 0x53, 0x8d), + field_lit: Color::Rgb(0x7d, 0x82, 0xd9), + field_hover: Color::Rgb(0xa4, 0xa8, 0xe4), + field_crest: Color::Rgb(0xce, 0xd0, 0xf1), + }, + Palette { + id: "everforest", + name: "Everforest", + scheme: Scheme::Dark, + bg: Color::Rgb(0x2d, 0x35, 0x3b), + surface: Color::Rgb(0x30, 0x3a, 0x40), + surface_2: Color::Rgb(0x34, 0x3f, 0x44), + border_subtle: Color::Rgb(0x34, 0x3f, 0x44), + border_strong: Color::Rgb(0x47, 0x52, 0x58), + text: Color::Rgb(0xd3, 0xc6, 0xaa), + text_secondary: Color::Rgb(0xd3, 0xc6, 0xaa), + text_muted: Color::Rgb(0x91, 0x8f, 0x84), + brand: Color::Rgb(0x7f, 0xbb, 0xb3), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x18, 0x1d, 0x20), + field_grid: Color::Rgb(0x1f, 0x27, 0x2a), + field_dim: Color::Rgb(0x37, 0x4c, 0x4c), + field_mid: Color::Rgb(0x58, 0x7f, 0x7b), + field_lit: Color::Rgb(0x7f, 0xbb, 0xb3), + field_hover: Color::Rgb(0xa5, 0xcf, 0xca), + field_crest: Color::Rgb(0xce, 0xe5, 0xe2), + }, + Palette { + id: "flexoki-light", + name: "Flexoki Light", + scheme: Scheme::Light, + bg: Color::Rgb(0xff, 0xfc, 0xf0), + surface: Color::Rgb(0xf9, 0xf6, 0xea), + surface_2: Color::Rgb(0xf2, 0xef, 0xe4), + border_subtle: Color::Rgb(0xe5, 0xe2, 0xd8), + border_strong: Color::Rgb(0xb7, 0xb5, 0xac), + text: Color::Rgb(0x10, 0x0f, 0x0f), + text_secondary: Color::Rgb(0x45, 0x43, 0x40), + text_muted: Color::Rgb(0x55, 0x54, 0x50), + brand: Color::Rgb(0x20, 0x5e, 0xa6), + brand_ink: Color::Rgb(0xff, 0xff, 0xff), + field_bg: Color::Rgb(0xe5, 0xe2, 0xd8), + field_grid: Color::Rgb(0xd8, 0xd9, 0xd5), + field_dim: Color::Rgb(0xaa, 0xba, 0xc9), + field_mid: Color::Rgb(0x6b, 0x90, 0xb9), + field_lit: Color::Rgb(0x20, 0x5e, 0xa6), + field_hover: Color::Rgb(0x19, 0x49, 0x81), + field_crest: Color::Rgb(0x12, 0x34, 0x5b), + }, + Palette { + id: "gruvbox", + name: "Gruvbox", + scheme: Scheme::Dark, + bg: Color::Rgb(0x28, 0x28, 0x28), + surface: Color::Rgb(0x32, 0x30, 0x2f), + surface_2: Color::Rgb(0x3c, 0x38, 0x36), + border_subtle: Color::Rgb(0x3c, 0x38, 0x36), + border_strong: Color::Rgb(0x66, 0x5c, 0x54), + text: Color::Rgb(0xd4, 0xbe, 0x98), + text_secondary: Color::Rgb(0xd4, 0xbe, 0x98), + text_muted: Color::Rgb(0xa8, 0x99, 0x84), + brand: Color::Rgb(0x7d, 0xae, 0xa3), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x16, 0x16, 0x16), + field_grid: Color::Rgb(0x1d, 0x20, 0x1f), + field_dim: Color::Rgb(0x35, 0x44, 0x40), + field_mid: Color::Rgb(0x56, 0x74, 0x6d), + field_lit: Color::Rgb(0x7d, 0xae, 0xa3), + field_hover: Color::Rgb(0xa4, 0xc6, 0xbf), + field_crest: Color::Rgb(0xce, 0xe0, 0xdc), + }, + Palette { + id: "hackerman", + name: "Hackerman", + scheme: Scheme::Dark, + bg: Color::Rgb(0x0b, 0x0c, 0x16), + surface: Color::Rgb(0x10, 0x12, 0x1f), + surface_2: Color::Rgb(0x15, 0x18, 0x28), + border_subtle: Color::Rgb(0x15, 0x18, 0x28), + border_strong: Color::Rgb(0x2d, 0x34, 0x50), + text: Color::Rgb(0xdd, 0xf7, 0xff), + text_secondary: Color::Rgb(0xdd, 0xf7, 0xff), + text_muted: Color::Rgb(0xa4, 0xb2, 0xca), + brand: Color::Rgb(0x82, 0xfb, 0x9c), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x06, 0x06, 0x0c), + field_grid: Color::Rgb(0x0e, 0x16, 0x15), + field_dim: Color::Rgb(0x2b, 0x50, 0x37), + field_mid: Color::Rgb(0x53, 0x9e, 0x65), + field_lit: Color::Rgb(0x82, 0xfb, 0x9c), + field_hover: Color::Rgb(0xa8, 0xfc, 0xba), + field_crest: Color::Rgb(0xd0, 0xfd, 0xd9), + }, + Palette { + id: "kanagawa", + name: "Kanagawa", + scheme: Scheme::Dark, + bg: Color::Rgb(0x1f, 0x1f, 0x28), + surface: Color::Rgb(0x20, 0x28, 0x38), + surface_2: Color::Rgb(0x22, 0x32, 0x49), + border_subtle: Color::Rgb(0x22, 0x32, 0x49), + border_strong: Color::Rgb(0x54, 0x54, 0x6d), + text: Color::Rgb(0xdc, 0xd7, 0xba), + text_secondary: Color::Rgb(0xdc, 0xd7, 0xba), + text_muted: Color::Rgb(0xa7, 0xa4, 0x92), + brand: Color::Rgb(0xdc, 0xd7, 0xba), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x11, 0x11, 0x16), + field_grid: Color::Rgb(0x1e, 0x1e, 0x21), + field_dim: Color::Rgb(0x4e, 0x4c, 0x47), + field_mid: Color::Rgb(0x8f, 0x8c, 0x7c), + field_lit: Color::Rgb(0xdc, 0xd7, 0xba), + field_hover: Color::Rgb(0xe6, 0xe3, 0xcf), + field_crest: Color::Rgb(0xf2, 0xf0, 0xe5), + }, + Palette { + id: "last-horizon", + name: "Last Horizon", + scheme: Scheme::Dark, + bg: Color::Rgb(0x0c, 0x0b, 0x0c), + surface: Color::Rgb(0x0c, 0x0b, 0x0c), + surface_2: Color::Rgb(0x0c, 0x0b, 0x0c), + border_subtle: Color::Rgb(0x0c, 0x0b, 0x0c), + border_strong: Color::Rgb(0x58, 0x4e, 0x51), + text: Color::Rgb(0xfa, 0xfc, 0xfb), + text_secondary: Color::Rgb(0xe2, 0xdd, 0xdc), + text_muted: Color::Rgb(0xa9, 0xa5, 0xa6), + brand: Color::Rgb(0xb5, 0x97, 0x90), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x06, 0x06, 0x06), + field_grid: Color::Rgb(0x11, 0x10, 0x0f), + field_dim: Color::Rgb(0x3a, 0x32, 0x2f), + field_mid: Color::Rgb(0x72, 0x60, 0x5c), + field_lit: Color::Rgb(0xb5, 0x97, 0x90), + field_hover: Color::Rgb(0xcb, 0xb6, 0xb1), + field_crest: Color::Rgb(0xe3, 0xd7, 0xd5), + }, + Palette { + id: "lumon", + name: "Lumon", + scheme: Scheme::Dark, + bg: Color::Rgb(0x16, 0x24, 0x2d), + surface: Color::Rgb(0x18, 0x28, 0x36), + surface_2: Color::Rgb(0x1b, 0x2d, 0x40), + border_subtle: Color::Rgb(0x1b, 0x2d, 0x40), + border_strong: Color::Rgb(0x30, 0x48, 0x60), + text: Color::Rgb(0xf2, 0xfc, 0xff), + text_secondary: Color::Rgb(0xd6, 0xe2, 0xee), + text_muted: Color::Rgb(0xa0, 0xc1, 0xd8), + brand: Color::Rgb(0x8b, 0xc9, 0xeb), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x0b, 0x12, 0x16), + field_grid: Color::Rgb(0x13, 0x1e, 0x24), + field_dim: Color::Rgb(0x31, 0x49, 0x56), + field_mid: Color::Rgb(0x5a, 0x83, 0x9a), + field_lit: Color::Rgb(0x8b, 0xc9, 0xeb), + field_hover: Color::Rgb(0xae, 0xd9, 0xf1), + field_crest: Color::Rgb(0xd3, 0xea, 0xf7), + }, + Palette { + id: "lupine", + name: "Lupine", + scheme: Scheme::Light, + bg: Color::Rgb(0xfa, 0xfa, 0xfa), + surface: Color::Rgb(0xff, 0xff, 0xff), + surface_2: Color::Rgb(0xec, 0xec, 0xec), + border_subtle: Color::Rgb(0xde, 0xde, 0xde), + border_strong: Color::Rgb(0x9e, 0x9e, 0x9e), + text: Color::Rgb(0x00, 0x00, 0x00), + text_secondary: Color::Rgb(0x21, 0x21, 0x21), + text_muted: Color::Rgb(0x48, 0x48, 0x48), + brand: Color::Rgb(0x32, 0x64, 0xeb), + brand_ink: Color::Rgb(0xff, 0xff, 0xff), + field_bg: Color::Rgb(0xde, 0xde, 0xde), + field_grid: Color::Rgb(0xd3, 0xd6, 0xdf), + field_dim: Color::Rgb(0xaa, 0xb9, 0xe2), + field_mid: Color::Rgb(0x73, 0x92, 0xe6), + field_lit: Color::Rgb(0x32, 0x64, 0xeb), + field_hover: Color::Rgb(0x27, 0x4e, 0xb7), + field_crest: Color::Rgb(0x1c, 0x37, 0x81), + }, + Palette { + id: "matte-black", + name: "Matte Black", + scheme: Scheme::Dark, + bg: Color::Rgb(0x12, 0x12, 0x12), + surface: Color::Rgb(0x18, 0x18, 0x18), + surface_2: Color::Rgb(0x1e, 0x1e, 0x1e), + border_subtle: Color::Rgb(0x1e, 0x1e, 0x1e), + border_strong: Color::Rgb(0x33, 0x33, 0x33), + text: Color::Rgb(0xea, 0xea, 0xea), + text_secondary: Color::Rgb(0xbe, 0xbe, 0xbe), + text_muted: Color::Rgb(0x8a, 0x8a, 0x8a), + brand: Color::Rgb(0xe6, 0x8e, 0x0d), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x09, 0x09, 0x09), + field_grid: Color::Rgb(0x18, 0x12, 0x09), + field_dim: Color::Rgb(0x4b, 0x31, 0x0a), + field_mid: Color::Rgb(0x92, 0x5b, 0x0b), + field_lit: Color::Rgb(0xe6, 0x8e, 0x0d), + field_hover: Color::Rgb(0xee, 0xb0, 0x56), + field_crest: Color::Rgb(0xf6, 0xd4, 0xa3), + }, + Palette { + id: "miasma", + name: "Miasma", + scheme: Scheme::Dark, + bg: Color::Rgb(0x22, 0x22, 0x22), + surface: Color::Rgb(0x27, 0x27, 0x27), + surface_2: Color::Rgb(0x2c, 0x2c, 0x2c), + border_subtle: Color::Rgb(0x2c, 0x2c, 0x2c), + border_strong: Color::Rgb(0x66, 0x66, 0x66), + text: Color::Rgb(0xc2, 0xc2, 0xb0), + text_secondary: Color::Rgb(0xc2, 0xc2, 0xb0), + text_muted: Color::Rgb(0x8c, 0x8c, 0x82), + brand: Color::Rgb(0x78, 0x82, 0x4b), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x12, 0x12, 0x12), + field_grid: Color::Rgb(0x19, 0x19, 0x16), + field_dim: Color::Rgb(0x31, 0x34, 0x23), + field_mid: Color::Rgb(0x51, 0x57, 0x35), + field_lit: Color::Rgb(0x78, 0x82, 0x4b), + field_hover: Color::Rgb(0xa0, 0xa8, 0x81), + field_crest: Color::Rgb(0xcc, 0xd0, 0xbb), + }, + Palette { + id: "nord", + name: "Nord", + scheme: Scheme::Dark, + bg: Color::Rgb(0x2e, 0x34, 0x40), + surface: Color::Rgb(0x34, 0x3b, 0x49), + surface_2: Color::Rgb(0x3b, 0x42, 0x52), + border_subtle: Color::Rgb(0x3b, 0x42, 0x52), + border_strong: Color::Rgb(0x4c, 0x56, 0x6a), + text: Color::Rgb(0xd8, 0xde, 0xe9), + text_secondary: Color::Rgb(0xd8, 0xde, 0xe9), + text_muted: Color::Rgb(0x9f, 0xa7, 0xb4), + brand: Color::Rgb(0x81, 0xa1, 0xc1), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x19, 0x1c, 0x23), + field_grid: Color::Rgb(0x20, 0x25, 0x2d), + field_dim: Color::Rgb(0x38, 0x44, 0x52), + field_mid: Color::Rgb(0x59, 0x6e, 0x85), + field_lit: Color::Rgb(0x81, 0xa1, 0xc1), + field_hover: Color::Rgb(0xa7, 0xbd, 0xd4), + field_crest: Color::Rgb(0xcf, 0xdb, 0xe7), + }, + Palette { + id: "osaka-jade", + name: "Osaka Jade", + scheme: Scheme::Dark, + bg: Color::Rgb(0x11, 0x1c, 0x18), + surface: Color::Rgb(0x1a, 0x2a, 0x22), + surface_2: Color::Rgb(0x23, 0x37, 0x2b), + border_subtle: Color::Rgb(0x23, 0x37, 0x2b), + border_strong: Color::Rgb(0x53, 0x68, 0x5b), + text: Color::Rgb(0xf7, 0xe8, 0xb2), + text_secondary: Color::Rgb(0xc1, 0xc4, 0x97), + text_muted: Color::Rgb(0x96, 0x99, 0x78), + brand: Color::Rgb(0x50, 0x94, 0x75), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x09, 0x0f, 0x0d), + field_grid: Color::Rgb(0x0e, 0x18, 0x14), + field_dim: Color::Rgb(0x1e, 0x37, 0x2c), + field_mid: Color::Rgb(0x35, 0x61, 0x4d), + field_lit: Color::Rgb(0x50, 0x94, 0x75), + field_hover: Color::Rgb(0x84, 0xb4, 0x9e), + field_crest: Color::Rgb(0xbc, 0xd6, 0xcb), + }, + Palette { + id: "retro-82", + name: "Retro 82", + scheme: Scheme::Dark, + bg: Color::Rgb(0x05, 0x18, 0x2e), + surface: Color::Rgb(0x08, 0x1e, 0x37), + surface_2: Color::Rgb(0x0a, 0x25, 0x40), + border_subtle: Color::Rgb(0x0a, 0x25, 0x40), + border_strong: Color::Rgb(0x2a, 0x6b, 0x78), + text: Color::Rgb(0xf6, 0xdc, 0xac), + text_secondary: Color::Rgb(0xf6, 0xdc, 0xac), + text_muted: Color::Rgb(0x9a, 0xb6, 0x9b), + brand: Color::Rgb(0xfa, 0xa9, 0x68), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x02, 0x0c, 0x17), + field_grid: Color::Rgb(0x12, 0x16, 0x1c), + field_dim: Color::Rgb(0x4c, 0x3b, 0x2f), + field_mid: Color::Rgb(0x9c, 0x6d, 0x49), + field_lit: Color::Rgb(0xfa, 0xa9, 0x68), + field_hover: Color::Rgb(0xfc, 0xc3, 0x95), + field_crest: Color::Rgb(0xfd, 0xde, 0xc6), + }, + Palette { + id: "ristretto", + name: "Ristretto", + scheme: Scheme::Dark, + bg: Color::Rgb(0x2c, 0x25, 0x25), + surface: Color::Rgb(0x34, 0x2a, 0x28), + surface_2: Color::Rgb(0x3d, 0x2f, 0x2a), + border_subtle: Color::Rgb(0x3d, 0x2f, 0x2a), + border_strong: Color::Rgb(0x72, 0x69, 0x6a), + text: Color::Rgb(0xe6, 0xd9, 0xdb), + text_secondary: Color::Rgb(0xe6, 0xd9, 0xdb), + text_muted: Color::Rgb(0xac, 0xa1, 0xa2), + brand: Color::Rgb(0xf3, 0x8d, 0x70), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x18, 0x14, 0x14), + field_grid: Color::Rgb(0x27, 0x1c, 0x1a), + field_dim: Color::Rgb(0x5a, 0x38, 0x30), + field_mid: Color::Rgb(0xa0, 0x5f, 0x4d), + field_lit: Color::Rgb(0xf3, 0x8d, 0x70), + field_hover: Color::Rgb(0xf7, 0xaf, 0x9b), + field_crest: Color::Rgb(0xfa, 0xd4, 0xc9), + }, + Palette { + id: "rose-pine", + name: "Rosé Pine", + scheme: Scheme::Light, + bg: Color::Rgb(0xfa, 0xf4, 0xed), + surface: Color::Rgb(0xff, 0xfa, 0xf3), + surface_2: Color::Rgb(0xed, 0xe7, 0xe1), + border_subtle: Color::Rgb(0xe1, 0xdb, 0xd5), + border_strong: Color::Rgb(0xce, 0xca, 0xcd), + text: Color::Rgb(0x57, 0x52, 0x79), + text_secondary: Color::Rgb(0x5e, 0x58, 0x7d), + text_muted: Color::Rgb(0x67, 0x62, 0x84), + brand: Color::Rgb(0x56, 0x94, 0x9f), + brand_ink: Color::Rgb(0xff, 0xff, 0xff), + field_bg: Color::Rgb(0xe1, 0xdb, 0xd5), + field_grid: Color::Rgb(0xd8, 0xd6, 0xd1), + field_dim: Color::Rgb(0xb7, 0xc6, 0xc5), + field_mid: Color::Rgb(0x8b, 0xaf, 0xb4), + field_lit: Color::Rgb(0x56, 0x94, 0x9f), + field_hover: Color::Rgb(0x43, 0x73, 0x7c), + field_crest: Color::Rgb(0x2f, 0x51, 0x57), + }, + Palette { + id: "solitude", + name: "Solitude", + scheme: Scheme::Dark, + bg: Color::Rgb(0x10, 0x13, 0x15), + surface: Color::Rgb(0x10, 0x13, 0x15), + surface_2: Color::Rgb(0x10, 0x13, 0x15), + border_subtle: Color::Rgb(0x10, 0x13, 0x15), + border_strong: Color::Rgb(0x4b, 0x4e, 0x55), + text: Color::Rgb(0xca, 0xcc, 0xcc), + text_secondary: Color::Rgb(0xa5, 0xae, 0xb4), + text_muted: Color::Rgb(0x8a, 0x8d, 0x90), + brand: Color::Rgb(0x79, 0x81, 0x86), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x08, 0x0a, 0x0b), + field_grid: Color::Rgb(0x0f, 0x12, 0x13), + field_dim: Color::Rgb(0x2a, 0x2e, 0x30), + field_mid: Color::Rgb(0x4e, 0x54, 0x57), + field_lit: Color::Rgb(0x79, 0x81, 0x86), + field_hover: Color::Rgb(0xa1, 0xa7, 0xaa), + field_crest: Color::Rgb(0xcc, 0xcf, 0xd1), + }, + Palette { + id: "tokyo-night", + name: "Tokyo Night", + scheme: Scheme::Dark, + bg: Color::Rgb(0x1a, 0x1b, 0x26), + surface: Color::Rgb(0x1f, 0x22, 0x30), + surface_2: Color::Rgb(0x24, 0x28, 0x3b), + border_subtle: Color::Rgb(0x24, 0x28, 0x3b), + border_strong: Color::Rgb(0x41, 0x48, 0x68), + text: Color::Rgb(0xc0, 0xca, 0xf5), + text_secondary: Color::Rgb(0xa9, 0xb1, 0xd6), + text_muted: Color::Rgb(0x8b, 0x93, 0xb8), + brand: Color::Rgb(0x9e, 0xce, 0x6a), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x0e, 0x0e, 0x14), + field_grid: Color::Rgb(0x17, 0x1b, 0x1a), + field_dim: Color::Rgb(0x39, 0x48, 0x2e), + field_mid: Color::Rgb(0x67, 0x85, 0x49), + field_lit: Color::Rgb(0x9e, 0xce, 0x6a), + field_hover: Color::Rgb(0xbb, 0xdd, 0x97), + field_crest: Color::Rgb(0xda, 0xec, 0xc6), + }, + Palette { + id: "vantablack", + name: "Vantablack", + scheme: Scheme::Dark, + bg: Color::Rgb(0x00, 0x00, 0x00), + surface: Color::Rgb(0x0d, 0x0d, 0x0d), + surface_2: Color::Rgb(0x1a, 0x1a, 0x1a), + border_subtle: Color::Rgb(0x1a, 0x1a, 0x1a), + border_strong: Color::Rgb(0x7a, 0x7a, 0x7a), + text: Color::Rgb(0xff, 0xff, 0xff), + text_secondary: Color::Rgb(0xff, 0xff, 0xff), + text_muted: Color::Rgb(0xa8, 0xa8, 0xa8), + brand: Color::Rgb(0x8d, 0x8d, 0x8d), + brand_ink: Color::Rgb(0x0c, 0x0e, 0x10), + field_bg: Color::Rgb(0x07, 0x07, 0x07), + field_grid: Color::Rgb(0x10, 0x10, 0x10), + field_dim: Color::Rgb(0x2f, 0x2f, 0x2f), + field_mid: Color::Rgb(0x5a, 0x5a, 0x5a), + field_lit: Color::Rgb(0x8d, 0x8d, 0x8d), + field_hover: Color::Rgb(0xaf, 0xaf, 0xaf), + field_crest: Color::Rgb(0xd4, 0xd4, 0xd4), + }, + Palette { + id: "white", + name: "White", + scheme: Scheme::Light, + bg: Color::Rgb(0xff, 0xff, 0xff), + surface: Color::Rgb(0xff, 0xff, 0xff), + surface_2: Color::Rgb(0xf5, 0xf5, 0xf5), + border_subtle: Color::Rgb(0xe8, 0xe8, 0xe8), + border_strong: Color::Rgb(0xc0, 0xc0, 0xc0), + text: Color::Rgb(0x00, 0x00, 0x00), + text_secondary: Color::Rgb(0x38, 0x38, 0x38), + text_muted: Color::Rgb(0x4a, 0x4a, 0x4a), + brand: Color::Rgb(0x6e, 0x6e, 0x6e), + brand_ink: Color::Rgb(0xff, 0xff, 0xff), + field_bg: Color::Rgb(0xe8, 0xe8, 0xe8), + field_grid: Color::Rgb(0xe0, 0xe0, 0xe0), + field_dim: Color::Rgb(0xc3, 0xc3, 0xc3), + field_mid: Color::Rgb(0x9c, 0x9c, 0x9c), + field_lit: Color::Rgb(0x6e, 0x6e, 0x6e), + field_hover: Color::Rgb(0x56, 0x56, 0x56), + field_crest: Color::Rgb(0x3c, 0x3c, 0x3c), + }, +]; + +/// The site's `DEFAULT_THEME`, so the two do not drift. +pub const DEFAULT: &str = "tokyo-night"; diff --git a/packages/tui/src/tool.rs b/packages/tui/src/tool.rs index b65d4dd3..ee7bb1c8 100644 --- a/packages/tui/src/tool.rs +++ b/packages/tui/src/tool.rs @@ -3,140 +3,155 @@ //! transcript shows. //! //! Everything here is pure and resolved once, when the event arrives, so the -//! renderer never re-parses JSON per frame. The only tool-specific knowledge is -//! a small name switch; a tool whose arguments we cannot read (a plugin's, say) -//! degrades to its label rather than failing, and every field read is -//! best-effort because `details` is arbitrary tool-defined JSON. +//! renderer never re-parses JSON per frame. use serde_json::Value; use crate::diff; -use crate::types::ToolResult; -/// Consecutive calls to one tool at or above this count collapse into one row. +/// Consecutive settled calls to one tool at or above this count collapse into one row. pub const GROUP_MIN_RUN: usize = 3; /// How many distinct targets a collapsed run lists before `…`. const RUN_TITLES_MAX: usize = 3; /// A path longer than this is shortened to its last two components. -const PATH_MAX_CHARS: usize = 40; +const PATH_MAX_CHARS: usize = 48; -/// What the call touched: `bash` → its command, `read`/`edit` → the path, -/// `search` → the query. `None` when the tool's arguments say nothing readable, -/// which is the normal case for a plugin tool. -pub fn target(name: &str, arguments: &Value) -> Option { - let arg = |key: &str| arguments.get(key).and_then(Value::as_str); - let derived = match name { - // The command is the single most useful thing to show for bash, and it is - // routinely multi-line: one line of layout is all the transcript has. - "bash" => arg("command").map(collapse_whitespace), - "read" | "edit" => arg("path").map(shorten_path), - "search" => arg("query").map(|query| { - if arg("mode") == Some("files") { - format!("files: {}", collapse_whitespace(query)) - } else { - collapse_whitespace(query) - } - }), - _ => None, - }; - derived.filter(|value| !value.is_empty()) -} - -/// Builds the display record for one settled call. -/// -/// `arguments` and `details` come straight off the wire and a missing or -/// unexpected field only ever means a plainer line, never an error. -pub fn derive( - name: &str, - label: Option<&str>, - arguments: &Value, - details: Option<&Value>, - is_error: bool, -) -> ToolResult { - let patch = string_field(details, "patch"); - let first_changed_line = details - .and_then(|details| details.get("firstChangedLine")) - .and_then(Value::as_u64); - let call_path = arguments.get("path").and_then(Value::as_str); - let summary = match &patch { - // An edit's summary *is* its diff: the counts are the whole point. - Some(patch) => Some(edit_summary(patch, call_path, first_changed_line)), - None => summary(name, details, is_error), - }; - - ToolResult { - name: name.to_string(), - target: target(name, arguments), - label: label.unwrap_or(name).to_string(), - summary, - is_error, - patch, - first_changed_line, - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Completed, + Failed, } -/// Carries the outcome from a settle onto the record of the call that was -/// running, keeping the identity the running line already resolved: the terminal -/// part repeats the arguments but not the tool's declared label. -pub fn settle(mut running: ToolResult, settled: ToolResult) -> ToolResult { - running.is_error = settled.is_error; - running.summary = settled.summary; - running.patch = settled.patch; - running.first_changed_line = settled.first_changed_line; - running +#[derive(Debug, Clone)] +pub struct ToolCall { + pub call_id: String, + pub name: String, + pub target: Option, + pub label: String, + pub summary: Option, + pub output: Option, + pub patch: Option, + pub status: ToolStatus, } -/// A short result summary — line window, exit code, match counts — or `None` -/// when the tool's details say nothing worth a line. -pub fn summary(name: &str, details: Option<&Value>, is_error: bool) -> Option { - let details = details?; - if is_error { - return failure_summary(details); +impl ToolCall { + pub fn start( + call_id: String, + name: String, + label: Option, + arguments: &Value, + ) -> Self { + let target = target(&name, arguments); + let label = label.unwrap_or_else(|| name.clone()); + Self { + call_id, + name, + target, + label, + summary: None, + output: None, + patch: None, + status: ToolStatus::Running, + } } - match name { - "read" => read_summary(details), - "bash" => bash_summary(details), - "search" => search_summary(details), - _ => None, + + #[allow(clippy::too_many_arguments)] + pub fn settled( + call_id: String, + name: Option, + arguments: &Value, + output: Option, + details: Option<&Value>, + is_error: bool, + ) -> Self { + let tool_name = name.unwrap_or_else(|| "tool".to_string()); + let mut call = Self::start(call_id, tool_name, None, arguments); + call.apply_settlement(None, arguments, output, details, is_error); + call + } + + pub fn apply_settlement( + &mut self, + name: Option, + arguments: &Value, + output: Option, + details: Option<&Value>, + is_error: bool, + ) { + if let Some(name) = name { + self.name = name; + } + if self.target.is_none() { + self.target = target(&self.name, arguments); + } + let patch = string_field(details, "patch"); + let first_changed_line = details + .and_then(|d| d.get("firstChangedLine")) + .and_then(Value::as_u64); + let call_path = arguments.get("path").and_then(Value::as_str); + + self.summary = match &patch { + Some(patch) => Some(edit_summary(patch, call_path, first_changed_line)), + None => summary(&self.name, details, is_error), + }; + self.patch = patch; + self.output = output; + self.status = if is_error { + ToolStatus::Failed + } else { + ToolStatus::Completed + }; } } -/// One row of a turn's settled tool list. +/// One row of a turn's tool list. #[derive(Debug, Clone)] pub enum ToolRow<'a> { - /// A call rendered on its own: heading, plus a diff when it produced one. - Single(&'a ToolResult), - /// [`GROUP_MIN_RUN`] or more consecutive calls to the same tool. + /// A call rendered on its own: heading, summary, plus a diff when it produced one. + Single(&'a ToolCall), + /// [`GROUP_MIN_RUN`] or more consecutive settled calls to the same non-diff tool. Run { name: String, - members: Vec<&'a ToolResult>, - /// How many of `members` failed. + members: Vec<&'a ToolCall>, failed: usize, }, } -/// Collapses runs of consecutive calls to the same tool, so a long stretch of +/// Collapses runs of consecutive settled calls to the same tool, so a stretch of /// `read`, `read`, `read` costs one line instead of twenty. /// -/// A run is never collapsed when any of its calls produced a diff: hiding a -/// change behind a count would defeat the point of rendering diffs at all. -pub fn group(tools: &[ToolResult]) -> Vec> { +/// Running calls and calls with a diff are never collapsed. +pub fn group(tools: &[ToolCall]) -> Vec> { let mut rows = Vec::new(); let mut index = 0; while index < tools.len() { - let name = tools[index].name.as_str(); + let first = &tools[index]; + if first.status == ToolStatus::Running || first.patch.is_some() { + rows.push(ToolRow::Single(first)); + index += 1; + continue; + } + let name = first.name.as_str(); let mut end = index + 1; - while end < tools.len() && tools[end].name.as_str() == name { + while end < tools.len() + && tools[end].name.as_str() == name + && tools[end].status != ToolStatus::Running + && tools[end].patch.is_none() + { end += 1; } let run = &tools[index..end]; - if run.len() >= GROUP_MIN_RUN && run.iter().all(|tool| tool.patch.is_none()) { + if run.len() >= GROUP_MIN_RUN { rows.push(ToolRow::Run { name: name.to_string(), members: run.iter().collect(), - failed: run.iter().filter(|tool| tool.is_error).count(), + failed: run + .iter() + .filter(|tool| tool.status == ToolStatus::Failed) + .count(), }); } else { rows.extend(run.iter().map(ToolRow::Single)); @@ -148,7 +163,7 @@ pub fn group(tools: &[ToolResult]) -> Vec> { /// The distinct targets a collapsed run touched, in first-seen order, capped at /// [`RUN_TITLES_MAX`] with a trailing `…` when more were involved. -pub fn run_targets(members: &[&ToolResult]) -> String { +pub fn run_targets(members: &[&ToolCall]) -> String { let mut distinct: Vec<&str> = Vec::new(); for member in members { let target = member.target.as_deref().unwrap_or(&member.label); @@ -164,6 +179,43 @@ pub fn run_targets(members: &[&ToolResult]) -> String { text } +/// What the call touched: `bash` → its command, `read`/`edit`/`write` → the path, +/// `search`/`grep`/`glob` → the query or pattern. +pub fn target(name: &str, arguments: &Value) -> Option { + let arg = |key: &str| arguments.get(key).and_then(Value::as_str); + let derived = match name { + "bash" => arg("command").map(collapse_whitespace), + "read" | "edit" | "write" => arg("path").map(shorten_path), + "search" => arg("query").map(|query| { + if arg("mode") == Some("files") { + format!("files: {}", collapse_whitespace(query)) + } else { + collapse_whitespace(query) + } + }), + _ => ["path", "pattern", "query", "url", "command"] + .into_iter() + .find_map(arg) + .map(collapse_whitespace), + }; + derived.filter(|value| !value.is_empty()) +} + +/// A short result summary — line window, exit code, match counts — or `None` +/// when the tool's details say nothing worth a line. +pub fn summary(name: &str, details: Option<&Value>, is_error: bool) -> Option { + let details = details?; + if is_error { + return failure_summary(details); + } + match name { + "read" => read_summary(details), + "bash" => bash_summary(details), + "search" => search_summary(details), + _ => None, + } +} + fn string_field(details: Option<&Value>, key: &str) -> Option { details?.get(key)?.as_str().map(str::to_string) } @@ -172,8 +224,6 @@ fn number(details: &Value, key: &str) -> Option { details.get(key).and_then(Value::as_u64) } -/// A declared failure: a message when the tool wrote one, otherwise whatever -/// structured fact it did leave behind. fn failure_summary(details: &Value) -> Option { if let Some(message) = string_field(Some(details), "message") { return Some(collapse_whitespace(&message)); @@ -184,8 +234,6 @@ fn failure_summary(details: &Value) -> Option { if let Some(code) = number(details, "exitCode") { return Some(format!("exit {code}")); } - // `reason` for the read/search/edit tools, `error` for an argument or - // unknown-tool rejection: both arrive as snake_case identifiers. string_field(Some(details), "reason") .or_else(|| string_field(Some(details), "error")) .map(|value| value.replace('_', " ")) @@ -211,8 +259,6 @@ fn bash_summary(details: &Value) -> Option { if let Some(code) = number(details, "exitCode") { parts.push(format!("exit {code}")); } - // How much output there was is the other thing worth knowing: it is how a - // reader spots that a command said far more than the line shows. if let Some(lines) = string_field(Some(details), "output") .map(|output| output.lines().count()) .filter(|lines| *lines > 1) @@ -248,8 +294,6 @@ fn search_summary(details: &Value) -> Option { Some(text) } -/// `call_path` is the path the call declared, which the heading already shows; -/// repeating it here would say the same thing twice. fn edit_summary(patch: &str, call_path: Option<&str>, first_changed_line: Option) -> String { let counts = diff::counts(patch); let mut parts = vec![format!("+{} −{}", counts.added, counts.removed)]; @@ -264,14 +308,10 @@ fn edit_summary(patch: &str, call_path: Option<&str>, first_changed_line: Option parts.join(" · ") } -/// Flattens all whitespace — including the newlines in a multi-line command — -/// so the value can sit on one row. fn collapse_whitespace(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } -/// Keeps the tail of a long path (`…/plugin/builtin/edit.ts`), which is the part -/// that identifies it, instead of letting the renderer cut the file name off. fn shorten_path(path: &str) -> String { if path.chars().count() <= PATH_MAX_CHARS { return path.to_string(); @@ -285,313 +325,3 @@ fn shorten_path(path: &str) -> String { } format!("…/{}", tail.last().copied().unwrap_or(path)) } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn call(name: &str, target: &str) -> ToolResult { - ToolResult { - name: name.to_string(), - target: Some(target.to_string()), - label: name.to_string(), - summary: None, - is_error: false, - patch: None, - first_changed_line: None, - } - } - - #[test] - fn bash_target_is_its_command_on_one_line() { - let args = json!({ "command": "npm test \\\n --watch\t--run" }); - assert_eq!(target("bash", &args).as_deref(), Some("npm test \\ --watch --run")); - } - - #[test] - fn read_and_edit_targets_are_the_path() { - let args = json!({ "path": "src/app.ts" }); - assert_eq!(target("read", &args).as_deref(), Some("src/app.ts")); - assert_eq!(target("edit", &args).as_deref(), Some("src/app.ts")); - } - - #[test] - fn search_target_marks_files_mode() { - assert_eq!(target("search", &json!({ "query": "useState" })).as_deref(), Some("useState")); - assert_eq!( - target("search", &json!({ "query": "*.test.ts", "mode": "files" })).as_deref(), - Some("files: *.test.ts") - ); - } - - #[test] - fn long_paths_keep_their_tail() { - let long = "packages/harness/src/plugin/builtin/tool/edit.ts"; - let shortened = target("read", &json!({ "path": long })).expect("a target"); - assert!(shortened.chars().count() <= PATH_MAX_CHARS, "{shortened}"); - assert!(shortened.ends_with("/tool/edit.ts"), "{shortened}"); - assert!(shortened.starts_with('…'), "{shortened}"); - } - - #[test] - fn unreadable_arguments_have_no_target() { - // A plugin tool, a tool with no arguments, a null value, a blank command. - assert_eq!(target("acme_echo", &json!({ "anything": 1 })), None); - assert_eq!(target("bash", &Value::Null), None); - assert_eq!(target("bash", &json!({ "command": "" })), None); - assert_eq!(target("bash", &json!({ "command": " \n " })), None); - } - - #[test] - fn heading_composes_the_name_and_target() { - let mut result = call("read", "src/app.ts"); - assert_eq!(result.heading(), "read src/app.ts"); - // With no readable target the label stands alone, as it did before. - result.target = None; - result.label = "echo".to_string(); - assert_eq!(result.heading(), "echo"); - } - - #[test] - fn derive_reads_the_line_window() { - let result = derive( - "read", - None, - &json!({ "path": "src/app.ts", "offset": 120, "limit": 140 }), - Some(&json!({ - "content": "1|…", - "truncated": false, - "path": "src/app.ts", - "startLine": 120, - "endLine": 260, - "totalLines": 431, - })), - false, - ); - assert_eq!(result.heading(), "read src/app.ts"); - assert_eq!(result.summary.as_deref(), Some("120-260 of 431")); - assert!(result.patch.is_none()); - } - - #[test] - fn read_summary_says_truncated() { - let details = json!({ - "truncated": true, - "startLine": 1, - "endLine": 200, - "totalLines": 900, - }); - assert_eq!( - summary("read", Some(&details), false).as_deref(), - Some("1-200 of 900 · truncated") - ); - } - - #[test] - fn bash_success_reports_exit_code_and_output_size() { - let details = json!({ "output": "a\nb\nc\n", "truncated": false, "exitCode": 0 }); - assert_eq!(summary("bash", Some(&details), false).as_deref(), Some("exit 0 · 3 lines")); - } - - #[test] - fn bash_single_line_output_reports_only_the_exit_code() { - let details = json!({ "output": "ok\n", "truncated": false, "exitCode": 0 }); - assert_eq!(summary("bash", Some(&details), false).as_deref(), Some("exit 0")); - } - - #[test] - fn search_summary_counts_files_and_matches() { - let details = json!({ - "matches": 17, - "files": 3, - "truncated": false, - "hasMore": false, - "backend": "ripgrep", - }); - assert_eq!( - summary("search", Some(&details), false).as_deref(), - Some("3 files · 17 matches") - ); - } - - #[test] - fn search_summary_pluralises_and_flags_more() { - let details = json!({ "matches": 1, "files": 1, "truncated": false, "hasMore": true }); - assert_eq!( - summary("search", Some(&details), false).as_deref(), - Some("1 file · 1 match · more") - ); - } - - #[test] - fn failure_summary_prefers_the_message() { - let details = json!({ - "path": "src/app.ts", - "reason": "no_match", - "message": "oldText not found\nin src/app.ts", - }); - assert_eq!( - summary("edit", Some(&details), true).as_deref(), - Some("oldText not found in src/app.ts") - ); - } - - #[test] - fn failure_summary_falls_back_to_structured_fields() { - let bash = json!({ "output": "boom", "truncated": false, "exitCode": 127 }); - assert_eq!(summary("bash", Some(&bash), true).as_deref(), Some("exit 127")); - - let timeout = json!({ "output": "", "truncated": false, "timeoutSeconds": 30 }); - assert_eq!(summary("bash", Some(&timeout), true).as_deref(), Some("timed out after 30s")); - - let rejected = json!({ "error": "invalid_arguments", "name": "read" }); - assert_eq!(summary("read", Some(&rejected), true).as_deref(), Some("invalid arguments")); - } - - #[test] - fn edit_summary_comes_from_the_patch_without_repeating_the_path() { - let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n"; - let result = derive( - "edit", - None, - &json!({ "path": "src/a.ts" }), - Some(&json!({ "patch": patch, "path": "src/a.ts", "editsApplied": 1, "created": false, "firstChangedLine": 2 })), - false, - ); - assert_eq!(result.heading(), "edit src/a.ts"); - assert_eq!(result.summary.as_deref(), Some("+1 −1 · line 2")); - assert_eq!(result.patch.as_deref(), Some(patch)); - assert_eq!(result.first_changed_line, Some(2)); - } - - #[test] - fn edit_summary_keeps_the_path_when_the_call_did_not_declare_one() { - let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; - assert_eq!( - edit_summary(patch, None, None), - "+1 −1 · src/a.ts", - "with no heading path the summary must name the file" - ); - } - - #[test] - fn missing_details_plainly_degrade() { - let result = derive("read", None, &json!({ "path": "src/app.ts" }), None, false); - assert!(result.summary.is_none()); - assert!(result.patch.is_none()); - assert!(result.first_changed_line.is_none()); - - let unknown = derive("acme_echo", None, &Value::Null, Some(&json!({ "stdout": "hi" })), false); - assert_eq!(unknown.heading(), "acme_echo"); - assert!(unknown.summary.is_none()); - } - - #[test] - fn settle_keeps_the_running_identity_and_takes_the_outcome() { - // The terminal part omits `arguments`, so a settled record built from it - // alone would lose the target; the running record supplies it. - let running = derive("read", Some("read"), &json!({ "path": "src/app.ts" }), None, false); - - let ok = settle( - running.clone(), - derive( - "read", - None, - &Value::Null, - Some(&json!({ "startLine": 1, "endLine": 10, "totalLines": 10, "truncated": false })), - false, - ), - ); - assert_eq!(ok.heading(), "read src/app.ts"); - assert_eq!(ok.summary.as_deref(), Some("1-10 of 10")); - assert!(!ok.is_error); - - let failed = settle( - running, - derive( - "read", - None, - &Value::Null, - Some(&json!({ "path": "src/app.ts", "reason": "not_found", "message": "File not found: src/app.ts" })), - true, - ), - ); - assert_eq!(failed.heading(), "read src/app.ts"); - assert!(failed.is_error); - assert_eq!(failed.summary.as_deref(), Some("File not found: src/app.ts")); - } - - #[test] - fn collapses_a_run_of_three_or_more() { - let tools = vec![ - call("read", "a.rs"), - call("read", "b.rs"), - call("read", "c.rs"), - call("read", "d.rs"), - ]; - let rows = group(&tools); - assert_eq!(rows.len(), 1); - match &rows[0] { - ToolRow::Run { name, members, failed } => { - assert_eq!(name, "read"); - assert_eq!(members.len(), 4); - assert_eq!(*failed, 0); - assert_eq!(run_targets(members), "a.rs, b.rs, c.rs, …"); - } - other => panic!("expected a run, got {other:?}"), - } - } - - #[test] - fn keeps_short_runs_and_different_tools_separate() { - let tools = vec![call("read", "a.rs"), call("read", "b.rs"), call("bash", "c")]; - let rows = group(&tools); - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| matches!(row, ToolRow::Single(_)))); - } - - #[test] - fn never_collapses_a_run_containing_a_diff() { - let mut edited = call("edit", "src/a.ts"); - edited.patch = Some("--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n".to_string()); - let tools = vec![call("edit", "b.ts"), edited, call("edit", "c.ts")]; - let rows = group(&tools); - assert_eq!(rows.len(), 3); - assert!(rows.iter().all(|row| matches!(row, ToolRow::Single(_)))); - } - - #[test] - fn a_run_reports_its_failures() { - let mut failed = call("bash", "npm test"); - failed.is_error = true; - let tools = vec![call("bash", "npm test"), failed, call("bash", "npm test")]; - let rows = group(&tools); - assert_eq!(rows.len(), 1); - match &rows[0] { - ToolRow::Run { failed, .. } => assert_eq!(*failed, 1), - other => panic!("expected a run, got {other:?}"), - } - } - - #[test] - fn run_targets_are_distinct_and_capped() { - let tools = vec![ - call("read", "a.rs"), - call("read", "a.rs"), - call("read", "b.rs"), - call("read", "c.rs"), - call("read", "d.rs"), - ]; - let rows = group(&tools); - match &rows[0] { - ToolRow::Run { members, .. } => assert_eq!(run_targets(members), "a.rs, b.rs, c.rs, …"), - other => panic!("expected a run, got {other:?}"), - } - } - - #[test] - fn grouping_an_empty_list_is_empty() { - assert!(group(&[]).is_empty()); - } -} diff --git a/packages/tui/src/tui.rs b/packages/tui/src/tui.rs new file mode 100644 index 00000000..b1ac5a23 --- /dev/null +++ b/packages/tui/src/tui.rs @@ -0,0 +1,152 @@ +//! Terminal lifecycle. +//! +//! Everything that takes over the user's terminal — raw mode, the alternate +//! screen, the input modes we switch on, the background colour — is turned on +//! here and turned off in exactly one place. `restore` is idempotent and is +//! reached from three directions: the guard's `Drop`, the panic hook, and normal +//! shutdown, so a panic or an early return cannot leave a shell with no echo. + +use std::io::{self, Stdout, Write}; +use std::panic; +use std::sync::Once; + +use crossterm::cursor::Show; +use crossterm::event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, +}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, EndSynchronizedUpdate, + EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::style::Color; +use ratatui::{Frame, Terminal}; + +pub type Tui = Terminal>; + +/// Escape hatch for a terminal that garbles the keyboard protocol (VS Code over +/// WSL is the known one, where it breaks dead-key composition). +const NO_KEYBOARD_ENHANCEMENT: &str = "CODEWORK_TUI_NO_KEYBOARD_ENHANCEMENT"; + +/// The terminal, owned for as long as the UI runs. Dropping it hands the +/// terminal back. +pub struct TerminalGuard { + terminal: Tui, + background: Color, +} + +impl TerminalGuard { + /// Take over the terminal. + pub fn init(background: Color) -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + + let mut stdout = io::stdout(); + // Bracketed paste and focus events are declared here rather than when a + // widget first needs them: a terminal that is not told about bracketed + // paste cannot be un-told, and by the time a paste arrives it is already + // indistinguishable from typing. + execute!( + stdout, + EnterAlternateScreen, + EnableBracketedPaste, + EnableFocusChange, + EnableMouseCapture + )?; + set_background(&mut stdout, background)?; + + // Kitty's keyboard protocol is what makes modified keys (ctrl+enter, + // shift+tab) distinguishable from their unmodified forms. Push it + // without asking first: `supports_keyboard_enhancement` writes a query + // and reads the reply off stdin, which blocks for its full timeout on + // unix and would stall startup by seconds. A terminal that does not + // speak the protocol ignores the push, so the request is harmless. + if std::env::var_os(NO_KEYBOARD_ENHANCEMENT).is_none() { + let _ = execute!( + stdout, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + } + + let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; + terminal.clear()?; + Ok(Self { + terminal, + background, + }) + } + + /// Draw one frame inside a synchronized update. + /// + /// The terminal holds the previous screen until the update ends, so a slow + /// repaint shows one whole frame instead of a half-written one. + pub fn draw(&mut self, background: Color, render: impl FnOnce(&mut Frame)) -> io::Result<()> { + execute!(self.terminal.backend_mut(), BeginSynchronizedUpdate)?; + let drawn = (|| { + if background != self.background { + set_background(self.terminal.backend_mut(), background)?; + self.background = background; + } + self.terminal.draw(render).map(|_| ()) + })(); + let _ = execute!(self.terminal.backend_mut(), EndSynchronizedUpdate); + drawn + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore(); + } +} + +/// Give the terminal back. Safe to call more than once, and safe to call before +/// init has finished — every step is best-effort, because the alternative to a +/// failed restore is a unusable shell. +pub fn restore() { + let mut stdout = io::stdout(); + // End any synchronized update first: a panic can land between begin and end, + // and a terminal left in that mode stops repainting. Popping a keyboard + // enhancement level that was never pushed is a no-op. + let _ = execute!( + stdout, + EndSynchronizedUpdate, + PopKeyboardEnhancementFlags, + DisableMouseCapture, + DisableBracketedPaste, + DisableFocusChange, + LeaveAlternateScreen + ); + let _ = execute!(stdout, Show); + // Hand back the background we claimed, then reset the pen. + let _ = stdout.write_all(b"\x1b]111\x07\x1b[0m"); + let _ = stdout.flush(); + let _ = disable_raw_mode(); +} + +/// Restore the terminal *before* any other panic hook prints, so the message +/// lands on a normal screen rather than inside a raw-mode alternate buffer. +fn install_panic_hook() { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + restore(); + previous(info); + })); + }); +} + +/// Point the terminal's own background at the app's colour (OSC 11), so no +/// lighter gutter shows through while the screen is painted. +fn set_background(stdout: &mut impl Write, color: Color) -> io::Result<()> { + let Color::Rgb(red, green, blue) = color else { + return Ok(()); + }; + write!(stdout, "\x1b]11;#{red:02X}{green:02X}{blue:02X}\x07")?; + write!(stdout, "\x1b[48;2;{red};{green};{blue}m")?; + stdout.flush() +} diff --git a/packages/tui/src/types.rs b/packages/tui/src/types.rs index 71ea7904..96e7a006 100644 --- a/packages/tui/src/types.rs +++ b/packages/tui/src/types.rs @@ -1,195 +1,146 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelConfig { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelRef { pub provider: String, - pub model: String, - #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")] - pub updated_at: Option, - /// Selected UI theme id (e.g. "tokyo-night"); absent means the default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub theme: Option, + pub id: String, } -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelEntry { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectInfo { pub id: String, pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(rename = "isDefault", default)] - pub is_default: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ModelCost { - #[serde(skip_serializing_if = "Option::is_none")] - pub input: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output: Option, - #[serde(rename = "cacheRead", skip_serializing_if = "Option::is_none")] - pub cache_read: Option, - #[serde(rename = "cacheWrite", skip_serializing_if = "Option::is_none")] - pub cache_write: Option, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxInfo { + pub id: String, + pub driver: String, + pub kind: String, + #[serde( + rename = "providerResourceId", + default, + skip_serializing_if = "Option::is_none" + )] + pub provider_resource_id: Option, + pub ownership: String, + pub status: String, + pub usage: String, + #[serde(rename = "refCount", default)] + pub ref_count: u64, + #[serde(rename = "createdAt", default)] + pub created_at: String, + #[serde(rename = "updatedAt", default)] + pub updated_at: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelDetails { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionInfo { pub id: String, - pub name: String, - pub provider_id: String, - pub provider_name: String, - pub env_keys: Vec, - pub base_url: Option, - pub npm: Option, - pub reasoning: bool, - pub input_modalities: Vec, - pub cost: Option, - pub context_window: Option, - pub max_tokens: Option, - pub family: String, - pub status: String, - pub docs_url: Option, - pub released: Option, - pub updated: Option, + pub title: String, + pub directory: String, + #[serde(rename = "hostDir", default, skip_serializing_if = "Option::is_none")] + pub host_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(rename = "updatedAt", default)] + pub updated_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderSummary { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoryEntry { pub id: String, - pub name: String, - pub model_count: usize, - pub env_keys: Vec, - pub base_url: Option, - pub npm: Option, - pub docs_url: Option, + #[serde(default)] + pub seq: u64, + pub role: String, + #[serde(default = "default_entry_state")] + pub state: String, + #[serde(rename = "createdAt", default)] + pub created_at: u64, + #[serde(default)] + pub parts: Vec, +} + +fn default_entry_state() -> String { + "committed".to_string() } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FocusedPanel { - Providers, - Models, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Part { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + }, + #[serde(rename = "thinking")] + Thinking { + thinking: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + redacted: Option, + }, + #[serde(rename = "toolCall")] + ToolCall { + #[serde(rename = "callID")] + call_id: String, + name: String, + #[serde(default)] + arguments: Value, + status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + time: Option, + }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModelSortMode { - Id, - Provider, - Cost, - Context, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolResult { + #[serde(default)] + pub content: Vec, + #[serde(rename = "isError", default)] + pub is_error: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, } -#[allow(dead_code)] -#[derive(Debug, Clone)] -pub struct ProviderDefinition { - pub id: &'static str, - pub name: &'static str, - pub description: &'static str, - pub env_keys: &'static [&'static str], - pub default_model: &'static str, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ToolResultContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionInfo { - pub id: String, - pub title: String, - pub directory: String, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolTime { + pub start: u64, + pub end: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct UsageReport { #[serde(rename = "totalTokens", default)] pub total_tokens: u64, - #[serde(rename = "inputTokens", default)] + #[serde(rename = "inputTokens", alias = "input", default)] pub input_tokens: u64, - #[serde(rename = "outputTokens", default)] + #[serde(rename = "outputTokens", alias = "output", default)] pub output_tokens: u64, #[serde(default)] pub cost: f64, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, } - -/// A tool call as the transcript shows it: one settled call, plus whatever its -/// result `details` exposed. -/// -/// `target` and `summary` are derived once, when the event arrives (see -/// `crate::tool`), so rendering never re-parses JSON per frame. `patch` is a -/// standard unified diff (the `edit` tool emits one); a tool that produces no -/// diff simply leaves it `None`. -#[derive(Debug, Clone)] -pub struct ToolResult { - /// Raw tool name: the row's identity, and what grouping keys on. - pub name: String, - /// What the call touched — its command, path, or query — read from the tool - /// call's arguments. `None` when they are unreadable, including for every - /// plugin tool whose argument shape we do not know. - pub target: Option, - /// Display fallback when there is no `target`: the tool's declared label. - pub label: String, - /// A short result summary: line window, exit code, match counts, +/− counts. - pub summary: Option, - pub is_error: bool, - pub patch: Option, - pub first_changed_line: Option, -} - -impl ToolResult { - /// The row's heading: the tool name plus what it touched, or just the label - /// when the arguments were unreadable. - pub fn heading(&self) -> String { - match &self.target { - Some(target) => format!("{} {}", self.name, target), - None => self.label.clone(), - } - } -} - -#[derive(Debug, Clone)] -pub struct ConversationTurn { - #[allow(dead_code)] - pub id: String, - pub prompt: String, - pub response: String, - pub thinking: String, - pub streaming: bool, - pub start_time: i64, - pub end_time: Option, - pub tokens: u64, - pub tokens_per_sec: f64, - pub model_name: String, - pub error: Option, - /// The tool call that is currently running, titled from its arguments. - pub active_tool: Option, - pub active_call_id: Option, - pub completed_tools: Vec, -} - -impl ConversationTurn { - pub fn new(id: String, prompt: String, model_name: String) -> Self { - Self { - id, - prompt, - response: String::new(), - thinking: String::new(), - streaming: true, - start_time: chrono::Utc::now().timestamp_millis(), - end_time: None, - tokens: 0, - tokens_per_sec: 0.0, - model_name, - error: None, - active_tool: None, - active_call_id: None, - completed_tools: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct SessionStats { - pub total_tokens: u64, - pub cost: f64, - pub turns_count: usize, -} diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs new file mode 100644 index 00000000..eba10beb --- /dev/null +++ b/packages/tui/src/ui.rs @@ -0,0 +1,152 @@ +//! Rendering. +//! +//! Pure: it reads state and draws. Nothing here mutates the app or performs +//! I/O, which is what makes a frame reproducible from a `TestBackend`. + +mod badge; +mod field; +mod glyphs; +mod input; +mod markdown; +mod menu; +mod session; +pub(crate) mod text; +mod wordmark; + +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; +use ratatui::Frame; + +use crate::app::{App, Popup, Screen}; + +/// The air the site leaves between its wordmark, its hero copy and its install box, in cells. +const GAP: u16 = 2; + +/// The line under the prompt, for each thing that can have the keyboard. Only keys the app answers +/// are listed, so nothing here promises something that does not happen. +const HINTS: &str = "←→ move · / commands · esc quit"; +const COMMAND_HINTS: &str = "↑↓ move · enter choose · esc close"; +const THEME_HINTS: &str = "↑↓ move · type to search · enter choose · esc close"; + +/// Draw the active screen. +pub fn view(app: &App, frame: &mut Frame) { + match app.screen { + Screen::Welcome => view_welcome(app, frame), + Screen::Session => session::draw(app, frame), + } +} + +fn view_welcome(app: &App, frame: &mut Frame) { + let area = frame.area(); + let palette = app.theme; + + // This screen is the site's hero, so it stands on the canvas ground rather than the page one: + // the field is a deeper panel than the page around it, and its lattice ink is mixed from this + // ground. Painted every frame, so a theme switch repaints rather than leaving the previous + // palette in the gaps. + frame.render_widget( + Block::default().style(Style::default().bg(palette.field_bg)), + area, + ); + + // The hero's column, in the order the site stacks it: the wordmark, the prompt box, and the + // line under it. Centred as one block without the menu, so opening or switching a menu never + // shifts the wordmark or the prompt line on screen. + // + // On a frame too short for the whole column it is the wordmark that goes first: the line you + // type into outranks the word above it. + let below = GAP + input::HEIGHT + GAP + 1; + let art_rows = wordmark::rows(area.width) + .unwrap_or(0) + .min(area.height.saturating_sub(below)); + let [art, _, prompt, _, mut hint] = Layout::vertical([ + Constraint::Length(art_rows), + Constraint::Length(GAP), + Constraint::Length(input::HEIGHT), + Constraint::Length(GAP), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(area); + + // The lattice is the wordmark's own grid, so the wordmark decides where it starts. It is laid + // down first, and everything else is drawn over it — though a frame with no room for the + // wordmark still gets it, because the site's field covers the whole canvas either way. + let slot = wordmark::slot(art); + field::paint( + frame.buffer_mut(), + area, + palette, + slot.map_or(0, |slot| slot.x), + ); + if let Some(slot) = slot { + wordmark::draw(frame.buffer_mut(), slot, palette); + } + + let prompt_caret = input::draw(frame.buffer_mut(), prompt, &app.prompt, palette); + let mut menu_caret = None; + if let Some(popup) = &app.popup { + // Hung directly under the prompt box and anchored on its left edge, so it reads as the + // list the line opens into without pushing the hero up. + let anchor = input::centred(area); + let searchable = popup.search().is_some(); + let wanted = menu::height(popup.menu(), searchable); + let room = area.bottom().saturating_sub(prompt.bottom()); + let reserve = if room >= wanted + GAP + 1 { + GAP + 1 + } else { + 2 + }; + let room_with_hint = room.saturating_sub(reserve); + let min_rows = if searchable { 5 } else { 3 }; + let max_height = if room_with_hint >= min_rows { + room_with_hint + } else { + room + }; + let height = wanted.min(max_height); + let width = + menu::width(popup.menu(), searchable).min(area.right().saturating_sub(anchor.x)); + let picker = Rect { + x: anchor.x, + y: prompt.bottom(), + width, + height, + }; + menu_caret = menu::draw( + frame.buffer_mut(), + picker, + popup.title(), + popup.menu(), + popup.search(), + palette, + ); + hint.y = (picker.bottom() + GAP).min(area.bottom().saturating_sub(1)); + } + if let Some(caret) = menu_caret.or(prompt_caret) { + frame.set_cursor_position(caret); + } + // A key the prompt cannot honour is answered here rather than dropped. The ink is on the span + // rather than the paragraph: a paragraph style would be patched onto every cell of the row, + // repainting the lattice behind the line in the hint's colour instead of the grid's. + let hints = match (&app.notice, &app.popup) { + (Some(notice), _) => notice.as_str(), + (None, Some(Popup::Commands(_) | Popup::Thinking(_))) => COMMAND_HINTS, + (None, Some(Popup::Themes { .. } | Popup::Models { .. } | Popup::Sessions { .. })) => { + THEME_HINTS + } + (None, None) => HINTS, + }; + if hint.y < area.bottom() { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + hints, + Style::default().fg(palette.text_muted), + ))) + .alignment(Alignment::Center), + hint, + ); + } +} diff --git a/packages/tui/src/ui/badge.rs b/packages/tui/src/ui/badge.rs new file mode 100644 index 00000000..f8551e12 --- /dev/null +++ b/packages/tui/src/ui/badge.rs @@ -0,0 +1,47 @@ +//! The tag a box clips to its top border. +//! +//! The site pins an `alpha` badge to the install box's top border, and the prompt box and every +//! menu wear the same badge. It is written on the border row itself rather than inside it: that row +//! belongs to the border, so the tag stands on the canvas the way the line does. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; + +use super::text; +use crate::theme::Palette; + +/// Draw `label` as a tag on the top border of `area`, inset from its right corner, in the accent +/// ink. The cells either side of the label are written with it, because they are part of the tag: +/// they clear the border line underneath. +pub fn draw(buffer: &mut Buffer, area: Rect, label: &str, palette: &Palette) { + draw_right(buffer, area, label, Style::default().fg(palette.brand)); +} + +/// Draw `label` as a tag on the top border of `area`, inset from its left corner. +pub fn draw_left(buffer: &mut Buffer, area: Rect, label: &str, ink: Style) { + let width = text::width(label) as u16 + 2; + if width + 4 > area.width { + return; + } + let at = area.x + 2; + write_tag(buffer, at, area.y, width, label, ink); +} + +/// Draw `label` as a tag on the top border of `area`, inset from its right corner. +pub fn draw_right(buffer: &mut Buffer, area: Rect, label: &str, ink: Style) { + let width = text::width(label) as u16 + 2; + // The inset is the site's, and a border too short for the tag keeps its line rather than + // wearing half a label. + if width + 2 > area.width { + return; + } + let at = area.right() - width - 2; + write_tag(buffer, at, area.y, width, label, ink); +} + +fn write_tag(buffer: &mut Buffer, at: u16, y: u16, width: u16, label: &str, ink: Style) { + buffer.set_string(at, y, " ", ink); + buffer.set_string(at + 1, y, label, ink); + buffer.set_string(at + width - 1, y, " ", ink); +} diff --git a/packages/tui/src/ui/field.rs b/packages/tui/src/ui/field.rs new file mode 100644 index 00000000..ebd3433b --- /dev/null +++ b/packages/tui/src/ui/field.rs @@ -0,0 +1,45 @@ +//! The hero's lattice. +//! +//! Ported from the landing site's `field.tsx`, whose hero "rests on the grid alone": a hairline +//! grid on the wordmark's own pixel grid, a line every `GRID_CELLS` (two) of its cells, drawn +//! across the whole canvas in an ink mixed 22% from the ground toward the dim ramp step — the +//! palette's `field_grid`, which the generator bakes from that recipe. +//! Everything else that file draws — the drifting noise, the glow that follows the pointer, the +//! mark stamped wherever you click, the extension tiles — belongs to interaction, or to the bare +//! texture of the footer, so none of it is on the hero at rest. +//! +//! A terminal has no hairline, but `─` and `│` are one pixel thick and join edge to edge, so the +//! lattice survives at cell resolution. One cell of the site's grid is one terminal column and half +//! a terminal row — the same pairing that keeps the wordmark square — so a line every two cells is +//! a line every second column, and a line every two rows is a line in every row. The lattice is +//! anchored on the wordmark's origin plus half a cell: that is the phase whose lines land on cell +//! centres, where a box-drawing character draws itself rather than at the cell's edge. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +use crate::theme::Palette; + +/// Each faint grid square contains two by two field cells. +const GRID_CELLS: i32 = 2; + +/// Paint the lattice over `area`, on top of the ground already painted there. `origin` is the +/// column the wordmark's own pixel grid starts at: the site anchors its field and this grid on the +/// wordmark slot, so these are the wordmark's lines rather than an arbitrary lattice. +pub fn paint(buffer: &mut Buffer, area: Rect, palette: &Palette, origin: u16) { + for row in 0..area.height { + for col in 0..area.width { + let x = (area.x + col) as i32 - origin as i32; + let cell = &mut buffer[(area.x + col, area.y + row)]; + // Every row carries the horizontal line and every second column the vertical one, so + // the crossings are the columns one step into each square. + cell.set_char(if x.rem_euclid(GRID_CELLS) == 1 { + '┼' + } else { + '─' + }); + // The ink is the line's own; the background stays whatever the ground painted. + cell.set_fg(palette.field_grid); + } + } +} diff --git a/packages/tui/src/ui/glyphs.rs b/packages/tui/src/ui/glyphs.rs new file mode 100644 index 00000000..730ff8ec --- /dev/null +++ b/packages/tui/src/ui/glyphs.rs @@ -0,0 +1,48 @@ +// @generated by scripts/wordmark.mjs from the landing site's pixel glyphs. Do not edit. +// +// Source: sites/www/src/components/landing/wordmark.ts. Re-run `node scripts/wordmark.mjs` +// after it moves; `--check` fails on drift. + +use super::wordmark::Glyph; + +/// CODEWORK, laid out on the site's pixel grid. +pub static WORDMARK: Glyph = Glyph { + width: 90, + height: 11, + rows: &[ + "111111111100111111110011111111000111111111101100011000110011111111001111111100011000000110", + "111111111101111111111011111111100111111111101100011000110111111111101111111110011000001100", + "110000000001100000011011000000110110000000001100011000110110000001101100000011011000011000", + "110000000001100000011011000000110110000000001100011000110110000001101100000011011000110000", + "110000000001100000011011000000110111111100001100011000110110000001101111111110011111100000", + "110000000001100000011011000000110111111100001100011000110110000001101111111100011111100000", + "110000000001100000011011000000110110000000001100011000110110000001101100011000011000110000", + "110000000001100000011011000000110110000000001110111101110110000001101100001100011000011000", + "111111111101111111111011111111100111111111100111111111100111111111101100000110011000001100", + "111111111100111111110011111111000111111111100011100111000011111111001100000011011000000110", + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + ], +}; + +/// The C-and-cursor logo, on the same grid and small enough to draw where the wordmark cannot fit. +pub static MARK: Glyph = Glyph { + width: 15, + height: 15, + rows: &[ + "111111111111111", + "111111111111111", + "111111111111111", + "111000000000000", + "111000000000000", + "111000000000000", + "111000000000111", + "111000000000111", + "111000000000111", + "111000000000000", + "111000000000000", + "111000000000000", + "111111111111111", + "111111111111111", + "111111111111111", + ], +}; diff --git a/packages/tui/src/ui/input.rs b/packages/tui/src/ui/input.rs index 985f95e9..8fa19dc9 100644 --- a/packages/tui/src/ui/input.rs +++ b/packages/tui/src/ui/input.rs @@ -1,401 +1,102 @@ -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; -use unicode_width::UnicodeWidthChar; - -use crate::ui::theme::Theme; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InputVisualLine { - pub text: String, - pub start_char_idx: usize, - pub char_count: usize, - pub cursor_col: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WrappedInput { - pub lines: Vec, - pub cursor_line: usize, - pub cursor_col: usize, +//! The prompt line. +//! +//! The site puts its install command in a bordered box under the hero copy, and the prompt takes +//! that place and that box: the same `max-w-xl` width, the same strong outline, the same `alpha` +//! tag on the top border. It is only an outline — the site fills its box with a card surface, and +//! a terminal cannot: the fill would sit on half-cells the border does not cover, so the box stands +//! open on the canvas and the lattice runs through it like it does through the word's gaps. +//! +//! It draws the line and *reports* the cell the caret belongs in instead of painting a block +//! there. The terminal's own cursor blinks, sits at the right height to be an input method's +//! anchor, and costs one call. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Widget}; +use unicode_width::UnicodeWidthStr; + +use super::badge; +use super::text::{fitted, scrolled}; +use crate::prompt::Prompt; +use crate::theme::Palette; + +/// The box's own width — the site's `max-w-xl`, an about-72-cell column — and one line inside a +/// border. +pub const WIDTH: u16 = 72; +pub const HEIGHT: u16 = 3; + +/// What opens the line, in the accent ink. Not the site's `$`: that one introduces a shell +/// command, and this one introduces a prompt. +const MARKER: &str = "❯ "; +/// What an empty line shows: the example the site gives for its own CLI. +const PLACEHOLDER: &str = "build me flappy bird"; +/// The tag the site clips to the box's top border, because this TUI is at the same stage the CLI +/// is. +const TAG: &str = "alpha"; + +/// Draw the prompt in `area`, centred to [`WIDTH`], and report where the caret goes. +pub fn draw( + buffer: &mut Buffer, + area: Rect, + prompt: &Prompt, + palette: &Palette, +) -> Option<(u16, u16)> { + draw_in(buffer, centred(area), prompt, palette) } -pub fn wrap_input_with_cursor(input: &str, cursor: usize, max_width: usize) -> WrappedInput { - let max_width = max_width.max(10); - let total_chars = input.chars().count(); - let cursor = cursor.min(total_chars); - - if input.is_empty() { - return WrappedInput { - lines: vec![InputVisualLine { - text: String::new(), - start_char_idx: 0, - char_count: 0, - cursor_col: Some(0), - }], - cursor_line: 0, - cursor_col: 0, - }; - } - - let chars_with_idx: Vec<(char, usize)> = input.chars().enumerate().map(|(i, c)| (c, i)).collect(); - - // 1. Split into logical lines by newline '\n' - let mut raw_lines: Vec<(Vec<(char, usize)>, bool)> = Vec::new(); - let mut cur_line_chars: Vec<(char, usize)> = Vec::new(); - - for (c, idx) in chars_with_idx { - if c == '\n' { - raw_lines.push((std::mem::take(&mut cur_line_chars), true)); - } else { - cur_line_chars.push((c, idx)); - } - } - raw_lines.push((cur_line_chars, false)); - - // 2. Wrap each logical line into visual lines of visual width <= max_width - struct RawVisLine { - chars: Vec<(char, usize)>, - start_char_idx: usize, - ended_with_newline: bool, - } - - let mut vis_lines: Vec = Vec::new(); - let mut current_offset = 0usize; - - for (line_chars, ended_with_newline) in raw_lines { - if line_chars.is_empty() { - vis_lines.push(RawVisLine { - chars: Vec::new(), - start_char_idx: current_offset, - ended_with_newline, - }); - if ended_with_newline { - current_offset += 1; - } - continue; - } - - // Tokenize line into alternating word and whitespace tokens - let mut tokens: Vec> = Vec::new(); - let mut cur_token: Vec<(char, usize)> = Vec::new(); - let mut in_ws = false; - - for (c, idx) in line_chars { - let is_ws = c == ' ' || c == '\t'; - if cur_token.is_empty() { - in_ws = is_ws; - cur_token.push((c, idx)); - } else if in_ws == is_ws { - cur_token.push((c, idx)); - } else { - tokens.push(std::mem::take(&mut cur_token)); - in_ws = is_ws; - cur_token.push((c, idx)); - } - } - if !cur_token.is_empty() { - tokens.push(cur_token); - } - - let mut cur_vis: Vec<(char, usize)> = Vec::new(); - let mut cur_vis_width: usize = 0; - - for token in tokens { - let tok_width: usize = token - .iter() - .map(|(c, _)| UnicodeWidthChar::width(*c).unwrap_or(1)) - .sum(); - - if cur_vis.is_empty() { - if tok_width <= max_width { - cur_vis.extend(token); - cur_vis_width = tok_width; - } else { - for (c, idx) in token { - let cw = UnicodeWidthChar::width(c).unwrap_or(1); - if cur_vis_width + cw > max_width && !cur_vis.is_empty() { - let start_idx = cur_vis.first().unwrap().1; - vis_lines.push(RawVisLine { - chars: std::mem::take(&mut cur_vis), - start_char_idx: start_idx, - ended_with_newline: false, - }); - cur_vis_width = 0; - } - cur_vis.push((c, idx)); - cur_vis_width += cw; - } - } - } else if cur_vis_width + tok_width <= max_width { - cur_vis.extend(token); - cur_vis_width += tok_width; - } else { - let start_idx = cur_vis.first().unwrap().1; - vis_lines.push(RawVisLine { - chars: std::mem::take(&mut cur_vis), - start_char_idx: start_idx, - ended_with_newline: false, - }); - cur_vis_width = 0; - - if tok_width <= max_width { - cur_vis.extend(token); - cur_vis_width = tok_width; - } else { - for (c, idx) in token { - let cw = UnicodeWidthChar::width(c).unwrap_or(1); - if cur_vis_width + cw > max_width && !cur_vis.is_empty() { - let start_idx = cur_vis.first().unwrap().1; - vis_lines.push(RawVisLine { - chars: std::mem::take(&mut cur_vis), - start_char_idx: start_idx, - ended_with_newline: false, - }); - cur_vis_width = 0; - } - cur_vis.push((c, idx)); - cur_vis_width += cw; - } - } - } - } - - if !cur_vis.is_empty() { - let start_idx = cur_vis.first().unwrap().1; - let count = cur_vis.len(); - vis_lines.push(RawVisLine { - chars: cur_vis, - start_char_idx: start_idx, - ended_with_newline, - }); - current_offset = start_idx + count; - if ended_with_newline { - current_offset += 1; - } - } else if let Some(last) = vis_lines.last_mut() { - last.ended_with_newline = ended_with_newline; - if ended_with_newline { - current_offset += 1; - } - } - } - - if vis_lines.is_empty() { - vis_lines.push(RawVisLine { - chars: Vec::new(), - start_char_idx: 0, - ended_with_newline: false, - }); - } - - // 3. Find cursor line and column - let total_lines = vis_lines.len(); - let mut resolved_cursor_line = total_lines - 1; - let mut resolved_cursor_col = vis_lines[resolved_cursor_line].chars.len(); - - for (i, r_line) in vis_lines.iter().enumerate() { - let is_last = i == total_lines - 1; - let start = r_line.start_char_idx; - let count = r_line.chars.len(); - let ended_nl = r_line.ended_with_newline; - - if ended_nl { - if cursor >= start && cursor <= start + count { - resolved_cursor_line = i; - resolved_cursor_col = cursor - start; - break; - } - } else if is_last { - if cursor >= start { - resolved_cursor_line = i; - resolved_cursor_col = (cursor - start).min(count); - break; - } - } else if cursor >= start && cursor < start + count { - resolved_cursor_line = i; - resolved_cursor_col = cursor - start; - break; - } - } - - // 4. Construct output lines - let mut output_lines = Vec::with_capacity(total_lines); - for (i, r_line) in vis_lines.iter().enumerate() { - let text: String = r_line.chars.iter().map(|(c, _)| *c).collect(); - let char_count = r_line.chars.len(); - let cursor_col = if i == resolved_cursor_line { - Some(resolved_cursor_col) - } else { - None - }; - - output_lines.push(InputVisualLine { - text, - start_char_idx: r_line.start_char_idx, - char_count, - cursor_col, - }); - } - - WrappedInput { - lines: output_lines, - cursor_line: resolved_cursor_line, - cursor_col: resolved_cursor_col, - } -} - -pub fn cursor_up_in_input(input: &str, cursor: usize, max_width: usize) -> Option { - let wrapped = wrap_input_with_cursor(input, cursor, max_width); - if wrapped.cursor_line == 0 { - return None; - } - let target_line = wrapped.cursor_line - 1; - let target = &wrapped.lines[target_line]; - let new_col = wrapped.cursor_col.min(target.char_count); - Some(target.start_char_idx + new_col) -} - -pub fn cursor_down_in_input(input: &str, cursor: usize, max_width: usize) -> Option { - let wrapped = wrap_input_with_cursor(input, cursor, max_width); - if wrapped.cursor_line + 1 >= wrapped.lines.len() { +/// Draw the prompt filling `column` directly, and report where the caret goes. `None` when +/// `column` is too small to hold the box or too narrow for the line inside it. +pub fn draw_in( + buffer: &mut Buffer, + column: Rect, + prompt: &Prompt, + palette: &Palette, +) -> Option<(u16, u16)> { + // Measured rather than assumed to be two cells: a terminal that draws `❯` wide would + // otherwise put the caret a column out. + let marker = MARKER.width() as u16; + // Too narrow for a marker and a line, or too short for the border: the box is all or nothing. + if column.width < marker + 2 || column.height < HEIGHT { return None; } - let target_line = wrapped.cursor_line + 1; - let target = &wrapped.lines[target_line]; - let new_col = wrapped.cursor_col.min(target.char_count); - Some(target.start_char_idx + new_col) -} + let block = Block::bordered().border_style(Style::default().fg(palette.border_strong)); + let inner = block.inner(column); + block.render(column, buffer); -pub fn render_line_spans( - line: &InputVisualLine, - cursor_color: Color, - cursor_visible: bool, -) -> Vec> { - let plain_text = |text: String| Span::styled(text, Style::default().fg(Theme::text_primary())); + // The tag rides the top border, as the site's badge does. + badge::draw(buffer, column, TAG, palette); - match line.cursor_col { - // Caret is on this line but in its "off" blink phase: draw the text - // untouched so the caret cell goes blank without shifting the layout. - Some(_) if !cursor_visible => vec![plain_text(line.text.clone())], - None => vec![plain_text(line.text.clone())], - Some(col) => { - let char_count = line.char_count; - let col = col.min(char_count); - let chars: Vec = line.text.chars().collect(); - - if col >= char_count { - vec![ - plain_text(line.text.clone()), - Span::styled("█", Style::default().fg(cursor_color)), - ] - } else { - let before: String = chars[..col].iter().collect(); - let cur_char = chars[col]; - let after: String = chars[col + 1..].iter().collect(); - vec![ - plain_text(before), - Span::styled( - cur_char.to_string(), - Style::default() - .fg(Theme::bg_card()) - .bg(cursor_color) - .add_modifier(Modifier::BOLD), - ), - plain_text(after), - ] - } - } + for x in inner.x..inner.x + inner.width { + buffer[(x, inner.y)].set_char(' '); } -} -#[allow(dead_code)] -pub fn render_input_with_cursor(input: &str, cursor: usize) -> Vec> { - let line = InputVisualLine { - text: input.to_string(), - start_char_idx: 0, - char_count: input.chars().count(), - cursor_col: Some(cursor), + // One column stays free at the end of the line, because that is where a terminal puts the + // caret once the text fills the rest. + let text_x = inner.x + marker; + let usable = inner.width.saturating_sub(marker + 1) as usize; + + buffer.set_string(inner.x, inner.y, MARKER, Style::default().fg(palette.brand)); + let (caret_x, text, ink) = if prompt.text().is_empty() { + (text_x, fitted(PLACEHOLDER, usable), palette.text_muted) + } else { + let (text, offset) = scrolled(prompt.text(), prompt.before_caret(), usable); + (text_x + offset, text, palette.text) }; - render_line_spans(&line, Theme::accent(), true) + buffer.set_string(text_x, inner.y, text, Style::default().fg(ink)); + Some((caret_x, inner.y)) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_input() { - let wrapped = wrap_input_with_cursor("", 0, 40); - assert_eq!(wrapped.lines.len(), 1); - assert_eq!(wrapped.cursor_line, 0); - assert_eq!(wrapped.cursor_col, 0); - assert_eq!(wrapped.lines[0].text, ""); - assert_eq!(wrapped.lines[0].cursor_col, Some(0)); - } - - #[test] - fn test_short_single_line_cursor_positions() { - let text = "hello"; - let w0 = wrap_input_with_cursor(text, 0, 20); - assert_eq!(w0.cursor_line, 0); - assert_eq!(w0.cursor_col, 0); - assert_eq!(w0.lines[0].cursor_col, Some(0)); - - let w2 = wrap_input_with_cursor(text, 2, 20); - assert_eq!(w2.cursor_line, 0); - assert_eq!(w2.cursor_col, 2); - - let w5 = wrap_input_with_cursor(text, 5, 20); - assert_eq!(w5.cursor_line, 0); - assert_eq!(w5.cursor_col, 5); - } - - #[test] - fn test_soft_wrap_words() { - let text = "hello world how are you"; - let wrapped = wrap_input_with_cursor(text, text.len(), 12); - assert!(wrapped.lines.len() >= 2); - let reconstructed: String = wrapped.lines.iter().map(|l| l.text.as_str()).collect(); - assert_eq!(reconstructed, text); - assert_eq!(wrapped.cursor_line, wrapped.lines.len() - 1); - assert_eq!(wrapped.cursor_col, wrapped.lines.last().unwrap().char_count); - } - - #[test] - fn test_long_word_split() { - let text = "abcdefghijklmnopqrstuvwxyz"; - let wrapped = wrap_input_with_cursor(text, 15, 10); - assert_eq!(wrapped.lines.len(), 3); - assert_eq!(wrapped.lines[0].text, "abcdefghij"); - assert_eq!(wrapped.lines[1].text, "klmnopqrst"); - assert_eq!(wrapped.lines[2].text, "uvwxyz"); - assert_eq!(wrapped.cursor_line, 1); - assert_eq!(wrapped.cursor_col, 5); - } - - #[test] - fn test_explicit_newlines() { - let text = "line1\nline2\nline3"; - let wrapped = wrap_input_with_cursor(text, 7, 30); - assert_eq!(wrapped.lines.len(), 3); - assert_eq!(wrapped.lines[0].text, "line1"); - assert_eq!(wrapped.lines[1].text, "line2"); - assert_eq!(wrapped.lines[2].text, "line3"); - // Index 7 is 'i' in "line2" (l=6, i=7) - assert_eq!(wrapped.cursor_line, 1); - assert_eq!(wrapped.cursor_col, 1); - } - - #[test] - fn test_cursor_up_down_navigation() { - let text = "first line\nsecond long line\nthird"; - let max_w = 40; - // Cursor at column 5 on second line ("second long line" starts at 11, col 5 is at 16) - let cur = 16; - let up = cursor_up_in_input(text, cur, max_w); - assert_eq!(up, Some(5)); // col 5 on "first line" (start 0 + 5) - - let down = cursor_down_in_input(text, cur, max_w); - assert_eq!(down, Some(28 + 5)); // third line starts at 28, length 5 +/// The box's own rect: [`WIDTH`] wide and centred in `area`, and no larger than it. +/// +/// A menu under the prompt anchors itself on this, so it opens flush with the box's left edge +/// rather than centred under the middle of the line. +pub fn centred(area: Rect) -> Rect { + let width = WIDTH.min(area.width); + Rect { + x: area.x + (area.width - width) / 2, + y: area.y, + width, + height: area.height.min(HEIGHT), } } diff --git a/packages/tui/src/ui/logo.rs b/packages/tui/src/ui/logo.rs deleted file mode 100644 index e34e89c1..00000000 --- a/packages/tui/src/ui/logo.rs +++ /dev/null @@ -1,125 +0,0 @@ -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; - -use crate::ui::spinner::SPINNER_FRAMES; - -pub const LOGO_LINES: [&str; 5] = [ - "▄██████▄ ▄██ ▄██▄", - "██▀ ███ ████", - "██ ███ █ ████", - "██▄ ██████████", - "▀██████▀ ▀█▀ ▀█▀", -]; - -pub const LOGO_GRADIENT: [Color; 9] = [ - Color::Rgb(243, 139, 168), // Pink #F38BA8 - Color::Rgb(235, 160, 172), // Yellow-ish #EBA0AC - Color::Rgb(215, 175, 215), // Mid Flamingo-Lavender - Color::Rgb(180, 190, 254), // Active Lavender #B4BEFE - Color::Rgb(158, 185, 252), // Mid Lavender-Blue - Color::Rgb(137, 180, 250), // Blue accent #89B4FA - Color::Rgb(126, 189, 243), // Mid Blue-Cyan - Color::Rgb(115, 199, 236), // Cyan #73C7EC - Color::Rgb(166, 227, 161), // Green #A6E3A1 -]; - -pub const LOGO_SPARKLE_FRAMES: &[&str] = &["✦", "✧", "✶", "✷", "✸", "✶", "✧", "✦"]; - -#[allow(dead_code)] -pub fn render_logo_lines(pad_left: usize) -> Vec> { - render_animated_logo_lines(pad_left, 0) -} - -pub fn render_animated_logo_lines(pad_left: usize, tick: usize) -> Vec> { - let mut result = Vec::new(); - let prefix = " ".repeat(pad_left); - for line in LOGO_LINES { - let chars: Vec = line.chars().collect(); - let mut spans = Vec::new(); - if !prefix.is_empty() { - spans.push(Span::raw(prefix.clone())); - } - for (col_idx, &ch) in chars.iter().enumerate() { - let base_idx = ((col_idx as f32 / chars.len() as f32) * LOGO_GRADIENT.len() as f32) as usize; - let color_idx = (base_idx + tick) % LOGO_GRADIENT.len(); - let color = LOGO_GRADIENT[color_idx]; - spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); - } - result.push(Line::from(spans)); - } - result -} - -pub fn render_header_logo(tick: usize, is_streaming: bool) -> Vec> { - let mut spans = Vec::new(); - - // 1. Animated Logo Icon - let icon_frame = if is_streaming { - SPINNER_FRAMES[tick % SPINNER_FRAMES.len()] - } else { - LOGO_SPARKLE_FRAMES[(tick / 2) % LOGO_SPARKLE_FRAMES.len()] - }; - - let icon_color = LOGO_GRADIENT[tick % LOGO_GRADIENT.len()]; - spans.push(Span::styled( - format!("{} ", icon_frame), - Style::default().fg(icon_color).add_modifier(Modifier::BOLD), - )); - - // 2. Animated Wordmark "codework" with flowing gradient wave - let word = "codework"; - for (i, ch) in word.chars().enumerate() { - let color_idx = (i + tick) % LOGO_GRADIENT.len(); - let color = LOGO_GRADIENT[color_idx]; - spans.push(Span::styled( - ch.to_string(), - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); - } - spans.push(Span::raw(" ")); - - spans -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_logo_dimensions() { - assert_eq!(LOGO_LINES.len(), 5); - for line in LOGO_LINES { - assert_eq!(line.chars().count(), 20); - } - } - - #[test] - fn test_render_logo_lines() { - let rendered = render_logo_lines(2); - assert_eq!(rendered.len(), 5); - assert_eq!(rendered[0].spans[0].content, " "); - } - - #[test] - fn test_render_animated_logo_lines() { - let rendered_tick0 = render_animated_logo_lines(0, 0); - let rendered_tick1 = render_animated_logo_lines(0, 1); - assert_eq!(rendered_tick0.len(), 5); - assert_eq!(rendered_tick1.len(), 5); - // Colors should shift between ticks - assert_ne!(rendered_tick0[0].spans[0].style.fg, rendered_tick1[0].spans[0].style.fg); - } - - #[test] - fn test_render_header_logo() { - let spans_idle = render_header_logo(0, false); - assert!(!spans_idle.is_empty()); - assert_eq!(spans_idle[0].content, "✦ "); - // Wordmark should spell "codework" - let word: String = spans_idle[1..=8].iter().map(|s| s.content.as_ref()).collect(); - assert_eq!(word, "codework"); - - let spans_streaming = render_header_logo(0, true); - assert_eq!(spans_streaming[0].content, "⠋ "); - } -} diff --git a/packages/tui/src/ui/markdown.rs b/packages/tui/src/ui/markdown.rs new file mode 100644 index 00000000..394cec35 --- /dev/null +++ b/packages/tui/src/ui/markdown.rs @@ -0,0 +1,501 @@ +//! Pure markdown renderer for assistant responses in the session transcript. +//! +//! Renders fenced code blocks, tables, headings, blockquotes, bulleted and numbered lists, +//! horizontal rules, and inline code/bold/italic/link spans using the active `Palette`. + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthChar; + +use super::text::{elide, width, wrap}; +use crate::theme::Palette; + +pub fn render(text: &str, max_width: usize, palette: &Palette) -> Vec> { + if max_width == 0 { + return Vec::new(); + } + + let mut lines: Vec> = Vec::new(); + let raw_lines: Vec<&str> = text.lines().collect(); + let mut i = 0; + + let border_style = Style::default().fg(palette.border_strong); + let code_bg = palette.surface; + + while i < raw_lines.len() { + let line = raw_lines[i]; + let trimmed = line.trim(); + + // 1. Fenced code block + if trimmed.starts_with("```") { + let lang = trimmed.trim_start_matches('`').trim(); + i += 1; + let mut code_lines = Vec::new(); + while i < raw_lines.len() { + if raw_lines[i].trim().starts_with("```") { + i += 1; + break; + } + code_lines.push(raw_lines[i]); + i += 1; + } + + let box_width = max_width.max(12); + let lang_tag = if !lang.is_empty() { + format!("── [{lang}] ─") + } else { + "──────".to_string() + }; + let top_rule = "─".repeat(box_width.saturating_sub(width(&lang_tag) + 2)); + lines.push(Line::from(vec![Span::styled( + format!("┌{lang_tag}{top_rule}┐"), + border_style, + )])); + + let max_code_w = box_width.saturating_sub(4).max(4); + for c_line in code_lines { + let expanded = c_line.replace('\t', " "); + let display_code = elide(&expanded, max_code_w); + let code_w = width(&display_code); + let pad = max_code_w.saturating_sub(code_w); + + lines.push(Line::from(vec![ + Span::styled("│ ", border_style.bg(code_bg)), + Span::styled( + display_code, + Style::default().fg(palette.text).bg(code_bg), + ), + Span::styled(" ".repeat(pad), Style::default().bg(code_bg)), + Span::styled(" │", border_style.bg(code_bg)), + ])); + } + + let bot_rule = "─".repeat(box_width.saturating_sub(2)); + lines.push(Line::from(vec![Span::styled( + format!("└{bot_rule}┘"), + border_style, + )])); + continue; + } + + // 2. Table block + if is_table_row(trimmed) { + let mut table_rows = Vec::new(); + while i < raw_lines.len() && is_table_row(raw_lines[i]) { + table_rows.push(raw_lines[i]); + i += 1; + } + lines.extend(render_table(&table_rows, max_width, palette)); + continue; + } + + // 3. Headings + if let Some(content) = trimmed + .strip_prefix("# ") + .or_else(|| trimmed.strip_prefix("## ")) + { + lines.push(Line::from(vec![ + Span::styled( + "┃ ", + Style::default() + .fg(palette.brand) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + content.trim().to_string(), + Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD), + ), + ])); + i += 1; + continue; + } + + if let Some(content) = trimmed + .strip_prefix("### ") + .or_else(|| trimmed.strip_prefix("#### ")) + { + lines.push(Line::from(vec![ + Span::styled("│ ", Style::default().fg(palette.text_muted)), + Span::styled( + content.trim().to_string(), + Style::default() + .fg(palette.text_secondary) + .add_modifier(Modifier::BOLD), + ), + ])); + i += 1; + continue; + } + + // 4. Blockquotes + if trimmed.starts_with('>') { + let content = trimmed.trim_start_matches('>').trim(); + let wrap_width = max_width.saturating_sub(2).max(8); + for w_line in wrap(content, wrap_width) { + let mut spans = vec![Span::styled("│ ", Style::default().fg(palette.text_muted))]; + spans.extend(parse_inline(&w_line, palette)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + + // 5. Unordered list + if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { + let indent = (line.len() - line.trim_start().len()).min(12); + let content = &trimmed[2..]; + let wrap_width = max_width.saturating_sub(indent + 2).max(8); + for (w_idx, w_line) in wrap(content, wrap_width).into_iter().enumerate() { + let mut spans = vec![Span::raw(" ".repeat(indent))]; + if w_idx == 0 { + spans.push(Span::styled("• ", Style::default().fg(palette.brand))); + } else { + spans.push(Span::raw(" ")); + } + spans.extend(parse_inline(&w_line, palette)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + + // 6. Ordered list + if let Some(dot_pos) = trimmed.find(". ") { + let num_part = &trimmed[..dot_pos]; + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { + let indent = (line.len() - line.trim_start().len()).min(12); + let prefix = format!("{num_part}. "); + let prefix_len = prefix.len(); + let content = &trimmed[dot_pos + 2..]; + let wrap_width = max_width.saturating_sub(indent + prefix_len).max(8); + for (w_idx, w_line) in wrap(content, wrap_width).into_iter().enumerate() { + let mut spans = vec![Span::raw(" ".repeat(indent))]; + if w_idx == 0 { + spans.push(Span::styled( + prefix.clone(), + Style::default().fg(palette.brand), + )); + } else { + spans.push(Span::raw(" ".repeat(prefix_len))); + } + spans.extend(parse_inline(&w_line, palette)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + } + + // 7. Horizontal rule + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + let hr = "─".repeat(max_width.min(60)); + lines.push(Line::from(vec![Span::styled( + hr, + Style::default().fg(palette.border_subtle), + )])); + i += 1; + continue; + } + + // 8. Empty line + if trimmed.is_empty() { + lines.push(Line::default()); + i += 1; + continue; + } + + // 9. Normal paragraph + for w_line in wrap(line, max_width) { + lines.push(Line::from(parse_inline(&w_line, palette))); + } + i += 1; + } + + lines +} + +pub fn parse_inline(text: &str, palette: &Palette) -> Vec> { + let mut spans = Vec::new(); + let mut chars = text.chars().peekable(); + let mut buffer = String::new(); + let base_style = Style::default().fg(palette.text); + + while let Some(&ch) = chars.peek() { + if ch == '`' { + if !buffer.is_empty() { + spans.push(Span::styled(std::mem::take(&mut buffer), base_style)); + } + chars.next(); + let mut code = String::new(); + for c in chars.by_ref() { + if c == '`' { + break; + } + code.push(c); + } + spans.push(Span::styled( + code, + Style::default().fg(palette.brand).bg(palette.surface_2), + )); + } else if ch == '*' { + chars.next(); + if chars.peek() == Some(&'*') { + chars.next(); + if !buffer.is_empty() { + spans.push(Span::styled(std::mem::take(&mut buffer), base_style)); + } + let mut bold_text = String::new(); + while let Some(c) = chars.next() { + if c == '*' && chars.peek() == Some(&'*') { + chars.next(); + break; + } + bold_text.push(c); + } + spans.push(Span::styled( + bold_text, + Style::default() + .fg(palette.field_crest) + .add_modifier(Modifier::BOLD), + )); + } else { + if !buffer.is_empty() { + spans.push(Span::styled(std::mem::take(&mut buffer), base_style)); + } + let mut italic_text = String::new(); + for c in chars.by_ref() { + if c == '*' { + break; + } + italic_text.push(c); + } + spans.push(Span::styled( + italic_text, + Style::default() + .fg(palette.text_secondary) + .add_modifier(Modifier::ITALIC), + )); + } + } else if ch == '[' { + if !buffer.is_empty() { + spans.push(Span::styled(std::mem::take(&mut buffer), base_style)); + } + chars.next(); + let mut link_text = String::new(); + for c in chars.by_ref() { + if c == ']' { + break; + } + link_text.push(c); + } + if chars.peek() == Some(&'(') { + chars.next(); + for c in chars.by_ref() { + if c == ')' { + break; + } + } + spans.push(Span::styled( + link_text, + Style::default() + .fg(palette.brand) + .add_modifier(Modifier::UNDERLINED), + )); + } else { + buffer.push('['); + buffer.push_str(&link_text); + buffer.push(']'); + } + } else { + buffer.push(ch); + chars.next(); + } + } + + if !buffer.is_empty() { + spans.push(Span::styled(buffer, base_style)); + } + + spans +} + +fn is_table_row(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('|') || (trimmed.contains('|') && trimmed.ends_with('|')) +} + +fn is_delimiter_row(line: &str) -> bool { + let trimmed = line.trim(); + if !is_table_row(trimmed) { + return false; + } + let cells = parse_cells(trimmed); + !cells.is_empty() + && cells.iter().all(|c| { + let s = c.trim(); + !s.is_empty() && s.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ') + }) +} + +fn parse_cells(line: &str) -> Vec { + let mut trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix('|') { + trimmed = rest; + } + if let Some(rest) = trimmed.strip_suffix('|') { + trimmed = rest; + } + trimmed.split('|').map(|c| c.trim().to_string()).collect() +} + +fn spans_visual_width(spans: &[Span]) -> usize { + spans.iter().map(|s| width(s.content.as_ref())).sum() +} + +fn truncate_spans(spans: Vec>, target_w: usize) -> Vec> { + if target_w == 0 { + return Vec::new(); + } + let max_avail = target_w.saturating_sub(1); + let mut result = Vec::new(); + let mut current_w = 0; + + for span in spans { + let span_w = width(span.content.as_ref()); + if current_w + span_w <= max_avail { + current_w += span_w; + result.push(span); + } else { + let mut partial = String::new(); + for ch in span.content.chars() { + let ch_w = ch.width().unwrap_or(0); + if current_w + ch_w <= max_avail { + partial.push(ch); + current_w += ch_w; + } else { + break; + } + } + if !partial.is_empty() { + result.push(Span::styled(partial, span.style)); + } + result.push(Span::styled("…", span.style)); + return result; + } + } + result.push(Span::raw("…")); + result +} + +fn render_table(rows: &[&str], max_width: usize, palette: &Palette) -> Vec> { + if rows.is_empty() { + return Vec::new(); + } + + let parsed_rows: Vec> = rows.iter().map(|r| parse_cells(r)).collect(); + let col_count = parsed_rows.iter().map(|r| r.len()).max().unwrap_or(0); + if col_count == 0 { + return Vec::new(); + } + + let mut col_widths = vec![4usize; col_count]; + for row in &parsed_rows { + for (i, cell) in row.iter().enumerate() { + if i < col_count { + let parsed = parse_inline(cell, palette); + let w = spans_visual_width(&parsed); + if w > col_widths[i] { + col_widths[i] = w; + } + } + } + } + + let border_overhead = col_count * 3 + 1; + let avail_for_cells = max_width.saturating_sub(border_overhead).max(col_count * 6); + let total_cell_w: usize = col_widths.iter().sum(); + + if total_cell_w > avail_for_cells { + col_widths = col_widths + .iter() + .map(|&w| (w * avail_for_cells / total_cell_w).max(6)) + .collect(); + while col_widths.iter().sum::() > avail_for_cells { + if let Some(max_col) = col_widths.iter_mut().max() { + if *max_col > 6 { + *max_col -= 1; + } else { + break; + } + } + } + } + + let border_style = Style::default().fg(palette.border_subtle); + let header_style = Style::default() + .fg(palette.brand) + .add_modifier(Modifier::BOLD); + + let mut lines = Vec::new(); + + let mut top_border = String::from("┌"); + for (i, w) in col_widths.iter().enumerate() { + top_border.push_str(&"─".repeat(*w + 2)); + top_border.push(if i + 1 < col_count { '┬' } else { '┐' }); + } + lines.push(Line::from(vec![Span::styled(top_border, border_style)])); + + let mut has_header = false; + for (row_idx, row) in parsed_rows.iter().enumerate() { + if row_idx == 1 && rows.get(1).is_some_and(|s| is_delimiter_row(s)) { + let mut mid_border = String::from("├"); + for (i, w) in col_widths.iter().enumerate() { + mid_border.push_str(&"─".repeat(*w + 2)); + mid_border.push(if i + 1 < col_count { '┼' } else { '┤' }); + } + lines.push(Line::from(vec![Span::styled(mid_border, border_style)])); + has_header = true; + continue; + } + + let mut row_spans = vec![Span::styled("│", border_style)]; + for (col_idx, &w) in col_widths.iter().enumerate() { + let cell = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + let is_header_row = row_idx == 0 && !has_header; + + let cell_spans = if is_header_row { + vec![Span::styled(cell.to_string(), header_style)] + } else { + parse_inline(cell, palette) + }; + + let visual_w = spans_visual_width(&cell_spans); + let final_spans = if visual_w > w { + truncate_spans(cell_spans, w) + } else { + cell_spans + }; + + let final_w = spans_visual_width(&final_spans); + let pad = w.saturating_sub(final_w); + + row_spans.push(Span::raw(" ")); + row_spans.extend(final_spans); + row_spans.push(Span::raw(" ".repeat(pad + 1))); + row_spans.push(Span::styled("│", border_style)); + } + + lines.push(Line::from(row_spans)); + } + + let mut bottom_border = String::from("└"); + for (i, w) in col_widths.iter().enumerate() { + bottom_border.push_str(&"─".repeat(*w + 2)); + bottom_border.push(if i + 1 < col_count { '┴' } else { '┘' }); + } + lines.push(Line::from(vec![Span::styled(bottom_border, border_style)])); + + lines +} diff --git a/packages/tui/src/ui/menu.rs b/packages/tui/src/ui/menu.rs new file mode 100644 index 00000000..0ae2facb --- /dev/null +++ b/packages/tui/src/ui/menu.rs @@ -0,0 +1,234 @@ +//! Drawing a menu. +//! +//! The command menu and the theme picker are one widget: a bordered list, the row under the cursor +//! marked and filled, every entry's note beside its name. Rows are drawn on the canvas, not on the +//! lattice the hero runs behind them, and an entry that previews a palette wears that palette's own +//! ground and ink on its name — so the theme picker shows what it offers instead of describing it, +//! the way the site's preview cards do. +//! +//! It scrolls. The site ships 22 palettes and a 24-row terminal has no room for 22 rows under the +//! prompt, so the window follows the cursor rather than the menu growing to fit every entry. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Widget}; + +use super::badge; +use super::text::{self, fitted, scrolled}; +use crate::menu::Menu; +use crate::prompt::Prompt; +use crate::theme::Palette; + +/// The most rows a menu shows at once. Eight leaves room on a 24-row terminal for the wordmark and +/// the line you are typing into, which is the whole point of scrolling the rest. +const MAX_ROWS: u16 = 8; +/// What marks the row the cursor is on. The other rows leave the same two cells blank, so the +/// names line up. +const MARKER: &str = "❯ "; +/// What a menu's search line shows before anything is typed into it. +const SEARCH_PLACEHOLDER: &str = "search..."; +/// What a filtered menu shows when no entry matches the search query. +const EMPTY: &str = "no matches"; +/// The tag on the top-right border showing how to close the menu. +const ESC: &str = "esc"; +/// The air between a name and its note. +const GAP: u16 = 2; +/// The blank cell that ends every row, so neither the fill nor a note touches the border. +const PAD: u16 = 1; + +/// The height a menu asks for: a border, an optional search line and divider, its rows, and a +/// border. +pub fn height(menu: &Menu, searchable: bool) -> u16 { + let rows = menu.entries().len().clamp(1, MAX_ROWS as usize) as u16; + let search = if searchable { 2 } else { 0 }; + rows + search + 2 +} + +/// The width a menu wants: the marker, the longest name, the longest note, and the border, measured +/// against the menu's full entry set so filtering never makes the box jump horizontally. +pub fn width(menu: &Menu, searchable: bool) -> u16 { + let marker = text::width(MARKER) as u16; + let entries = menu.all(); + let names = entries + .iter() + .map(|entry| text::width(&entry.name)) + .max() + .unwrap_or(0) as u16; + let notes = entries + .iter() + .map(|entry| text::width(&entry.detail)) + .max() + .unwrap_or(0) as u16; + let rows_width = marker + names + GAP + notes + PAD + 2; + if searchable { + let search_width = marker + text::width(SEARCH_PLACEHOLDER) as u16 + PAD + 14; + rows_width.max(search_width) + } else { + rows_width + } +} + +/// Draw `menu` in `area`, titled `title` on its top-left border and `esc` on its top-right border, +/// with an optional `search` input at the top. Reports the search caret's cell when `search` is +/// present. +/// +/// Nothing is drawn in too little space: a menu needs a border either side of at least one row, and +/// room for a marker beside it. A frame that small has nowhere to put a list, and the keys still +/// reach the menu that is there. +pub fn draw( + buffer: &mut Buffer, + area: Rect, + title: &str, + menu: &Menu, + search: Option<&Prompt>, + palette: &Palette, +) -> Option<(u16, u16)> { + // The screen's layout can ask for rows a short frame does not have, so the menu draws inside + // what is left of the buffer rather than trusting the rect it was handed. + let area = area.intersection(buffer.area); + let marker = text::width(MARKER) as u16; + if area.height < 3 || area.width < marker + GAP + PAD + 2 { + return None; + } + + let border_style = Style::default().fg(palette.border_subtle); + let block = Block::bordered().border_style(border_style); + let inner = block.inner(area); + block.render(area, buffer); + badge::draw_left(buffer, area, title, Style::default().fg(palette.brand)); + if area.width >= text::width(title) as u16 + text::width(ESC) as u16 + 9 { + badge::draw_right(buffer, area, ESC, Style::default().fg(palette.text_muted)); + } + + let (caret, list_y, list_height) = if let Some(search) = search { + for x in inner.x..inner.x + inner.width { + buffer[(x, inner.y)].set_char(' '); + } + buffer.set_string(inner.x, inner.y, MARKER, Style::default().fg(palette.brand)); + let text_x = inner.x + marker; + let usable = inner.width.saturating_sub(marker + 1) as usize; + let (caret_x, text, ink) = if search.text().is_empty() { + ( + text_x, + fitted(SEARCH_PLACEHOLDER, usable), + palette.text_muted, + ) + } else { + let (text, offset) = scrolled(search.text(), search.before_caret(), usable); + (text_x + offset, text, palette.text) + }; + buffer.set_string(text_x, inner.y, text, Style::default().fg(ink)); + let caret = Some((caret_x, inner.y)); + + if inner.height >= 3 { + let div_y = inner.y + 1; + buffer[(area.x, div_y)] + .set_char('├') + .set_style(border_style); + for x in inner.x..inner.x + inner.width { + buffer[(x, div_y)].set_char('─').set_style(border_style); + } + buffer[(area.right() - 1, div_y)] + .set_char('┤') + .set_style(border_style); + (caret, inner.y + 2, inner.height - 2) + } else { + (caret, inner.y + 1, inner.height.saturating_sub(1)) + } + } else { + (None, inner.y, inner.height) + }; + + if list_height == 0 { + return caret; + } + + let entries = menu.entries(); + if entries.is_empty() { + for x in inner.x..inner.x + inner.width { + buffer[(x, list_y)].set_char(' '); + } + let usable = inner.width.saturating_sub(marker + PAD) as usize; + buffer.set_string( + inner.x + marker, + list_y, + fitted(EMPTY, usable), + Style::default().fg(palette.text_muted), + ); + return caret; + } + + // The two columns every row is laid out in, measured from all entries so the note column does + // not shift while filtering. A narrow frame takes from the notes first: the names are what you + // are reading. + let names = menu + .all() + .iter() + .map(|entry| text::width(&entry.name)) + .max() + .unwrap_or(0) as u16; + let named = names.min(inner.width.saturating_sub(marker + GAP + PAD)); + let notes = inner.width.saturating_sub(marker + named + GAP + PAD); + + let first = window(menu.cursor(), list_height as usize, entries.len()); + for (row, entry) in entries[first..] + .iter() + .take(list_height as usize) + .enumerate() + { + let y = list_y + row as u16; + let selected = first + row == menu.cursor(); + + // The row's own cells first, so a name and its note sit on the canvas rather than on the + // lattice behind them — the same blanking as the preview below, without a colour of its + // own. Only the cursor's row then gets a fill: right across, the way the site fills a + // hovered row, and inside the border, where a row of whole cells lands. + for x in inner.x..inner.x + inner.width { + buffer[(x, y)].set_char(' '); + } + if selected { + buffer.set_style( + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + Style::default().bg(palette.surface_2), + ); + buffer.set_string(inner.x, y, MARKER, Style::default().fg(palette.brand)); + } + + // The name, in its own colours when the entry has some: a theme's row wears that theme's + // ground and ink, so a list of themes previews itself. + let name = fitted(&entry.name, named as usize); + let ink = match entry.preview { + Some(preview) => Style::default().fg(preview.fg).bg(preview.bg), + None if selected => Style::default().fg(palette.text), + None => Style::default().fg(palette.text_secondary), + }; + buffer.set_string(inner.x + marker, y, name, ink); + + buffer.set_string( + inner.x + marker + named + GAP, + y, + fitted(&entry.detail, notes as usize), + Style::default().fg(if selected { + palette.text + } else { + palette.text_muted + }), + ); + } + + caret +} + +/// The first entry the window shows, so that the cursor's entry is in it and the window is full +/// whenever there are entries to fill it. +fn window(cursor: usize, rows: usize, entries: usize) -> usize { + cursor + .saturating_sub(rows.saturating_sub(1)) + .min(entries.saturating_sub(rows)) +} diff --git a/packages/tui/src/ui/mod.rs b/packages/tui/src/ui/mod.rs deleted file mode 100644 index 680d08f7..00000000 --- a/packages/tui/src/ui/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod input; -pub mod logo; -pub mod model; -pub mod session; -pub mod spinner; -pub mod text; -pub mod theme; -pub mod theme_picker; -pub mod welcome; - -#[allow(unused_imports)] -pub use input::{ - cursor_down_in_input, cursor_up_in_input, render_input_with_cursor, render_line_spans, - wrap_input_with_cursor, InputVisualLine, WrappedInput, -}; -pub use model::render_model_browser; -pub use session::render_session; -pub use theme_picker::render_theme_picker; -pub use theme::Theme; -#[allow(unused_imports)] -pub use welcome::{commands, render_welcome, CommandItem}; diff --git a/packages/tui/src/ui/model.rs b/packages/tui/src/ui/model.rs deleted file mode 100644 index 6ffb3369..00000000 --- a/packages/tui/src/ui/model.rs +++ /dev/null @@ -1,999 +0,0 @@ -use std::time::Instant; -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; -use ratatui::Frame; - -use crate::catalog::{ - format_context, format_cost_table, format_rate, get_all_models, - get_all_providers, -}; -use crate::types::{FocusedPanel, ModelDetails, ModelSortMode, ProviderSummary}; -use crate::ui::spinner::Spinner; -use crate::ui::theme::Theme; - -#[derive(Debug, Clone)] -pub struct ModelBrowserState { - pub focused_panel: FocusedPanel, - pub provider_index: usize, - pub provider_scroll_offset: usize, - pub provider_search: String, - pub model_index: usize, - pub model_scroll_offset: usize, - pub model_search: String, - pub search_query: String, - pub is_searching: bool, - pub sort_mode: ModelSortMode, - pub show_help: bool, - pub status_notification: Option<(String, Instant)>, - - // API key entry modal (when model selected requires key) - pub api_key_modal_open: bool, - pub api_key_input: String, - pub pending_model: Option, - pub validating: bool, - pub validation_error: Option, -} - -impl Default for ModelBrowserState { - fn default() -> Self { - Self::new() - } -} - -impl ModelBrowserState { - pub fn new() -> Self { - Self { - focused_panel: FocusedPanel::Models, - provider_index: 0, - provider_scroll_offset: 0, - provider_search: String::new(), - model_index: 0, - model_scroll_offset: 0, - model_search: String::new(), - search_query: String::new(), - is_searching: false, - sort_mode: ModelSortMode::Id, - show_help: false, - status_notification: None, - api_key_modal_open: false, - api_key_input: String::new(), - pending_model: None, - validating: false, - validation_error: None, - } - } - - pub fn set_notification(&mut self, msg: String) { - self.status_notification = Some((msg, Instant::now())); - } - - pub fn active_notification(&self) -> Option<&str> { - if let Some((msg, time)) = &self.status_notification { - if time.elapsed().as_secs() < 3 { - return Some(msg); - } - } - None - } - - pub fn filtered_providers(&self) -> Vec<&'static ProviderSummary> { - let all = get_all_providers(); - let q = self.provider_search.trim().to_lowercase(); - if q.is_empty() { - all.iter().collect() - } else { - all.iter() - .filter(|p| { - p.id.to_lowercase().contains(&q) - || p.name.to_lowercase().contains(&q) - }) - .collect() - } - } - - pub fn selected_provider_id(&self) -> Option<&str> { - let filtered = self.filtered_providers(); - if self.provider_index == 0 { - None - } else { - filtered.get(self.provider_index - 1).map(|p| p.id.as_str()) - } - } - - pub fn filtered_models(&self) -> Vec<&'static ModelDetails> { - let all = get_all_models(); - let selected_prov = self.selected_provider_id(); - let q = if !self.model_search.trim().is_empty() { - self.model_search.trim().to_lowercase() - } else { - self.search_query.trim().to_lowercase() - }; - - let mut models: Vec<&'static ModelDetails> = all - .iter() - .filter(|m| { - if let Some(prov_id) = selected_prov { - if m.provider_id != prov_id { - return false; - } - } - if q.is_empty() { - true - } else { - m.id.to_lowercase().contains(&q) - || m.name.to_lowercase().contains(&q) - || m.provider_id.to_lowercase().contains(&q) - || m.family.to_lowercase().contains(&q) - } - }) - .collect(); - - match self.sort_mode { - ModelSortMode::Id => { - models.sort_by(|a, b| a.id.cmp(&b.id)); - } - ModelSortMode::Provider => { - models.sort_by(|a, b| a.provider_id.cmp(&b.provider_id).then_with(|| a.id.cmp(&b.id))); - } - ModelSortMode::Context => { - models.sort_by(|a, b| { - b.context_window - .unwrap_or(0) - .cmp(&a.context_window.unwrap_or(0)) - .then_with(|| a.id.cmp(&b.id)) - }); - } - ModelSortMode::Cost => { - models.sort_by(|a, b| { - let cost_a = a.cost.as_ref().and_then(|c| c.input).unwrap_or(999999.0); - let cost_b = b.cost.as_ref().and_then(|c| c.input).unwrap_or(999999.0); - cost_a - .partial_cmp(&cost_b) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.id.cmp(&b.id)) - }); - } - } - - models - } - - pub fn current_model(&self) -> Option<&'static ModelDetails> { - let models = self.filtered_models(); - models.get(self.model_index).copied() - } - - pub fn provider_for_details(&self) -> Option<&'static ProviderSummary> { - let providers = get_all_providers(); - if let Some(prov_id) = self.selected_provider_id() { - providers.iter().find(|p| p.id == prov_id) - } else if let Some(m) = self.current_model() { - providers.iter().find(|p| p.id == m.provider_id) - } else { - providers.first() - } - } -} - -pub fn render_model_browser( - f: &mut Frame, - area: Rect, - state: &ModelBrowserState, - spinner: &Spinner, -) { - if area.width < 50 || area.height < 15 { - // Fallback for extremely cramped terminal - let msg = Paragraph::new("Terminal too small for AI Model Browser (min 50x15)"); - f.render_widget(msg, area); - return; - } - - let cursor_visible = spinner.cursor_visible(); - - // 1. Header (1 line) - let header_area = Rect::new(area.x, area.y, area.width, 1); - let mut header_spans = vec![ - Span::styled("models", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), - Span::styled(" - AI Model Browser", Style::default().fg(Theme::text_muted())), - ]; - - if state.is_searching { - header_spans.push(Span::raw(" ")); - let search_target = match state.focused_panel { - FocusedPanel::Providers => "providers", - FocusedPanel::Models => "models", - }; - let current_query = match state.focused_panel { - FocusedPanel::Providers => &state.provider_search, - FocusedPanel::Models => { - if !state.model_search.is_empty() { - &state.model_search - } else { - &state.search_query - } - } - }; - header_spans.push(Span::styled(format!("/ search {}: ", search_target), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD))); - header_spans.push(Span::styled(current_query, Style::default().fg(Theme::text_primary()))); - header_spans.push(if cursor_visible { - Span::styled("█", Style::default().fg(Theme::activity())) - } else { - Span::raw(" ") - }); - header_spans.push(Span::styled(" (Enter: browse, Esc: clear)", Style::default().fg(Theme::text_muted()))); - } else { - let mut filter_tags = Vec::new(); - if !state.provider_search.is_empty() { - filter_tags.push(format!("Provider: \"{}\"", state.provider_search)); - } - let m_query = if !state.model_search.is_empty() { - &state.model_search - } else { - &state.search_query - }; - if !m_query.is_empty() { - filter_tags.push(format!("Model: \"{}\"", m_query)); - } - - if !filter_tags.is_empty() { - header_spans.push(Span::raw(" ")); - header_spans.push(Span::styled(format!("[Filter: {}]", filter_tags.join(", ")), Style::default().fg(Theme::activity()))); - } else if let Some(msg) = state.active_notification() { - header_spans.push(Span::raw(" ")); - header_spans.push(Span::styled(format!("✓ {}", msg), Style::default().fg(Theme::success()))); - } - } - f.render_widget(Paragraph::new(Line::from(header_spans)), header_area); - - // 2. Footer (1 line at bottom) - let footer_area = Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1); - let yellow = Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(Theme::text_muted()); - - let footer_line = Line::from(vec![ - Span::styled("q", yellow), Span::styled(" quit ", muted), - Span::styled("↑/↓", yellow), Span::styled(" nav ", muted), - Span::styled("Tab", yellow), Span::styled(" switch ", muted), - Span::styled("/", yellow), Span::styled(" search ", muted), - Span::styled("s", yellow), Span::styled(" sort ", muted), - Span::styled("c", yellow), Span::styled(" copy (prov/model) ", muted), - Span::styled("Enter", yellow), Span::styled(" select", muted), - Span::raw(" ".repeat((area.width as usize).saturating_sub(76))), - Span::styled("? help", yellow), - ]); - f.render_widget(Paragraph::new(footer_line), footer_area); - - // 3. Body Layout: split vertically into Top (Providers, Models) and Bottom (Provider, Details) - let body_area = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(2)); - - // Dynamic bottom height based on total terminal height - let total_h = body_area.height; - let bottom_height = if total_h >= 34 { - 13u16 - } else if total_h >= 28 { - 12u16 - } else if total_h >= 22 { - 10u16 - } else { - (total_h * 40 / 100).clamp(7, 9) - }; - let top_height = total_h.saturating_sub(bottom_height); - - let top_area = Rect::new(body_area.x, body_area.y, body_area.width, top_height); - let bottom_area = Rect::new(body_area.x, body_area.y + top_height, body_area.width, bottom_height); - - // Responsive left column width based on total terminal width - let total_w = body_area.width; - let left_width = if total_w >= 160 { - (total_w * 26 / 100).clamp(36, 46) - } else if total_w >= 120 { - (total_w * 28 / 100).clamp(32, 40) - } else if total_w >= 90 { - (total_w * 30 / 100).clamp(28, 34) - } else { - (total_w * 32 / 100).clamp(22, 28) - }; - let right_width = total_w.saturating_sub(left_width); - - let top_left_area = Rect::new(top_area.x, top_area.y, left_width, top_area.height); - let top_right_area = Rect::new(top_area.x + left_width, top_area.y, right_width, top_area.height); - - let bottom_left_area = Rect::new(bottom_area.x, bottom_area.y, left_width, bottom_area.height); - let bottom_right_area = Rect::new(bottom_area.x + left_width, bottom_area.y, right_width, bottom_area.height); - - // ---------------------------------------------------- - // Panel 1: Providers (Top-Left) - // ---------------------------------------------------- - let providers_focused = state.focused_panel == FocusedPanel::Providers; - let prov_border_style = if providers_focused { - Style::default().fg(Theme::sky()) - } else { - Style::default().fg(Theme::border_box()) - }; - let prov_title_style = if providers_focused { - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::text_secondary()) - }; - - let all_providers = get_all_providers(); - let filtered_providers = state.filtered_providers(); - let total_models = get_all_models().len(); - - let prov_title = if !state.provider_search.is_empty() { - format!(" Providers ({}/{}) [\"{}\"] ", filtered_providers.len(), all_providers.len(), state.provider_search) - } else { - format!(" Providers ({}) ", all_providers.len()) - }; - - let prov_block = Block::default() - .title(Span::styled(prov_title, prov_title_style)) - .borders(Borders::ALL) - .border_style(prov_border_style); - f.render_widget(prov_block, top_left_area); - - let prov_inner = Rect::new( - top_left_area.x + 1, - top_left_area.y + 1, - top_left_area.width.saturating_sub(2), - top_left_area.height.saturating_sub(2), - ); - - let total_provider_items = 1 + filtered_providers.len(); - let visible_prov_rows = prov_inner.height as usize; - - let mut prov_lines = Vec::new(); - let prov_start = state.provider_scroll_offset; - let prov_end = (prov_start + visible_prov_rows).min(total_provider_items); - let prov_inner_w = prov_inner.width as usize; - - if filtered_providers.is_empty() && !state.provider_search.is_empty() { - let is_selected = state.provider_index == 0; - let prefix = if is_selected { "> " } else { " " }; - prov_lines.push(Line::from(vec![ - Span::styled(prefix, if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_muted()) }), - Span::styled("All", if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_primary()) }), - Span::raw(" "), - Span::styled(format!("({})", total_models), if is_selected { Style::default().fg(Theme::activity()) } else { Style::default().fg(Theme::text_muted()) }), - ])); - prov_lines.push(Line::from(Span::styled( - format!(" (no providers match \"{}\")", state.provider_search), - Style::default().fg(Theme::text_muted()), - ))); - } else { - for idx in prov_start..prov_end { - let is_selected = idx == state.provider_index; - let (prefix, id_str, count_str) = if idx == 0 { - let pfx = if is_selected { "> " } else { " " }; - (pfx, "All".to_string(), format!("({})", total_models)) - } else { - let p = &filtered_providers[idx - 1]; - let pfx = if is_selected { "> " } else { " " }; - (pfx, p.id.clone(), format!("({})", p.model_count)) - }; - - // Ensure the count (e.g. "(103)") is NEVER clipped! Truncate ID with "..." if needed - let avail_id_w = prov_inner_w.saturating_sub(prefix.len() + count_str.len() + 1); - let display_id = if id_str.len() > avail_id_w && avail_id_w >= 4 { - format!("{}...", &id_str[..avail_id_w.saturating_sub(3)]) - } else { - id_str - }; - - let name_style = if is_selected { - Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::text_primary()) - }; - - let count_style = if is_selected { - Style::default().fg(Theme::activity()) - } else { - Style::default().fg(Theme::text_muted()) - }; - - prov_lines.push(Line::from(vec![ - Span::styled(prefix, if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_muted()) }), - Span::styled(display_id, name_style), - Span::raw(" "), - Span::styled(count_str, count_style), - ])); - } - } - f.render_widget(Paragraph::new(prov_lines), prov_inner); - - // ---------------------------------------------------- - // Panel 2: Models (Top-Right) - // ---------------------------------------------------- - let models_focused = state.focused_panel == FocusedPanel::Models; - let models_border_style = if models_focused { - Style::default().fg(Theme::sky()) - } else { - Style::default().fg(Theme::border_box()) - }; - let models_title_style = if models_focused { - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::text_secondary()) - }; - - let filtered_models = state.filtered_models(); - let sort_tag = match state.sort_mode { - ModelSortMode::Id => "", - ModelSortMode::Provider => " [sorted by provider]", - ModelSortMode::Context => " [sorted by context]", - ModelSortMode::Cost => " [sorted by cost]", - }; - let models_title = if let Some(p_id) = state.selected_provider_id() { - format!(" Models ({}: {}){} ", p_id, filtered_models.len(), sort_tag) - } else { - format!(" Models ({}){} ", filtered_models.len(), sort_tag) - }; - - let models_block = Block::default() - .title(Span::styled(models_title, models_title_style)) - .borders(Borders::ALL) - .border_style(models_border_style); - f.render_widget(models_block, top_right_area); - - let models_inner = Rect::new( - top_right_area.x + 1, - top_right_area.y + 1, - top_right_area.width.saturating_sub(2), - top_right_area.height.saturating_sub(2), - ); - - // Exact responsive column width allocation without any horizontal overflow - let inner_w = models_inner.width as usize; - let prefix_w = 2usize; - let (prov_w, cost_w, ctx_w) = if inner_w >= 115 { - (24usize, 16usize, 8usize) - } else if inner_w >= 95 { - (20usize, 15usize, 8usize) - } else if inner_w >= 75 { - (16usize, 14usize, 8usize) - } else if inner_w >= 60 { - (14usize, 12usize, 7usize) - } else { - (11usize, 10usize, 6usize) - }; - - // Exactly 3 spaces between the 4 columns: [prefix:2][ID] [Prov] [Cost] [Ctx] - let non_id_w = prefix_w + 3 + prov_w + cost_w + ctx_w; - let id_w = inner_w.saturating_sub(non_id_w).max(10); - - let mut model_lines = Vec::new(); - - // Table Header - let table_header = format!( - " {:cost_w$} {:>ctx_w$}", - "Model ID", - "Provider", - "Cost", - "Context", - id_w = id_w, - prov_w = prov_w, - cost_w = cost_w, - ctx_w = ctx_w, - ); - model_lines.push(Line::from(Span::styled( - table_header, - Style::default().fg(Theme::text_muted()), - ))); - - // Model rows - let visible_model_rows = models_inner.height.saturating_sub(1) as usize; - let model_start = state.model_scroll_offset; - let model_end = (model_start + visible_model_rows).min(filtered_models.len()); - - for idx in model_start..model_end { - if let Some(m) = filtered_models.get(idx) { - let is_selected = idx == state.model_index; - let prefix = if is_selected { "> " } else { " " }; - - // Truncate Model ID if longer than column - let mut id_display = m.id.clone(); - if id_display.len() > id_w { - id_display = format!("{}...", &id_display[..id_w.saturating_sub(3)]); - } - - // Truncate Provider if longer than column - let mut prov_display = m.provider_id.clone(); - if prov_display.len() > prov_w { - prov_display = format!("{}...", &prov_display[..prov_w.saturating_sub(3)]); - } - - let cost_display = format_cost_table(m.cost.as_ref()); - let context_display = format_context(m.context_window); - - if is_selected { - let row_str = format!( - "{}{:cost_w$} {:>ctx_w$}", - prefix, - id_display, - prov_display, - cost_display, - context_display, - id_w = id_w, - prov_w = prov_w, - cost_w = cost_w, - ctx_w = ctx_w, - ); - model_lines.push(Line::from(Span::styled( - row_str, - Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD), - ))); - } else { - model_lines.push(Line::from(vec![ - Span::raw(prefix), - Span::styled(format!("{:cost_w$}", cost_display, cost_w = cost_w), Style::default().fg(Theme::text_muted())), - Span::raw(" "), - Span::styled(format!("{:>ctx_w$}", context_display, ctx_w = ctx_w), Style::default().fg(Theme::text_secondary())), - ])); - } - } - } - f.render_widget(Paragraph::new(model_lines), models_inner); - - // ---------------------------------------------------- - // Panel 3: Provider (Bottom-Left) - // ---------------------------------------------------- - let prov_bottom_block = Block::default() - .title(Span::styled(" Provider ", Style::default().fg(Theme::text_secondary()))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border_box())); - f.render_widget(prov_bottom_block, bottom_left_area); - - let bottom_left_inner = Rect::new( - bottom_left_area.x + 1, - bottom_left_area.y + 1, - bottom_left_area.width.saturating_sub(2), - bottom_left_area.height.saturating_sub(2), - ); - - let prov_info = state.provider_for_details(); - let mut prov_detail_lines = Vec::new(); - let bottom_left_w = bottom_left_inner.width as usize; - let bottom_left_h = bottom_left_inner.height as usize; - - if let Some(p) = prov_info { - prov_detail_lines.push(Line::from(Span::styled( - &p.name, - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), - ))); - - if bottom_left_h >= 8 { - prov_detail_lines.push(Line::raw("")); - } - - let max_field_val_w = bottom_left_w.saturating_sub(7); - - let truncate_field = |val: &str| -> String { - if val.len() > max_field_val_w && max_field_val_w > 4 { - format!("{}...", &val[..max_field_val_w.saturating_sub(3)]) - } else { - val.to_string() - } - }; - - let docs = p.docs_url.as_deref().unwrap_or("-"); - prov_detail_lines.push(Line::from(vec![ - Span::styled("Docs: ", Style::default().fg(Theme::text_muted())), - Span::styled(truncate_field(docs), Style::default().fg(Theme::text_secondary())), - ])); - - let api = p.base_url.as_deref().unwrap_or("-"); - prov_detail_lines.push(Line::from(vec![ - Span::styled("API: ", Style::default().fg(Theme::text_muted())), - Span::styled(truncate_field(api), Style::default().fg(Theme::text_secondary())), - ])); - - let npm = p.npm.as_deref().unwrap_or("-"); - prov_detail_lines.push(Line::from(vec![ - Span::styled("NPM: ", Style::default().fg(Theme::text_muted())), - Span::styled(truncate_field(npm), Style::default().fg(Theme::text_secondary())), - ])); - - let env = if !p.env_keys.is_empty() { - p.env_keys.join(", ") - } else { - "-".to_string() - }; - prov_detail_lines.push(Line::from(vec![ - Span::styled("Env: ", Style::default().fg(Theme::text_muted())), - Span::styled(truncate_field(&env), Style::default().fg(Theme::text_secondary())), - ])); - - if bottom_left_h >= 8 { - prov_detail_lines.push(Line::raw("")); - } - - prov_detail_lines.push(Line::from(vec![ - Span::styled("o", yellow), - Span::styled(" open docs ", muted), - Span::styled("c", yellow), - Span::styled(" copy env", muted), - ])); - } - f.render_widget(Paragraph::new(prov_detail_lines), bottom_left_inner); - - // ---------------------------------------------------- - // Panel 4: Details (Bottom-Right) - // ---------------------------------------------------- - let details_block = Block::default() - .title(Span::styled(" Details ", Style::default().fg(Theme::text_secondary()))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border_box())); - f.render_widget(details_block, bottom_right_area); - - let details_inner = Rect::new( - bottom_right_area.x + 1, - bottom_right_area.y + 1, - bottom_right_area.width.saturating_sub(2), - bottom_right_area.height.saturating_sub(2), - ); - - let details_inner_w = details_inner.width as usize; - let details_inner_h = details_inner.height as usize; - let col1_w = (details_inner_w / 2).clamp(24, 38); - - let mut details_lines = Vec::new(); - if let Some(m) = state.current_model() { - // Line 0: Name (ID) with safe truncation - let id_tag = format!("({})", m.id); - let id_avail = details_inner_w.saturating_sub(m.name.len() + 3); - let id_display = if id_tag.len() > id_avail && id_avail > 6 { - format!("({}...)", &m.id[..id_avail.saturating_sub(5)]) - } else { - id_tag - }; - details_lines.push(Line::from(vec![ - Span::styled(&m.name, Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD)), - Span::raw(" "), - Span::styled(id_display, Style::default().fg(Theme::text_muted())), - ])); - - // Line 1: Provider & Family aligned in grid - details_lines.push(Line::from(vec![ - Span::styled("Provider: ", Style::default().fg(Theme::text_muted())), - Span::styled(&m.provider_id, Style::default().fg(Theme::sky())), - Span::raw(" ".repeat(col1_w.saturating_sub(10 + m.provider_id.len()).max(2))), - Span::styled("Family: ", Style::default().fg(Theme::text_muted())), - Span::styled(&m.family, Style::default().fg(Theme::text_primary())), - ])); - - // Line 2: Status - details_lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Theme::text_muted())), - Span::styled("active", Style::default().fg(Theme::success())), - ])); - - if details_inner_h >= 11 { - details_lines.push(Line::raw("")); - } - - // Line 4: Context, Input Limit, Output Limit with clean column spacing - let ctx_str = format_context(m.context_window); - let out_limit_str = format_context(m.max_tokens); - let c1_context = format!("Context: {:<7} Input: -", ctx_str); - let c1_context_pad = col1_w.saturating_sub(c1_context.len()).max(2); - details_lines.push(Line::from(vec![ - Span::styled("Context: ", Style::default().fg(Theme::text_muted())), - Span::styled(format!("{:<7} ", ctx_str), Style::default().fg(Theme::text_primary())), - Span::styled("Input: ", Style::default().fg(Theme::text_muted())), - Span::styled("-", Style::default().fg(Theme::text_primary())), - Span::raw(" ".repeat(c1_context_pad)), - Span::styled("Output: ", Style::default().fg(Theme::text_muted())), - Span::styled(out_limit_str, Style::default().fg(Theme::text_primary())), - ])); - - // Line 5: Input & Output Pricing with clean column spacing - let in_rate = format_rate(m.cost.as_ref().and_then(|c| c.input)); - let out_rate = format_rate(m.cost.as_ref().and_then(|c| c.output)); - let in_rate_str = format!("Input: {}", in_rate); - let in_rate_pad = col1_w.saturating_sub(in_rate_str.len()).max(2); - details_lines.push(Line::from(vec![ - Span::styled("Input: ", Style::default().fg(Theme::text_muted())), - Span::styled(in_rate, Style::default().fg(Theme::text_primary())), - Span::raw(" ".repeat(in_rate_pad)), - Span::styled("Output: ", Style::default().fg(Theme::text_muted())), - Span::styled(out_rate, Style::default().fg(Theme::text_primary())), - ])); - - // Line 6: Cache Read & Write with clean column spacing (no more glue!) - let cache_r_rate = format_rate(m.cost.as_ref().and_then(|c| c.cache_read)); - let cache_w_rate = format_rate(m.cost.as_ref().and_then(|c| c.cache_write)); - let cr_str = format!("Cache Read: {}", cache_r_rate); - let cr_pad = col1_w.saturating_sub(cr_str.len()).max(2); - details_lines.push(Line::from(vec![ - Span::styled("Cache Read: ", Style::default().fg(Theme::text_muted())), - Span::styled(cache_r_rate, Style::default().fg(Theme::text_primary())), - Span::raw(" ".repeat(cr_pad)), - Span::styled("Cache Write: ", Style::default().fg(Theme::text_muted())), - Span::styled(cache_w_rate, Style::default().fg(Theme::text_primary())), - ])); - - if details_inner_h >= 11 { - details_lines.push(Line::raw("")); - } - - // Line 8: Capabilities - let caps = if m.reasoning { - "reasoning, tools, temperature" - } else { - "tools, temperature" - }; - details_lines.push(Line::from(vec![ - Span::styled("Capabilities: ", Style::default().fg(Theme::text_muted())), - Span::styled(caps, Style::default().fg(Theme::text_primary())), - ])); - - // Line 9: Modalities - let modalities_str = if m.input_modalities.len() > 1 { - format!("{} -> text", m.input_modalities.join(", ")) - } else { - "text -> text".to_string() - }; - details_lines.push(Line::from(vec![ - Span::styled("Modalities: ", Style::default().fg(Theme::text_muted())), - Span::styled(modalities_str, Style::default().fg(Theme::text_primary())), - ])); - - // Line 10: Released & Knowledge with clean spacing - let rel = m.released.as_deref().unwrap_or("-"); - let rel_str = format!("Released: {}", rel); - let rel_pad = col1_w.saturating_sub(rel_str.len()).max(2); - details_lines.push(Line::from(vec![ - Span::styled("Released: ", Style::default().fg(Theme::text_muted())), - Span::styled(rel, Style::default().fg(Theme::text_primary())), - Span::raw(" ".repeat(rel_pad)), - Span::styled("Knowledge: ", Style::default().fg(Theme::text_muted())), - Span::styled("-", Style::default().fg(Theme::text_primary())), - ])); - - // Line 11: Updated - let upd = m.updated.as_deref().unwrap_or("-"); - details_lines.push(Line::from(vec![ - Span::styled("Updated: ", Style::default().fg(Theme::text_muted())), - Span::styled(upd, Style::default().fg(Theme::text_primary())), - ])); - } - f.render_widget(Paragraph::new(details_lines), details_inner); - - // ---------------------------------------------------- - // Overlay: API Key Configuration Modal - // ---------------------------------------------------- - if state.api_key_modal_open { - let card_w = 66u16.min(area.width.saturating_sub(4)); - let card_h = 13u16.min(area.height.saturating_sub(2)); - let modal_x = (area.width.saturating_sub(card_w)) / 2; - let modal_y = (area.height.saturating_sub(card_h)) / 2; - let modal_area = Rect::new(modal_x, modal_y, card_w, card_h); - - f.render_widget(Clear, modal_area); - - let prov_name = state.pending_model.as_ref().map(|m| m.provider_name.as_str()).unwrap_or("Provider"); - let modal_block = Block::default() - .title(Span::styled( - format!(" Configure API Key: {} ", prov_name), - Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border())); - f.render_widget(modal_block, modal_area); - - let modal_inner = Rect::new( - modal_area.x + 2, - modal_area.y + 1, - modal_area.width.saturating_sub(4), - modal_area.height.saturating_sub(2), - ); - - let mut lines = Vec::new(); - if let Some(m) = &state.pending_model { - lines.push(Line::from(vec![ - Span::styled("Model: ", Style::default().fg(Theme::text_muted())), - Span::styled(&m.name, Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD)), - Span::styled(format!(" ({})", m.id), Style::default().fg(Theme::text_muted())), - ])); - let env_desc = if !m.env_keys.is_empty() { - m.env_keys.join(" or ") - } else { - "API key".to_string() - }; - lines.push(Line::from(vec![ - Span::styled("Environment key: ", Style::default().fg(Theme::text_muted())), - Span::styled(env_desc, Style::default().fg(Theme::accent())), - ])); - lines.push(Line::raw("")); - } - - if state.validating { - lines.push(Line::from(vec![ - Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), - Span::styled("Validating API key with provider...", Style::default().fg(Theme::activity())), - ])); - } else { - lines.push(Line::from(Span::styled("Enter your API key below:", Style::default().fg(Theme::text_primary())))); - let masked = "•".repeat(state.api_key_input.len()); - lines.push(Line::from(vec![ - Span::styled("API Key: ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), - Span::styled(masked, Style::default().fg(Theme::text_primary())), - if cursor_visible { - Span::styled("█", Style::default().fg(Theme::accent())) - } else { - Span::raw(" ") - }, - ])); - - if let Some(err) = &state.validation_error { - lines.push(Line::from(vec![ - Span::styled("✗ ", Style::default().fg(Theme::error()).add_modifier(Modifier::BOLD)), - Span::styled(err, Style::default().fg(Theme::error())), - ])); - } else { - lines.push(Line::raw("")); - } - - lines.push(Line::from(Span::styled("Press Enter to validate & save, Esc to cancel", Style::default().fg(Theme::text_muted())))); - } - - f.render_widget(Paragraph::new(lines), modal_inner); - } - - // ---------------------------------------------------- - // Overlay: Help Modal - // ---------------------------------------------------- - if state.show_help { - let help_w = 64u16.min(area.width.saturating_sub(4)); - let help_h = 16u16.min(area.height.saturating_sub(2)); - let help_x = (area.width.saturating_sub(help_w)) / 2; - let help_y = (area.height.saturating_sub(help_h)) / 2; - let help_area = Rect::new(help_x, help_y, help_w, help_h); - - f.render_widget(Clear, help_area); - - let help_block = Block::default() - .title(Span::styled(" AI Model Browser - Help ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD))) - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border())); - f.render_widget(help_block, help_area); - - let help_inner = Rect::new( - help_area.x + 2, - help_area.y + 1, - help_area.width.saturating_sub(4), - help_area.height.saturating_sub(2), - ); - - let shortcuts = [ - ("Tab / Shift+Tab", "Switch focus between Providers and Models"), - ("↑ / ↓ (or k / j)", "Navigate items in the focused list"), - ("PgUp / PgDn", "Scroll list by page"), - ("/", "Filter providers or models depending on focus"), - ("s", "Cycle sort mode: ID, Provider, Context, Cost"), - ("c", "Copy selected model ID or provider env key to clipboard"), - ("o", "Open active provider documentation in browser"), - ("Enter", "Select model (validates credentials & updates active model)"), - ("q / Esc", "Exit browser / cancel search"), - ]; - - let mut lines = Vec::new(); - for (key_str, desc) in shortcuts { - lines.push(Line::from(vec![ - Span::styled(format!("{:<18}", key_str), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), - Span::styled(desc, Style::default().fg(Theme::text_primary())), - ])); - } - lines.push(Line::raw("")); - lines.push(Line::from(Span::styled("Press any key or Esc to close help", Style::default().fg(Theme::text_muted())))); - - f.render_widget(Paragraph::new(lines), help_inner); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - #[test] - fn test_model_browser_filtering_and_sorting() { - let mut state = ModelBrowserState::new(); - let initial_models = state.filtered_models(); - assert_eq!(initial_models.len(), 6001, "Expected all 6001 models by default"); - - // Filter by search query - state.search_query = "claude-3-7-sonnet".to_string(); - let claude_models = state.filtered_models(); - assert!(!claude_models.is_empty(), "Expected claude models to match"); - for m in &claude_models { - assert!( - m.id.to_lowercase().contains("claude-3-7-sonnet") - || m.name.to_lowercase().contains("claude-3-7-sonnet") - ); - } - - // Test sorting by context - state.search_query.clear(); - state.sort_mode = ModelSortMode::Context; - let sorted_by_ctx = state.filtered_models(); - let first_ctx = sorted_by_ctx.first().unwrap().context_window.unwrap_or(0); - let last_ctx = sorted_by_ctx.last().unwrap().context_window.unwrap_or(0); - assert!(first_ctx >= last_ctx, "Expected descending context window"); - } - - #[test] - fn test_model_browser_provider_details() { - let mut state = ModelBrowserState::new(); - // Index 0 is "All", provider_for_details should match first model's provider - let prov = state.provider_for_details(); - assert!(prov.is_some(), "Expected provider details to be present"); - - // Set provider_index to 1 (first alphabetically sorted provider) - state.provider_index = 1; - let prov1 = state.provider_for_details().unwrap(); - let all_provs = get_all_providers(); - assert_eq!(prov1.id, all_provs[0].id); - } - - #[test] - fn test_render_model_browser_no_panic() { - let resolutions = [(60, 18), (80, 24), (100, 30), (120, 35), (140, 40), (180, 50)]; - let state = ModelBrowserState::new(); - let spinner = Spinner::new(); - - for (w, h) in resolutions { - let backend = TestBackend::new(w, h); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|f| { - render_model_browser(f, f.area(), &state, &spinner); - }) - .unwrap(); - } - } - - #[test] - fn test_provider_search_filtering() { - let mut state = ModelBrowserState::new(); - assert_eq!(state.filtered_providers().len(), 206); - - // Search for anthropic - state.provider_search = "anthropic".to_string(); - let filtered = state.filtered_providers(); - assert!(!filtered.is_empty()); - assert!(filtered.iter().any(|p| p.id == "anthropic")); - for p in &filtered { - assert!( - p.id.to_lowercase().contains("anthropic") - || p.name.to_lowercase().contains("anthropic") - ); - } - - // When provider_index is set to the index of anthropic - let anthropic_idx = filtered.iter().position(|p| p.id == "anthropic").unwrap() + 1; - state.provider_index = anthropic_idx; - assert_eq!(state.selected_provider_id(), Some("anthropic")); - - // Models should only contain anthropic models - let models = state.filtered_models(); - assert!(!models.is_empty()); - for m in &models { - assert_eq!(m.provider_id, "anthropic"); - } - - // Non-existent search - state.provider_search = "nonexistent_provider_xyz".to_string(); - assert!(state.filtered_providers().is_empty()); - assert_eq!(state.selected_provider_id(), None); - } -} - diff --git a/packages/tui/src/ui/session.rs b/packages/tui/src/ui/session.rs index babf37e7..06cf38c9 100644 --- a/packages/tui/src/ui/session.rs +++ b/packages/tui/src/ui/session.rs @@ -1,764 +1,482 @@ -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +//! The session screen: header, streaming transcript, and prompt composer. +//! +//! Pure, like the welcome screen: it reads `App` and paints into the frame's buffer without +//! mutating state or performing I/O. + +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; -use unicode_width::UnicodeWidthStr; - -use crate::markdown::{render_markdown, wrap_text}; -use crate::tool; -use crate::types::{ConversationTurn, ModelConfig, SessionInfo, SessionStats, ToolResult}; -use crate::ui::logo::{render_animated_logo_lines, render_header_logo}; -use crate::ui::spinner::Spinner; -use crate::ui::text::elide; -use crate::ui::theme::Theme; -use crate::ui::welcome::{commands, CommandItem}; - -pub fn render_session( - f: &mut Frame, - area: Rect, - session_info: Option<&SessionInfo>, - turns: &[ConversationTurn], - stats: &SessionStats, - config: &ModelConfig, - input_buffer: &str, - cursor_pos: usize, - scroll_offset: usize, - dropdown_index: usize, - spinner: &Spinner, - is_streaming: bool, - reconnecting: bool, -) { - let pad_top = if area.height >= 12 { 1u16 } else { 0u16 }; - let cursor_visible = spinner.cursor_visible(); - let content_area = Rect::new( - area.x, - area.y + pad_top, - area.width, - area.height.saturating_sub(pad_top), - ); - - let sidebar_width = (content_area.width / 4).clamp(24, 32); - let main_width = content_area.width.saturating_sub(sidebar_width + 1); - - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(main_width), - Constraint::Length(1), - Constraint::Length(sidebar_width), - ]) - .split(content_area); - - let max_input_text_width = (main_width as usize).saturating_sub(6).max(10); - let wrapped_input = crate::ui::input::wrap_input_with_cursor(input_buffer, cursor_pos, max_input_text_width); - let max_visible_lines = 8usize; - let num_input_lines = if is_streaming { - 1 - } else { - wrapped_input.lines.len().clamp(1, max_visible_lines) - }; - let input_area_height = (num_input_lines + 2) as u16; - - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Top header (title, divider, padding) - Constraint::Min(5), // Conversation turns - Constraint::Length(input_area_height), // Input area (top line, text lines, bottom line) - ]) - .split(chunks[0]); - - let border_style = Style::default().fg(Theme::border()); - // 1. Main Header with 2-space padding - let sess_title = session_info - .map(|s| s.title.as_str()) - .unwrap_or("CodeWork Session"); - let sess_dir = session_info - .map(|s| s.directory.as_str()) - .unwrap_or("active session"); - - let max_dir_len = (main_width as usize).saturating_sub(32).clamp(12, 40); - let formatted_dir = crate::git::truncate_path(std::path::Path::new(sess_dir), max_dir_len); - let mut title_spans = vec![Span::raw(" ")]; - title_spans.extend(render_header_logo(spinner.tick_count(), is_streaming)); - title_spans.push(Span::styled(format!("· {} ", sess_title), Style::default().fg(Theme::text_primary()))); - title_spans.push(Span::styled(format!("({})", formatted_dir), Style::default().fg(Theme::text_muted()))); - - let header_lines = vec![ - Line::from(title_spans), - Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), - ]), - Line::raw(""), - ]; - f.render_widget(Paragraph::new(header_lines), main_chunks[0]); - - // 2. Conversation Render Lines (inside conv_area with 2-column padding) - let conv_area = Rect::new( - main_chunks[1].x + 2, - main_chunks[1].y, - main_chunks[1].width.saturating_sub(4), - main_chunks[1].height, +use super::input; +use super::markdown; +use super::menu; +use super::text::{self, elide, fitted, wrap}; +use crate::app::{App, Popup, TurnState}; +use crate::diff::{self, DEFAULT_MAX_DIFF_LINES}; +use crate::tool::{self, ToolRow, ToolStatus}; + +const IDLE_HINTS: &str = "enter send · ↑↓/wheel scroll · / commands · esc quit"; +const BUSY_HINTS: &str = "↑↓/wheel scroll · / commands · esc interrupt"; +const COMMAND_HINTS: &str = "↑↓ move · enter choose · esc close"; +const SEARCH_HINTS: &str = "↑↓ move · type to search · enter choose · esc close"; + +pub fn draw(app: &App, frame: &mut Frame) { + let area = frame.area(); + let palette = app.theme; + + frame.render_widget( + Block::default().style(Style::default().bg(palette.bg)), + area, ); - let max_text_width = conv_area.width as usize; - let mut conv_lines: Vec> = Vec::new(); - - for turn in turns { - // Prompt Box with border and background color - conv_lines.extend(render_user_prompt_box(&turn.prompt, max_text_width)); - conv_lines.push(Line::raw("")); - - // Thinking indicator - if !turn.thinking.is_empty() { - let spin_char = if turn.streaming { spinner.current() } else { "●" }; - conv_lines.push(Line::from(vec![ - Span::styled(format!("{} ", spin_char), Style::default().fg(Theme::activity())), - Span::styled("Thinking", Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), - ])); - - let wrap_think_w = max_text_width.saturating_sub(4).max(10); - for t_line in turn.thinking.lines().take(8) { - let wrapped = wrap_text(t_line, wrap_think_w); - for w_line in wrapped { - conv_lines.push(Line::from(vec![ - Span::styled(" │ ", Style::default().fg(Theme::text_muted())), - Span::styled(w_line, Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC)), - ])); - } - } - conv_lines.push(Line::raw("")); - } - - // Active Tool, titled from the arguments it was called with - if let Some(running) = &turn.active_tool { - conv_lines.push(Line::from(vec![ - Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), - Span::styled(running.heading(), Style::default().fg(Theme::text_primary())), - ])); - } - // Completed Tools: titled from their arguments, runs collapsed, and an - // inline diff whenever the tool produced a patch - conv_lines.extend(render_tool_rows(&turn.completed_tools, max_text_width)); - - if turn.active_tool.is_some() || !turn.completed_tools.is_empty() { - conv_lines.push(Line::raw("")); - } - - // Response markdown (word-wrapped and formatted) - if !turn.response.is_empty() { - let rendered = render_markdown(&turn.response, max_text_width); - conv_lines.extend(rendered); - conv_lines.push(Line::raw("")); - } - - // Error line - if let Some(err) = &turn.error { - conv_lines.push(Line::from(vec![ - Span::styled("✗ ", Style::default().fg(Theme::error()).add_modifier(Modifier::BOLD)), - Span::styled(err.clone(), Style::default().fg(Theme::error())), - ])); - conv_lines.push(Line::raw("")); - } - - // Turn Footer - let elapsed_sec = if let Some(end) = turn.end_time { - (end - turn.start_time) as f64 / 1000.0 - } else { - (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0 - }; - - let footer_spans = vec![ - Span::styled("● ", Style::default().fg(Theme::accent())), - Span::styled("Build ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), - Span::styled(format!("{} ", turn.model_name), Style::default().fg(Theme::model())), - Span::styled(format!("{:.1}s ", elapsed_sec), Style::default().fg(Theme::text_muted())), - Span::styled(format!("{:.1} tok/s", turn.tokens_per_sec), Style::default().fg(Theme::text_muted())), - ]; - conv_lines.push(Line::from(footer_spans)); - conv_lines.push(Line::raw("")); + if area.height < 6 || area.width < 16 { + return; } - let total_lines = conv_lines.len(); - let viewport_height = conv_area.height as usize; - - let max_scroll = total_lines.saturating_sub(viewport_height); - let clamped_offset = scroll_offset.min(max_scroll); - let effective_scroll = max_scroll.saturating_sub(clamped_offset); - - let chat_paragraph = Paragraph::new(conv_lines).scroll((effective_scroll as u16, 0)); - f.render_widget(chat_paragraph, conv_area); - - // Bottom Input Section: line on top, input text line, line on bottom - let top_divider_line = if clamped_offset > 0 { - let tag = format!(" [↑ History +{} lines (End to bottom)] ", clamped_offset); - let rem = (main_width as usize).saturating_sub(tag.len() + 3); - let left_dashes = rem / 2; - let right_dashes = rem.saturating_sub(left_dashes); - Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat(left_dashes), border_style), - Span::styled(tag, Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), - Span::styled("─".repeat(right_dashes), border_style), - ]) - } else { - Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), - ]) + let [header, rule, body, prompt, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(input::HEIGHT), + Constraint::Length(1), + ]) + .areas(area); + + // Responsive horizontal padding: 1 column on narrow terminals (< 44 cols), 2 otherwise. + let pad = if area.width < 44 { 1 } else { 2 }; + let column = Rect { + x: area.x + pad, + y: area.y, + width: area.width.saturating_sub(pad * 2), + height: area.height, }; - let mut input_widget_lines = Vec::new(); - input_widget_lines.push(top_divider_line); - - if reconnecting { - // The turn is still running server-side; this says the socket is being - // re-established, not that the work failed. - input_widget_lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), - Span::styled("Reconnecting to server...", Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), - ])); - } else if is_streaming { - input_widget_lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), - Span::styled("Generating response... (Ctrl+C to interrupt)", Style::default().fg(Theme::text_muted())), - ])); - } else { - let total_input_lines = wrapped_input.lines.len(); - let scroll_top = if wrapped_input.cursor_line >= max_visible_lines { - wrapped_input.cursor_line + 1 - max_visible_lines - } else { - 0 - }; - let visible_slice = &wrapped_input.lines[scroll_top..(scroll_top + max_visible_lines).min(total_input_lines)]; - - for (idx, line) in visible_slice.iter().enumerate() { - let line_idx = scroll_top + idx; - let mut spans = if line_idx == 0 { - vec![ - Span::raw(" "), - Span::styled("❯ ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), - ] - } else { - vec![ - Span::raw(" "), - ] - }; - spans.extend(crate::ui::input::render_line_spans(line, Theme::accent(), cursor_visible)); - input_widget_lines.push(Line::from(spans)); - } - } + draw_header( + app, + frame, + Rect { + y: header.y, + height: 1, + ..column + }, + ); + draw_rule(frame, rule, palette.border_subtle); + draw_transcript( + app, + frame, + Rect { + x: column.x, + y: body.y, + width: column.width, + height: body.height, + }, + ); - let bottom_divider_line = Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), - ]); - input_widget_lines.push(bottom_divider_line); - - f.render_widget(Paragraph::new(input_widget_lines), main_chunks[2]); - - // Command Dropdown Popup in Session - let is_dropdown_open = !is_streaming && input_buffer.starts_with('/') && !input_buffer.contains(' '); - if is_dropdown_open { - let commands = commands(); - let matching_cmds: Vec<&CommandItem> = commands - .iter() - .filter(|cmd| cmd.name.starts_with(input_buffer)) - .collect(); - - let num_rows = matching_cmds.len().max(1) as u16; - let popup_h = (num_rows + 2).min(main_chunks[1].height); - let popup_w = (main_width as u16).saturating_sub(4).max(20); - let popup_x = main_chunks[2].x + 2; - let popup_y = main_chunks[2].y.saturating_sub(popup_h); - - let popup_rect = Rect::new(popup_x, popup_y, popup_w, popup_h); - - f.render_widget(Clear, popup_rect); - - let popup_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::sky())) - .title(Span::styled(" Commands ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD))) - .title_bottom(Span::styled( - " ↑/↓ navigate • Tab complete • Enter select • Esc dismiss ", - Style::default().fg(Theme::text_muted()), - )) - .style(Style::default().bg(Theme::bg_surface())); - - f.render_widget(popup_block, popup_rect); - - let inner_rect = Rect::new( - popup_rect.x + 1, - popup_rect.y + 1, - popup_rect.width.saturating_sub(2), - popup_rect.height.saturating_sub(2), + let prompt_box = Rect { + x: column.x, + y: prompt.y, + width: column.width, + height: prompt.height, + }; + let prompt_caret = input::draw_in(frame.buffer_mut(), prompt_box, &app.prompt, palette); + let mut menu_caret = None; + + if let Some(popup) = &app.popup { + let searchable = popup.search().is_some(); + let wanted = menu::height(popup.menu(), searchable); + let room = prompt_box.y.saturating_sub(body.y); + let height = wanted.min(room); + let width = menu::width(popup.menu(), searchable).min(column.width); + let picker = Rect { + x: column.x, + y: prompt_box.y.saturating_sub(height), + width, + height, + }; + menu_caret = menu::draw( + frame.buffer_mut(), + picker, + popup.title(), + popup.menu(), + popup.search(), + palette, ); - - let mut cmd_lines = Vec::new(); - if matching_cmds.is_empty() { - cmd_lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("No matching commands", Style::default().fg(Theme::text_muted())), - ])); - } else { - for (idx, cmd) in matching_cmds.iter().enumerate() { - let is_selected = idx == dropdown_index; - if is_selected { - cmd_lines.push(Line::from(vec![ - Span::styled("▶ ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), - Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color).add_modifier(Modifier::BOLD)), - Span::styled( - format!("{:<10}", cmd.name), - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), - ), - Span::styled(cmd.description, Style::default().fg(Theme::text_primary())), - ])); - } else { - cmd_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color)), - Span::styled( - format!("{:<10}", cmd.name), - Style::default().fg(Theme::text_secondary()), - ), - Span::styled(cmd.description, Style::default().fg(Theme::text_muted())), - ])); - } - } - } - - f.render_widget(Paragraph::new(cmd_lines), inner_rect); } - // Vertical Divider - let vert_div = (0..chunks[1].height) - .map(|_| Line::from(Span::styled("│", border_style))) - .collect::>(); - f.render_widget(Paragraph::new(vert_div), chunks[1]); - - // 3. Right Sidebar with padding - let sidebar_area = Rect::new( - chunks[2].x + 2, - chunks[2].y, - chunks[2].width.saturating_sub(3), - chunks[2].height, - ); - - let sidebar_block = Block::default() - .borders(Borders::NONE); - - let mut sidebar_lines = Vec::new(); - - // Codework logo at the top of the right sidebar - if sidebar_area.height >= 18 { - let logo_pad = (sidebar_area.width as usize).saturating_sub(20) / 2; - sidebar_lines.extend(render_animated_logo_lines(logo_pad, spinner.tick_count())); - sidebar_lines.push(Line::raw("")); + if let Some(caret) = menu_caret.or(prompt_caret) { + frame.set_cursor_position(caret); } - sidebar_lines.push(Line::from(Span::styled("MODEL", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); - sidebar_lines.push(Line::from(vec![ - Span::styled(format!("{} ", config.provider), Style::default().fg(Theme::text_primary())), - Span::styled(&config.model, Style::default().fg(Theme::model()).add_modifier(Modifier::BOLD)), - ])); - sidebar_lines.push(Line::raw("")); - - sidebar_lines.push(Line::from(Span::styled("CONTEXT", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); - let pct = (stats.total_tokens as f64 / 1_000_000.0 * 100.0).clamp(0.0, 100.0); - let filled = ((pct / 10.0).round() as usize).min(10); - let empty = 10 - filled; - let bar = format!("[{}{}] {:.1}%", "█".repeat(filled), "░".repeat(empty), pct); - sidebar_lines.push(Line::from(Span::styled(bar, Style::default().fg(Theme::accent())))); - sidebar_lines.push(Line::from(Span::styled( - format!("{} / 1.0M tokens", stats.total_tokens), - Style::default().fg(Theme::text_muted()), - ))); - sidebar_lines.push(Line::raw("")); - - sidebar_lines.push(Line::from(Span::styled("METRICS", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); - sidebar_lines.push(Line::from(vec![ - Span::styled("Turns: ", Style::default().fg(Theme::text_muted())), - Span::styled(format!("{}", stats.turns_count), Style::default().fg(Theme::text_primary())), - ])); - sidebar_lines.push(Line::from(vec![ - Span::styled("Cost: ", Style::default().fg(Theme::text_muted())), - Span::styled(format!("${:.4}", stats.cost), Style::default().fg(Theme::success())), - ])); - sidebar_lines.push(Line::raw("")); - - sidebar_lines.push(Line::from(Span::styled("CONTROLS", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); - sidebar_lines.push(Line::from(Span::styled("Ctrl+C Interrupt / Exit", Style::default().fg(Theme::text_secondary())))); - sidebar_lines.push(Line::from(Span::styled("↑ / ↓ Scroll chat", Style::default().fg(Theme::text_secondary())))); - sidebar_lines.push(Line::from(Span::styled("Home/End Top / Bottom", Style::default().fg(Theme::text_secondary())))); - sidebar_lines.push(Line::from(Span::styled("Esc Return to home", Style::default().fg(Theme::text_secondary())))); - - f.render_widget(Paragraph::new(sidebar_lines).block(sidebar_block), sidebar_area); -} - -/// Renders a turn's settled calls, collapsing runs of consecutive calls to the -/// same tool into one row so a long stretch of `read` costs a line, not twenty. -pub fn render_tool_rows(tools: &[ToolResult], max_text_width: usize) -> Vec> { - let mut lines = Vec::new(); - for row in tool::group(tools) { - match row { - tool::ToolRow::Single(result) => lines.extend(render_tool_result(result, max_text_width)), - tool::ToolRow::Run { name, members, failed } => { - lines.push(render_tool_run(&name, &members, failed, max_text_width)); - } + let hints = match (&app.notice, &app.popup) { + (Some(notice), _) => notice.as_str(), + (None, Some(Popup::Commands(_) | Popup::Thinking(_))) => COMMAND_HINTS, + (None, Some(Popup::Themes { .. } | Popup::Models { .. } | Popup::Sessions { .. })) => { + SEARCH_HINTS } - } - lines + (None, None) if app.busy => BUSY_HINTS, + (None, None) => IDLE_HINTS, + }; + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + hints, + Style::default().fg(palette.text_muted), + ))) + .alignment(Alignment::Center), + hint, + ); } -/// One collapsed run: `✓ read ×7 a.rs, b.rs, src/c.rs, …`. -fn render_tool_run( - name: &str, - members: &[&ToolResult], - failed: usize, - max_text_width: usize, -) -> Line<'static> { - // A run with any failure takes the error colour, so a wall of green never - // hides the one call that did not work. - let (icon, color) = if failed > 0 { - ("✗ ", Theme::error()) - } else { - ("✓ ", Theme::success()) - }; - let label = format!("{name} ×{}", members.len()); +fn draw_header(app: &App, frame: &mut Frame, area: Rect) { + let palette = app.theme; + let buffer = frame.buffer_mut(); - let mut spans = vec![ - Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled(label.clone(), Style::default().fg(color)), - ]; + let title = app + .session + .as_ref() + .map(|s| s.title.as_str()) + .filter(|t| !t.is_empty()) + .unwrap_or("session"); - let mut meta = tool::run_targets(members); - if failed > 0 { - if !meta.is_empty() { + let mut meta = app.model_label(); + if let Some(thinking) = &app.thinking { + if thinking != "off" { meta.push_str(" · "); + meta.push_str(thinking); } - meta.push_str(&format!("{failed} failed")); } - if !meta.is_empty() { - let used = 2 + UnicodeWidthStr::width(label.as_str()) + 2; - spans.push(Span::raw(" ")); - spans.push(Span::styled( - elide(&meta, max_text_width.saturating_sub(used)), - Style::default().fg(Theme::text_muted()), + if app.usage.total_tokens > 0 { + meta.push_str(&format!( + " · {} tok · ${:.4}", + app.usage.total_tokens, app.usage.cost )); } - Line::from(spans) -} - -/// Renders one settled tool call: a status line headed by the tool and what it -/// touched, plus a truncated inline unified diff when it returned a `patch`. -pub fn render_tool_result(tool: &ToolResult, max_text_width: usize) -> Vec> { - let (icon, color) = if tool.is_error { - ("✗ ", Theme::error()) + let meta_width = text::width(&meta) as u16; + let right_x = if meta_width + 6 <= area.width { + let x = area.right() - meta_width; + buffer.set_string(x, area.y, &meta, Style::default().fg(palette.text_muted)); + x.saturating_sub(2) } else { - ("✓ ", Theme::success()) + area.right() }; - // The summary is the outcome and is never dropped; the heading gives way to - // it when the pane is too narrow for both. - let summary_width = tool - .summary - .as_ref() - .map(|summary| UnicodeWidthStr::width(summary.as_str()) + 2) - .unwrap_or(0); - - let mut header = vec![ - Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled( - elide(&tool.heading(), max_text_width.saturating_sub(2 + summary_width)), - Style::default().fg(color), - ), - ]; - if let Some(summary) = &tool.summary { - header.push(Span::raw(" ")); - header.push(Span::styled(summary.clone(), Style::default().fg(Theme::text_muted()))); - } - - let Some(patch) = &tool.patch else { - return vec![Line::from(header)]; - }; - - let (diff_lines, _) = crate::diff::render_diff( - patch, - max_text_width.saturating_sub(2), - crate::diff::DEFAULT_MAX_DIFF_LINES, + buffer.set_string(area.x, area.y, "● ", Style::default().fg(palette.brand)); + let title_room = right_x.saturating_sub(area.x + 2) as usize; + let fitted_title = fitted(title, title_room); + buffer.set_string( + area.x + 2, + area.y, + fitted_title, + Style::default().fg(palette.text), ); - let mut lines = vec![Line::from(header)]; - for mut diff_line in diff_lines { - diff_line.spans.insert(0, Span::raw(" ")); - lines.push(diff_line); - } - lines -} - -#[allow(dead_code)] -pub fn render_input_with_cursor(input: &str, cursor: usize) -> Vec> { - crate::ui::input::render_input_with_cursor(input, cursor) -} - -pub fn render_user_prompt_box(prompt: &str, max_text_width: usize) -> Vec> { - let box_bg = Theme::bg_user_msg(); - let border_color = Theme::border_user_msg(); - let wrap_w = max_text_width.saturating_sub(6).max(10); - - let mut prompt_lines = Vec::new(); - for raw_line in prompt.lines() { - let wrapped = wrap_text(raw_line, wrap_w); - if wrapped.is_empty() { - prompt_lines.push(String::new()); - } else { - prompt_lines.extend(wrapped); + // On wider terminals, show the session's working directory beside its title. + if area.width >= 64 { + if let Some(session) = &app.session { + let dir = session.host_dir.as_deref().unwrap_or(&session.directory); + let title_cols = text::width(fitted_title) as u16; + let dir_x = area.x + 2 + title_cols + 2; + if dir_x + 8 < right_x { + let dir_room = right_x.saturating_sub(dir_x) as usize; + buffer.set_string( + dir_x, + area.y, + fitted(dir, dir_room), + Style::default().fg(palette.text_muted), + ); + } } } - if prompt_lines.is_empty() { - prompt_lines.push(prompt.to_string()); - } +} - // Full width user message box across the entire conversation area - let box_w = max_text_width; - let avail_w = box_w.saturating_sub(4); - - let mut lines = Vec::new(); - - // 1. Top border: ╭── ❯ ──────────────────────╮ - let top_prefix = "╭── "; - let top_icon = "❯ "; - let top_right = "╮"; - let bar_len = box_w.saturating_sub(7); - - lines.push(Line::from(vec![ - Span::styled(top_prefix, Style::default().fg(border_color).bg(box_bg)), - Span::styled( - top_icon, - Style::default().fg(Theme::primary()).bg(box_bg).add_modifier(Modifier::BOLD), - ), - Span::styled("─".repeat(bar_len), Style::default().fg(border_color).bg(box_bg)), - Span::styled(top_right, Style::default().fg(border_color).bg(box_bg)), - ])); - - // 2. Middle lines: │ │ - for p_line in prompt_lines { - let line_w = UnicodeWidthStr::width(p_line.as_str()); - let (display_text, actual_w) = if line_w > avail_w { - let mut truncated = String::new(); - let mut cur_w = 0; - for ch in p_line.chars() { - let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if cur_w + ch_w + 1 <= avail_w { - truncated.push(ch); - cur_w += ch_w; - } else { - break; - } - } - truncated.push('…'); - let tw = UnicodeWidthStr::width(truncated.as_str()); - (truncated, tw) - } else { - (p_line, line_w) - }; - let pad = avail_w.saturating_sub(actual_w); - lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(border_color).bg(box_bg)), - Span::styled( - display_text, - Style::default().fg(Theme::text_primary()).bg(box_bg).add_modifier(Modifier::BOLD), - ), - Span::styled(" ".repeat(pad), Style::default().bg(box_bg)), - Span::styled(" │", Style::default().fg(border_color).bg(box_bg)), - ])); +fn draw_rule(frame: &mut Frame, area: Rect, color: ratatui::style::Color) { + let buffer = frame.buffer_mut(); + let style = Style::default().fg(color); + for x in area.x..area.right() { + buffer[(x, area.y)].set_char('─').set_style(style); } - - // 3. Bottom border: ╰────────────────────────╯ - let bot_bar_len = box_w.saturating_sub(2); - lines.push(Line::from(vec![ - Span::styled("╰", Style::default().fg(border_color).bg(box_bg)), - Span::styled("─".repeat(bot_bar_len), Style::default().fg(border_color).bg(box_bg)), - Span::styled("╯", Style::default().fg(border_color).bg(box_bg)), - ])); - - lines } -#[cfg(test)] -mod tests { - use super::*; - - fn line_visual_width(line: &Line) -> usize { - line.spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum() +fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width < 6 { + return; } - #[test] - fn test_render_user_prompt_box_single_line() { - let lines = render_user_prompt_box("Hello CodeWork", 60); - assert_eq!(lines.len(), 3); - let expected_w = line_visual_width(&lines[0]); - assert_eq!(expected_w, 60, "Box width must equal full max_text_width"); - for line in &lines { - assert_eq!(line_visual_width(line), expected_w, "All lines in user prompt box must have equal visual width"); + let palette = app.theme; + let wrap_width = area.width.saturating_sub(2) as usize; + let mut lines: Vec> = Vec::new(); + + for (idx, turn) in app.turns.iter().enumerate() { + if idx > 0 { + lines.push(Line::default()); } - } - #[test] - fn test_render_user_prompt_box_multiline() { - let lines = render_user_prompt_box("Line 1\nLine 2 is longer\nLine 3", 60); - assert_eq!(lines.len(), 5); - let expected_w = line_visual_width(&lines[0]); - assert_eq!(expected_w, 60, "Box width must equal full max_text_width"); - for line in &lines { - assert_eq!(line_visual_width(line), expected_w, "All lines in multiline box must have equal visual width"); + // User prompt + for (line_idx, chunk) in wrap(&turn.prompt, wrap_width).into_iter().enumerate() { + let prefix = if line_idx == 0 { "❯ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette.brand)), + Span::styled(chunk, Style::default().fg(palette.text)), + ])); } - } - #[test] - fn test_render_user_prompt_box_has_background() { - let lines = render_user_prompt_box("check background color", 50); - for line in &lines { - for span in &line.spans { - assert_eq!(span.style.bg, Some(Theme::bg_user_msg())); + // Reasoning / thinking + if !turn.thinking.is_empty() { + for (line_idx, chunk) in wrap(turn.thinking.trim(), wrap_width.saturating_sub(2)) + .into_iter() + .enumerate() + { + let prefix = if line_idx == 0 { " ◌ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette.text_muted)), + Span::styled(chunk, Style::default().fg(palette.text_muted)), + ])); } } - } - - fn tool_text(lines: &[Line]) -> String { - lines - .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) - .collect::>() - .join("\n") - } - - fn edit_result(patch: &str) -> ToolResult { - crate::tool::derive( - "edit", - None, - &serde_json::json!({ "path": "src/a.ts" }), - Some(&serde_json::json!({ "patch": patch, "path": "src/a.ts", "firstChangedLine": 2 })), - false, - ) - } - fn read_call(target: &str) -> ToolResult { - crate::tool::derive("read", None, &serde_json::json!({ "path": target }), None, false) - } - - fn headings(lines: &[Line]) -> usize { - lines - .iter() - .filter(|line| line.spans.first().map(|span| span.content.as_ref()) == Some("✓ ")) - .count() - } + // Tool calls (grouped when 3+ identical non-diff calls settle in a row) + for row in tool::group(&turn.tools) { + match row { + ToolRow::Single(tool) => { + if let Some(patch) = &tool.patch { + let counts = diff::counts(patch); + let path = tool + .target + .clone() + .or_else(|| diff::patch_path(patch)) + .unwrap_or_else(|| tool.name.clone()); + let spans = vec![ + Span::styled(" ← ", Style::default().fg(palette.brand)), + Span::styled("Edit ", Style::default().fg(palette.text)), + Span::styled(path, Style::default().fg(palette.text_secondary)), + Span::styled(" (", Style::default().fg(palette.text_muted)), + Span::styled( + format!("+{}", counts.added), + Style::default().fg(palette.success()), + ), + Span::raw(" "), + Span::styled( + format!("-{}", counts.removed), + Style::default().fg(palette.error()), + ), + Span::styled(")", Style::default().fg(palette.text_muted)), + ]; + lines.push(Line::from(spans)); + + let diff_width = wrap_width.saturating_sub(4).max(16); + let (diff_lines, _) = + diff::render_diff(patch, diff_width, DEFAULT_MAX_DIFF_LINES, palette); + for mut dline in diff_lines { + dline.spans.insert(0, Span::raw(" ")); + lines.push(dline); + } + continue; + } + + let (icon, icon_color) = match tool.status { + ToolStatus::Running => (" ⚙ ", palette.brand), + ToolStatus::Completed => (" ✓ ", palette.success()), + ToolStatus::Failed => (" ✗ ", palette.error()), + }; + let mut spans = vec![ + Span::styled(icon, Style::default().fg(icon_color)), + Span::styled(tool.name.clone(), Style::default().fg(palette.text)), + ]; + let mut used = 4 + text::width(&tool.name); + if let Some(target) = &tool.target { + let room = wrap_width.saturating_sub(used + 1); + if room > 4 { + let shown = elide(target, room); + used += 1 + text::width(&shown); + spans.push(Span::styled( + format!(" {shown}"), + Style::default().fg(palette.text_secondary), + )); + } + } + if let Some(summary) = &tool.summary { + let room = wrap_width.saturating_sub(used + 3); + if room > 4 { + let shown = elide(summary, room); + spans.push(Span::styled( + format!(" · {shown}"), + Style::default().fg(if tool.status == ToolStatus::Failed { + palette.error() + } else { + palette.text_muted + }), + )); + } + } + lines.push(Line::from(spans)); + + if tool.status == ToolStatus::Failed { + if let Some(output) = &tool.output { + for err_line in output.lines().take(3) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + elide(err_line, wrap_width.saturating_sub(2)), + Style::default().fg(palette.error()), + ), + ])); + } + } + } + } + ToolRow::Run { + name, + members, + failed, + } => { + let (icon, icon_color) = if failed > 0 { + (" ✗ ", palette.error()) + } else { + (" ✓ ", palette.success()) + }; + let heading = format!("{name} ×{}", members.len()); + let targets = tool::run_targets(&members); + let room = wrap_width.saturating_sub(4 + text::width(&heading) + 3); + let mut spans = vec![ + Span::styled(icon, Style::default().fg(icon_color)), + Span::styled(heading, Style::default().fg(palette.text)), + ]; + if !targets.is_empty() && room > 4 { + spans.push(Span::styled( + format!(" · {}", elide(&targets, room)), + Style::default().fg(palette.text_muted), + )); + } + if failed > 0 { + spans.push(Span::styled( + format!(" · {failed} failed"), + Style::default().fg(palette.error()), + )); + } + lines.push(Line::from(spans)); + } + } + } - #[test] - fn test_render_tool_result_with_patch() { - let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n"; - let lines = render_tool_result(&edit_result(patch), 60); - let text = tool_text(&lines); - assert!(text.contains("edit"), "{text}"); - assert!(text.contains("+1 −1"), "{text}"); - assert!(text.contains("src/a.ts"), "{text}"); - assert!(text.contains("line 2"), "{text}"); - assert!(text.contains("- let b = 2;"), "{text}"); - assert!(text.contains("+ let b = 3;"), "{text}"); - } + // Plugin / runtime notices + for notice in &turn.notices { + for (line_idx, chunk) in wrap(notice, wrap_width.saturating_sub(2)) + .into_iter() + .enumerate() + { + let prefix = if line_idx == 0 { " ⚠ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette.warning())), + Span::styled(chunk, Style::default().fg(palette.text_secondary)), + ])); + } + } - #[test] - fn test_render_tool_result_tints_added_and_removed() { - let patch = "--- a\n+++ a\n@@ -1 +1 @@\n-old\n+new\n"; - let lines = render_tool_result(&edit_result(patch), 60); - let spans = || lines.iter().flat_map(|l| l.spans.iter()); - let added = spans() - .find(|s| s.content.as_ref() == "new") - .expect("added content span"); - assert_eq!(added.style.bg, Some(Theme::diff_add_bg())); - let removed = spans() - .find(|s| s.content.as_ref() == "old") - .expect("removed content span"); - assert_eq!(removed.style.bg, Some(Theme::diff_del_bg())); - } + // Assistant response (markdown) + if !turn.text.is_empty() { + lines.push(Line::default()); + for mut md_line in markdown::render(turn.text.trim_end(), wrap_width, palette) { + md_line.spans.insert(0, Span::raw(" ")); + lines.push(md_line); + } + } - #[test] - fn test_render_tool_result_without_patch_is_single_line() { - let lines = render_tool_result(&read_call("src/a.ts"), 60); - assert_eq!(lines.len(), 1); - assert!(tool_text(&lines).contains("read src/a.ts")); + // Turn status + match &turn.state { + TurnState::Running => { + if app.reconnecting { + lines.push(Line::from(Span::styled( + " ◌ reconnecting to server...", + Style::default().fg(palette.warning()), + ))); + } else if turn.text.is_empty() && turn.thinking.is_empty() && turn.tools.is_empty() + { + lines.push(Line::from(Span::styled( + " ● thinking...", + Style::default().fg(palette.brand), + ))); + } + } + TurnState::Succeeded => {} + TurnState::Failed(err) => { + for (line_idx, chunk) in wrap(err, wrap_width.saturating_sub(2)) + .into_iter() + .enumerate() + { + let prefix = if line_idx == 0 { " ✗ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette.error())), + Span::styled(chunk, Style::default().fg(palette.error())), + ])); + } + } + TurnState::Interrupted(reason) => { + lines.push(Line::from(Span::styled( + format!(" ○ interrupted ({reason})"), + Style::default().fg(palette.text_muted), + ))); + } + } } - #[test] - fn test_render_tool_result_keeps_the_summary_when_narrow() { - let long = "packages/harness/src/plugin/builtin/tool/something-very-long/edit.ts"; - let tool = crate::tool::derive( - "read", - None, - &serde_json::json!({ "path": long }), - Some(&serde_json::json!({ - "startLine": 1, - "endLine": 10, - "totalLines": 99, - "truncated": false, - })), - false, - ); - let lines = render_tool_result(&tool, 30); - let text = tool_text(&lines); - assert!(text.contains("1-10 of 99"), "the outcome must survive elision: {text}"); - assert!(UnicodeWidthStr::width(text.as_str()) <= 30, "{text}"); - } + let total_lines = lines.len(); + let visible = area.height as usize; + let max_scroll = total_lines.saturating_sub(visible); + app.max_scroll.set(max_scroll); + let scroll_from_bottom = app.scroll.min(max_scroll); + let start = max_scroll.saturating_sub(scroll_from_bottom); + let slice: Vec> = lines.into_iter().skip(start).take(visible).collect(); + + let text_area = if max_scroll > 0 { + Rect { + width: area.width.saturating_sub(2), + ..area + } + } else { + area + }; + frame.render_widget(Paragraph::new(slice), text_area); - #[test] - fn test_render_tool_rows_collapses_a_run() { - let tools = vec![read_call("a.rs"), read_call("b.rs"), read_call("c.rs")]; - let lines = render_tool_rows(&tools, 60); - assert_eq!(lines.len(), 1, "three reads collapse into one row"); - let text = tool_text(&lines); - assert!(text.contains("read ×3"), "{text}"); - assert!(text.contains("a.rs, b.rs, c.rs"), "{text}"); + if max_scroll > 0 { + draw_scrollbar(frame, area, total_lines, visible, start, app.theme); } +} - #[test] - fn test_render_tool_rows_keeps_a_short_run_separate() { - let tools = vec![read_call("a.rs"), read_call("b.rs")]; - let lines = render_tool_rows(&tools, 60); - assert_eq!(lines.len(), 2); - assert_eq!(headings(&lines), 2); +fn draw_scrollbar( + frame: &mut Frame, + area: Rect, + total_lines: usize, + visible: usize, + start: usize, + palette: &crate::theme::Palette, +) { + if area.height == 0 || total_lines <= visible { + return; } + let track_h = area.height as usize; + let thumb_h = ((visible * track_h) / total_lines).clamp(1, track_h); + let max_start = total_lines.saturating_sub(visible); + let max_thumb_top = track_h.saturating_sub(thumb_h); + let thumb_top = if max_start == 0 { + 0 + } else { + (start * max_thumb_top + (max_start / 2)) / max_start + }; - #[test] - fn test_render_tool_rows_marks_failures_in_a_run() { - let mut failed = read_call("b.rs"); - failed.is_error = true; - let tools = vec![read_call("a.rs"), failed, read_call("c.rs")]; - let lines = render_tool_rows(&tools, 60); - assert_eq!(lines.len(), 1); - let text = tool_text(&lines); - assert!(text.contains("read ×3"), "{text}"); - assert!(text.contains("1 failed"), "{text}"); - let label = lines[0] - .spans - .iter() - .find(|span| span.content.contains("read ×3")) - .expect("the run label"); - assert_eq!(label.style.fg, Some(Theme::error())); - } + let x = area.right().saturating_sub(1); + let buffer = frame.buffer_mut(); + let track_style = Style::default().fg(palette.border_subtle); + let thumb_style = Style::default().fg(palette.brand); - #[test] - fn test_render_tool_rows_never_collapses_a_run_of_edits() { - let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; - let tools = vec![edit_result(patch), edit_result(patch), edit_result(patch)]; - let lines = render_tool_rows(&tools, 60); - assert_eq!(headings(&lines), 3, "each edit keeps its own heading and diff"); - assert!(!tool_text(&lines).contains('×')); + for row in 0..track_h { + let y = area.y + row as u16; + if row >= thumb_top && row < thumb_top + thumb_h { + buffer[(x, y)].set_char('┃').set_style(thumb_style); + } else { + buffer[(x, y)].set_char('│').set_style(track_style); + } } } diff --git a/packages/tui/src/ui/spinner.rs b/packages/tui/src/ui/spinner.rs deleted file mode 100644 index f38dc90b..00000000 --- a/packages/tui/src/ui/spinner.rs +++ /dev/null @@ -1,49 +0,0 @@ -pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -/// Number of 80ms ticks per caret blink phase (~480ms on / ~480ms off). -const BLINK_TICKS: usize = 6; - -#[derive(Debug, Clone, Default)] -pub struct Spinner { - frame: usize, - tick_count: usize, - blink_tick: usize, -} - -impl Spinner { - pub fn new() -> Self { - Self { - frame: 0, - tick_count: 0, - blink_tick: 0, - } - } - - pub fn tick(&mut self) { - self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); - self.tick_count = self.tick_count.wrapping_add(1); - self.blink_tick = self.blink_tick.wrapping_add(1); - } - - pub fn current(&self) -> &'static str { - SPINNER_FRAMES[self.frame] - } - - - pub fn tick_count(&self) -> usize { - self.tick_count - } - - /// Whether the text caret should currently be drawn. - /// Toggles on a fixed cadence driven by the render tick. - pub fn cursor_visible(&self) -> bool { - (self.blink_tick / BLINK_TICKS) % 2 == 0 - } - - /// Restart the caret blink cycle. Call on user input so the caret stays - /// solid while typing instead of vanishing mid-keystroke. - pub fn reset_blink(&mut self) { - self.blink_tick = 0; - } -} - diff --git a/packages/tui/src/ui/text.rs b/packages/tui/src/ui/text.rs index b5cdce5f..5a440e03 100644 --- a/packages/tui/src/ui/text.rs +++ b/packages/tui/src/ui/text.rs @@ -1,60 +1,106 @@ -//! Width-aware text helpers shared by the transcript renderers. +//! Text measured and cut the way a terminal shows it: columns, not bytes or characters. +//! +//! A buffer write is clipped by the buffer, not by the box it was aimed at, so anything that must +//! not run into a border has to be cut before it is written. The prompt line and the menus both +//! need that, which is why it lives here rather than in either of them. +use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; -/// Truncates `text` to at most `max_width` display columns, appending `…` when -/// it does not fit. -/// -/// Width is measured in Unicode columns rather than bytes or characters, so a -/// line of CJK text or emoji stays inside the pane instead of overflowing it. -pub fn elide(text: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(text) <= max_width { - return text.to_string(); +/// The longest prefix of `text` that fits `width` columns, cut on a character boundary. A character +/// wider than the room left is dropped rather than half-drawn. +pub fn fitted(text: &str, width: usize) -> &str { + let mut used = 0; + for (offset, ch) in text.char_indices() { + let char_width = ch.width().unwrap_or(0); + if used + char_width > width { + return &text[..offset]; + } + used += char_width; } - let mut out = String::new(); - let mut width = 0usize; - for ch in text.chars() { - let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - // +1 reserves the column the ellipsis itself needs. - if width + ch_width + 1 > max_width { + text +} + +/// A slice of `text` scrolled so the caret at the end of `before_caret` stays inside `width` +/// columns, and the column offset of the caret within that slice. +pub fn scrolled<'a>(text: &'a str, before_caret: &str, width: usize) -> (&'a str, u16) { + let (mut used, mut start) = (0, before_caret.len()); + for (offset, ch) in before_caret.char_indices().rev() { + let char_width = ch.width().unwrap_or(0); + if used + char_width > width { break; } - out.push(ch); - width += ch_width; + used += char_width; + start = offset; } - out.push('…'); - out + (fitted(&text[start..], width), used as u16) } -#[cfg(test)] -mod tests { - use super::*; +/// The columns `text` takes, which is what a layout has to reserve for it. +pub fn width(text: &str) -> usize { + text.width() +} - #[test] - fn keeps_text_that_fits() { - assert_eq!(elide("src/app.ts", 40), "src/app.ts"); - assert_eq!(elide("src/app.ts", 10), "src/app.ts"); +/// Fit `text` into `max_width` columns, replacing any cut tail with `…`. +pub fn elide(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); } - - #[test] - fn adds_an_ellipsis_when_it_cannot_fit() { - assert_eq!(elide("abcdefghij", 5), "abcd…"); + if text.width() <= max_width { + return text.to_string(); } - - #[test] - fn zero_width_yields_nothing() { - assert_eq!(elide("anything", 0), ""); + if max_width == 1 { + return "…".to_string(); } + format!("{}…", fitted(text, max_width - 1)) +} - #[test] - fn measures_display_columns_not_characters() { - // Each glyph is two columns, so five of them plus the ellipsis fill exactly - // eleven — a byte- or char-based cut would have overflowed the pane. - let elided = elide("你好你好你好", 11); - assert_eq!(elided, "你好你好你…"); - assert_eq!(UnicodeWidthStr::width(elided.as_str()), 11); +/// Wrap `text` into lines that fit within `max_width` terminal columns. +pub fn wrap(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return Vec::new(); } + let mut out = Vec::new(); + for raw_line in text.split('\n') { + if raw_line.is_empty() { + out.push(String::new()); + continue; + } + let mut current = String::new(); + let mut current_width = 0usize; + for word in raw_line.split_whitespace() { + let word_width = width(word); + if current.is_empty() { + if word_width <= max_width { + current.push_str(word); + current_width = word_width; + } else { + let mut rest = word; + while !rest.is_empty() { + let part = fitted(rest, max_width); + if part.is_empty() { + break; + } + out.push(part.to_string()); + rest = &rest[part.len()..]; + } + } + } else if current_width + 1 + word_width <= max_width { + current.push(' '); + current.push_str(word); + current_width += 1 + word_width; + } else { + out.push(current); + current = word.to_string(); + current_width = word_width; + } + } + if !current.is_empty() { + out.push(current); + } + } + if out.is_empty() { + out.push(String::new()); + } + out } diff --git a/packages/tui/src/ui/theme.rs b/packages/tui/src/ui/theme.rs deleted file mode 100644 index 5affeee1..00000000 --- a/packages/tui/src/ui/theme.rs +++ /dev/null @@ -1,416 +0,0 @@ -use std::sync::atomic::{AtomicUsize, Ordering}; - -use ratatui::style::Color; - -/// One full palette. Every palette fills the same role set, so any palette can -/// be swapped in at runtime without touching call sites. Add a palette to -/// [`PALETTES`] and it shows up in the `/theme` picker automatically. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Palette { - pub id: &'static str, - pub label: &'static str, - pub dark: bool, - pub bg_app: Color, - pub bg_surface: Color, - pub bg_card: Color, - pub bg_user_msg: Color, - pub border: Color, - pub border_box: Color, - pub border_active: Color, - pub border_user_msg: Color, - pub primary: Color, - pub model: Color, - pub accent: Color, - pub secondary: Color, - pub text_secondary: Color, - pub sky: Color, - pub hint: Color, - pub pink: Color, - pub error: Color, - pub success: Color, - pub code_accent: Color, - pub warning: Color, - pub activity: Color, - pub bold_accent: Color, - pub text_primary: Color, - pub text_muted: Color, - pub diff_add_bg: Color, - pub diff_del_bg: Color, - pub diff_hunk: Color, - pub diff_meta: Color, -} - -/// Catppuccin Mocha — the original CodeWork look. -const CATPPUCCIN_MOCHA: Palette = Palette { - id: "catppuccin-mocha", - label: "Catppuccin Mocha", - dark: true, - bg_app: Color::Rgb(30, 30, 46), - bg_surface: Color::Rgb(37, 37, 56), - bg_card: Color::Rgb(37, 37, 56), - bg_user_msg: Color::Rgb(49, 50, 68), - border: Color::Rgb(108, 112, 134), - border_box: Color::Rgb(108, 112, 134), - border_active: Color::Rgb(180, 190, 254), - border_user_msg: Color::Rgb(180, 190, 254), - primary: Color::Rgb(180, 190, 254), - model: Color::Rgb(180, 190, 254), - accent: Color::Rgb(137, 180, 250), - secondary: Color::Rgb(137, 180, 250), - text_secondary: Color::Rgb(137, 180, 250), - sky: Color::Rgb(115, 199, 236), - hint: Color::Rgb(115, 199, 236), - pink: Color::Rgb(243, 139, 168), - error: Color::Rgb(243, 139, 168), - success: Color::Rgb(166, 227, 161), - code_accent: Color::Rgb(166, 227, 161), - warning: Color::Rgb(235, 160, 172), - activity: Color::Rgb(235, 160, 172), - bold_accent: Color::Rgb(235, 160, 172), - text_primary: Color::Rgb(166, 173, 200), - text_muted: Color::Rgb(108, 112, 134), - diff_add_bg: Color::Rgb(30, 46, 36), - diff_del_bg: Color::Rgb(52, 31, 40), - diff_hunk: Color::Rgb(115, 199, 236), - diff_meta: Color::Rgb(108, 112, 134), -}; - -/// Tokyo Night — deep blue-black with violet accents. -const TOKYO_NIGHT: Palette = Palette { - id: "tokyo-night", - label: "Tokyo Night", - dark: true, - bg_app: Color::Rgb(26, 27, 38), - bg_surface: Color::Rgb(31, 33, 46), - bg_card: Color::Rgb(31, 33, 46), - bg_user_msg: Color::Rgb(41, 46, 66), - border: Color::Rgb(41, 46, 66), - border_box: Color::Rgb(41, 46, 66), - border_active: Color::Rgb(125, 207, 255), - border_user_msg: Color::Rgb(125, 207, 255), - primary: Color::Rgb(187, 154, 247), - model: Color::Rgb(125, 207, 255), - accent: Color::Rgb(125, 207, 255), - secondary: Color::Rgb(125, 207, 255), - text_secondary: Color::Rgb(125, 207, 255), - sky: Color::Rgb(125, 207, 255), - hint: Color::Rgb(125, 207, 255), - pink: Color::Rgb(247, 118, 142), - error: Color::Rgb(247, 118, 142), - success: Color::Rgb(158, 206, 106), - code_accent: Color::Rgb(158, 206, 106), - warning: Color::Rgb(224, 175, 104), - activity: Color::Rgb(224, 175, 104), - bold_accent: Color::Rgb(224, 175, 104), - text_primary: Color::Rgb(192, 202, 245), - text_muted: Color::Rgb(86, 95, 137), - diff_add_bg: Color::Rgb(32, 44, 33), - diff_del_bg: Color::Rgb(51, 34, 46), - diff_hunk: Color::Rgb(125, 207, 255), - diff_meta: Color::Rgb(86, 95, 137), -}; - -/// Gruvbox Dark — warm earth tones. -const GRUVBOX_DARK: Palette = Palette { - id: "gruvbox-dark", - label: "Gruvbox Dark", - dark: true, - bg_app: Color::Rgb(40, 40, 40), - bg_surface: Color::Rgb(50, 50, 50), - bg_card: Color::Rgb(50, 50, 50), - bg_user_msg: Color::Rgb(60, 56, 54), - border: Color::Rgb(124, 111, 100), - border_box: Color::Rgb(124, 111, 100), - border_active: Color::Rgb(184, 187, 38), - border_user_msg: Color::Rgb(184, 187, 38), - primary: Color::Rgb(250, 189, 47), - model: Color::Rgb(184, 187, 38), - accent: Color::Rgb(131, 165, 152), - secondary: Color::Rgb(131, 165, 152), - text_secondary: Color::Rgb(131, 165, 152), - sky: Color::Rgb(131, 165, 152), - hint: Color::Rgb(131, 165, 152), - pink: Color::Rgb(251, 73, 52), - error: Color::Rgb(251, 73, 52), - success: Color::Rgb(184, 187, 38), - code_accent: Color::Rgb(184, 187, 38), - warning: Color::Rgb(254, 128, 25), - activity: Color::Rgb(254, 128, 25), - bold_accent: Color::Rgb(254, 128, 25), - text_primary: Color::Rgb(235, 219, 178), - text_muted: Color::Rgb(146, 131, 116), - diff_add_bg: Color::Rgb(50, 54, 38), - diff_del_bg: Color::Rgb(58, 42, 38), - diff_hunk: Color::Rgb(131, 165, 152), - diff_meta: Color::Rgb(146, 131, 116), -}; - -/// Catppuccin Latte — Catppuccin's light variant. -const CATPPUCCIN_LATTE: Palette = Palette { - id: "catppuccin-latte", - label: "Catppuccin Latte", - dark: false, - bg_app: Color::Rgb(239, 241, 245), - bg_surface: Color::Rgb(230, 233, 239), - bg_card: Color::Rgb(230, 233, 239), - bg_user_msg: Color::Rgb(204, 208, 216), - border: Color::Rgb(140, 143, 160), - border_box: Color::Rgb(140, 143, 160), - border_active: Color::Rgb(30, 102, 245), - border_user_msg: Color::Rgb(30, 102, 245), - primary: Color::Rgb(114, 135, 253), - model: Color::Rgb(114, 135, 253), - accent: Color::Rgb(4, 165, 229), - secondary: Color::Rgb(4, 165, 229), - text_secondary: Color::Rgb(4, 165, 229), - sky: Color::Rgb(4, 165, 229), - hint: Color::Rgb(4, 165, 229), - pink: Color::Rgb(210, 15, 57), - error: Color::Rgb(210, 15, 57), - success: Color::Rgb(64, 160, 43), - code_accent: Color::Rgb(64, 160, 43), - warning: Color::Rgb(223, 142, 29), - activity: Color::Rgb(223, 142, 29), - bold_accent: Color::Rgb(223, 142, 29), - text_primary: Color::Rgb(92, 99, 112), - text_muted: Color::Rgb(140, 143, 160), - diff_add_bg: Color::Rgb(214, 236, 216), - diff_del_bg: Color::Rgb(245, 213, 218), - diff_hunk: Color::Rgb(4, 165, 229), - diff_meta: Color::Rgb(140, 143, 160), -}; - -/// GitHub Light — the classic clean light theme. -const GITHUB_LIGHT: Palette = Palette { - id: "github-light", - label: "GitHub Light", - dark: false, - bg_app: Color::Rgb(255, 255, 255), - bg_surface: Color::Rgb(246, 248, 250), - bg_card: Color::Rgb(246, 248, 250), - bg_user_msg: Color::Rgb(234, 238, 242), - border: Color::Rgb(208, 215, 222), - border_box: Color::Rgb(208, 215, 222), - border_active: Color::Rgb(9, 105, 218), - border_user_msg: Color::Rgb(9, 105, 218), - primary: Color::Rgb(135, 86, 228), - model: Color::Rgb(9, 105, 218), - accent: Color::Rgb(9, 105, 218), - secondary: Color::Rgb(9, 105, 218), - text_secondary: Color::Rgb(9, 105, 218), - sky: Color::Rgb(9, 105, 218), - hint: Color::Rgb(9, 105, 218), - pink: Color::Rgb(207, 34, 46), - error: Color::Rgb(207, 34, 46), - success: Color::Rgb(26, 127, 55), - code_accent: Color::Rgb(26, 127, 55), - warning: Color::Rgb(154, 103, 0), - activity: Color::Rgb(154, 103, 0), - bold_accent: Color::Rgb(154, 103, 0), - text_primary: Color::Rgb(31, 35, 40), - text_muted: Color::Rgb(101, 108, 118), - diff_add_bg: Color::Rgb(218, 244, 224), - diff_del_bg: Color::Rgb(255, 224, 227), - diff_hunk: Color::Rgb(9, 105, 218), - diff_meta: Color::Rgb(101, 108, 118), -}; - -/// Solarized Light — the timeless warm light theme. -const SOLARIZED_LIGHT: Palette = Palette { - id: "solarized-light", - label: "Solarized Light", - dark: false, - bg_app: Color::Rgb(253, 246, 227), - bg_surface: Color::Rgb(238, 232, 213), - bg_card: Color::Rgb(238, 232, 213), - bg_user_msg: Color::Rgb(221, 215, 195), - border: Color::Rgb(147, 161, 161), - border_box: Color::Rgb(147, 161, 161), - border_active: Color::Rgb(38, 139, 210), - border_user_msg: Color::Rgb(38, 139, 210), - primary: Color::Rgb(133, 153, 0), - model: Color::Rgb(38, 139, 210), - accent: Color::Rgb(38, 139, 210), - secondary: Color::Rgb(38, 139, 210), - text_secondary: Color::Rgb(38, 139, 210), - sky: Color::Rgb(38, 139, 210), - hint: Color::Rgb(38, 139, 210), - pink: Color::Rgb(220, 50, 47), - error: Color::Rgb(220, 50, 47), - success: Color::Rgb(133, 153, 0), - code_accent: Color::Rgb(133, 153, 0), - warning: Color::Rgb(203, 75, 22), - activity: Color::Rgb(203, 75, 22), - bold_accent: Color::Rgb(203, 75, 22), - text_primary: Color::Rgb(101, 123, 131), - text_muted: Color::Rgb(147, 161, 161), - diff_add_bg: Color::Rgb(230, 240, 213), - diff_del_bg: Color::Rgb(250, 229, 224), - diff_hunk: Color::Rgb(38, 139, 210), - diff_meta: Color::Rgb(147, 161, 161), -}; - -/// The 6 built-in themes: 3 dark, 3 light. -pub const PALETTES: &[Palette] = &[ - CATPPUCCIN_MOCHA, - TOKYO_NIGHT, - GRUVBOX_DARK, - CATPPUCCIN_LATTE, - GITHUB_LIGHT, - SOLARIZED_LIGHT, -]; - -static ACTIVE: AtomicUsize = AtomicUsize::new(0); - -/// Switch the active palette by index; returns `false` when out of range. -pub fn set_active(index: usize) -> bool { - if index >= PALETTES.len() { - return false; - } - ACTIVE.store(index, Ordering::Relaxed); - true -} - -/// Index of the active palette into [`PALETTES`]. -pub fn active_index() -> usize { - ACTIVE.load(Ordering::Relaxed) -} - -pub fn active() -> &'static Palette { - &PALETTES[ACTIVE.load(Ordering::Relaxed)] -} - -/// Look up a palette by id, e.g. to restore a persisted choice. -pub fn by_id(id: &str) -> Option { - PALETTES.iter().position(|p| p.id == id) -} - -/// Legacy role names used across the codebase, resolved against the active -/// palette. `Theme::bg_app()` etc. read naturally next to the old -/// `Theme::BG_APP` constants they replaced. -pub struct Theme; - -#[allow(dead_code)] -impl Theme { - pub fn bg_app() -> Color { - active().bg_app - } - pub fn bg_surface() -> Color { - active().bg_surface - } - pub fn bg_card() -> Color { - active().bg_card - } - pub fn bg_user_msg() -> Color { - active().bg_user_msg - } - pub fn border() -> Color { - active().border - } - pub fn border_box() -> Color { - active().border_box - } - pub fn border_active() -> Color { - active().border_active - } - pub fn border_user_msg() -> Color { - active().border_user_msg - } - pub fn primary() -> Color { - active().primary - } - pub fn model() -> Color { - active().model - } - pub fn accent() -> Color { - active().accent - } - pub fn secondary() -> Color { - active().secondary - } - pub fn text_secondary() -> Color { - active().text_secondary - } - pub fn sky() -> Color { - active().sky - } - pub fn hint() -> Color { - active().hint - } - pub fn pink() -> Color { - active().pink - } - pub fn error() -> Color { - active().error - } - pub fn success() -> Color { - active().success - } - pub fn code_accent() -> Color { - active().code_accent - } - pub fn warning() -> Color { - active().warning - } - pub fn activity() -> Color { - active().activity - } - pub fn bold_accent() -> Color { - active().bold_accent - } - pub fn text_primary() -> Color { - active().text_primary - } - pub fn text_muted() -> Color { - active().text_muted - } - pub fn diff_add_bg() -> Color { - active().diff_add_bg - } - pub fn diff_del_bg() -> Color { - active().diff_del_bg - } - pub fn diff_hunk() -> Color { - active().diff_hunk - } - pub fn diff_meta() -> Color { - active().diff_meta - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn six_palettes_split_dark_light() { - assert_eq!(PALETTES.len(), 6); - assert_eq!(PALETTES.iter().filter(|p| p.dark).count(), 3); - assert_eq!(PALETTES.iter().filter(|p| !p.dark).count(), 3); - } - - #[test] - fn ids_are_unique_and_lookup_works() { - for (i, p) in PALETTES.iter().enumerate() { - assert_eq!(by_id(p.id), Some(i)); - } - assert_eq!(by_id("nope"), None); - } - - /// One sequential test: the active palette is process-global, so parallel - /// tests touching it would race. - #[test] - fn set_active_switches_roles_and_rejects_out_of_range() { - let original = active_index(); - assert!(set_active(2)); - assert_eq!(active_index(), 2); - assert_eq!(Theme::bg_app(), GRUVBOX_DARK.bg_app); - assert!(!set_active(6)); - assert_eq!(active_index(), 2); - - assert!(set_active(by_id("solarized-light").unwrap())); - assert_eq!(Theme::text_primary(), SOLARIZED_LIGHT.text_primary); - - assert!(set_active(original)); - } -} diff --git a/packages/tui/src/ui/theme_picker.rs b/packages/tui/src/ui/theme_picker.rs deleted file mode 100644 index 1a5dbcef..00000000 --- a/packages/tui/src/ui/theme_picker.rs +++ /dev/null @@ -1,105 +0,0 @@ -use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; -use ratatui::Frame; - -use crate::ui::theme::{self, Theme}; - -/// The `/theme` picker: a centered overlay listing all 6 palettes. Moving the -/// cursor switches the live palette, so the entire UI recolors behind it as a -/// preview before you commit with Enter. -pub fn render_theme_picker(f: &mut Frame, area: Rect, cursor: usize) { - let palettes = theme::PALETTES; - let width = 46u16.min(area.width.saturating_sub(4)); - let height = (palettes.len() as u16 + 6).min(area.height.saturating_sub(2)); - if width == 0 || height < 3 { - return; - } - - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - let rect = Rect::new(x, y, width, height); - - f.render_widget(Clear, rect); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border_active())) - .title(Span::styled( - " Theme ", - Style::default() - .fg(Theme::border_active()) - .add_modifier(Modifier::BOLD), - )) - .title_bottom(Span::styled( - " ↑/↓ preview • Enter apply • Esc revert ", - Style::default().fg(Theme::text_muted()), - )) - .style(Style::default().bg(Theme::bg_surface())); - f.render_widget(block, rect); - - let inner = Rect::new(x + 1, y + 1, width.saturating_sub(2), height.saturating_sub(2)); - let mut lines = Vec::with_capacity(palettes.len() + 1); - lines.push(Line::from("")); - - for (idx, palette) in palettes.iter().enumerate() { - let is_selected = idx == cursor; - let is_active = idx == theme::active_index(); - - let cursor_span = if is_selected { - Span::styled( - "▶ ", - Style::default() - .fg(Theme::border_active()) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::raw(" ") - }; - - // A two-cell swatch in the palette's own background gives an at-a-glance - // preview even for themes that only differ in subtle tones. - let swatch = Span::styled(" ", Style::default().bg(palette.bg_app)); - let mode_tag = if palette.dark { "dark" } else { "light" }; - - let name_style = if is_selected { - Style::default() - .fg(Theme::text_primary()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Theme::text_secondary()) - }; - - lines.push(Line::from(vec![ - cursor_span, - swatch, - Span::raw(" "), - Span::styled(format!("{:<18}", palette.label), name_style), - Span::styled( - format!("({mode_tag})"), - Style::default().fg(Theme::text_muted()), - ), - Span::raw(" "), - Span::styled( - if is_active { "● current" } else { "" }, - Style::default() - .fg(Theme::success()) - .add_modifier(Modifier::BOLD), - ), - ])); - } - - f.render_widget(Paragraph::new(lines), inner); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn six_palettes_available_for_picker() { - assert_eq!(theme::PALETTES.len(), 6); - assert_eq!(theme::PALETTES[0].id, "catppuccin-mocha"); - } -} diff --git a/packages/tui/src/ui/welcome.rs b/packages/tui/src/ui/welcome.rs deleted file mode 100644 index f850a106..00000000 --- a/packages/tui/src/ui/welcome.rs +++ /dev/null @@ -1,512 +0,0 @@ -use ratatui::layout::{Alignment, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Frame; - -use crate::types::ModelConfig; -use crate::ui::logo::{render_animated_logo_lines, LOGO_GRADIENT, LOGO_SPARKLE_FRAMES}; -use crate::ui::spinner::Spinner; -use crate::ui::theme::Theme; - -#[derive(Debug, Clone, Copy)] -pub struct CommandItem { - pub name: &'static str, - pub icon: &'static str, - pub description: &'static str, - pub icon_color: Color, -} - -/// Built as a function (not a `const`) so icon colors resolve against the -/// active palette — a `const` would freeze the startup theme's colors. -pub fn commands() -> Vec { - vec![ - CommandItem { - name: "/help", - icon: "?", - description: "Show available commands and shortcuts", - icon_color: Theme::sky(), - }, - CommandItem { - name: "/clear", - icon: "↺", - description: "Clear conversation history and screen", - icon_color: Theme::warning(), - }, - CommandItem { - name: "/model", - icon: "✦", - description: "Switch or view active LLM model", - icon_color: Theme::primary(), - }, - CommandItem { - name: "/theme", - icon: "◑", - description: "Change the color theme", - icon_color: Theme::pink(), - }, - CommandItem { - name: "/session", - icon: "◷", - description: "List or resume recent sessions", - icon_color: Theme::secondary(), - }, - CommandItem { - name: "/compact", - icon: "⇥", - description: "Compact current conversation context", - icon_color: Theme::success(), - }, - CommandItem { - name: "/exit", - icon: "✕", - description: "Exit CodeWork TUI", - icon_color: Theme::error(), - }, - ] -} - -pub fn render_welcome( - f: &mut Frame, - area: Rect, - input: &str, - cursor_pos: usize, - dropdown_index: usize, - active_config: Option<&ModelConfig>, - status_message: Option<&str>, - spinner: &Spinner, -) { - let card_width = 78u16.min(area.width.saturating_sub(2)); - let cursor_visible = spinner.cursor_visible(); - let is_dropdown_open = input.starts_with('/') && !input.contains(' '); - - let commands = commands(); - let matching_cmds: Vec<&CommandItem> = if is_dropdown_open { - commands - .iter() - .filter(|cmd| cmd.name.starts_with(input)) - .collect() - } else { - Vec::new() - }; - - let max_welcome_text_w = (card_width as usize).saturating_sub(6).max(10); - let wrapped_welcome = crate::ui::input::wrap_input_with_cursor(input, cursor_pos, max_welcome_text_w); - let max_visible_welcome = 5usize; - let num_welcome_lines = if input.is_empty() { - 1 - } else { - wrapped_welcome.lines.len().clamp(1, max_visible_welcome) - }; - - // Calculate Card 2 (Bottom Card) height - let card2_height = if is_dropdown_open { - let rows = matching_cmds.len().max(1) as u16; - // 1 (top border) + 1 (header "Commands") + rows + 1 (empty line) + num_lines + 1 (bottom border) - 4 + rows + num_welcome_lines as u16 - } else { - 2 + num_welcome_lines as u16 - }; - - // Total content height - let total_height = 17 + 1 + 2 + 1 + card2_height + 1; - - let start_x = (area.width.saturating_sub(card_width)) / 2; - let start_y = (area.height.saturating_sub(total_height)) / 2; - - // 1. Top Card (Main Status / Welcome Card) - let top_h = 17u16.min(area.height.saturating_sub(start_y)); - let top_rect = Rect::new(start_x, start_y, card_width, top_h); - - let top_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::border())) - .style(Style::default().bg(Theme::bg_surface())); - f.render_widget(top_block, top_rect); - - if top_h >= 5 { - let inner_w = card_width.saturating_sub(2); - let inner_x = start_x + 1; - let inner_y = start_y + 1; - - // Header bar inside card - let sparkle_frame = LOGO_SPARKLE_FRAMES[(spinner.tick_count() / 2) % LOGO_SPARKLE_FRAMES.len()]; - let sparkle_color = LOGO_GRADIENT[spinner.tick_count() % LOGO_GRADIENT.len()]; - let left_part_len = 2 + "codework v0.0.1".len(); - let right_part = "open-source harness"; - let header_pad = (inner_w as usize).saturating_sub(left_part_len + right_part.len()); - - let header_line = Line::from(vec![ - Span::styled( - format!("{} ", sparkle_frame), - Style::default().fg(sparkle_color).add_modifier(Modifier::BOLD), - ), - Span::styled( - "codework ", - Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), - ), - Span::styled("v0.0.1", Style::default().fg(Theme::text_muted())), - Span::raw(" ".repeat(header_pad)), - Span::styled(right_part, Style::default().fg(Theme::text_muted())), - ]); - let divider_line = Line::from(Span::styled( - "─".repeat(inner_w as usize), - Style::default().fg(Theme::border()), - )); - f.render_widget( - Paragraph::new(vec![header_line, divider_line]), - Rect::new(inner_x, inner_y, inner_w, 2), - ); - - // Columns area - let content_y = inner_y + 2; - let content_h = top_h.saturating_sub(4); - let left_w = 28u16.min(inner_w); - - // Left Column: Headline + Logo + Model info - let mut left_lines = Vec::new(); - left_lines.push(Line::from(Span::styled( - "The Open-Source", - Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), - ))); - left_lines.push(Line::from(Span::styled( - "Coding Agent Harness", - Style::default().fg(Theme::accent()), - ))); - left_lines.push(Line::raw("")); - - // CW Logo with animated gradient - left_lines.extend(render_animated_logo_lines(0, spinner.tick_count())); - - left_lines.push(Line::raw("")); - left_lines.push(Line::raw("")); - - let model_str = active_config - .map(|c| format!("{}:{}", c.provider, c.model)) - .unwrap_or_else(|| "codework:default".to_string()); - left_lines.push(Line::from(Span::styled( - model_str, - Style::default().fg(Theme::model()), - ))); - left_lines.push(Line::from(Span::styled( - "plugins • sandboxed", - Style::default().fg(Theme::text_muted()), - ))); - - f.render_widget( - Paragraph::new(left_lines).alignment(Alignment::Center), - Rect::new(inner_x, content_y, left_w, content_h), - ); - - // Vertical divider - if inner_w > left_w { - let div_lines: Vec = (0..content_h) - .map(|_| Line::from(Span::styled("│", Style::default().fg(Theme::border())))) - .collect(); - f.render_widget( - Paragraph::new(div_lines), - Rect::new(inner_x + left_w, content_y, 1, content_h), - ); - } - - // Right Column: Agent Loop, Plugins, Platform - let right_x = inner_x + left_w + 1; - let right_w = inner_w.saturating_sub(left_w + 1); - if right_w > 0 { - let div_width = right_w.saturating_sub(4) as usize; - let right_lines = vec![ - Line::from(vec![ - Span::raw(" "), - Span::styled( - "Agent Loop & Tools", - Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Understand ", Style::default().fg(Theme::text_secondary())), - Span::styled("search & read codebase", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Modify ", Style::default().fg(Theme::text_secondary())), - Span::styled("edit files & apply diffs", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Execute ", Style::default().fg(Theme::text_secondary())), - Span::styled("sandboxed commands & shell", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Debug ", Style::default().fg(Theme::text_secondary())), - Span::styled("autonomous test & iterate", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat(div_width), Style::default().fg(Theme::border())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled( - "Plugins & MCP", - Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Plugin SDK ", Style::default().fg(Theme::text_secondary())), - Span::styled("custom tools, drivers & hooks", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("MCP Ready ", Style::default().fg(Theme::text_secondary())), - Span::styled("plug in external tool servers", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("─".repeat(div_width), Style::default().fg(Theme::border())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled( - "Developer Platform", - Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Sandboxed ", Style::default().fg(Theme::text_secondary())), - Span::styled("isolated execution", Style::default().fg(Theme::text_muted())), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Open Source", Style::default().fg(Theme::text_secondary())), - Span::styled("build & run your agent", Style::default().fg(Theme::text_muted())), - ]), - ]; - f.render_widget( - Paragraph::new(right_lines), - Rect::new(right_x, content_y, right_w, content_h), - ); - } - } - - // 2. Supporting Copy - let copy_y = start_y + top_h + 1; - if copy_y + 2 <= area.height { - let copy_lines = vec![ - Line::from(vec![ - Span::styled( - "Codework: ", - Style::default().fg(Theme::pink()), - ), - Span::styled( - "Build, run, and customize AI coding agents with plugins to", - Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC), - ), - ]), - Line::from(vec![ - Span::styled( - "understand code, edit files, execute commands, and debug autonomously.", - Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC), - ), - ]), - ]; - f.render_widget(Paragraph::new(copy_lines), Rect::new(start_x, copy_y, card_width, 2)); - } - - // 3. Unified Input and Commands Box - let bot_y = copy_y + 3; - let bot_h = card2_height.min(area.height.saturating_sub(bot_y)); - if bot_h >= 3 { - let bot_border_color = if is_dropdown_open { - Theme::sky() - } else { - Theme::border_box() - }; - - let bot_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(bot_border_color)) - .style(Style::default().bg(Theme::bg_surface())); - let bot_inner = Rect::new(start_x + 1, bot_y + 1, card_width.saturating_sub(2), bot_h.saturating_sub(2)); - - f.render_widget(bot_block, Rect::new(start_x, bot_y, card_width, bot_h)); - - let mut bot_lines = Vec::new(); - if is_dropdown_open { - bot_lines.push(Line::from(Span::styled( - "Commands", - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), - ))); - - if matching_cmds.is_empty() { - bot_lines.push(Line::from(Span::styled( - "No matching commands", - Style::default().fg(Theme::text_muted()), - ))); - } else { - for (idx, cmd) in matching_cmds.iter().enumerate() { - let is_selected = idx == dropdown_index; - if is_selected { - bot_lines.push(Line::from(vec![ - Span::styled("▶ ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), - Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color).add_modifier(Modifier::BOLD)), - Span::styled( - format!("{:<10}", cmd.name), - Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), - ), - Span::styled(cmd.description, Style::default().fg(Theme::text_primary())), - ])); - } else { - bot_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color)), - Span::styled( - format!("{:<10}", cmd.name), - Style::default().fg(Theme::text_secondary()), - ), - Span::styled(cmd.description, Style::default().fg(Theme::text_muted())), - ])); - } - } - } - - bot_lines.push(Line::raw("")); - - let total_w_lines = wrapped_welcome.lines.len(); - let scroll_top = if wrapped_welcome.cursor_line >= max_visible_welcome { - wrapped_welcome.cursor_line + 1 - max_visible_welcome - } else { - 0 - }; - let visible_slice = &wrapped_welcome.lines[scroll_top..(scroll_top + max_visible_welcome).min(total_w_lines)]; - for (i, line) in visible_slice.iter().enumerate() { - let l_idx = scroll_top + i; - let prefix = if l_idx == 0 { - Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)) - } else { - Span::raw(" ") - }; - let mut spans = vec![prefix]; - spans.extend(crate::ui::input::render_line_spans(line, Theme::sky(), cursor_visible)); - bot_lines.push(Line::from(spans)); - } - } else { - if input.is_empty() { - let mut spans = vec![ - Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), - Span::styled( - "Type a message or / for commands...", - Style::default().fg(Theme::text_muted()), - ), - ]; - spans.push(if cursor_visible { - Span::styled("█", Style::default().fg(Theme::sky())) - } else { - Span::raw(" ") - }); - bot_lines.push(Line::from(spans)); - } else { - let total_w_lines = wrapped_welcome.lines.len(); - let scroll_top = if wrapped_welcome.cursor_line >= max_visible_welcome { - wrapped_welcome.cursor_line + 1 - max_visible_welcome - } else { - 0 - }; - let visible_slice = &wrapped_welcome.lines[scroll_top..(scroll_top + max_visible_welcome).min(total_w_lines)]; - for (i, line) in visible_slice.iter().enumerate() { - let l_idx = scroll_top + i; - let prefix = if l_idx == 0 { - Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)) - } else { - Span::raw(" ") - }; - let mut spans = vec![prefix]; - spans.extend(crate::ui::input::render_line_spans(line, Theme::sky(), cursor_visible)); - bot_lines.push(Line::from(spans)); - } - } - } - - f.render_widget(Paragraph::new(bot_lines), bot_inner); - } - - // 4. Status Bar / Footer - let footer_y = bot_y + bot_h; - if footer_y < area.height { - let left_text = if is_dropdown_open { - "↑/↓ to navigate • Enter to select • Esc to dismiss" - } else { - "Press Enter to submit • Esc to exit" - }; - let left_span = Span::styled(left_text, Style::default().fg(Theme::text_muted())); - - let (right_spans, right_len) = if let Some(msg) = status_message { - ( - vec![Span::styled( - msg.to_string(), - Style::default().fg(Theme::activity()), - )], - msg.len(), - ) - } else if let Some(cfg) = active_config { - let text = format!("✓ Connected: {} ({})", cfg.provider, cfg.model); - ( - vec![ - Span::styled("✓ ", Style::default().fg(Theme::success())), - Span::styled("Connected: ", Style::default().fg(Theme::text_muted())), - Span::styled(&cfg.provider, Style::default().fg(Theme::model())), - Span::styled(" (", Style::default().fg(Theme::text_muted())), - Span::styled(&cfg.model, Style::default().fg(Theme::model())), - Span::styled(")", Style::default().fg(Theme::text_muted())), - ], - text.len(), - ) - } else { - ( - vec![Span::styled( - "No model connected (/model)", - Style::default().fg(Theme::text_muted()), - )], - "No model connected (/model)".len(), - ) - }; - - let footer_pad = (card_width as usize).saturating_sub(left_text.len() + right_len); - let mut footer_spans = vec![left_span]; - footer_spans.push(Span::raw(" ".repeat(footer_pad))); - footer_spans.extend(right_spans); - - f.render_widget( - Paragraph::new(Line::from(footer_spans)), - Rect::new(start_x, footer_y, card_width, 1), - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_commands_have_valid_icons() { - for cmd in commands() { - assert!(!cmd.name.is_empty(), "command name must not be empty"); - assert!(cmd.name.starts_with('/'), "command name must start with /"); - assert!(!cmd.icon.is_empty(), "command icon must not be empty"); - assert!(!cmd.description.is_empty(), "description must not be empty"); - } - } - - #[test] - fn test_commands_filter_prefix() { - let all = commands(); - let matching: Vec<&CommandItem> = all.iter().filter(|c| c.name.starts_with("/m")).collect(); - assert_eq!(matching.len(), 1); - assert_eq!(matching[0].name, "/model"); - assert_eq!(matching[0].icon, "✦"); - } -} - diff --git a/packages/tui/src/ui/wordmark.rs b/packages/tui/src/ui/wordmark.rs new file mode 100644 index 00000000..bee2fdd3 --- /dev/null +++ b/packages/tui/src/ui/wordmark.rs @@ -0,0 +1,126 @@ +//! The CodeWork wordmark, drawn on the site's pixel grid. +//! +//! The site's glyph cells are square, but a terminal cell is about twice as tall as it is wide. +//! Pairing two glyph rows into the upper and lower half of one block character gives each glyph +//! cell a square terminal cell, so the wordmark keeps its proportions at 1:1 — which is the only +//! scale where a ten-row letter survives, as a downscaled copy turns to mush. +//! +//! The site also draws two faint hairline echoes of the glyph outline behind the wordmark, offset +//! by a fraction of a cell. A stroke that thin has no cell-resolution equivalent: any shifted copy +//! doubles the stroke weight and fills the counters of O, D and R, so the wordmark is drawn in one +//! ink instead. +//! +//! Its pixel cells are also the field's grid, so this module owns where that grid starts, in +//! [`Slot`]. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +use super::glyphs::{MARK, WORDMARK}; +use crate::theme::Palette; + +/// Air on each side, as the site keeps the wordmark clear of its frame rather than edge to edge. +const MARGIN: usize = 2; + +/// A pixel glyph: `rows[y]` holds `width` cells, `1` filled and `0` empty. +pub struct Glyph { + pub width: usize, + pub height: usize, + pub rows: &'static [&'static str], +} + +impl Glyph { + fn filled(&self, x: usize, y: usize) -> bool { + self.rows + .get(y) + .and_then(|row| row.as_bytes().get(x)) + .is_some_and(|cell| *cell == b'1') + } + + fn row_filled(&self, row: usize) -> bool { + (0..self.width).any(|x| self.filled(x, row * 2) || self.filled(x, row * 2 + 1)) + } + + /// The terminal rows the glyph needs, two glyph rows to a row. Trailing empty rows are + /// dropped: the last glyph row exists only to leave room for the site's outline echoes. + fn rows(&self) -> usize { + (0..self.height.div_ceil(2)) + .rev() + .find(|row| self.row_filled(*row)) + .map_or(0, |row| row + 1) + } +} + +/// One terminal cell, from the two glyph pixels it carries. +fn cell(upper: bool, lower: bool) -> char { + match (upper, lower) { + (true, true) => '█', + (true, false) => '▀', + (false, true) => '▄', + (false, false) => ' ', + } +} + +/// The glyph to draw in a frame this wide: the wordmark, else the mark, else nothing. +fn glyph_for(width: usize) -> Option<&'static Glyph> { + for glyph in [&WORDMARK, &MARK] { + if glyph.width + MARGIN * 2 <= width { + return Some(glyph); + } + } + None +} + +/// How many terminal rows the glyph for a frame this wide needs, or `None` when no glyph fits the +/// width. The screen stacks its blocks by this before it can ask for a [`Slot`]. +pub fn rows(width: u16) -> Option { + glyph_for(width as usize).map(|glyph| glyph.rows() as u16) +} + +/// Where the wordmark sits in the rect it was given, and which glyph it is. The glyph's pixel grid +/// is the field's grid too, so the field is anchored on `x`. +#[derive(Clone, Copy)] +pub struct Slot { + /// The column of the glyph's first pixel column, and the row of its first pixel row. + pub x: u16, + pub y: u16, + glyph: &'static Glyph, +} + +/// The slot for `area`: centred in it, or `None` when `area` cannot hold the glyph — too narrow for +/// even the mark, or too short for its rows. Nothing is scaled down to fit, because at any smaller +/// scale a ten-row letter turns to mush. +pub fn slot(area: Rect) -> Option { + let glyph = glyph_for(area.width as usize)?; + let rows = glyph.rows() as u16; + if rows > area.height { + return None; + } + Some(Slot { + x: area.x + area.width.saturating_sub(glyph.width as u16) / 2, + y: area.y + (area.height - rows) / 2, + glyph, + }) +} + +/// Draw the glyph into the slot. +/// +/// Only its filled pixels are written: an empty one keeps whatever is behind it, so the lattice +/// stays unbroken through the counters of O, D and R and the gaps between the letters, exactly as +/// it does on the site. +pub fn draw(buffer: &mut Buffer, slot: Slot, palette: &Palette) { + for row in 0..slot.glyph.rows() { + for col in 0..slot.glyph.width { + let symbol = cell( + slot.glyph.filled(col, row * 2), + slot.glyph.filled(col, row * 2 + 1), + ); + if symbol == ' ' { + continue; + } + let pixel = &mut buffer[(slot.x + col as u16, slot.y + row as u16)]; + pixel.set_char(symbol); + pixel.set_fg(palette.brand); + } + } +}