From e42bf1404c5a664bcdfa07942f0ccf52ca0c8701 Mon Sep 17 00:00:00 2001 From: prem315 Date: Mon, 12 Oct 2026 18:30:41 +0530 Subject: [PATCH 01/12] refactor: encapsulate turn completion logic into a new ConversationTurn::finish method --- packages/tui/src/app.rs | 13 +++++-------- packages/tui/src/types.rs | 9 +++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 835b2a44..0480fcfd 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -229,8 +229,7 @@ impl App { } 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()); + turn.finish(); // 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; @@ -241,7 +240,7 @@ impl App { } StreamEvent::Failed(err) => { if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.streaming = false; + turn.finish(); turn.error = Some(err); turn.active_tool = None; turn.active_call_id = None; @@ -251,7 +250,7 @@ impl App { } StreamEvent::Interrupted(reason) => { if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.streaming = false; + turn.finish(); turn.error = Some(format!("Interrupted: {}", reason)); turn.active_tool = None; turn.active_call_id = None; @@ -524,8 +523,7 @@ impl App { } "/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.finish(); 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); @@ -537,8 +535,7 @@ impl App { 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.finish(); compact_turn.response = format!("Previous {} turns compacted into session context summary.", old_count - 1); self.turns.push(compact_turn); self.turns.push(last_turn); diff --git a/packages/tui/src/types.rs b/packages/tui/src/types.rs index 71ea7904..074b0b36 100644 --- a/packages/tui/src/types.rs +++ b/packages/tui/src/types.rs @@ -185,6 +185,15 @@ impl ConversationTurn { completed_tools: Vec::new(), } } + + /// The turn is over: stop its clock with it. Every terminal stream event ends + /// here, because the footer reads `end_time` for elapsed seconds and falls + /// back to the current time while it is unset — so a terminal event that + /// forgets this call leaves the timer counting up forever after an error. + pub fn finish(&mut self) { + self.streaming = false; + self.end_time = Some(chrono::Utc::now().timestamp_millis()); + } } #[derive(Debug, Clone, Default)] From 10304f60126a8e19e5f80f49a0374102e2387441 Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 12:36:44 +0530 Subject: [PATCH 02/12] chore: ignore .freebuff directory in gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0a0c8259..97185550 100644 --- a/.gitignore +++ b/.gitignore @@ -56,5 +56,7 @@ models.gen.json .alchemy +.freebuff + # Rust build output (packages/tui binary; the launcher loads the built artifact). target From c1f1d3873d978016f1b4adb666c9f962e52e3d8b Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 13:03:01 +0530 Subject: [PATCH 03/12] refactor: remove modular TUI components and consolidate UI code into single module --- packages/tui/Cargo.lock | 801 +------------ packages/tui/Cargo.toml | 3 - packages/tui/README.md | 99 +- packages/tui/src/app.rs | 1660 +-------------------------- packages/tui/src/catalog.rs | 930 --------------- packages/tui/src/config.rs | 49 - packages/tui/src/credentials.rs | 77 -- packages/tui/src/diff.rs | 317 ----- packages/tui/src/git.rs | 47 - packages/tui/src/main.rs | 122 +- packages/tui/src/markdown/block.rs | 281 ----- packages/tui/src/markdown/inline.rs | 126 -- packages/tui/src/markdown/mod.rs | 5 - packages/tui/src/markdown/table.rs | 237 ---- packages/tui/src/tool.rs | 597 ---------- packages/tui/src/types.rs | 182 --- packages/tui/src/ui.rs | 27 + packages/tui/src/ui/input.rs | 401 ------- packages/tui/src/ui/logo.rs | 125 -- packages/tui/src/ui/mod.rs | 21 - packages/tui/src/ui/model.rs | 999 ---------------- packages/tui/src/ui/session.rs | 764 ------------ packages/tui/src/ui/spinner.rs | 49 - packages/tui/src/ui/text.rs | 60 - packages/tui/src/ui/theme.rs | 416 ------- packages/tui/src/ui/theme_picker.rs | 105 -- packages/tui/src/ui/welcome.rs | 512 --------- 27 files changed, 94 insertions(+), 8918 deletions(-) delete mode 100644 packages/tui/src/catalog.rs delete mode 100644 packages/tui/src/config.rs delete mode 100644 packages/tui/src/credentials.rs delete mode 100644 packages/tui/src/diff.rs delete mode 100644 packages/tui/src/git.rs delete mode 100644 packages/tui/src/markdown/block.rs delete mode 100644 packages/tui/src/markdown/inline.rs delete mode 100644 packages/tui/src/markdown/mod.rs delete mode 100644 packages/tui/src/markdown/table.rs delete mode 100644 packages/tui/src/tool.rs create mode 100644 packages/tui/src/ui.rs delete mode 100644 packages/tui/src/ui/input.rs delete mode 100644 packages/tui/src/ui/logo.rs delete mode 100644 packages/tui/src/ui/mod.rs delete mode 100644 packages/tui/src/ui/model.rs delete mode 100644 packages/tui/src/ui/session.rs delete mode 100644 packages/tui/src/ui/spinner.rs delete mode 100644 packages/tui/src/ui/text.rs delete mode 100644 packages/tui/src/ui/theme.rs delete mode 100644 packages/tui/src/ui/theme_picker.rs delete mode 100644 packages/tui/src/ui/welcome.rs diff --git a/packages/tui/Cargo.lock b/packages/tui/Cargo.lock index e6f0f666..cf060930 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,15 +106,12 @@ version = "0.0.1" dependencies = [ "chrono", "crossterm", - "dirs", "futures-util", "ratatui", - "reqwest", "serde", "serde_json", "tokio", "tokio-tungstenite", - "unicode-width 0.2.0", ] [[package]] @@ -191,15 +153,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" @@ -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..7e12d135 100644 --- a/packages/tui/Cargo.toml +++ b/packages/tui/Cargo.toml @@ -18,7 +18,4 @@ 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" diff --git a/packages/tui/README.md b/packages/tui/README.md index f2aaf9a4..1b96f87c 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -2,21 +2,26 @@ 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 that welcomes you to CodeWork. +Prompt entry, model selection and the session view are not built yet; the launcher and the +RPC transport they will need are already in place. --- -## Features +## Architecture -- **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. +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` | Terminal setup and the event loop. | +| `src/app.rs` | The UI state (today: whether the user asked to quit). | +| `src/ui.rs` | Rendering. | +| `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. | --- @@ -29,9 +34,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 +54,16 @@ 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 | +| :------- | :----------- | +| `Esc` | Exit the app | +| `Ctrl+C` | Exit the app | --- diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 0480fcfd..6145a59d 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -1,1668 +1,30 @@ -use std::sync::Arc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -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::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 { - Welcome, - ModelFlow, - Session, -} +/// The whole UI today: one screen that says hello, and one piece of state — whether +/// the user has asked to leave. Prompt entry, model selection and the session view +/// are deliberately not built yet. 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 reconnecting: bool, - - // 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 { + Self::new() + } } 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(); - - Self { - screen: ActiveScreen::Welcome, - should_quit: false, - config_manager, - active_config, - spinner: Spinner::new(), - - welcome_input: String::new(), - welcome_cursor: 0, - welcome_dropdown_index: 0, - welcome_status_message: None, - - model_browser: ModelBrowserState::new(), - - 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, - - theme_picker_open: false, - theme_picker_index: 0, - - rpc_client: Arc::new(RpcClient::default()), - stream_tx, - stream_rx, - } - } - - pub fn on_tick(&mut self) { - self.spinner.tick(); - - // Process any pending stream events from background RPC tasks - while let Ok(event) = self.stream_rx.try_recv() { - self.handle_stream_event(event); - } - } - - 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)); - } - self.screen = ActiveScreen::Welcome; - } - StreamEvent::Failed(err) => { - self.model_browser.validating = false; - self.model_browser.validation_error = Some(err); - } - _ => {} - } - 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; - } - StreamEvent::Reconnected => { - self.reconnecting = false; - return; - } - _ => {} - } - - let last_turn_idx = if self.turns.is_empty() { - return; - } else { - self.turns.len() - 1 - }; - - match event { - StreamEvent::SessionCreated(info) => { - self.session_info = Some(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; - } - } - } - StreamEvent::ThinkingDelta(delta) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.thinking.push_str(&delta); - } - } - StreamEvent::ToolStarted { - call_id, - name, - 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); - } - } - StreamEvent::ToolSettled { - call_id, - name, - is_error, - arguments, - 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() - } 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); - } - } - 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; - } - } - } - StreamEvent::Succeeded => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.finish(); - // 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); - } - } - self.is_streaming = false; - } - StreamEvent::Failed(err) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.finish(); - turn.error = Some(err); - turn.active_tool = None; - turn.active_call_id = None; - } - self.is_streaming = false; - self.reconnecting = false; - } - StreamEvent::Interrupted(reason) => { - if let Some(turn) = self.turns.get_mut(last_turn_idx) { - turn.finish(); - turn.error = Some(format!("Interrupted: {}", reason)); - turn.active_tool = None; - turn.active_call_id = None; - } - self.is_streaming = false; - self.reconnecting = false; - } - // Handled before the transcript: they are connection state, not turns. - StreamEvent::Disconnected | StreamEvent::Reconnected => {} - } - } - - 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) + Self { should_quit: false } } 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; - } - - if self.theme_picker_open && self.screen != ActiveScreen::ModelFlow { - return self.handle_theme_picker_key(key); - } - - 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), - } - } - - /// 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 _ = 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 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; - } - _ => {} - } - } - - pub fn execute_command(&mut self, cmd: &str) -> bool { - let trimmed = cmd.trim(); - let cmd_name = trimmed.split_whitespace().next().unwrap_or(trimmed); - - 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" => { + KeyCode::Esc => self.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 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.finish(); - 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.finish(); - 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 - } - _ => false, - } - } - - pub fn open_model_flow(&mut self) { - self.model_browser = ModelBrowserState::new(); - self.screen = ActiveScreen::ModelFlow; - } - - 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; - } - - // Case 2: API Key Modal is Open - if self.model_browser.api_key_modal_open { - if self.model_browser.validating { - return; - } - 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; - } - - // 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; - } - 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; - } - _ => {} - }, - 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; - } - KeyCode::Enter => { - self.model_browser.is_searching = false; - self.model_browser.focused_panel = FocusedPanel::Models; - } - 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; - } - 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; - } - _ => {} - }, - } - 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; - } - } - } - 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; - } - } - } - } - } - 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; - } - } - } - _ => {} - } - } - - 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); - } - _ => {} - } - } - } - - 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; - } - - 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; - } - } - } - _ => {} - } - } - - 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; } _ => {} } } - - 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; - } - } - } - } - - 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, - }; - - let rpc = self.rpc_client.clone(); - let tx = self.stream_tx.clone(); - let prompt_clone = initial_prompt.clone(); - - 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; - } - - 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)); - } - } - }); - } - - pub fn dispatch_turn(&mut self, prompt: String) { - let cfg = match &self.active_config { - Some(c) => c.clone(), - None => return, - }; - - 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; - - 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 - } - Err(e) => { - let _ = tx.send(StreamEvent::Failed(e)); - return; - } - }, - }; - let _ = rpc.prompt_session(sess_id, prompt, tx).await; - }); - } -} - -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 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(); -} - -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 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 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; - } - 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; -} - -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; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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); - } - - #[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); - } - - #[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); - - // End resets to 0 (when input empty) - app.handle_key(KeyEvent::from(KeyCode::End)); - assert_eq!(app.session_scroll_offset, 0); - } - - #[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); - 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); - } } - - 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 deleted file mode 100644 index 9e209a6c..00000000 --- a/packages/tui/src/diff.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! 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}; -use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; - -use crate::ui::text::elide; -use crate::ui::theme::Theme; - -/// How many diff lines are shown before the "… +N more lines" footer. -pub const DEFAULT_MAX_DIFF_LINES: usize = 12; - -/// Added/removed counts for the whole patch, before any truncation. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct DiffSummary { - pub added: usize, - pub removed: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Kind { - FileHeader, - Hunk, - Added, - Removed, - Context, - NoNewline, -} - -struct ParsedLine { - kind: Kind, - text: String, - /// 1-based line number in the new file; `None` for removals and metadata. - line_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() -} - -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 - } -} - -fn parse(patch: &str) -> Vec { - let mut parsed = Vec::new(); - let mut new_line: Option = None; - - 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); - } - parsed.push(ParsedLine { kind, text, line_no }); - } - - parsed -} - -/// 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 -} - -/// Added/removed counts for the whole of `patch`, without building any styled -/// lines — for callers that only want the numbers (a tool-call summary). -pub fn counts(patch: &str) -> DiffSummary { - summarize(&parse(patch)) -} - -/// The new-file path from a patch's `+++` header, when present. -pub fn patch_path(patch: &str) -> Option { - patch - .lines() - .find_map(|line| line.strip_prefix("+++ ")) - .map(|path| path.trim().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); - - 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() - } else { - max_lines.min(parsed.len()) - }; - - let mut lines: Vec> = Vec::new(); - - 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) - } else { - Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::BOLD) - }; - 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)), - Span::styled( - elide(&line.text, max_width.saturating_sub(gutter_width + 1)), - Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::ITALIC), - ), - ])); - } - 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(" ")); - - 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), - None => Style::default().fg(fg), - }; - spans.push(Span::styled(shown, content_style)); - if let Some(background) = bg { - if used < max_width { - spans.push(Span::styled(" ".repeat(max_width - used), Style::default().bg(background))); - } - } - lines.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())), - ])); - } - - (lines, summary) -} - -#[cfg(test)] -mod tests { - use super::*; - - 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"; - - fn text_of(line: &Line) -> String { - line.spans.iter().map(|span| span.content.as_ref()).collect() - } - - fn joined(lines: &[Line]) -> String { - lines.iter().map(text_of).collect::>().join("\n") - } - - #[test] - fn counts_added_and_removed() { - let (_, summary) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); - assert_eq!(summary.added, 1); - assert_eq!(summary.removed, 1); - } - - #[test] - fn extracts_path_from_patch() { - 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); - 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}"); - } - - #[test] - fn classifies_hunk_and_file_headers() { - let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); - let text = joined(&lines); - assert!(text.contains("--- src/a.ts")); - assert!(text.contains("+++ src/a.ts")); - assert!(text.contains("@@ -1,3 +1,3 @@")); - } - - #[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"))); - } -} 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..8a666ef1 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -1,33 +1,23 @@ mod app; -mod catalog; -mod config; -mod credentials; -mod diff; -mod git; -mod markdown; +// The RPC transport (client + wire protocol) is kept whole: it is the layer the +// session screen will stream prompts over. Nothing on the welcome screen uses it. +#[allow(dead_code, unused_imports)] mod rpc; -mod tool; +#[allow(dead_code)] mod types; mod ui; use std::io::{self, Write}; -use std::time::Duration; -use crossterm::event::{ - self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind, -}; +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; #[tokio::main] async fn main() -> Result<(), Box> { @@ -54,89 +44,34 @@ async fn main() -> Result<(), Box> { 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; - } + std::thread::spawn(move || loop { + match event::read() { + Ok(ev) => { + if event_tx.send(ev).is_err() { + break; } - Err(_) => break, } + Err(_) => break, } }); - 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); - } - })?; + terminal.draw(ui::render_welcome)?; - 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); - } - } + while let Some(event) = event_rx.recv().await { + handle_event(&mut app, event); + // Drain any additional events buffered so a burst or paste does not lag. + while let Ok(event) = event_rx.try_recv() { + handle_event(&mut app, event); } + terminal.draw(ui::render_welcome)?; + if app.should_quit { break; } @@ -158,19 +93,10 @@ async fn main() -> Result<(), Box> { } 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); + if let Event::Key(key) = event { + // Ignore key release events from terminals supporting keyboard protocols + if key.kind != KeyEventKind::Release { + app.handle_key(key); } - _ => {} } } 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/tool.rs b/packages/tui/src/tool.rs deleted file mode 100644 index b65d4dd3..00000000 --- a/packages/tui/src/tool.rs +++ /dev/null @@ -1,597 +0,0 @@ -//! Turns one tool call's raw payload — the tool name, its `arguments`, and the -//! result `details` the harness publishes — into the short human view the -//! 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. - -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. -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; - -/// 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, - } -} - -/// 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 -} - -/// 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, - } -} - -/// One row of a turn's settled 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. - Run { - name: String, - members: Vec<&'a ToolResult>, - /// How many of `members` failed. - failed: usize, - }, -} - -/// Collapses runs of consecutive calls to the same tool, so a long 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> { - let mut rows = Vec::new(); - let mut index = 0; - while index < tools.len() { - let name = tools[index].name.as_str(); - let mut end = index + 1; - while end < tools.len() && tools[end].name.as_str() == name { - end += 1; - } - let run = &tools[index..end]; - if run.len() >= GROUP_MIN_RUN && run.iter().all(|tool| tool.patch.is_none()) { - rows.push(ToolRow::Run { - name: name.to_string(), - members: run.iter().collect(), - failed: run.iter().filter(|tool| tool.is_error).count(), - }); - } else { - rows.extend(run.iter().map(ToolRow::Single)); - } - index = end; - } - rows -} - -/// 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 { - let mut distinct: Vec<&str> = Vec::new(); - for member in members { - let target = member.target.as_deref().unwrap_or(&member.label); - if !distinct.contains(&target) { - distinct.push(target); - } - } - let shown = distinct.len().min(RUN_TITLES_MAX); - let mut text = distinct[..shown].join(", "); - if distinct.len() > shown { - text.push_str(", …"); - } - text -} - -fn string_field(details: Option<&Value>, key: &str) -> Option { - details?.get(key)?.as_str().map(str::to_string) -} - -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)); - } - if let Some(seconds) = number(details, "timeoutSeconds") { - return Some(format!("timed out after {seconds}s")); - } - 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('_', " ")) -} - -fn read_summary(details: &Value) -> Option { - let start = number(details, "startLine")?; - let end = number(details, "endLine")?; - let total = number(details, "totalLines")?; - let mut text = if start == end { - format!("line {start} of {total}") - } else { - format!("{start}-{end} of {total}") - }; - if details.get("truncated").and_then(Value::as_bool) == Some(true) { - text.push_str(" · truncated"); - } - Some(text) -} - -fn bash_summary(details: &Value) -> Option { - let mut parts = Vec::new(); - 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) - { - parts.push(format!("{lines} lines")); - } - if parts.is_empty() { - None - } else { - Some(parts.join(" · ")) - } -} - -fn search_summary(details: &Value) -> Option { - let files = number(details, "files")?; - let matches = number(details, "matches")?; - let files_label = if files == 1 { - "1 file".to_string() - } else { - format!("{files} files") - }; - let matches_label = if matches == 1 { - "1 match".to_string() - } else { - format!("{matches} matches") - }; - let mut text = format!("{files_label} · {matches_label}"); - if details.get("truncated").and_then(Value::as_bool) == Some(true) { - text.push_str(" · truncated"); - } else if details.get("hasMore").and_then(Value::as_bool) == Some(true) { - text.push_str(" · more"); - } - 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)]; - if let Some(path) = diff::patch_path(patch) { - if call_path != Some(path.as_str()) { - parts.push(shorten_path(&path)); - } - } - if let Some(line) = first_changed_line { - parts.push(format!("line {line}")); - } - 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(); - } - let mut tail: Vec<&str> = path.rsplitn(3, '/').collect(); - tail.truncate(2); - tail.reverse(); - let candidate = format!("…/{}", tail.join("/")); - if candidate.chars().count() <= PATH_MAX_CHARS { - return candidate; - } - 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/types.rs b/packages/tui/src/types.rs index 074b0b36..e1bb8aba 100644 --- a/packages/tui/src/types.rs +++ b/packages/tui/src/types.rs @@ -1,95 +1,5 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelConfig { - 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, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelEntry { - 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, Serialize, Deserialize)] -pub struct ModelDetails { - 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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProviderSummary { - 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, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FocusedPanel { - Providers, - Models, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModelSortMode { - Id, - Provider, - Cost, - Context, -} - -#[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, Serialize, Deserialize)] pub struct SessionInfo { pub id: String, @@ -110,95 +20,3 @@ pub struct UsageReport { #[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(), - } - } - - /// The turn is over: stop its clock with it. Every terminal stream event ends - /// here, because the footer reads `end_time` for elapsed seconds and falls - /// back to the current time while it is unset — so a terminal event that - /// forgets this call leaves the timer counting up forever after an error. - pub fn finish(&mut self) { - self.streaming = false; - self.end_time = Some(chrono::Utc::now().timestamp_millis()); - } -} - -#[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..f8ef636b --- /dev/null +++ b/packages/tui/src/ui.rs @@ -0,0 +1,27 @@ +use ratatui::layout::{Alignment, Constraint, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Paragraph}; +use ratatui::Frame; + +/// The app's background, painted over the whole viewport each frame. +const BG: Color = Color::Rgb(11, 11, 15); +const TEXT: Color = Color::Rgb(205, 214, 244); + +/// The one screen the TUI has today. +pub fn render_welcome(f: &mut Frame) { + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(BG)), area); + + let [_, middle, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(area); + f.render_widget( + Paragraph::new("Welcome to CodeWork") + .alignment(Alignment::Center) + .style(Style::default().fg(TEXT).add_modifier(Modifier::BOLD)), + middle, + ); +} diff --git a/packages/tui/src/ui/input.rs b/packages/tui/src/ui/input.rs deleted file mode 100644 index 985f95e9..00000000 --- a/packages/tui/src/ui/input.rs +++ /dev/null @@ -1,401 +0,0 @@ -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, -} - -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() { - 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 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())); - - 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), - ] - } - } - } -} - -#[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), - }; - render_line_spans(&line, Theme::accent(), true) -} - -#[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 - } -} 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/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 deleted file mode 100644 index babf37e7..00000000 --- a/packages/tui/src/ui/session.rs +++ /dev/null @@ -1,764 +0,0 @@ -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, 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, - ); - 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("")); - } - - 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 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)); - } - } - - 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 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("")); - } - - 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)); - } - } - } - lines -} - -/// 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()); - - let mut spans = vec![ - Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled(label.clone(), Style::default().fg(color)), - ]; - - let mut meta = tool::run_targets(members); - if failed > 0 { - if !meta.is_empty() { - meta.push_str(" · "); - } - 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()), - )); - } - - 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()) - } else { - ("✓ ", Theme::success()) - }; - - // 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, - ); - - 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); - } - } - 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)), - ])); - } - - // 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() - } - - #[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"); - } - } - - #[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"); - } - } - - #[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())); - } - } - } - - 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() - } - - #[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}"); - } - - #[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())); - } - - #[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")); - } - - #[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}"); - } - - #[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}"); - } - - #[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); - } - - #[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())); - } - - #[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('×')); - } -} 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 deleted file mode 100644 index b5cdce5f..00000000 --- a/packages/tui/src/ui/text.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Width-aware text helpers shared by the transcript renderers. - -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(); - } - 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 { - break; - } - out.push(ch); - width += ch_width; - } - out.push('…'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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"); - } - - #[test] - fn adds_an_ellipsis_when_it_cannot_fit() { - assert_eq!(elide("abcdefghij", 5), "abcd…"); - } - - #[test] - fn zero_width_yields_nothing() { - assert_eq!(elide("anything", 0), ""); - } - - #[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); - } -} 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, "✦"); - } -} - From a2109fcbd3551a7d324341b224ca348518d729c1 Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 13:49:54 +0530 Subject: [PATCH 04/12] refactor(tui): split the UI into terminal, input, action, state and view modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.rs was doing five jobs at once — terminal setup, input, key resolution, rendering and cleanup — which is why the tree could not grow. The previous attempt at a modular TUI collapsed into a 1,660-line app.rs before c1f1d38 removed it. Give each responsibility its own module so adding a screen is additive rather than another round of untangling main. - tui.rs owns the terminal. Raw mode, alternate screen, paste and focus modes and the keyboard protocol are switched on in one place and released by one idempotent restore(), reached from Drop, the panic hook and normal shutdown, so a panic or an early return can no longer strand a shell without echo. - event.rs reads input on a dedicated thread and translates it into app events, dropping key releases so the keyboard protocol cannot fire a key twice. - action.rs resolves key presses to named actions, reusing OpenCode's ctrl+x leader and action names so its bindings carry over. Adds ctrl+d and q. - app.rs is the only place state changes; update() reports whether a repaint is needed, so an idle TUI does no work and a burst of events costs one frame. - ui.rs stays pure, which is what will make a frame reproducible from TestBackend. Renders now run inside a synchronized update so a slow repaint shows one whole frame. Resize repaints explicitly instead of relying on an incidental redraw. --- packages/tui/Cargo.lock | 1 - packages/tui/Cargo.toml | 4 +- packages/tui/README.md | 29 ++++---- packages/tui/src/action.rs | 69 ++++++++++++++++++ packages/tui/src/app.rs | 49 ++++++++++--- packages/tui/src/event.rs | 79 +++++++++++++++++++++ packages/tui/src/main.rs | 98 ++++++-------------------- packages/tui/src/tui.rs | 139 +++++++++++++++++++++++++++++++++++++ packages/tui/src/ui.rs | 28 ++++++-- 9 files changed, 387 insertions(+), 109 deletions(-) create mode 100644 packages/tui/src/action.rs create mode 100644 packages/tui/src/event.rs create mode 100644 packages/tui/src/tui.rs diff --git a/packages/tui/Cargo.lock b/packages/tui/Cargo.lock index cf060930..a8387b53 100644 --- a/packages/tui/Cargo.lock +++ b/packages/tui/Cargo.lock @@ -161,7 +161,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "futures-core", "mio", "parking_lot", "rustix", diff --git a/packages/tui/Cargo.toml b/packages/tui/Cargo.toml index 7e12d135..1399f416 100644 --- a/packages/tui/Cargo.toml +++ b/packages/tui/Cargo.toml @@ -12,7 +12,9 @@ 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" diff --git a/packages/tui/README.md b/packages/tui/README.md index 1b96f87c..9fad71b3 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -14,14 +14,17 @@ The TUI is a thin client. It draws the screen and reads keystrokes, and a CodeWo 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` | Terminal setup and the event loop. | -| `src/app.rs` | The UI state (today: whether the user asked to quit). | -| `src/ui.rs` | Rendering. | -| `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. | +| 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/ui.rs` | Rendering: reads state and draws, nothing else. | +| `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. | --- @@ -60,10 +63,12 @@ pnpm start ## Keyboard Shortcuts -| Shortcut | Action | -| :------- | :----------- | -| `Esc` | Exit the app | -| `Ctrl+C` | Exit the app | +| Shortcut | Action | +| :---------- | :-------------------------------- | +| `Esc` | 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/src/action.rs b/packages/tui/src/action.rs new file mode 100644 index 00000000..91ef8b16 --- /dev/null +++ b/packages/tui/src/action.rs @@ -0,0 +1,69 @@ +//! 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. + +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, 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, +} + +/// 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() + } + + /// The action this key triggers. + /// + /// Takes `&mut self` because a key can be consumed without triggering + /// anything: the leader key arms, and any other key clears the arming. + pub fn resolve(&mut self, key: KeyEvent) -> Option { + let leader = self.take_leader(key); + match (key.code, key.modifiers) { + // OpenCode's defaults for `app.exit`: ctrl+c, ctrl+d, q. + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Some(Action::Exit), + (KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::Exit), + (KeyCode::Char('q'), _) if leader => Some(Action::Exit), + // Ours, not OpenCode's: a full-screen app needs a key that leaves, and + // there is nothing for escape to close until dialogs exist. Drop it + // when the first dialog claims escape *and* a quit key is documented. + (KeyCode::Esc, _) => Some(Action::Exit), + _ => None, + } + } + + /// Whether this key press completes a `` sequence, arming the leader + /// if the key is the leader itself. + fn take_leader(&mut self, key: KeyEvent) -> bool { + let armed = self + .leader_armed + .take() + .is_some_and(|at| at.elapsed() < LEADER_TIMEOUT); + if (key.code, key.modifiers) == LEADER { + self.leader_armed = Some(Instant::now()); + } + armed + } +} diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 6145a59d..6775cb10 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -1,10 +1,24 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +//! 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 crossterm::event::KeyEvent; + +use crate::action::{Action, Keymap}; +use crate::event::AppEvent; + +/// The screens the TUI can show. One, for now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Welcome, +} -/// The whole UI today: one screen that says hello, and one piece of state — whether -/// the user has asked to leave. Prompt entry, model selection and the session view -/// are deliberately not built yet. pub struct App { + pub screen: Screen, pub should_quit: bool, + keymap: Keymap, } impl Default for App { @@ -15,16 +29,31 @@ impl Default for App { impl App { pub fn new() -> Self { - Self { should_quit: false } + Self { + screen: Screen::Welcome, + should_quit: false, + keymap: Keymap::new(), + } + } + + /// 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), + // The view reads its size from the frame it draws into, so a resize + // needs no state change — only a repaint. + AppEvent::Resize => true, + } } - pub fn handle_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => self.should_quit = true, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + fn handle_key(&mut self, key: KeyEvent) -> bool { + match self.keymap.resolve(key) { + // Nothing to redraw: the loop is about to end. + Some(Action::Exit) => { self.should_quit = true; + false } - _ => {} + None => false, } } } diff --git a/packages/tui/src/event.rs b/packages/tui/src/event.rs new file mode 100644 index 00000000..8d7af291 --- /dev/null +++ b/packages/tui/src/event.rs @@ -0,0 +1,79 @@ +//! 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}; +use tokio::sync::mpsc; + +/// 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, Eq)] +pub enum AppEvent { + Key(KeyEvent), + /// 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, +} + +pub struct EventHandler { + rx: mpsc::UnboundedReceiver, +} + +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} + +impl EventHandler { + pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + std::thread::spawn(move || loop { + match crossterm::event::read() { + Ok(event) => { + let Some(event) = translate(event) else { + continue; + }; + if tx.send(event).is_err() { + break; + } + } + Err(_) => break, + } + }); + Self { rx } + } + + /// 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::Resize(_, _) => Some(AppEvent::Resize), + _ => None, + } +} diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index 8a666ef1..f08b7435 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -1,102 +1,44 @@ +mod action; mod app; +mod event; // The RPC transport (client + wire protocol) is kept whole: it is the layer the // session screen will stream prompts over. Nothing on the welcome screen uses it. #[allow(dead_code, unused_imports)] mod rpc; +mod tui; #[allow(dead_code)] mod types; mod ui; -use std::io::{self, Write}; - -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::Terminal; +use std::error::Error; 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()?; - +async fn main() -> Result<(), Box> { + // Dropping the guard restores the terminal, including on an early return. + let mut terminal = TerminalGuard::init(ui::BG)?; let mut app = App::new(); + let mut events = EventHandler::new(); - // 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, - } - }); + terminal.draw(|frame| ui::view(&app, frame))?; - terminal.draw(ui::render_welcome)?; - - while let Some(event) = event_rx.recv().await { - handle_event(&mut app, event); - // Drain any additional events buffered so a burst or paste does not lag. - while let Ok(event) = event_rx.try_recv() { - handle_event(&mut app, event); + 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); } - - terminal.draw(ui::render_welcome)?; - if app.should_quit { break; } + if redraw { + terminal.draw(|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) { - if let Event::Key(key) = event { - // Ignore key release events from terminals supporting keyboard protocols - if key.kind != KeyEventKind::Release { - app.handle_key(key); - } - } -} diff --git a/packages/tui/src/tui.rs b/packages/tui/src/tui.rs new file mode 100644 index 00000000..fa673e0b --- /dev/null +++ b/packages/tui/src/tui.rs @@ -0,0 +1,139 @@ +//! 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, EnableBracketedPaste, EnableFocusChange, + 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, +} + +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 + )?; + 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 }) + } + + /// 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, render: impl FnOnce(&mut Frame)) -> io::Result<()> { + execute!(io::stdout(), BeginSynchronizedUpdate)?; + let drawn = self.terminal.draw(render); + let _ = execute!(io::stdout(), EndSynchronizedUpdate); + drawn.map(|_| ()) + } +} + +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, + 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 Stdout, 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/ui.rs b/packages/tui/src/ui.rs index f8ef636b..a20dded5 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -1,16 +1,30 @@ +//! 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`. + use ratatui::layout::{Alignment, Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; -/// The app's background, painted over the whole viewport each frame. -const BG: Color = Color::Rgb(11, 11, 15); +use crate::app::{App, Screen}; + +/// The app background. Painted over the whole viewport each frame, and handed to +/// the terminal at startup so its own background matches. +pub const BG: Color = Color::Rgb(11, 11, 15); const TEXT: Color = Color::Rgb(205, 214, 244); -/// The one screen the TUI has today. -pub fn render_welcome(f: &mut Frame) { - let area = f.area(); - f.render_widget(Block::default().style(Style::default().bg(BG)), area); +/// Draw the active screen. +pub fn view(app: &App, frame: &mut Frame) { + match app.screen { + Screen::Welcome => view_welcome(frame), + } +} + +fn view_welcome(frame: &mut Frame) { + let area = frame.area(); + frame.render_widget(Block::default().style(Style::default().bg(BG)), area); let [_, middle, _] = Layout::vertical([ Constraint::Fill(1), @@ -18,7 +32,7 @@ pub fn render_welcome(f: &mut Frame) { Constraint::Fill(1), ]) .areas(area); - f.render_widget( + frame.render_widget( Paragraph::new("Welcome to CodeWork") .alignment(Alignment::Center) .style(Style::default().fg(TEXT).add_modifier(Modifier::BOLD)), From f9a1abc2bb3e1cceecc33919485c2c572661addd Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 14:08:49 +0530 Subject: [PATCH 05/12] feat: add theme system with generated landing site color palettes --- packages/tui/README.md | 1 + packages/tui/scripts/themes.mjs | 145 +++++++++ packages/tui/src/app.rs | 5 + packages/tui/src/main.rs | 7 +- packages/tui/src/theme.rs | 73 +++++ packages/tui/src/theme/palettes.rs | 477 +++++++++++++++++++++++++++++ packages/tui/src/ui.rs | 22 +- 7 files changed, 718 insertions(+), 12 deletions(-) create mode 100644 packages/tui/scripts/themes.mjs create mode 100644 packages/tui/src/theme.rs create mode 100644 packages/tui/src/theme/palettes.rs diff --git a/packages/tui/README.md b/packages/tui/README.md index 9fad71b3..14a1a4f2 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -22,6 +22,7 @@ newline-delimited JSON-RPC. | `src/action.rs` | Named actions and the keymap, with the `` prefix. | | `src/app.rs` | UI state and `update`, the one place state changes. | | `src/ui.rs` | Rendering: reads state and draws, nothing else. | +| `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. | diff --git a/packages/tui/scripts/themes.mjs b/packages/tui/scripts/themes.mjs new file mode 100644 index 00000000..daaee160 --- /dev/null +++ b/packages/tui/scripts/themes.mjs @@ -0,0 +1,145 @@ +// Generates `src/theme/palettes.rs` from the landing site's palettes, which are the source of +// truth for the TUI's 16 tokens. 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"); + +/** 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); + +/** `[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(); + +// 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])},`).join("\n"); + return ` Palette { + id: "${theme.id}", + name: ${JSON.stringify(theme.name)}, + scheme: Scheme::${theme.light ? "Light" : "Dark"}, +${fields} + },`; + }) + .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) and", + "// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default).", + "// Re-run `node scripts/themes.mjs` after either 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/src/app.rs b/packages/tui/src/app.rs index 6775cb10..b13ce336 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -8,6 +8,7 @@ use crossterm::event::KeyEvent; use crate::action::{Action, Keymap}; use crate::event::AppEvent; +use crate::theme::{self, Palette}; /// The screens the TUI can show. One, for now. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,6 +18,9 @@ pub enum Screen { pub struct App { 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, pub should_quit: bool, keymap: Keymap, } @@ -31,6 +35,7 @@ impl App { pub fn new() -> Self { Self { screen: Screen::Welcome, + theme: theme::default_palette(), should_quit: false, keymap: Keymap::new(), } diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index f08b7435..f391c8bb 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -5,6 +5,7 @@ mod event; // session screen will stream prompts over. Nothing on the welcome screen uses it. #[allow(dead_code, unused_imports)] mod rpc; +mod theme; mod tui; #[allow(dead_code)] mod types; @@ -18,9 +19,11 @@ use tui::TerminalGuard; #[tokio::main] async fn main() -> Result<(), Box> { - // Dropping the guard restores the terminal, including on an early return. - let mut terminal = TerminalGuard::init(ui::BG)?; + // 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(); + // Dropping the guard restores the terminal, including on an early return. + let mut terminal = TerminalGuard::init(app.theme.bg)?; let mut events = EventHandler::new(); terminal.draw(|frame| ui::view(&app, frame))?; diff --git a/packages/tui/src/theme.rs b/packages/tui/src/theme.rs new file mode 100644 index 00000000..b2bd21ce --- /dev/null +++ b/packages/tui/src/theme.rs @@ -0,0 +1,73 @@ +//! The active palette. +//! +//! The landing site owns the colours. It ships 22 palettes that each fill the same 16 tokens, and +//! `scripts/themes.mjs` turns them 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, +} + +/// One palette: the site's 16 tokens, and how it is named. +/// +/// The tokens are the whole contract, so a palette carries all 16 even while the UI draws with a +/// few of them — 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, + 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) +} diff --git a/packages/tui/src/theme/palettes.rs b/packages/tui/src/theme/palettes.rs new file mode 100644 index 00000000..20d463e5 --- /dev/null +++ b/packages/tui/src/theme/palettes.rs @@ -0,0 +1,477 @@ +// @generated by scripts/themes.mjs from the landing site's palettes. Do not edit. +// +// Source: sites/www/src/styles/themes.css (the 16 tokens) and +// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default). +// Re-run `node scripts/themes.mjs` after either 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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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/ui.rs b/packages/tui/src/ui.rs index a20dded5..6b8d5116 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -4,27 +4,26 @@ //! I/O, which is what makes a frame reproducible from a `TestBackend`. use ratatui::layout::{Alignment, Constraint, Layout}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; use crate::app::{App, Screen}; -/// The app background. Painted over the whole viewport each frame, and handed to -/// the terminal at startup so its own background matches. -pub const BG: Color = Color::Rgb(11, 11, 15); -const TEXT: Color = Color::Rgb(205, 214, 244); - /// Draw the active screen. pub fn view(app: &App, frame: &mut Frame) { match app.screen { - Screen::Welcome => view_welcome(frame), + Screen::Welcome => view_welcome(app, frame), } } -fn view_welcome(frame: &mut Frame) { +fn view_welcome(app: &App, frame: &mut Frame) { + let palette = app.theme; let area = frame.area(); - frame.render_widget(Block::default().style(Style::default().bg(BG)), area); + // The ground is painted every frame, so a theme switch repaints rather than + // leaving the previous palette in the gaps. + let ground = Style::default().bg(palette.bg); + frame.render_widget(Block::default().style(ground), area); let [_, middle, _] = Layout::vertical([ Constraint::Fill(1), @@ -32,10 +31,13 @@ fn view_welcome(frame: &mut Frame) { Constraint::Fill(1), ]) .areas(area); + let heading = Style::default() + .fg(palette.text) + .add_modifier(Modifier::BOLD); frame.render_widget( Paragraph::new("Welcome to CodeWork") .alignment(Alignment::Center) - .style(Style::default().fg(TEXT).add_modifier(Modifier::BOLD)), + .style(heading), middle, ); } From 994790db5fd8669f2686eef41afab16fde9b5f0f Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 15:04:18 +0530 Subject: [PATCH 06/12] feat: render landing site wordmark and hairline lattice in TUI --- packages/tui/README.md | 38 +++++---- packages/tui/scripts/themes.mjs | 55 +++++++++++-- packages/tui/scripts/wordmark.mjs | 123 +++++++++++++++++++++++++++++ packages/tui/src/theme.rs | 12 ++- packages/tui/src/theme/palettes.rs | 29 ++++++- packages/tui/src/ui.rs | 47 +++++------ packages/tui/src/ui/field.rs | 45 +++++++++++ packages/tui/src/ui/glyphs.rs | 48 +++++++++++ packages/tui/src/ui/wordmark.rs | 120 ++++++++++++++++++++++++++++ 9 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 packages/tui/scripts/wordmark.mjs create mode 100644 packages/tui/src/ui/field.rs create mode 100644 packages/tui/src/ui/glyphs.rs create mode 100644 packages/tui/src/ui/wordmark.rs diff --git a/packages/tui/README.md b/packages/tui/README.md index 14a1a4f2..f5c7deda 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -2,9 +2,9 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh), written in **Rust**. -The TUI is an early skeleton. Today it opens one screen that welcomes you to CodeWork. -Prompt entry, model selection and the session view are not built yet; the launcher and the -RPC transport they will need are already in place. +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. Prompt entry, model selection and the session view +are not built yet; the launcher and the RPC transport they will need are already in place. --- @@ -14,18 +14,26 @@ The TUI is a thin client. It draws the screen and reads keystrokes, and a CodeWo 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/ui.rs` | Rendering: reads state and draws, nothing else. | -| `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. | +| 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/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/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. --- diff --git a/packages/tui/scripts/themes.mjs b/packages/tui/scripts/themes.mjs index daaee160..1a80446b 100644 --- a/packages/tui/scripts/themes.mjs +++ b/packages/tui/scripts/themes.mjs @@ -1,6 +1,7 @@ // Generates `src/theme/palettes.rs` from the landing site's palettes, which are the source of -// truth for the TUI's 16 tokens. 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. +// 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 @@ -12,6 +13,7 @@ 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 = [ @@ -36,6 +38,37 @@ const TOKENS = [ 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"); @@ -72,6 +105,7 @@ function registry() { 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`); @@ -99,12 +133,18 @@ const rgb = (hex) => { const body = themes .map((theme) => { const { tokens } = found.get(theme.id); - const fields = TOKENS.map(([token, field]) => ` ${field}: ${rgb(tokens[token])},`).join("\n"); + 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} +${fields.join("\n")} },`; }) .join("\n"); @@ -112,9 +152,10 @@ ${fields} 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) and", - "// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default).", - "// Re-run `node scripts/themes.mjs` after either moves; `--check` fails on drift.", + "// 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}";`; 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/theme.rs b/packages/tui/src/theme.rs index b2bd21ce..4bbcf532 100644 --- a/packages/tui/src/theme.rs +++ b/packages/tui/src/theme.rs @@ -1,7 +1,8 @@ //! The active palette. //! //! The landing site owns the colours. It ships 22 palettes that each fill the same 16 tokens, and -//! `scripts/themes.mjs` turns them into [`palettes`] so the two surfaces cannot drift. Widgets read +//! 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. @@ -20,10 +21,10 @@ pub enum Scheme { Light, } -/// One palette: the site's 16 tokens, and how it is named. +/// 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 16 even while the UI draws with a -/// few of them — that is what keeps a new widget from having to re-pull every theme. Every id here +/// 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 { @@ -53,6 +54,9 @@ pub struct Palette { /// 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, diff --git a/packages/tui/src/theme/palettes.rs b/packages/tui/src/theme/palettes.rs index 20d463e5..f5a35a22 100644 --- a/packages/tui/src/theme/palettes.rs +++ b/packages/tui/src/theme/palettes.rs @@ -1,8 +1,9 @@ // @generated by scripts/themes.mjs from the landing site's palettes. Do not edit. // -// Source: sites/www/src/styles/themes.css (the 16 tokens) and -// sites/www/src/components/landing/theme.ts (ids, labels, scheme, default). -// Re-run `node scripts/themes.mjs` after either moves; `--check` fails on drift. +// 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; @@ -24,6 +25,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -45,6 +47,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -66,6 +69,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -87,6 +91,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -108,6 +113,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -129,6 +135,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -150,6 +157,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -171,6 +179,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -192,6 +201,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -213,6 +223,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -234,6 +245,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -255,6 +267,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -276,6 +289,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -297,6 +311,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -318,6 +333,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -339,6 +355,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -360,6 +377,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -381,6 +399,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -402,6 +421,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -423,6 +443,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -444,6 +465,7 @@ pub static PALETTES: &[Palette] = &[ 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), @@ -465,6 +487,7 @@ pub static PALETTES: &[Palette] = &[ 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), diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs index 6b8d5116..242c3758 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -3,9 +3,12 @@ //! 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`. -use ratatui::layout::{Alignment, Constraint, Layout}; -use ratatui::style::{Modifier, Style}; -use ratatui::widgets::{Block, Paragraph}; +mod field; +mod glyphs; +mod wordmark; + +use ratatui::style::Style; +use ratatui::widgets::Block; use ratatui::Frame; use crate::app::{App, Screen}; @@ -18,26 +21,26 @@ pub fn view(app: &App, frame: &mut Frame) { } fn view_welcome(app: &App, frame: &mut Frame) { - let palette = app.theme; let area = frame.area(); - // The ground is painted every frame, so a theme switch repaints rather than - // leaving the previous palette in the gaps. - let ground = Style::default().bg(palette.bg); - frame.render_widget(Block::default().style(ground), area); + // 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. + let canvas = Style::default().bg(app.theme.field_bg); + frame.render_widget(Block::default().style(canvas), area); - let [_, middle, _] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - ]) - .areas(area); - let heading = Style::default() - .fg(palette.text) - .add_modifier(Modifier::BOLD); - frame.render_widget( - Paragraph::new("Welcome to CodeWork") - .alignment(Alignment::Center) - .style(heading), - middle, + // Three layers, in the order the site paints them: the ground, the lattice on the + // wordmark's own pixel grid, then the word over it. A frame with no room for + // either glyph still gets the lattice: the site's field covers the whole canvas + // whether or not the word fits. + let slot = wordmark::slot(area); + field::paint( + frame.buffer_mut(), + area, + app.theme, + slot.map_or(0, |slot| slot.x), ); + if let Some(slot) = slot { + wordmark::draw(frame.buffer_mut(), slot, app.theme); + } } 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/wordmark.rs b/packages/tui/src/ui/wordmark.rs new file mode 100644 index 00000000..66f8f7be --- /dev/null +++ b/packages/tui/src/ui/wordmark.rs @@ -0,0 +1,120 @@ +//! 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 +} + +/// Where the wordmark sits in this frame, 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 a frame this size: centred, or `None` when the frame 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.width.saturating_sub(glyph.width as u16) / 2, + 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); + } + } +} From 0612525d68ca18df8126bd135ed013bd2d3bf79d Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 18:48:20 +0530 Subject: [PATCH 07/12] feat: add TUI menu, badge, input, and text components with updated view layout --- packages/tui/Cargo.lock | 1 + packages/tui/Cargo.toml | 4 + packages/tui/README.md | 38 ++++-- packages/tui/src/action.rs | 10 +- packages/tui/src/app.rs | 198 +++++++++++++++++++++++++++++++- packages/tui/src/event.rs | 5 + packages/tui/src/main.rs | 7 +- packages/tui/src/menu.rs | 86 ++++++++++++++ packages/tui/src/prompt.rs | 112 ++++++++++++++++++ packages/tui/src/theme.rs | 10 ++ packages/tui/src/ui.rs | 110 +++++++++++++++--- packages/tui/src/ui/badge.rs | 29 +++++ packages/tui/src/ui/input.rs | 105 +++++++++++++++++ packages/tui/src/ui/menu.rs | 142 +++++++++++++++++++++++ packages/tui/src/ui/text.rs | 27 +++++ packages/tui/src/ui/wordmark.rs | 20 ++-- 16 files changed, 859 insertions(+), 45 deletions(-) create mode 100644 packages/tui/src/menu.rs create mode 100644 packages/tui/src/prompt.rs create mode 100644 packages/tui/src/ui/badge.rs create mode 100644 packages/tui/src/ui/input.rs create mode 100644 packages/tui/src/ui/menu.rs create mode 100644 packages/tui/src/ui/text.rs diff --git a/packages/tui/Cargo.lock b/packages/tui/Cargo.lock index a8387b53..dd5a65d5 100644 --- a/packages/tui/Cargo.lock +++ b/packages/tui/Cargo.lock @@ -112,6 +112,7 @@ dependencies = [ "serde_json", "tokio", "tokio-tungstenite", + "unicode-width 0.2.0", ] [[package]] diff --git a/packages/tui/Cargo.toml b/packages/tui/Cargo.toml index 1399f416..fadc1e8c 100644 --- a/packages/tui/Cargo.toml +++ b/packages/tui/Cargo.toml @@ -21,3 +21,7 @@ futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" 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 f5c7deda..7fa5e89b 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -2,9 +2,12 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh), written in **Rust**. -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. Prompt entry, model selection and the session view -are not built yet; the launcher and the RPC transport they will need are already in place. +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. --- @@ -19,12 +22,17 @@ newline-delimited JSON-RPC. | `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/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. | @@ -72,12 +80,20 @@ pnpm start ## Keyboard Shortcuts -| Shortcut | Action | -| :---------- | :-------------------------------- | -| `Esc` | Exit the app | -| `Ctrl+C` | Exit the app | -| `Ctrl+D` | Exit the app | -| `q` | Exit the app (`Ctrl+X`, then `q`) | +| 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/src/action.rs b/packages/tui/src/action.rs index 91ef8b16..58a5b3aa 100644 --- a/packages/tui/src/action.rs +++ b/packages/tui/src/action.rs @@ -5,6 +5,10 @@ //! 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}; @@ -46,10 +50,8 @@ impl Keymap { (KeyCode::Char('c'), KeyModifiers::CONTROL) => Some(Action::Exit), (KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::Exit), (KeyCode::Char('q'), _) if leader => Some(Action::Exit), - // Ours, not OpenCode's: a full-screen app needs a key that leaves, and - // there is nothing for escape to close until dialogs exist. Drop it - // when the first dialog claims escape *and* a quit key is documented. - (KeyCode::Esc, _) => Some(Action::Exit), + // 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. _ => None, } } diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index b13ce336..6f244b78 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -4,23 +4,85 @@ //! 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 crossterm::event::KeyEvent; +use crossterm::event::{KeyCode, KeyEvent}; use crate::action::{Action, Keymap}; use crate::event::AppEvent; +use crate::menu::{Entry, Menu, Preview}; +use crate::prompt::Prompt; use crate::theme::{self, Palette}; +/// What the prompt answers an Enter with, until the session screen can act on it. A key that does +/// nothing reads as a bug, so the one that will send says so instead. +const NOT_SENDING: &str = "sending is not wired up yet"; + +/// 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 { + name: "theme", + detail: "pick a palette", + preview: None, + }, + Entry { + name: "exit", + detail: "close the app", + preview: None, + }, +]; + /// The screens the TUI can show. One, for now. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Welcome, } +/// A menu open over the hero, taking the keyboard while it is there. +/// +/// There is one at a time: choosing a command opens the menu it names, so that one replaces the +/// command menu rather than stacking under it. +pub enum Popup { + /// What a leading slash asks for. + Commands(Menu), + /// The palettes the site ships. + Themes(Menu), +} + +impl Popup { + pub fn menu(&self) -> &Menu { + match self { + Popup::Commands(menu) | Popup::Themes(menu) => menu, + } + } + + fn menu_mut(&mut self) -> &mut Menu { + match self { + Popup::Commands(menu) | Popup::Themes(menu) => menu, + } + } + + /// What the menu calls itself, on its border. + pub fn title(&self) -> &'static str { + match self { + Popup::Commands(_) => "commands", + Popup::Themes(_) => "themes", + } + } +} + pub struct App { 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. It is modal: while it is up the prompt behind it does + /// not see a key. + pub popup: Option, + /// A line of plain truth about the prompt, shown in place of the key hints. It is set by keys + /// the prompt cannot honour and cleared by the first one it can. + pub notice: Option<&'static str>, pub should_quit: bool, keymap: Keymap, } @@ -36,6 +98,9 @@ impl App { Self { screen: Screen::Welcome, theme: theme::default_palette(), + prompt: Prompt::default(), + popup: None, + notice: None, should_quit: false, keymap: Keymap::new(), } @@ -45,6 +110,18 @@ impl App { pub fn update(&mut self, event: AppEvent) -> bool { match event { AppEvent::Key(key) => self.handle_key(key), + // A paste is text, and a menu takes no text: it is dropped rather than typed into the + // line the menu is standing in front of. + AppEvent::Paste(text) => { + if self.popup.is_some() { + return false; + } + let changed = self.prompt.insert_text(&text); + if changed { + self.notice = None; + } + changed + } // The view reads its size from the frame it draws into, so a resize // needs no state change — only a repaint. AppEvent::Resize => true, @@ -52,13 +129,122 @@ impl App { } fn handle_key(&mut self, key: KeyEvent) -> bool { - match self.keymap.resolve(key) { + // Named actions first, so a key bound to the app is never typed into the prompt. The exits + // are the terminal's whether or not a menu is up. + if let Some(Action::Exit) = self.keymap.resolve(key) { + self.should_quit = true; // Nothing to redraw: the loop is about to end. - Some(Action::Exit) => { - self.should_quit = true; - false + return false; + } + // Escape closes what is open and, with nothing open, leaves. It is not in the keymap + // because a keymap holds one answer per key and this one has two. + if key.code == KeyCode::Esc { + return self.escape(); + } + // A menu takes the keyboard while it is up. It is modal, so a key it does not want is + // dropped rather than typed into the prompt standing behind it. + if self.popup.is_some() { + return self.menu_key(key); + } + // Enter is the prompt's, not the keymap's, because it only means something once there is + // something to send. + if key.code == KeyCode::Enter { + self.notice = Some(NOT_SENDING); + return true; + } + // Everything the keymap did not claim belongs to the prompt, so a character types rather + // than doing nothing. + let edited = self.prompt.edit(key); + if edited { + self.notice = None; + // A slash alone on the line asks for the commands, the way a chat prompt does. The + // slash stays on the line: it is the command being completed, and what Escape takes + // back. + if self.prompt.text() == "/" { + self.popup = Some(Popup::Commands(Menu::new(COMMANDS.to_vec()))); } - None => false, } + edited } + + /// 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.popup + .as_mut() + .is_some_and(|popup| popup.menu_mut().step(step)) + } + + /// Take the entry the cursor is on. + fn choose(&mut self) -> bool { + let Some(popup) = self.popup.take() else { + return false; + }; + let chosen = popup.menu().selected().name; + match popup { + // The command is spent with the slash that was typed for it, and the picker it opened + // starts on the palette already in use. + Popup::Commands(_) => match chosen { + "theme" => { + self.prompt.clear(); + self.popup = Some(Popup::Themes(themes(self.theme.name))); + } + "exit" => self.should_quit = true, + _ => {} + }, + // A palette: take it. Choosing the one already on screen just closes the picker, as + // the site's does. + Popup::Themes(_) => { + if let Some(palette) = theme::PALETTES + .iter() + .find(|palette| palette.name == chosen) + { + self.theme = palette; + } + } + } + true + } + + /// Close what is open, or leave when nothing is. + fn escape(&mut self) -> bool { + let Some(popup) = self.popup.take() else { + self.should_quit = true; + return false; + }; + // Closing the command menu takes its slash back, so the line is what it was before the + // command started and `/` asks for it again. The theme picker was opened by a command that + // spent its slash already. + if matches!(popup, Popup::Commands(_)) { + self.prompt.clear(); + } + true + } +} + +/// The theme picker: one entry per palette the site ships, its cursor on `active`. +/// +/// Each entry previews its own palette on the strength of `field_bg` and `text` alone — the ground +/// the screen would stand on and the ink the words would be in, which is what tells one palette +/// from another at a glance. +fn themes(active: &str) -> Menu { + Menu::at( + theme::PALETTES + .iter() + .map(|palette| Entry { + name: palette.name, + detail: palette.scheme.name(), + preview: Some(Preview { + bg: palette.field_bg, + fg: palette.text, + }), + }) + .collect(), + active, + ) } diff --git a/packages/tui/src/event.rs b/packages/tui/src/event.rs index 8d7af291..f1b09e5a 100644 --- a/packages/tui/src/event.rs +++ b/packages/tui/src/event.rs @@ -15,6 +15,10 @@ use tokio::sync::mpsc; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppEvent { Key(KeyEvent), + /// 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. @@ -73,6 +77,7 @@ impl EventHandler { fn translate(event: Event) -> Option { match event { Event::Key(key) if key.kind != KeyEventKind::Release => Some(AppEvent::Key(key)), + Event::Paste(text) => Some(AppEvent::Paste(text)), Event::Resize(_, _) => Some(AppEvent::Resize), _ => None, } diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index f391c8bb..444da022 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -1,6 +1,8 @@ mod action; mod app; mod event; +mod menu; +mod prompt; // The RPC transport (client + wire protocol) is kept whole: it is the layer the // session screen will stream prompts over. Nothing on the welcome screen uses it. #[allow(dead_code, unused_imports)] @@ -20,10 +22,11 @@ use tui::TerminalGuard; #[tokio::main] async fn main() -> Result<(), Box> { // 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. + // ground beneath the frames matches the ground inside them — which on this screen is the + // landing canvas rather than the page. let mut app = App::new(); // Dropping the guard restores the terminal, including on an early return. - let mut terminal = TerminalGuard::init(app.theme.bg)?; + let mut terminal = TerminalGuard::init(app.theme.field_bg)?; let mut events = EventHandler::new(); terminal.draw(|frame| ui::view(&app, frame))?; diff --git a/packages/tui/src/menu.rs b/packages/tui/src/menu.rs new file mode 100644 index 00000000..14b9dc43 --- /dev/null +++ b/packages/tui/src/menu.rs @@ -0,0 +1,86 @@ +//! 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 ratatui::style::Color; + +/// One choice in a menu. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Entry { + /// What the entry is called, and the first thing its row shows. It is also its identity: the + /// app matches on it, and [`Menu::at`] finds it. + pub name: &'static str, + /// A short note beside the name: what choosing it does, or what it is. + pub detail: &'static str, + /// The colours the entry is about, when it is about colour. + pub preview: Option, +} + +/// 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 { + entries: Vec, + cursor: usize, +} + +impl Menu { + /// A menu over `entries`, its cursor on the first of them. + pub fn new(entries: Vec) -> Self { + Self { entries, cursor: 0 } + } + + /// A menu over `entries`, its cursor on the entry named `name`. + /// + /// 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) + .unwrap_or(0); + Self { entries, cursor } + } + + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + /// The entry under the cursor. + pub fn selected(&self) -> &Entry { + &self.entries[self.cursor] + } + + /// 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..63169a02 --- /dev/null +++ b/packages/tui/src/prompt.rs @@ -0,0 +1,112 @@ +//! 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// The prompt's text and caret. +/// +/// The caret counts characters rather than bytes: a byte index would have to be kept on a +/// boundary by hand every time the text changes, and a prompt line is short. +#[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.byte(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 { + // Only a plain or shifted key types: ctrl belongs to the app (exit, the leader) and alt + // to the terminal. + KeyCode::Char(ch) if key.modifiers.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; + } + self.insert_text(&ch.to_string()) + } + KeyCode::Backspace => self.remove(self.caret.checked_sub(1)), + KeyCode::Delete => self.remove(Some(self.caret)), + KeyCode::Left => self.move_caret(-1), + KeyCode::Right => self.move_caret(1), + KeyCode::Home => self.jump(0), + KeyCode::End => self.jump(self.len()), + _ => false, + } + } + + /// Insert text at the caret, as a paste arrives. + /// + /// Control characters are dropped: the line holds one line, and a paste can carry newlines and + /// tabs that a terminal would otherwise act on. + pub fn insert_text(&mut self, text: &str) -> bool { + let text: String = text.chars().filter(|ch| !ch.is_control()).collect(); + if text.is_empty() { + return false; + } + let at = self.byte(self.caret); + self.text.insert_str(at, &text); + self.caret += text.chars().count(); + true + } + + /// Delete the character at `index`, when there is one. + fn remove(&mut self, index: Option) -> bool { + let Some(index) = index else { + return false; + }; + let (start, end) = (self.byte(index), self.byte(index + 1)); + if start == end { + return false; + } + self.text.replace_range(start..end, ""); + self.caret = index; + true + } + + fn move_caret(&mut self, step: isize) -> bool { + let at = self.caret as isize + step; + self.jump(at.clamp(0, self.len() as isize) as usize) + } + + fn jump(&mut self, caret: usize) -> bool { + if caret == self.caret { + return false; + } + self.caret = caret; + true + } + + fn len(&self) -> usize { + self.text.chars().count() + } + + /// The byte offset of a char index, clamped to the end of the text. + fn byte(&self, index: usize) -> usize { + self.text + .char_indices() + .nth(index) + .map_or(self.text.len(), |(byte, _)| byte) + } +} diff --git a/packages/tui/src/theme.rs b/packages/tui/src/theme.rs index 4bbcf532..bab2d96c 100644 --- a/packages/tui/src/theme.rs +++ b/packages/tui/src/theme.rs @@ -21,6 +21,16 @@ pub enum Scheme { 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 diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs index 242c3758..e8316050 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -3,16 +3,30 @@ //! 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 menu; +mod text; mod wordmark; +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::style::Style; -use ratatui::widgets::Block; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; use crate::app::{App, 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 MENU_HINTS: &str = "↑↓ move · enter choose · esc back"; + /// Draw the active screen. pub fn view(app: &App, frame: &mut Frame) { match app.screen { @@ -22,25 +36,91 @@ pub fn view(app: &App, frame: &mut Frame) { fn view_welcome(app: &App, frame: &mut Frame) { let area = frame.area(); - // 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. - let canvas = Style::default().bg(app.theme.field_bg); - frame.render_widget(Block::default().style(canvas), area); - - // Three layers, in the order the site paints them: the ground, the lattice on the - // wordmark's own pixel grid, then the word over it. A frame with no room for - // either glyph still gets the lattice: the site's field covers the whole canvas - // whether or not the word fits. - let slot = wordmark::slot(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 — with a menu, when one is open, hung directly under the box like the list a + // prompt completes into. Centred as one block, which is the space the site's own elastic + // spacers leave above and below it. + // + // On a frame too short for the whole column it is the wordmark that goes first, then the menu + // gives up its rows: the line you type into outranks the word above it and the list below it. + let below = GAP + input::HEIGHT + GAP + 1; + let menu_rows = app.popup.as_ref().map_or(0, |popup| { + menu::height(popup.menu().entries().len()).min(area.height.saturating_sub(below)) + }); + let art_rows = wordmark::rows(area.width) + .unwrap_or(0) + .min(area.height.saturating_sub(below + menu_rows)); + let [art, _, prompt, picker, _, hint] = Layout::vertical([ + Constraint::Length(art_rows), + Constraint::Length(GAP), + Constraint::Length(input::HEIGHT), + Constraint::Length(menu_rows), + 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, - app.theme, + palette, slot.map_or(0, |slot| slot.x), ); if let Some(slot) = slot { - wordmark::draw(frame.buffer_mut(), slot, app.theme); + wordmark::draw(frame.buffer_mut(), slot, palette); + } + + if let Some(caret) = input::draw(frame.buffer_mut(), prompt, &app.prompt, palette) { + frame.set_cursor_position(caret); } + if let Some(popup) = &app.popup { + // As wide as its longest row and anchored on the box's left edge, so it reads as the list + // the line opens into rather than a panel of its own. + let anchor = input::centred(area); + let width = menu::width(popup.menu().entries()).min(area.right().saturating_sub(anchor.x)); + menu::draw( + frame.buffer_mut(), + Rect { + x: anchor.x, + y: picker.y, + width, + height: picker.height, + }, + popup.title(), + popup.menu(), + palette, + ); + } + // 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, + (None, Some(_)) => MENU_HINTS, + (None, None) => HINTS, + }; + 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..71f821d4 --- /dev/null +++ b/packages/tui/src/ui/badge.rs @@ -0,0 +1,29 @@ +//! 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) { + let ink = Style::default().fg(palette.brand); + 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; + buffer.set_string(at, area.y, " ", ink); + buffer.set_string(at + 1, area.y, label, ink); + buffer.set_string(at + width - 1, area.y, " ", ink); +} diff --git a/packages/tui/src/ui/input.rs b/packages/tui/src/ui/input.rs new file mode 100644 index 00000000..92e5023b --- /dev/null +++ b/packages/tui/src/ui/input.rs @@ -0,0 +1,105 @@ +//! 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::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::badge; +use super::text::fitted; +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, and report where the caret goes. `None` when the area is +/// too small to hold the box, or too narrow for the line inside it. +pub fn draw( + buffer: &mut Buffer, + area: Rect, + prompt: &Prompt, + palette: &Palette, +) -> Option<(u16, u16)> { + let column = centred(area); + // 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 block = Block::bordered().border_style(Style::default().fg(palette.border_strong)); + let inner = block.inner(column); + block.render(column, buffer); + + // The tag rides the top border, as the site's badge does. + badge::draw(buffer, column, TAG, palette); + + // 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, PLACEHOLDER, palette.text_muted) + } else { + // Scroll the line under the caret: take the longest tail of the text before the caret that + // fits, so a long line keeps its beginning out of the way rather than running off the box. + let (mut used, mut start) = (0, prompt.before_caret().len()); + for (offset, ch) in prompt.before_caret().char_indices().rev() { + let width = ch.width().unwrap_or(0); + if used + width > usable { + break; + } + used += width; + start = offset; + } + let text = &prompt.text()[start..]; + (text_x + used as u16, text, palette.text) + }; + buffer.set_string( + text_x, + inner.y, + fitted(text, usable), + Style::default().fg(ink), + ); + Some((caret_x, inner.y)) +} + +/// 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/menu.rs b/packages/tui/src/ui/menu.rs new file mode 100644 index 00000000..e904e7d8 --- /dev/null +++ b/packages/tui/src/ui/menu.rs @@ -0,0 +1,142 @@ +//! 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}; +use crate::menu::{Entry, Menu}; +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 = "❯ "; +/// 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 of `entries` asks for: a border, its rows, and a border. +pub fn height(entries: usize) -> u16 { + entries.clamp(1, MAX_ROWS as usize) as u16 + 2 +} + +/// The width a menu of `entries` wants: the marker, the longest name, the longest note, and the +/// border. It is a list of choices rather than a panel, so it is no wider than what it holds. +pub fn width(entries: &[Entry]) -> u16 { + 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; + text::width(MARKER) as u16 + names + GAP + notes + PAD + 2 +} + +/// Draw `menu` in `area`, titled `title` on its top border. +/// +/// 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, palette: &Palette) { + // 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; + } + + let block = Block::bordered().border_style(Style::default().fg(palette.border_subtle)); + let inner = block.inner(area); + block.render(area, buffer); + badge::draw(buffer, area, title, palette); + + // The two columns every row is laid out in. A narrow frame takes from the notes first: the + // names are what you are reading. + let entries = menu.entries(); + let names = entries + .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(), inner.height as usize, entries.len()); + for (row, entry) in entries[first..] + .iter() + .take(inner.height as usize) + .enumerate() + { + let y = inner.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 + }), + ); + } +} + +/// 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/text.rs b/packages/tui/src/ui/text.rs new file mode 100644 index 00000000..e7f01cfb --- /dev/null +++ b/packages/tui/src/ui/text.rs @@ -0,0 +1,27 @@ +//! 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; + +/// 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; + } + text +} + +/// The columns `text` takes, which is what a layout has to reserve for it. +pub fn width(text: &str) -> usize { + text.width() +} diff --git a/packages/tui/src/ui/wordmark.rs b/packages/tui/src/ui/wordmark.rs index 66f8f7be..bee2fdd3 100644 --- a/packages/tui/src/ui/wordmark.rs +++ b/packages/tui/src/ui/wordmark.rs @@ -71,8 +71,14 @@ fn glyph_for(width: usize) -> Option<&'static Glyph> { None } -/// Where the wordmark sits in this frame, and which glyph it is. The glyph's pixel grid is the -/// field's grid too, so the field is anchored on `x`. +/// 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. @@ -81,9 +87,9 @@ pub struct Slot { glyph: &'static Glyph, } -/// The slot for a frame this size: centred, or `None` when the frame 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. +/// 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; @@ -91,8 +97,8 @@ pub fn slot(area: Rect) -> Option { return None; } Some(Slot { - x: area.width.saturating_sub(glyph.width as u16) / 2, - y: (area.height - rows) / 2, + x: area.x + area.width.saturating_sub(glyph.width as u16) / 2, + y: area.y + (area.height - rows) / 2, glyph, }) } From 066493879bf521436001421223928f1316f3bec7 Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 23:27:11 +0530 Subject: [PATCH 08/12] feat: add search filtering and left badge support to menus and terminal guards --- packages/tui/README.md | 5 +- packages/tui/src/app.rs | 161 +++++++++++++++++++++++++++-------- packages/tui/src/main.rs | 4 +- packages/tui/src/menu.rs | 40 +++++++-- packages/tui/src/tui.rs | 24 ++++-- packages/tui/src/ui.rs | 91 ++++++++++++-------- packages/tui/src/ui/badge.rs | 26 +++++- packages/tui/src/ui/input.rs | 28 ++---- packages/tui/src/ui/menu.rs | 132 +++++++++++++++++++++++----- packages/tui/src/ui/text.rs | 15 ++++ 10 files changed, 394 insertions(+), 132 deletions(-) diff --git a/packages/tui/README.md b/packages/tui/README.md index 7fa5e89b..0b90a84f 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -22,9 +22,10 @@ newline-delimited JSON-RPC. | `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/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/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. | diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 6f244b78..43a9bba0 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -44,20 +44,33 @@ pub enum Screen { pub enum Popup { /// What a leading slash asks for. Commands(Menu), - /// The palettes the site ships. - Themes(Menu), + /// 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::Themes(menu) => menu, + Popup::Commands(menu) | Popup::Themes { menu, .. } => menu, } } fn menu_mut(&mut self) -> &mut Menu { match self { - Popup::Commands(menu) | Popup::Themes(menu) => menu, + Popup::Commands(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(_) => None, + Popup::Themes { search, .. } => Some(search), } } @@ -65,7 +78,7 @@ impl Popup { pub fn title(&self) -> &'static str { match self { Popup::Commands(_) => "commands", - Popup::Themes(_) => "themes", + Popup::Themes { .. } => "themes", } } } @@ -110,15 +123,25 @@ impl App { pub fn update(&mut self, event: AppEvent) -> bool { match event { AppEvent::Key(key) => self.handle_key(key), - // A paste is text, and a menu takes no text: it is dropped rather than typed into the - // line the menu is standing in front of. + // A paste is text: into the theme search when the theme picker is open, otherwise into + // the prompt line. AppEvent::Paste(text) => { - if self.popup.is_some() { - return false; + if let Some(Popup::Themes { + search, + menu, + initial, + }) = &mut self.popup + { + if !search.insert_text(&text) { + return false; + } + self.theme = filter_themes(search.text(), menu, *initial); + return true; } let changed = self.prompt.insert_text(&text); if changed { self.notice = None; + self.sync_commands(); } changed } @@ -141,10 +164,25 @@ impl App { if key.code == KeyCode::Esc { return self.escape(); } - // A menu takes the keyboard while it is up. It is modal, so a key it does not want is - // dropped rather than typed into the prompt standing behind it. + // A menu claims the keys that walk or choose from it. The theme picker takes any other + // editing key into its own search line; the command menu leaves editing keys to the prompt + // line so typing or erasing `/` filters or closes it. if self.popup.is_some() { - return self.menu_key(key); + if self.menu_key(key) { + return true; + } + if let Some(Popup::Themes { + search, + menu, + initial, + }) = &mut self.popup + { + if !search.edit(key) { + return false; + } + self.theme = filter_themes(search.text(), menu, *initial); + return true; + } } // Enter is the prompt's, not the keymap's, because it only means something once there is // something to send. @@ -157,16 +195,30 @@ impl App { let edited = self.prompt.edit(key); if edited { self.notice = None; - // A slash alone on the line asks for the commands, the way a chat prompt does. The - // slash stays on the line: it is the command being completed, and what Escape takes - // back. - if self.prompt.text() == "/" { - self.popup = Some(Popup::Commands(Menu::new(COMMANDS.to_vec()))); - } + self.sync_commands(); } edited } + /// 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; + } + 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)); + } + } + /// 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 { @@ -175,35 +227,54 @@ impl App { KeyCode::Enter => return self.choose(), _ => return false, }; - self.popup - .as_mut() - .is_some_and(|popup| popup.menu_mut().step(step)) + let Some(popup) = &mut self.popup else { + return false; + }; + if !popup.menu_mut().step(step) { + return false; + } + // 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; + } + } + true } /// 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()) + .map(|entry| entry.name) + else { + return false; + }; let Some(popup) = self.popup.take() else { return false; }; - let chosen = popup.menu().selected().name; match popup { // The command is spent with the slash that was typed for it, and the picker it opened // starts on the palette already in use. Popup::Commands(_) => match chosen { "theme" => { self.prompt.clear(); - self.popup = Some(Popup::Themes(themes(self.theme.name))); + self.popup = Some(Popup::Themes { + search: Prompt::default(), + menu: themes(self.theme.name), + initial: self.theme, + }); } "exit" => self.should_quit = true, _ => {} }, - // A palette: take it. Choosing the one already on screen just closes the picker, as - // the site's does. - Popup::Themes(_) => { - if let Some(palette) = theme::PALETTES - .iter() - .find(|palette| palette.name == chosen) - { + // A palette: moving the cursor already applied it, so Enter only has to close the + // picker and keep what is on screen. + Popup::Themes { .. } => { + if let Some(palette) = theme_named(chosen) { self.theme = palette; } } @@ -217,16 +288,36 @@ impl App { self.should_quit = true; return false; }; - // Closing the command menu takes its slash back, so the line is what it was before the - // command started and `/` asks for it again. The theme picker was opened by a command that - // spent its slash already. - if matches!(popup, Popup::Commands(_)) { - self.prompt.clear(); + match popup { + // Closing the command menu takes its slash back, so the line is what it was before the + // command started and `/` asks for it again. + Popup::Commands(_) => self.prompt.clear(), + // Leaving the theme picker without choosing puts back the palette that was on screen + // before the preview started. + Popup::Themes { initial, .. } => self.theme = initial, } true } } +fn theme_named(name: &str) -> Option<&'static Palette> { + theme::PALETTES.iter().find(|palette| palette.name == name) +} + +/// 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) +} + /// The theme picker: one entry per palette the site ships, its cursor on `active`. /// /// Each entry previews its own palette on the strength of `field_bg` and `text` alone — the ground diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index 444da022..1142c584 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -29,7 +29,7 @@ async fn main() -> Result<(), Box> { let mut terminal = TerminalGuard::init(app.theme.field_bg)?; let mut events = EventHandler::new(); - terminal.draw(|frame| ui::view(&app, frame))?; + terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?; while let Some(event) = events.next().await { let mut redraw = app.update(event); @@ -42,7 +42,7 @@ async fn main() -> Result<(), Box> { break; } if redraw { - terminal.draw(|frame| ui::view(&app, frame))?; + terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?; } } diff --git a/packages/tui/src/menu.rs b/packages/tui/src/menu.rs index 14b9dc43..a123c907 100644 --- a/packages/tui/src/menu.rs +++ b/packages/tui/src/menu.rs @@ -33,6 +33,7 @@ pub struct Preview { /// The entries, and where the cursor is among them. pub struct Menu { + all: Vec, entries: Vec, cursor: usize, } @@ -40,7 +41,11 @@ pub struct Menu { impl Menu { /// A menu over `entries`, its cursor on the first of them. pub fn new(entries: Vec) -> Self { - Self { entries, cursor: 0 } + Self { + all: entries.clone(), + entries, + cursor: 0, + } } /// A menu over `entries`, its cursor on the entry named `name`. @@ -52,7 +57,17 @@ impl Menu { .iter() .position(|entry| entry.name == name) .unwrap_or(0); - Self { entries, cursor } + 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] { @@ -63,9 +78,24 @@ impl Menu { self.cursor } - /// The entry under the cursor. - pub fn selected(&self) -> &Entry { - &self.entries[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.name); + self.entries = self + .all + .iter() + .copied() + .filter(|entry| keep(entry)) + .collect(); + self.cursor = active + .and_then(|name| self.entries.iter().position(|entry| entry.name == name)) + .unwrap_or(0); } /// Move the cursor `step` entries, wrapping at both ends, so the last entry is one key from the diff --git a/packages/tui/src/tui.rs b/packages/tui/src/tui.rs index fa673e0b..96977687 100644 --- a/packages/tui/src/tui.rs +++ b/packages/tui/src/tui.rs @@ -34,6 +34,7 @@ const NO_KEYBOARD_ENHANCEMENT: &str = "CODEWORK_TUI_NO_KEYBOARD_ENHANCEMENT"; /// terminal back. pub struct TerminalGuard { terminal: Tui, + background: Color, } impl TerminalGuard { @@ -70,18 +71,27 @@ impl TerminalGuard { let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; terminal.clear()?; - Ok(Self { terminal }) + 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, render: impl FnOnce(&mut Frame)) -> io::Result<()> { - execute!(io::stdout(), BeginSynchronizedUpdate)?; - let drawn = self.terminal.draw(render); - let _ = execute!(io::stdout(), EndSynchronizedUpdate); - drawn.map(|_| ()) + 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 } } @@ -129,7 +139,7 @@ fn install_panic_hook() { /// 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 Stdout, color: Color) -> io::Result<()> { +fn set_background(stdout: &mut impl Write, color: Color) -> io::Result<()> { let Color::Rgb(red, green, blue) = color else { return Ok(()); }; diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs index e8316050..82f6c865 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -17,7 +17,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; -use crate::app::{App, Screen}; +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; @@ -25,7 +25,8 @@ 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 MENU_HINTS: &str = "↑↓ move · enter choose · esc back"; +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) { @@ -48,24 +49,19 @@ fn view_welcome(app: &App, frame: &mut Frame) { ); // The hero's column, in the order the site stacks it: the wordmark, the prompt box, and the - // line under it — with a menu, when one is open, hung directly under the box like the list a - // prompt completes into. Centred as one block, which is the space the site's own elastic - // spacers leave above and below it. + // 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, then the menu - // gives up its rows: the line you type into outranks the word above it and the list below it. + // 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 menu_rows = app.popup.as_ref().map_or(0, |popup| { - menu::height(popup.menu().entries().len()).min(area.height.saturating_sub(below)) - }); let art_rows = wordmark::rows(area.width) .unwrap_or(0) - .min(area.height.saturating_sub(below + menu_rows)); - let [art, _, prompt, picker, _, hint] = Layout::vertical([ + .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(menu_rows), Constraint::Length(GAP), Constraint::Length(1), ]) @@ -86,41 +82,66 @@ fn view_welcome(app: &App, frame: &mut Frame) { wordmark::draw(frame.buffer_mut(), slot, palette); } - if let Some(caret) = input::draw(frame.buffer_mut(), prompt, &app.prompt, palette) { - frame.set_cursor_position(caret); - } + let prompt_caret = input::draw(frame.buffer_mut(), prompt, &app.prompt, palette); + let mut menu_caret = None; if let Some(popup) = &app.popup { - // As wide as its longest row and anchored on the box's left edge, so it reads as the list - // the line opens into rather than a panel of its own. + // 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 width = menu::width(popup.menu().entries()).min(area.right().saturating_sub(anchor.x)); - menu::draw( + 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(), - Rect { - x: anchor.x, - y: picker.y, - width, - height: picker.height, - }, + 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, - (None, Some(_)) => MENU_HINTS, + (None, Some(Popup::Commands(_))) => COMMAND_HINTS, + (None, Some(Popup::Themes { .. })) => THEME_HINTS, (None, None) => HINTS, }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - hints, - Style::default().fg(palette.text_muted), - ))) - .alignment(Alignment::Center), - hint, - ); + 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 index 71f821d4..f8551e12 100644 --- a/packages/tui/src/ui/badge.rs +++ b/packages/tui/src/ui/badge.rs @@ -15,7 +15,21 @@ use crate::theme::Palette; /// 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) { - let ink = Style::default().fg(palette.brand); + 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. @@ -23,7 +37,11 @@ pub fn draw(buffer: &mut Buffer, area: Rect, label: &str, palette: &Palette) { return; } let at = area.right() - width - 2; - buffer.set_string(at, area.y, " ", ink); - buffer.set_string(at + 1, area.y, label, ink); - buffer.set_string(at + width - 1, area.y, " ", ink); + 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/input.rs b/packages/tui/src/ui/input.rs index 92e5023b..ee8d8296 100644 --- a/packages/tui/src/ui/input.rs +++ b/packages/tui/src/ui/input.rs @@ -14,10 +14,10 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::widgets::{Block, Widget}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use unicode_width::UnicodeWidthStr; use super::badge; -use super::text::fitted; +use super::text::{fitted, scrolled}; use crate::prompt::Prompt; use crate::theme::Palette; @@ -65,28 +65,12 @@ pub fn draw( 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, PLACEHOLDER, palette.text_muted) + (text_x, fitted(PLACEHOLDER, usable), palette.text_muted) } else { - // Scroll the line under the caret: take the longest tail of the text before the caret that - // fits, so a long line keeps its beginning out of the way rather than running off the box. - let (mut used, mut start) = (0, prompt.before_caret().len()); - for (offset, ch) in prompt.before_caret().char_indices().rev() { - let width = ch.width().unwrap_or(0); - if used + width > usable { - break; - } - used += width; - start = offset; - } - let text = &prompt.text()[start..]; - (text_x + used as u16, text, palette.text) + let (text, offset) = scrolled(prompt.text(), prompt.before_caret(), usable); + (text_x + offset, text, palette.text) }; - buffer.set_string( - text_x, - inner.y, - fitted(text, usable), - Style::default().fg(ink), - ); + buffer.set_string(text_x, inner.y, text, Style::default().fg(ink)); Some((caret_x, inner.y)) } diff --git a/packages/tui/src/ui/menu.rs b/packages/tui/src/ui/menu.rs index e904e7d8..23ace0f0 100644 --- a/packages/tui/src/ui/menu.rs +++ b/packages/tui/src/ui/menu.rs @@ -15,8 +15,9 @@ use ratatui::style::Style; use ratatui::widgets::{Block, Widget}; use super::badge; -use super::text::{self, fitted}; -use crate::menu::{Entry, Menu}; +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 @@ -25,19 +26,30 @@ 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 themes..."; +/// What a filtered menu shows when no entry matches the search query. +const EMPTY: &str = "no matching themes"; +/// 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 of `entries` asks for: a border, its rows, and a border. -pub fn height(entries: usize) -> u16 { - entries.clamp(1, MAX_ROWS as usize) as u16 + 2 +/// 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 of `entries` wants: the marker, the longest name, the longest note, and the -/// border. It is a list of choices rather than a panel, so it is no wider than what it holds. -pub fn width(entries: &[Entry]) -> u16 { +/// 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)) @@ -48,32 +60,110 @@ pub fn width(entries: &[Entry]) -> u16 { .map(|entry| text::width(entry.detail)) .max() .unwrap_or(0) as u16; - text::width(MARKER) as u16 + names + GAP + notes + PAD + 2 + let rows_width = marker + names + GAP + notes + PAD + 2; + if searchable { + let search_width = marker + text::width(SEARCH_PLACEHOLDER) as u16 + PAD + 2; + rows_width.max(search_width) + } else { + rows_width + } } -/// Draw `menu` in `area`, titled `title` on its top border. +/// 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, palette: &Palette) { +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; + return None; } - let block = Block::bordered().border_style(Style::default().fg(palette.border_subtle)); + 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(buffer, area, title, palette); + 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; + } - // The two columns every row is laid out in. A narrow frame takes from the notes first: the - // names are what you are reading. let entries = menu.entries(); - let names = 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() @@ -81,13 +171,13 @@ pub fn draw(buffer: &mut Buffer, area: Rect, title: &str, menu: &Menu, palette: 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(), inner.height as usize, entries.len()); + let first = window(menu.cursor(), list_height as usize, entries.len()); for (row, entry) in entries[first..] .iter() - .take(inner.height as usize) + .take(list_height as usize) .enumerate() { - let y = inner.y + row as u16; + 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 @@ -131,6 +221,8 @@ pub fn draw(buffer: &mut Buffer, area: Rect, title: &str, menu: &Menu, palette: }), ); } + + caret } /// The first entry the window shows, so that the cursor's entry is in it and the window is full diff --git a/packages/tui/src/ui/text.rs b/packages/tui/src/ui/text.rs index e7f01cfb..13272c60 100644 --- a/packages/tui/src/ui/text.rs +++ b/packages/tui/src/ui/text.rs @@ -21,6 +21,21 @@ pub fn fitted(text: &str, width: usize) -> &str { 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; + } + used += char_width; + start = offset; + } + (fitted(&text[start..], width), used as u16) +} + /// The columns `text` takes, which is what a layout has to reserve for it. pub fn width(text: &str) -> usize { text.width() From a205ac6534d1af65799586092d0fa6af618574ca Mon Sep 17 00:00:00 2001 From: prem315 Date: Tue, 13 Oct 2026 23:34:40 +0530 Subject: [PATCH 09/12] refactor: improve key resolution and prompt editing with byte-offset carets and text editing shortcuts --- packages/tui/src/action.rs | 53 ++++++++------ packages/tui/src/app.rs | 14 ++-- packages/tui/src/prompt.rs | 137 ++++++++++++++++++++++++++----------- 3 files changed, 137 insertions(+), 67 deletions(-) diff --git a/packages/tui/src/action.rs b/packages/tui/src/action.rs index 58a5b3aa..8b19f0a4 100644 --- a/packages/tui/src/action.rs +++ b/packages/tui/src/action.rs @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +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); @@ -28,6 +28,17 @@ pub enum Action { 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 { @@ -39,33 +50,31 @@ impl Keymap { Self::default() } - /// The action this key triggers. + /// 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 other key clears the arming. - pub fn resolve(&mut self, key: KeyEvent) -> Option { - let leader = self.take_leader(key); - match (key.code, key.modifiers) { - // OpenCode's defaults for `app.exit`: ctrl+c, ctrl+d, q. - (KeyCode::Char('c'), KeyModifiers::CONTROL) => Some(Action::Exit), - (KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::Exit), - (KeyCode::Char('q'), _) if leader => Some(Action::Exit), - // 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. - _ => None, + /// 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; } - } - - /// Whether this key press completes a `` sequence, arming the leader - /// if the key is the leader itself. - fn take_leader(&mut self, key: KeyEvent) -> bool { - let armed = self + let leader = self .leader_armed .take() .is_some_and(|at| at.elapsed() < LEADER_TIMEOUT); - if (key.code, key.modifiers) == LEADER { - self.leader_armed = Some(Instant::now()); + 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, } - armed } } diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index 43a9bba0..a70fa8e6 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent}; -use crate::action::{Action, Keymap}; +use crate::action::{Action, Keymap, Resolution}; use crate::event::AppEvent; use crate::menu::{Entry, Menu, Preview}; use crate::prompt::Prompt; @@ -154,10 +154,14 @@ impl App { fn handle_key(&mut self, key: KeyEvent) -> bool { // Named actions first, so a key bound to the app is never typed into the prompt. The exits // are the terminal's whether or not a menu is up. - if let Some(Action::Exit) = self.keymap.resolve(key) { - self.should_quit = true; - // Nothing to redraw: the loop is about to end. - return false; + match self.keymap.resolve(key) { + Resolution::Action(Action::Exit) => { + self.should_quit = true; + // Nothing to redraw: the loop is about to end. + return false; + } + Resolution::Consumed => return false, + Resolution::Unbound => {} } // Escape closes what is open and, with nothing open, leaves. It is not in the keymap // because a keymap holds one answer per key and this one has two. diff --git a/packages/tui/src/prompt.rs b/packages/tui/src/prompt.rs index 63169a02..dc69ae5f 100644 --- a/packages/tui/src/prompt.rs +++ b/packages/tui/src/prompt.rs @@ -4,12 +4,14 @@ //! 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 counts characters rather than bytes: a byte index would have to be kept on a -/// boundary by hand every time the text changes, and a prompt line is short. +/// 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, @@ -23,7 +25,7 @@ impl Prompt { /// 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.byte(self.caret)] + &self.text[..self.caret] } /// Empty the line. Escape does this when it takes back the slash that asked for the command @@ -35,61 +37,94 @@ impl Prompt { /// Apply one key. Reports whether the prompt changed, which is the redraw. pub fn edit(&mut self, key: KeyEvent) -> bool { - match key.code { + 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) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => { + (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; } - self.insert_text(&ch.to_string()) + let mut buf = [0u8; 4]; + self.insert_text(ch.encode_utf8(&mut buf)) } - KeyCode::Backspace => self.remove(self.caret.checked_sub(1)), - KeyCode::Delete => self.remove(Some(self.caret)), - KeyCode::Left => self.move_caret(-1), - KeyCode::Right => self.move_caret(1), - KeyCode::Home => self.jump(0), - KeyCode::End => self.jump(self.len()), + (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. /// - /// Control characters are dropped: the line holds one line, and a paste can carry newlines and - /// tabs that a terminal would otherwise act on. + /// 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 text: String = text.chars().filter(|ch| !ch.is_control()).collect(); - if text.is_empty() { + 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; } - let at = self.byte(self.caret); - self.text.insert_str(at, &text); - self.caret += text.chars().count(); + self.text.insert_str(self.caret, &clean); + self.caret += clean.len(); true } - /// Delete the character at `index`, when there is one. - fn remove(&mut self, index: Option) -> bool { - let Some(index) = index else { - return false; - }; - let (start, end) = (self.byte(index), self.byte(index + 1)); - if start == end { + fn remove_range(&mut self, range: Range) -> bool { + if range.is_empty() { return false; } - self.text.replace_range(start..end, ""); - self.caret = index; + let start = range.start; + self.text.replace_range(range, ""); + self.caret = start; true } - fn move_caret(&mut self, step: isize) -> bool { - let at = self.caret as isize + step; - self.jump(at.clamp(0, self.len() as isize) as usize) - } - fn jump(&mut self, caret: usize) -> bool { if caret == self.caret { return false; @@ -98,15 +133,37 @@ impl Prompt { true } - fn len(&self) -> usize { - self.text.chars().count() + 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()) } - /// The byte offset of a char index, clamped to the end of the text. - fn byte(&self, index: usize) -> usize { - self.text + fn prev_word(&self) -> usize { + let before = &self.text[..self.caret]; + let trimmed = before.trim_end_matches(char::is_whitespace); + trimmed .char_indices() - .nth(index) - .map_or(self.text.len(), |(byte, _)| byte) + .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) } } From 8b0c2a477a270c2b55f10ff9e2afbf6ab0f9590c Mon Sep 17 00:00:00 2001 From: prem315 Date: Wed, 14 Oct 2026 10:52:52 +0530 Subject: [PATCH 10/12] feat: add session management, model selection, and tool execution tracking to TUI --- packages/tui/src/app.rs | 855 +++++++++++++++++++++++++++---- packages/tui/src/event.rs | 27 +- packages/tui/src/main.rs | 17 +- packages/tui/src/menu.rs | 56 +- packages/tui/src/rpc/client.rs | 515 +++++++++++++------ packages/tui/src/rpc/protocol.rs | 19 +- packages/tui/src/types.rs | 10 +- packages/tui/src/ui.rs | 12 +- packages/tui/src/ui/menu.rs | 16 +- packages/tui/src/ui/session.rs | 356 +++++++++++++ 10 files changed, 1563 insertions(+), 320 deletions(-) create mode 100644 packages/tui/src/ui/session.rs diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index a70fa8e6..99d0a98c 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -5,45 +5,155 @@ //! there is no tick, so an idle TUI does no work and no repaint. use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::style::Color; +use serde_json::Value; +use tokio::sync::mpsc; use crate::action::{Action, Keymap, Resolution}; -use crate::event::AppEvent; +use crate::event::{AppEvent, RpcEvent}; use crate::menu::{Entry, Menu, Preview}; use crate::prompt::Prompt; +use crate::rpc::{RpcClient, StreamEvent}; use crate::theme::{self, Palette}; - -/// What the prompt answers an Enter with, until the session screen can act on it. A key that does -/// nothing reads as a bug, so the one that will send says so instead. -const NOT_SENDING: &str = "sending is not wired up yet"; +use crate::types::{SessionInfo, 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 { - name: "theme", - detail: "pick a palette", - preview: None, - }, - Entry { - name: "exit", - detail: "close the app", - preview: None, - }, + 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. One, for now. +/// The screens the TUI can show. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Welcome, + Session, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TurnState { + Running, + Succeeded, + Failed(String), + Interrupted(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Completed, + Failed, } -/// A menu open over the hero, taking the keyboard while it is there. -/// -/// There is one at a time: choosing a command opens the menu it names, so that one replaces the -/// command menu rather than stacking under it. +#[derive(Debug, Clone)] +pub struct ToolCall { + pub call_id: String, + pub name: String, + pub summary: String, + pub output: Option, + pub status: ToolStatus, +} + +#[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 { @@ -56,21 +166,31 @@ pub enum Popup { impl Popup { pub fn menu(&self) -> &Menu { match self { - Popup::Commands(menu) | Popup::Themes { menu, .. } => menu, + 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::Themes { menu, .. } => menu, + 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(_) => None, - Popup::Themes { search, .. } => Some(search), + Popup::Commands(_) | Popup::Thinking(_) => None, + Popup::Models { search, .. } + | Popup::Sessions { search, .. } + | Popup::Themes { search, .. } => Some(search), } } @@ -78,6 +198,9 @@ impl Popup { pub fn title(&self) -> &'static str { match self { Popup::Commands(_) => "commands", + Popup::Models { .. } => "models", + Popup::Thinking(_) => "thinking", + Popup::Sessions { .. } => "sessions", Popup::Themes { .. } => "themes", } } @@ -90,112 +213,213 @@ pub struct App { 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. It is modal: while it is up the prompt behind it does - /// not see a key. + /// The menu on screen, if there is one. pub popup: Option, - /// A line of plain truth about the prompt, shown in place of the key hints. It is set by keys - /// the prompt cannot honour and cleared by the first one it can. - pub notice: Option<&'static str>, + /// 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, pub should_quit: bool, keymap: Keymap, + tx: mpsc::UnboundedSender, } impl Default for App { fn default() -> Self { - Self::new() + let (tx, _) = mpsc::unbounded_channel(); + Self::new(tx) } } impl App { - pub fn new() -> Self { + pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { 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, should_quit: false, keymap: Keymap::new(), + tx, + } + } + + /// 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, + } + } + + /// 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() } /// 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), - // A paste is text: into the theme search when the theme picker is open, otherwise into - // the prompt line. - AppEvent::Paste(text) => { - if let Some(Popup::Themes { + AppEvent::Paste(text) => self.handle_paste(&text), + AppEvent::Resize => true, + AppEvent::Rpc(rpc) => self.handle_rpc(rpc), + } + } + + fn handle_paste(&mut self, text: &str) -> bool { + if let Some(popup) = &mut self.popup { + match popup { + Popup::Themes { search, menu, initial, - }) = &mut self.popup - { - if !search.insert_text(&text) { + } => { + if !search.insert_text(text) { return false; } self.theme = filter_themes(search.text(), menu, *initial); return true; } - let changed = self.prompt.insert_text(&text); - if changed { - self.notice = None; - self.sync_commands(); + 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; } - changed + 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(_) => {} } - // The view reads its size from the frame it draws into, so a resize - // needs no state change — only a repaint. - AppEvent::Resize => true, } + let changed = self.prompt.insert_text(text); + if changed { + self.notice = None; + self.sync_commands(); + } + changed } fn handle_key(&mut self, key: KeyEvent) -> bool { - // Named actions first, so a key bound to the app is never typed into the prompt. The exits - // are the terminal's whether or not a menu is up. + // 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; - // Nothing to redraw: the loop is about to end. return false; } Resolution::Consumed => return false, Resolution::Unbound => {} } - // Escape closes what is open and, with nothing open, leaves. It is not in the keymap - // because a keymap holds one answer per key and this one has two. + if key.code == KeyCode::Esc { return self.escape(); } - // A menu claims the keys that walk or choose from it. The theme picker takes any other - // editing key into its own search line; the command menu leaves editing keys to the prompt - // line so typing or erasing `/` filters or closes it. + if self.popup.is_some() { if self.menu_key(key) { return true; } - if let Some(Popup::Themes { - search, - menu, - initial, - }) = &mut self.popup - { - if !search.edit(key) { - return false; + match &mut self.popup { + Some(Popup::Themes { + search, + menu, + initial, + }) => { + if !search.edit(key) { + return false; + } + self.theme = filter_themes(search.text(), menu, *initial); + return true; + } + 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 => { + self.scroll = self.scroll.saturating_add(5); + return true; } - self.theme = filter_themes(search.text(), menu, *initial); - return true; + KeyCode::PageDown => { + self.scroll = self.scroll.saturating_sub(5); + return true; + } + _ => {} } } - // Enter is the prompt's, not the keymap's, because it only means something once there is - // something to send. + if key.code == KeyCode::Enter { - self.notice = Some(NOT_SENDING); - return true; + return self.submit_prompt(); } - // Everything the keymap did not claim belongs to the prompt, so a character types rather - // than doing nothing. + let edited = self.prompt.edit(key); if edited { self.notice = None; @@ -204,6 +428,235 @@ impl App { edited } + fn handle_rpc(&mut self, event: RpcEvent) -> bool { + match event { + RpcEvent::SessionsLoaded(Ok(sessions)) => { + 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 + } + RpcEvent::SessionsLoaded(Err(err)) => { + self.notice = Some(err); + true + } + RpcEvent::SessionConfigured(Ok(info)) => { + self.session = Some(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), + } + } + + fn handle_stream(&mut self, event: StreamEvent) -> bool { + match event { + StreamEvent::SessionCreated(info) => { + self.session = Some(info); + } + StreamEvent::TextDelta(delta) => { + if let Some(turn) = self.turns.last_mut() { + turn.text.push_str(&delta); + } + } + StreamEvent::ThinkingDelta(delta) => { + if let Some(turn) = self.turns.last_mut() { + turn.thinking.push_str(&delta); + } + } + StreamEvent::ToolStarted { + call_id, + name, + label, + arguments, + } => { + if let Some(turn) = self.turns.last_mut() { + let summary = tool_summary(&name, &arguments, label.as_deref()); + if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == call_id) { + existing.name = name; + existing.summary = summary; + } else { + turn.tools.push(ToolCall { + call_id, + name, + summary, + output: None, + status: ToolStatus::Running, + }); + } + } + } + StreamEvent::ToolSettled { + call_id, + name, + is_error, + arguments, + output, + .. + } => { + if let Some(turn) = self.turns.last_mut() { + let status = if is_error { + ToolStatus::Failed + } else { + ToolStatus::Completed + }; + if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == call_id) { + if let Some(name) = name { + existing.name = name; + } + if existing.summary.is_empty() { + existing.summary = tool_summary(&existing.name, &arguments, None); + } + existing.output = output; + existing.status = status; + } else { + let tool_name = name.unwrap_or_else(|| "tool".to_string()); + let summary = tool_summary(&tool_name, &arguments, None); + turn.tools.push(ToolCall { + call_id, + name: tool_name, + summary, + output, + status, + }); + } + } + } + StreamEvent::Notice(notice) => { + if let Some(turn) = self.turns.last_mut() { + turn.notices.push(notice); + } + } + StreamEvent::Usage(usage) => { + 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.last_mut() { + turn.state = TurnState::Succeeded; + } + self.busy = false; + self.reconnecting = false; + } + StreamEvent::Failed(err) => { + if let Some(turn) = self.turns.last_mut() { + turn.state = TurnState::Failed(err); + } + self.busy = false; + self.reconnecting = false; + } + StreamEvent::Interrupted(reason) => { + if let Some(turn) = self.turns.last_mut() { + turn.state = TurnState::Interrupted(reason); + } + self.busy = false; + self.reconnecting = false; + } + } + true + } + + fn submit_prompt(&mut self) -> bool { + let text = self.prompt.text().trim().to_string(); + if text.is_empty() { + return false; + } + if self.busy { + self.notice = Some("a turn is already running · press esc to interrupt".to_string()); + return true; + } + + 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, + }); + + 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 (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 _ = client.prompt_session(session_id, text, stream_tx).await; + let _ = forward.await; + }); + } + + 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 { @@ -240,7 +693,7 @@ impl App { // 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)) { + if let Some(palette) = menu.selected().and_then(|entry| theme_named(&entry.name)) { self.theme = palette; } } @@ -253,7 +706,7 @@ impl App { .popup .as_ref() .and_then(|popup| popup.menu().selected()) - .map(|entry| entry.name) + .cloned() else { return false; }; @@ -261,24 +714,77 @@ impl App { return false; }; match popup { - // The command is spent with the slash that was typed for it, and the picker it opened - // starts on the palette already in use. - Popup::Commands(_) => match chosen { - "theme" => { - self.prompt.clear(); - self.popup = Some(Popup::Themes { - search: Prompt::default(), - menu: themes(self.theme.name), - initial: self.theme, - }); + 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)), + }); + } + "thinking" => { + let current = self.thinking.as_deref().unwrap_or("off"); + self.popup = Some(Popup::Thinking(Menu::at( + THINKING_LEVELS.to_vec(), + current, + ))); + } + "session" => { + self.notice = Some("loading sessions...".to_string()); + self.spawn_list_sessions(); + } + "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()); + } + "reload" => { + self.notice = Some("reloading plugins...".to_string()); + self.spawn_reload_plugins(); + } + "theme" => { + self.popup = Some(Popup::Themes { + search: Prompt::default(), + menu: themes(self.theme.name), + initial: self.theme, + }); + } + "exit" => self.should_quit = true, + _ => {} } - "exit" => self.should_quit = true, - _ => {} - }, - // A palette: moving the cursor already applied it, so Enter only has to close the - // picker and keep what is on screen. + } + 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(); + } + } + 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 title = info.title.clone(); + self.session = Some(info); + self.turns.clear(); + self.usage = UsageReport::default(); + self.scroll = 0; + self.screen = Screen::Session; + self.notice = Some(format!("switched to session {title}")); + } + } Popup::Themes { .. } => { - if let Some(palette) = theme_named(chosen) { + if let Some(palette) = theme_named(&chosen.name) { self.theme = palette; } } @@ -286,21 +792,77 @@ impl App { true } - /// Close what is open, or leave when nothing is. + /// Close what is open, interrupt a running turn, or leave when idle. fn escape(&mut self) -> bool { - let Some(popup) = self.popup.take() else { - self.should_quit = true; - return false; + 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 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))); + }); + } + } + + fn spawn_configure_session(&self) { + let Some(session) = &self.session else { + return; }; - match popup { - // Closing the command menu takes its slash back, so the line is what it was before the - // command started and `/` asks for it again. - Popup::Commands(_) => self.prompt.clear(), - // Leaving the theme picker without choosing puts back the palette that was on screen - // before the preview started. - Popup::Themes { initial, .. } => self.theme = initial, + 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))); + }); + } + } + + 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))); + }); + } + } + + 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; + }); } - true } } @@ -308,6 +870,33 @@ fn theme_named(name: &str) -> Option<&'static Palette> { theme::PALETTES.iter().find(|palette| palette.name == name) } +/// 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(), + )) +} + +/// Summarise a tool call's primary argument, matching `acp/feed.ts:title`. +fn tool_summary(name: &str, args: &Value, label: Option<&str>) -> String { + if name == "bash" { + if let Some(cmd) = args.get("command").and_then(Value::as_str) { + return cmd.lines().next().unwrap_or(cmd).to_string(); + } + } + for key in ["path", "pattern", "query", "url", "command"] { + if let Some(val) = args.get(key).and_then(Value::as_str) { + return val.lines().next().unwrap_or(val).to_string(); + } + } + label.unwrap_or("").to_string() +} + /// 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 { @@ -318,26 +907,68 @@ fn filter_themes(query: &str, menu: &mut Menu, initial: &'static Palette) -> &'s || entry.detail.to_ascii_lowercase().contains(&query) }); menu.selected() - .and_then(|entry| theme_named(entry.name)) + .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 +} + +/// Build the session picker's menu from the server's `session.list` response. +fn filter_sessions(query: &str, sessions: &[SessionInfo], active: Option<&str>) -> Menu { + let needle = query.trim().to_ascii_lowercase(); + let entries: Vec = sessions + .iter() + .rev() + .map(|s| { + let title = if s.title.is_empty() { + s.id.clone() + } else { + s.title.clone() + }; + let dir = s.host_dir.as_deref().unwrap_or(&s.directory).to_string(); + Entry::owned(s.id.clone(), title, dir) + }) + .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 +} + /// The theme picker: one entry per palette the site ships, its cursor on `active`. -/// -/// Each entry previews its own palette on the strength of `field_bg` and `text` alone — the ground -/// the screen would stand on and the ink the words would be in, which is what tells one palette -/// from another at a glance. fn themes(active: &str) -> Menu { Menu::at( theme::PALETTES .iter() - .map(|palette| Entry { - name: palette.name, - detail: palette.scheme.name(), - preview: Some(Preview { - bg: palette.field_bg, - fg: palette.text, - }), + .map(|palette| { + Entry::with_preview( + palette.name, + palette.scheme.name(), + Preview { + bg: palette.field_bg, + fg: palette.text, + }, + ) }) .collect(), active, diff --git a/packages/tui/src/event.rs b/packages/tui/src/event.rs index f1b09e5a..e25b3c44 100644 --- a/packages/tui/src/event.rs +++ b/packages/tui/src/event.rs @@ -9,10 +9,22 @@ use crossterm::event::{Event, KeyEvent, KeyEventKind}; use tokio::sync::mpsc; +use crate::rpc::StreamEvent; +use crate::types::SessionInfo; + +/// Asynchronous results arriving from the RPC server. +#[derive(Debug, Clone, PartialEq)] +pub enum RpcEvent { + Stream(StreamEvent), + SessionsLoaded(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, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum AppEvent { Key(KeyEvent), /// Text pasted into the app, carried whole. The terminal brackets it, so a @@ -23,9 +35,12 @@ pub enum AppEvent { /// 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, } @@ -38,20 +53,26 @@ impl Default for EventHandler { 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 tx.send(event).is_err() { + if reader_tx.send(event).is_err() { break; } } Err(_) => break, } }); - Self { rx } + 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. diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs index 1142c584..180f90a3 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -3,13 +3,9 @@ mod app; mod event; mod menu; mod prompt; -// The RPC transport (client + wire protocol) is kept whole: it is the layer the -// session screen will stream prompts over. Nothing on the welcome screen uses it. -#[allow(dead_code, unused_imports)] mod rpc; mod theme; mod tui; -#[allow(dead_code)] mod types; mod ui; @@ -21,15 +17,14 @@ use tui::TerminalGuard; #[tokio::main] 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 — which on this screen is the - // landing canvas rather than the page. - let mut app = App::new(); + // 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.theme.field_bg)?; - let mut events = EventHandler::new(); + let mut terminal = TerminalGuard::init(app.background())?; - terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?; + terminal.draw(app.background(), |frame| ui::view(&app, frame))?; while let Some(event) = events.next().await { let mut redraw = app.update(event); @@ -42,7 +37,7 @@ async fn main() -> Result<(), Box> { break; } if redraw { - terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?; + terminal.draw(app.background(), |frame| ui::view(&app, frame))?; } } diff --git a/packages/tui/src/menu.rs b/packages/tui/src/menu.rs index a123c907..0a9b5c75 100644 --- a/packages/tui/src/menu.rs +++ b/packages/tui/src/menu.rs @@ -5,20 +5,56 @@ //! 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, Copy, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct Entry { - /// What the entry is called, and the first thing its row shows. It is also its identity: the - /// app matches on it, and [`Menu::at`] finds it. - pub name: &'static str, + /// 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: &'static str, + 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 @@ -48,14 +84,14 @@ impl Menu { } } - /// A menu over `entries`, its cursor on the entry named `name`. + /// 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) + .position(|entry| entry.name == name || entry.id == name) .unwrap_or(0); Self { all: entries.clone(), @@ -86,15 +122,15 @@ impl Menu { /// 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.name); + let active = self.selected().map(|entry| entry.id.clone()); self.entries = self .all .iter() - .copied() .filter(|entry| keep(entry)) + .cloned() .collect(); self.cursor = active - .and_then(|name| self.entries.iter().position(|entry| entry.name == name)) + .and_then(|id| self.entries.iter().position(|entry| entry.id == id)) .unwrap_or(0); } diff --git a/packages/tui/src/rpc/client.rs b/packages/tui/src/rpc/client.rs index 87686d45..919c821d 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,8 +10,8 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use crate::rpc::protocol::{ - CreateSessionPayload, ExitPayload, InterruptSessionPayload, ModelRefPayload, - PromptSessionPayload, RpcAck, RpcRequest, RuntimePayload, ServerMessage, + ConfigureSessionPayload, CreateSessionPayload, ExitPayload, ModelRefPayload, + PromptSessionPayload, RpcAck, RpcRequest, RuntimePayload, ServerMessage, SessionIdPayload, }; use crate::types::{SessionInfo, UsageReport}; @@ -31,7 +32,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 +50,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 +89,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 +118,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 +142,120 @@ 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) + /// 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 +271,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 +328,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 +351,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 +377,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 +386,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 +398,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 +408,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 +451,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 +476,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 +516,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 +562,34 @@ 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 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 +610,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 +629,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 +704,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 +776,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")); @@ -621,7 +804,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..d0573e17 100644 --- a/packages/tui/src/rpc/protocol.rs +++ b/packages/tui/src/rpc/protocol.rs @@ -98,19 +98,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 +135,7 @@ pub struct PromptSessionPayload { } #[derive(Debug, Serialize)] -pub struct InterruptSessionPayload { +pub struct SessionIdPayload { #[serde(rename = "sessionId")] pub session_id: String, } diff --git a/packages/tui/src/types.rs b/packages/tui/src/types.rs index e1bb8aba..1aa324a5 100644 --- a/packages/tui/src/types.rs +++ b/packages/tui/src/types.rs @@ -1,19 +1,21 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionInfo { pub id: String, pub title: String, pub directory: String, + #[serde(rename = "hostDir", default, skip_serializing_if = "Option::is_none")] + pub host_dir: Option, } -#[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, diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs index 82f6c865..f6d8ecf0 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -8,6 +8,7 @@ mod field; mod glyphs; mod input; mod menu; +mod session; mod text; mod wordmark; @@ -32,6 +33,7 @@ const THEME_HINTS: &str = "↑↓ move · type to search · enter choose · esc pub fn view(app: &App, frame: &mut Frame) { match app.screen { Screen::Welcome => view_welcome(app, frame), + Screen::Session => session::draw(app, frame), } } @@ -128,10 +130,12 @@ fn view_welcome(app: &App, frame: &mut Frame) { // 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, - (None, Some(Popup::Commands(_))) => COMMAND_HINTS, - (None, Some(Popup::Themes { .. })) => THEME_HINTS, + 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() { diff --git a/packages/tui/src/ui/menu.rs b/packages/tui/src/ui/menu.rs index 23ace0f0..0ae2facb 100644 --- a/packages/tui/src/ui/menu.rs +++ b/packages/tui/src/ui/menu.rs @@ -27,9 +27,9 @@ const MAX_ROWS: u16 = 8; /// names line up. const MARKER: &str = "❯ "; /// What a menu's search line shows before anything is typed into it. -const SEARCH_PLACEHOLDER: &str = "search themes..."; +const SEARCH_PLACEHOLDER: &str = "search..."; /// What a filtered menu shows when no entry matches the search query. -const EMPTY: &str = "no matching themes"; +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. @@ -52,17 +52,17 @@ pub fn width(menu: &Menu, searchable: bool) -> u16 { let entries = menu.all(); let names = entries .iter() - .map(|entry| text::width(entry.name)) + .map(|entry| text::width(&entry.name)) .max() .unwrap_or(0) as u16; let notes = entries .iter() - .map(|entry| text::width(entry.detail)) + .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 + 2; + let search_width = marker + text::width(SEARCH_PLACEHOLDER) as u16 + PAD + 14; rows_width.max(search_width) } else { rows_width @@ -165,7 +165,7 @@ pub fn draw( let names = menu .all() .iter() - .map(|entry| text::width(entry.name)) + .map(|entry| text::width(&entry.name)) .max() .unwrap_or(0) as u16; let named = names.min(inner.width.saturating_sub(marker + GAP + PAD)); @@ -202,7 +202,7 @@ pub fn draw( // 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 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), @@ -213,7 +213,7 @@ pub fn draw( buffer.set_string( inner.x + marker + named + GAP, y, - fitted(entry.detail, notes as usize), + fitted(&entry.detail, notes as usize), Style::default().fg(if selected { palette.text } else { diff --git a/packages/tui/src/ui/session.rs b/packages/tui/src/ui/session.rs new file mode 100644 index 00000000..128743ec --- /dev/null +++ b/packages/tui/src/ui/session.rs @@ -0,0 +1,356 @@ +//! 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, Paragraph}; +use ratatui::Frame; + +use super::input; +use super::menu; +use super::text::{self, fitted}; +use crate::app::{App, Popup, ToolStatus, TurnState}; + +const IDLE_HINTS: &str = "enter send · pgup/pgdn scroll · / commands · esc quit"; +const BUSY_HINTS: &str = "pgup/pgdn 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, + ); + + if area.height < 6 || area.width < 16 { + return; + } + + 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); + + let column = input::centred(Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height, + }); + + draw_header(app, frame, Rect { y: header.y, height: 1, ..column }); + draw_rule(frame, Rect { y: rule.y, height: 1, ..column }, palette.border_subtle); + draw_transcript( + app, + frame, + Rect { + x: column.x, + y: body.y, + width: column.width, + height: body.height, + }, + ); + + let prompt_caret = input::draw(frame.buffer_mut(), prompt, &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.y.saturating_sub(body.y); + let height = wanted.min(room); + let width = + menu::width(popup.menu(), searchable).min(area.right().saturating_sub(column.x)); + let picker = Rect { + x: column.x, + y: prompt.y.saturating_sub(height), + width, + height, + }; + menu_caret = menu::draw( + frame.buffer_mut(), + picker, + popup.title(), + popup.menu(), + popup.search(), + palette, + ); + } + + if let Some(caret) = menu_caret.or(prompt_caret) { + frame.set_cursor_position(caret); + } + + 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 + } + (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, + ); +} + +fn draw_header(app: &App, frame: &mut Frame, area: Rect) { + let palette = app.theme; + let buffer = frame.buffer_mut(); + + let title = app + .session + .as_ref() + .map(|s| s.title.as_str()) + .filter(|t| !t.is_empty()) + .unwrap_or("session"); + + let mut meta = app.model_label(); + if let Some(thinking) = &app.thinking { + if thinking != "off" { + meta.push_str(" · "); + meta.push_str(thinking); + } + } + if app.usage.total_tokens > 0 { + meta.push_str(&format!( + " · {} tok · ${:.4}", + app.usage.total_tokens, app.usage.cost + )); + } + + 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 { + area.right() + }; + + buffer.set_string(area.x, area.y, "● ", Style::default().fg(palette.brand)); + let title_room = right_x.saturating_sub(area.x + 2) as usize; + buffer.set_string( + area.x + 2, + area.y, + fitted(title, title_room), + Style::default().fg(palette.text), + ); +} + +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); + } +} + +fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width < 6 { + return; + } + + 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()); + } + + // 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)), + ])); + } + + // 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)), + ])); + } + } + + // Tool calls + for tool in &turn.tools { + let (icon, icon_color) = match tool.status { + ToolStatus::Running => (" ⚙ ", palette.brand), + ToolStatus::Completed => (" ✓ ", palette.brand), + ToolStatus::Failed => (" ✗ ", palette.field_crest), + }; + let room = wrap_width.saturating_sub(text::width(&tool.name) + 3); + let detail = if tool.summary.is_empty() || room == 0 { + String::new() + } else { + format!(" {}", fitted(&tool.summary, room)) + }; + lines.push(Line::from(vec![ + Span::styled(icon, Style::default().fg(icon_color)), + Span::styled(tool.name.clone(), Style::default().fg(palette.text_secondary)), + Span::styled(detail, Style::default().fg(palette.text_muted)), + ])); + if tool.status == ToolStatus::Failed { + if let Some(output) = &tool.output { + if let Some(first_line) = output.lines().next() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + fitted(first_line, wrap_width.saturating_sub(2)).to_string(), + Style::default().fg(palette.field_crest), + ), + ])); + } + } + } + } + + // 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.field_hover)), + Span::styled(chunk, Style::default().fg(palette.text_secondary)), + ])); + } + } + + // Assistant response + if !turn.text.is_empty() { + lines.push(Line::default()); + for chunk in wrap(turn.text.trim_end(), wrap_width) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(chunk, Style::default().fg(palette.text)), + ])); + } + } + + // Turn status + match &turn.state { + TurnState::Running => { + if app.reconnecting { + lines.push(Line::from(Span::styled( + " ◌ reconnecting to server...", + Style::default().fg(palette.field_hover), + ))); + } 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.field_crest)), + Span::styled(chunk, Style::default().fg(palette.field_crest)), + ])); + } + } + TurnState::Interrupted(reason) => { + lines.push(Line::from(Span::styled( + format!(" ○ interrupted ({reason})"), + Style::default().fg(palette.text_muted), + ))); + } + } + } + + let visible = area.height as usize; + let max_scroll = lines.len().saturating_sub(visible); + 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(); + + frame.render_widget(Paragraph::new(slice), area); +} + +/// Wrap `text` into lines that fit within `max_width` terminal columns. +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 = text::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 +} From 67b79a38fa9504ac788c188374a27f0ab00db2f1 Mon Sep 17 00:00:00 2001 From: prem315 Date: Fri, 16 Oct 2026 17:19:36 +0530 Subject: [PATCH 11/12] feat(tui): add markdown rendering, diff views, tool grouping, and mouse scroll --- .gitignore | 4 + packages/tui/src/app.rs | 139 +++++---- packages/tui/src/diff.rs | 525 ++++++++++++++++++++++++++++++++ packages/tui/src/event.rs | 10 +- packages/tui/src/main.rs | 2 + packages/tui/src/theme.rs | 60 ++++ packages/tui/src/tool.rs | 327 ++++++++++++++++++++ packages/tui/src/tui.rs | 9 +- packages/tui/src/ui.rs | 3 +- packages/tui/src/ui/input.rs | 19 +- packages/tui/src/ui/markdown.rs | 501 ++++++++++++++++++++++++++++++ packages/tui/src/ui/session.rs | 324 ++++++++++++++------ packages/tui/src/ui/text.rs | 64 ++++ 13 files changed, 1810 insertions(+), 177 deletions(-) create mode 100644 packages/tui/src/diff.rs create mode 100644 packages/tui/src/tool.rs create mode 100644 packages/tui/src/ui/markdown.rs diff --git a/.gitignore b/.gitignore index 97185550..1a72c8ec 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,10 @@ 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/src/app.rs b/packages/tui/src/app.rs index 99d0a98c..c117c4e0 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -4,9 +4,10 @@ //! 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 crossterm::event::{KeyCode, KeyEvent}; +use std::cell::Cell; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::style::Color; -use serde_json::Value; use tokio::sync::mpsc; use crate::action::{Action, Keymap, Resolution}; @@ -15,6 +16,7 @@ use crate::menu::{Entry, Menu, Preview}; use crate::prompt::Prompt; use crate::rpc::{RpcClient, StreamEvent}; use crate::theme::{self, Palette}; +use crate::tool::ToolCall; use crate::types::{SessionInfo, UsageReport}; /// What a leading slash offers. Only commands the app can actually perform are listed, and an @@ -114,22 +116,6 @@ pub enum TurnState { Interrupted(String), } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolStatus { - Running, - Completed, - Failed, -} - -#[derive(Debug, Clone)] -pub struct ToolCall { - pub call_id: String, - pub name: String, - pub summary: String, - pub output: Option, - pub status: ToolStatus, -} - #[derive(Debug, Clone)] pub struct Turn { pub prompt: String, @@ -233,6 +219,8 @@ pub struct App { 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, @@ -261,6 +249,7 @@ impl App { busy: false, reconnecting: false, scroll: 0, + max_scroll: Cell::new(0), should_quit: false, keymap: Keymap::new(), tx, @@ -290,12 +279,39 @@ impl App { 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), } } + 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 + } + + 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 + } + fn handle_paste(&mut self, text: &str) -> bool { if let Some(popup) = &mut self.popup { match popup { @@ -404,13 +420,19 @@ impl App { if self.screen == Screen::Session { match key.code { - KeyCode::PageUp => { - self.scroll = self.scroll.saturating_add(5); - return true; + 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::PageDown => { - self.scroll = self.scroll.saturating_sub(5); - return true; + KeyCode::Down + if key.modifiers.contains(KeyModifiers::SHIFT) + || self.prompt.text().is_empty() => + { + return self.scroll_by(-1); } _ => {} } @@ -489,18 +511,13 @@ impl App { arguments, } => { if let Some(turn) = self.turns.last_mut() { - let summary = tool_summary(&name, &arguments, label.as_deref()); + 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 = name; - existing.summary = summary; + existing.name = started.name; + existing.target = started.target; + existing.label = started.label; } else { - turn.tools.push(ToolCall { - call_id, - name, - summary, - output: None, - status: ToolStatus::Running, - }); + turn.tools.push(started); } } } @@ -510,33 +527,26 @@ impl App { is_error, arguments, output, - .. + details, } => { if let Some(turn) = self.turns.last_mut() { - let status = if is_error { - ToolStatus::Failed - } else { - ToolStatus::Completed - }; if let Some(existing) = turn.tools.iter_mut().find(|t| t.call_id == call_id) { - if let Some(name) = name { - existing.name = name; - } - if existing.summary.is_empty() { - existing.summary = tool_summary(&existing.name, &arguments, None); - } - existing.output = output; - existing.status = status; + existing.apply_settlement( + name, + &arguments, + output, + details.as_ref(), + is_error, + ); } else { - let tool_name = name.unwrap_or_else(|| "tool".to_string()); - let summary = tool_summary(&tool_name, &arguments, None); - turn.tools.push(ToolCall { + turn.tools.push(ToolCall::settled( call_id, - name: tool_name, - summary, + name, + &arguments, output, - status, - }); + details.as_ref(), + is_error, + )); } } } @@ -684,6 +694,10 @@ impl App { KeyCode::Enter => return self.choose(), _ => return false, }; + self.step_menu(step) + } + + fn step_menu(&mut self, step: isize) -> bool { let Some(popup) = &mut self.popup else { return false; }; @@ -882,21 +896,6 @@ fn parse_model(value: &str) -> Option<(String, String)> { )) } -/// Summarise a tool call's primary argument, matching `acp/feed.ts:title`. -fn tool_summary(name: &str, args: &Value, label: Option<&str>) -> String { - if name == "bash" { - if let Some(cmd) = args.get("command").and_then(Value::as_str) { - return cmd.lines().next().unwrap_or(cmd).to_string(); - } - } - for key in ["path", "pattern", "query", "url", "command"] { - if let Some(val) = args.get(key).and_then(Value::as_str) { - return val.lines().next().unwrap_or(val).to_string(); - } - } - label.unwrap_or("").to_string() -} - /// 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 { diff --git a/packages/tui/src/diff.rs b/packages/tui/src/diff.rs new file mode 100644 index 00000000..d0cbd73d --- /dev/null +++ b/packages/tui/src/diff.rs @@ -0,0 +1,525 @@ +//! Renders a unified diff (the `patch` field tools such as `edit` return in +//! their result `details`) into styled terminal lines. +//! +//! 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::UnicodeWidthChar; + +use crate::theme::Palette; +use crate::ui::text::{elide, width}; + +/// Default cap on rendered diff rows per tool call (`0` means unlimited). +pub const DEFAULT_MAX_DIFF_LINES: usize = 60; + +/// 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)] +pub struct DiffSummary { + pub added: usize, + pub removed: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Added, + Removed, + Context, +} + +#[derive(Debug, Clone)] +struct DiffLine { + kind: Kind, + text: String, + old_no: Option, + new_no: Option, +} + +#[derive(Debug, Clone, Default)] +struct Hunk { + lines: Vec, +} + +/// 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_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() { + 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), + }); + } + } + + hunks.retain(|h| !h.lines.is_empty()); + (hunks, summary) +} + +fn expand_tabs(text: &str) -> String { + text.replace('\t', " ") +} + +/// Added/removed counts for the whole of `patch`, without building any styled lines. +pub fn counts(patch: &str) -> DiffSummary { + parse_hunks(patch).1 +} + +/// The new-file path from a patch's `+++` header, when present. +pub fn patch_path(patch: &str) -> Option { + patch + .lines() + .find_map(|line| line.strip_prefix("+++ ")) + .map(|path| { + let trimmed = path.trim(); + trimmed + .strip_prefix("b/") + .unwrap_or(trimmed) + .to_string() + }) +} + +/// 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 mut all_lines = if use_split { + render_split(&hunks, max_width, palette) + } else { + render_unified(&hunks, max_width, palette) + }; + + 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) +} + +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 { + format!("{:>gutter_width$} ", "") + }; + let sign_text = if chunk_idx == 0 { sign } else { " " }; + + let mut spans = vec![ + Span::styled( + gutter_text, + Style::default().fg(gutter_fg).bg(gutter_bg), + ), + Span::styled( + sign_text, + match line_bg { + Some(bg) => Style::default().fg(gutter_fg).bg(bg), + None => Style::default().fg(gutter_fg), + }, + ), + ]; + + 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(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(bg), + )); + } + } + + out.push(Line::from(spans)); + } + } + } + + 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)); + } + } + } + + 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 = "--- 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() + } + + fn joined(lines: &[Line]) -> String { + lines.iter().map(text_of).collect::>().join("\n") + } + + #[test] + fn counts_added_and_removed() { + let summary = counts(PATCH); + assert_eq!(summary.added, 1); + assert_eq!(summary.removed, 1); + } + + #[test] + fn strips_b_prefix_from_patch_path() { + assert_eq!(patch_path(PATCH).as_deref(), Some("src/a.ts")); + } + + #[test] + 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("---"), "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 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('⋮'), "expected ⋮ separator between hunks:\n{text}"); + } + + #[test] + 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 index e25b3c44..466ee6fd 100644 --- a/packages/tui/src/event.rs +++ b/packages/tui/src/event.rs @@ -6,7 +6,7 @@ //! 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}; +use crossterm::event::{Event, KeyEvent, KeyEventKind, MouseEventKind}; use tokio::sync::mpsc; use crate::rpc::StreamEvent; @@ -27,6 +27,9 @@ pub enum RpcEvent { #[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. @@ -98,6 +101,11 @@ impl EventHandler { 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/main.rs b/packages/tui/src/main.rs index 180f90a3..69ee3b49 100644 --- a/packages/tui/src/main.rs +++ b/packages/tui/src/main.rs @@ -1,10 +1,12 @@ mod action; mod app; +mod diff; mod event; mod menu; mod prompt; mod rpc; mod theme; +mod tool; mod tui; mod types; mod ui; diff --git a/packages/tui/src/theme.rs b/packages/tui/src/theme.rs index bab2d96c..df61b99f 100644 --- a/packages/tui/src/theme.rs +++ b/packages/tui/src/theme.rs @@ -85,3 +85,63 @@ pub fn default_palette() -> &'static Palette { 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/tool.rs b/packages/tui/src/tool.rs new file mode 100644 index 00000000..ee7bb1c8 --- /dev/null +++ b/packages/tui/src/tool.rs @@ -0,0 +1,327 @@ +//! Turns one tool call's raw payload — the tool name, its `arguments`, and the +//! result `details` the harness publishes — into the short human view the +//! transcript shows. +//! +//! Everything here is pure and resolved once, when the event arrives, so the +//! renderer never re-parses JSON per frame. + +use serde_json::Value; + +use crate::diff; + +/// 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 = 48; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Completed, + Failed, +} + +#[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, +} + +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, + } + } + + #[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 tool list. +#[derive(Debug, Clone)] +pub enum ToolRow<'a> { + /// 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 ToolCall>, + failed: usize, + }, +} + +/// Collapses runs of consecutive settled calls to the same tool, so a stretch of +/// `read`, `read`, `read` costs one line instead of twenty. +/// +/// 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 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 + && tools[end].status != ToolStatus::Running + && tools[end].patch.is_none() + { + end += 1; + } + let run = &tools[index..end]; + if run.len() >= GROUP_MIN_RUN { + rows.push(ToolRow::Run { + name: name.to_string(), + members: run.iter().collect(), + failed: run + .iter() + .filter(|tool| tool.status == ToolStatus::Failed) + .count(), + }); + } else { + rows.extend(run.iter().map(ToolRow::Single)); + } + index = end; + } + rows +} + +/// 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: &[&ToolCall]) -> String { + let mut distinct: Vec<&str> = Vec::new(); + for member in members { + let target = member.target.as_deref().unwrap_or(&member.label); + if !distinct.contains(&target) { + distinct.push(target); + } + } + let shown = distinct.len().min(RUN_TITLES_MAX); + let mut text = distinct[..shown].join(", "); + if distinct.len() > shown { + text.push_str(", …"); + } + 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) +} + +fn number(details: &Value, key: &str) -> Option { + details.get(key).and_then(Value::as_u64) +} + +fn failure_summary(details: &Value) -> Option { + if let Some(message) = string_field(Some(details), "message") { + return Some(collapse_whitespace(&message)); + } + if let Some(seconds) = number(details, "timeoutSeconds") { + return Some(format!("timed out after {seconds}s")); + } + if let Some(code) = number(details, "exitCode") { + return Some(format!("exit {code}")); + } + string_field(Some(details), "reason") + .or_else(|| string_field(Some(details), "error")) + .map(|value| value.replace('_', " ")) +} + +fn read_summary(details: &Value) -> Option { + let start = number(details, "startLine")?; + let end = number(details, "endLine")?; + let total = number(details, "totalLines")?; + let mut text = if start == end { + format!("line {start} of {total}") + } else { + format!("{start}-{end} of {total}") + }; + if details.get("truncated").and_then(Value::as_bool) == Some(true) { + text.push_str(" · truncated"); + } + Some(text) +} + +fn bash_summary(details: &Value) -> Option { + let mut parts = Vec::new(); + if let Some(code) = number(details, "exitCode") { + parts.push(format!("exit {code}")); + } + if let Some(lines) = string_field(Some(details), "output") + .map(|output| output.lines().count()) + .filter(|lines| *lines > 1) + { + parts.push(format!("{lines} lines")); + } + if parts.is_empty() { + None + } else { + Some(parts.join(" · ")) + } +} + +fn search_summary(details: &Value) -> Option { + let files = number(details, "files")?; + let matches = number(details, "matches")?; + let files_label = if files == 1 { + "1 file".to_string() + } else { + format!("{files} files") + }; + let matches_label = if matches == 1 { + "1 match".to_string() + } else { + format!("{matches} matches") + }; + let mut text = format!("{files_label} · {matches_label}"); + if details.get("truncated").and_then(Value::as_bool) == Some(true) { + text.push_str(" · truncated"); + } else if details.get("hasMore").and_then(Value::as_bool) == Some(true) { + text.push_str(" · more"); + } + Some(text) +} + +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)]; + if let Some(path) = diff::patch_path(patch) { + if call_path != Some(path.as_str()) { + parts.push(shorten_path(&path)); + } + } + if let Some(line) = first_changed_line { + parts.push(format!("line {line}")); + } + parts.join(" · ") +} + +fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn shorten_path(path: &str) -> String { + if path.chars().count() <= PATH_MAX_CHARS { + return path.to_string(); + } + let mut tail: Vec<&str> = path.rsplitn(3, '/').collect(); + tail.truncate(2); + tail.reverse(); + let candidate = format!("…/{}", tail.join("/")); + if candidate.chars().count() <= PATH_MAX_CHARS { + return candidate; + } + format!("…/{}", tail.last().copied().unwrap_or(path)) +} diff --git a/packages/tui/src/tui.rs b/packages/tui/src/tui.rs index 96977687..b1ac5a23 100644 --- a/packages/tui/src/tui.rs +++ b/packages/tui/src/tui.rs @@ -12,8 +12,9 @@ use std::sync::Once; use crossterm::cursor::Show; use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, - KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, }; use crossterm::execute; use crossterm::terminal::{ @@ -52,7 +53,8 @@ impl TerminalGuard { stdout, EnterAlternateScreen, EnableBracketedPaste, - EnableFocusChange + EnableFocusChange, + EnableMouseCapture )?; set_background(&mut stdout, background)?; @@ -113,6 +115,7 @@ pub fn restore() { stdout, EndSynchronizedUpdate, PopKeyboardEnhancementFlags, + DisableMouseCapture, DisableBracketedPaste, DisableFocusChange, LeaveAlternateScreen diff --git a/packages/tui/src/ui.rs b/packages/tui/src/ui.rs index f6d8ecf0..eba10beb 100644 --- a/packages/tui/src/ui.rs +++ b/packages/tui/src/ui.rs @@ -7,9 +7,10 @@ mod badge; mod field; mod glyphs; mod input; +mod markdown; mod menu; mod session; -mod text; +pub(crate) mod text; mod wordmark; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; diff --git a/packages/tui/src/ui/input.rs b/packages/tui/src/ui/input.rs index ee8d8296..8fa19dc9 100644 --- a/packages/tui/src/ui/input.rs +++ b/packages/tui/src/ui/input.rs @@ -35,15 +35,24 @@ const PLACEHOLDER: &str = "build me flappy bird"; /// is. const TAG: &str = "alpha"; -/// Draw the prompt in `area`, centred, and report where the caret goes. `None` when the area is -/// too small to hold the box, or too narrow for the line inside it. +/// 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)> { - let column = centred(area); + draw_in(buffer, centred(area), prompt, palette) +} + +/// 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; @@ -58,6 +67,10 @@ pub fn draw( // The tag rides the top border, as the site's badge does. badge::draw(buffer, column, TAG, palette); + for x in inner.x..inner.x + inner.width { + buffer[(x, inner.y)].set_char(' '); + } + // 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; 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/session.rs b/packages/tui/src/ui/session.rs index 128743ec..06cf38c9 100644 --- a/packages/tui/src/ui/session.rs +++ b/packages/tui/src/ui/session.rs @@ -10,12 +10,15 @@ use ratatui::widgets::{Block, Paragraph}; use ratatui::Frame; use super::input; +use super::markdown; use super::menu; -use super::text::{self, fitted}; -use crate::app::{App, Popup, ToolStatus, TurnState}; +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 · pgup/pgdn scroll · / commands · esc quit"; -const BUSY_HINTS: &str = "pgup/pgdn scroll · / commands · esc interrupt"; +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"; @@ -41,15 +44,25 @@ pub fn draw(app: &App, frame: &mut Frame) { ]) .areas(area); - let column = input::centred(Rect { - x: area.x, + // 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, + width: area.width.saturating_sub(pad * 2), height: area.height, - }); + }; - draw_header(app, frame, Rect { y: header.y, height: 1, ..column }); - draw_rule(frame, Rect { y: rule.y, height: 1, ..column }, palette.border_subtle); + draw_header( + app, + frame, + Rect { + y: header.y, + height: 1, + ..column + }, + ); + draw_rule(frame, rule, palette.border_subtle); draw_transcript( app, frame, @@ -61,19 +74,24 @@ pub fn draw(app: &App, frame: &mut Frame) { }, ); - let prompt_caret = input::draw(frame.buffer_mut(), prompt, &app.prompt, palette); + 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.y.saturating_sub(body.y); + let room = prompt_box.y.saturating_sub(body.y); let height = wanted.min(room); - let width = - menu::width(popup.menu(), searchable).min(area.right().saturating_sub(column.x)); + let width = menu::width(popup.menu(), searchable).min(column.width); let picker = Rect { x: column.x, - y: prompt.y.saturating_sub(height), + y: prompt_box.y.saturating_sub(height), width, height, }; @@ -147,12 +165,31 @@ fn draw_header(app: &App, frame: &mut Frame, area: Rect) { 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, title_room), + fitted_title, Style::default().fg(palette.text), ); + + // 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), + ); + } + } + } } fn draw_rule(frame: &mut Frame, area: Rect, color: ratatui::style::Color) { @@ -200,36 +237,127 @@ fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { } } - // Tool calls - for tool in &turn.tools { - let (icon, icon_color) = match tool.status { - ToolStatus::Running => (" ⚙ ", palette.brand), - ToolStatus::Completed => (" ✓ ", palette.brand), - ToolStatus::Failed => (" ✗ ", palette.field_crest), - }; - let room = wrap_width.saturating_sub(text::width(&tool.name) + 3); - let detail = if tool.summary.is_empty() || room == 0 { - String::new() - } else { - format!(" {}", fitted(&tool.summary, room)) - }; - lines.push(Line::from(vec![ - Span::styled(icon, Style::default().fg(icon_color)), - Span::styled(tool.name.clone(), Style::default().fg(palette.text_secondary)), - Span::styled(detail, Style::default().fg(palette.text_muted)), - ])); - if tool.status == ToolStatus::Failed { - if let Some(output) = &tool.output { - if let Some(first_line) = output.lines().next() { - lines.push(Line::from(vec![ - Span::raw(" "), + // 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( - fitted(first_line, wrap_width.saturating_sub(2)).to_string(), - Style::default().fg(palette.field_crest), + 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)); + } } } @@ -241,20 +369,18 @@ fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { { let prefix = if line_idx == 0 { " ⚠ " } else { " " }; lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(palette.field_hover)), + Span::styled(prefix, Style::default().fg(palette.warning())), Span::styled(chunk, Style::default().fg(palette.text_secondary)), ])); } } - // Assistant response + // Assistant response (markdown) if !turn.text.is_empty() { lines.push(Line::default()); - for chunk in wrap(turn.text.trim_end(), wrap_width) { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(chunk, Style::default().fg(palette.text)), - ])); + 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); } } @@ -264,7 +390,7 @@ fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { if app.reconnecting { lines.push(Line::from(Span::styled( " ◌ reconnecting to server...", - Style::default().fg(palette.field_hover), + Style::default().fg(palette.warning()), ))); } else if turn.text.is_empty() && turn.thinking.is_empty() && turn.tools.is_empty() { @@ -282,8 +408,8 @@ fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { { let prefix = if line_idx == 0 { " ✗ " } else { " " }; lines.push(Line::from(vec![ - Span::styled(prefix, Style::default().fg(palette.field_crest)), - Span::styled(chunk, Style::default().fg(palette.field_crest)), + Span::styled(prefix, Style::default().fg(palette.error())), + Span::styled(chunk, Style::default().fg(palette.error())), ])); } } @@ -296,61 +422,61 @@ fn draw_transcript(app: &App, frame: &mut Frame, area: Rect) { } } + let total_lines = lines.len(); let visible = area.height as usize; - let max_scroll = lines.len().saturating_sub(visible); + 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(); - frame.render_widget(Paragraph::new(slice), area); + 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); + + if max_scroll > 0 { + draw_scrollbar(frame, area, total_lines, visible, start, app.theme); + } } -/// Wrap `text` into lines that fit within `max_width` terminal columns. -fn wrap(text: &str, max_width: usize) -> Vec { - if max_width == 0 { - return Vec::new(); +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 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 = text::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); + 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 + }; + + 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); + + 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); } } - if out.is_empty() { - out.push(String::new()); - } - out } diff --git a/packages/tui/src/ui/text.rs b/packages/tui/src/ui/text.rs index 13272c60..5a440e03 100644 --- a/packages/tui/src/ui/text.rs +++ b/packages/tui/src/ui/text.rs @@ -40,3 +40,67 @@ pub fn scrolled<'a>(text: &'a str, before_caret: &str, width: usize) -> (&'a str pub fn width(text: &str) -> usize { text.width() } + +/// 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(); + } + if text.width() <= max_width { + return text.to_string(); + } + if max_width == 1 { + return "…".to_string(); + } + format!("{}…", fitted(text, max_width - 1)) +} + +/// 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 +} From 256dbbf9f29fdd5c0e089181d6bf89f0c43184a5 Mon Sep 17 00:00:00 2001 From: prem315 Date: Fri, 16 Oct 2026 17:47:45 +0530 Subject: [PATCH 12/12] feat(tui): load session history on resume and sync session metadata --- packages/tui/src/app.rs | 515 ++++++++++++++++++++++++++++++- packages/tui/src/event.rs | 3 +- packages/tui/src/rpc/client.rs | 218 ++++++++++++- packages/tui/src/rpc/protocol.rs | 9 + packages/tui/src/types.rs | 122 ++++++++ 5 files changed, 853 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs index c117c4e0..a6ac5060 100644 --- a/packages/tui/src/app.rs +++ b/packages/tui/src/app.rs @@ -17,7 +17,7 @@ use crate::prompt::Prompt; use crate::rpc::{RpcClient, StreamEvent}; use crate::theme::{self, Palette}; use crate::tool::ToolCall; -use crate::types::{SessionInfo, UsageReport}; +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. @@ -264,6 +264,14 @@ impl App { } } + 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); + } + /// Display label for the active model. pub fn model_label(&self) -> String { if let Some((provider, id)) = &self.model { @@ -452,7 +460,8 @@ impl App { fn handle_rpc(&mut self, event: RpcEvent) -> bool { match event { - RpcEvent::SessionsLoaded(Ok(sessions)) => { + 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 { @@ -466,8 +475,21 @@ impl App { 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.session = Some(info); + self.apply_session(info); true } RpcEvent::SessionConfigured(Err(err)) => { @@ -492,7 +514,7 @@ impl App { fn handle_stream(&mut self, event: StreamEvent) -> bool { match event { StreamEvent::SessionCreated(info) => { - self.session = Some(info); + self.apply_session(info); } StreamEvent::TextDelta(delta) => { if let Some(turn) = self.turns.last_mut() { @@ -788,13 +810,17 @@ impl App { } 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.session = Some(info); + 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); } } Popup::Themes { .. } => { @@ -839,6 +865,17 @@ impl App { } } + 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))); + }); + } + } + fn spawn_configure_session(&self) { let Some(session) = &self.session else { return; @@ -928,20 +965,157 @@ fn filter_models(query: &str, active: Option<&str>) -> Menu { menu } -/// Build the session picker's menu from the server's `session.list` response. +/// Reconstruct conversation turns from the server's `session.history` entries. +pub fn turns_from_history(entries: &[HistoryEntry]) -> Vec { + let mut turns: Vec = Vec::new(); + + 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 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"); + } + 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'); + } + 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 entry_turn_state(state: &str) -> TurnState { + match state { + "draft" => TurnState::Running, + "aborted" => TurnState::Interrupted("aborted".to_string()), + _ => TurnState::Succeeded, + } +} + +/// 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 filter_sessions_at( + query: &str, + sessions: &[SessionInfo], + active: Option<&str>, + now_ms: u64, +) -> Menu { let needle = query.trim().to_ascii_lowercase(); - let entries: Vec = sessions - .iter() - .rev() + 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() }; - let dir = s.host_dir.as_deref().unwrap_or(&s.directory).to_string(); - Entry::owned(s.id.clone(), title, dir) + Entry::owned(s.id.clone(), title, session_detail(s, now_ms)) }) .collect(); let mut menu = Menu::at(entries, active.unwrap_or("")); @@ -954,6 +1128,39 @@ fn filter_sessions(query: &str, sessions: &[SessionInfo], active: Option<&str>) menu } +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 format_ago(updated_at: u64, now_ms: u64) -> Option { + if updated_at == 0 { + return None; + } + 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() + }) +} + /// The theme picker: one entry per palette the site ships, its cursor on `active`. fn themes(active: &str) -> Menu { Menu::at( @@ -973,3 +1180,289 @@ fn themes(active: &str) -> Menu { active, ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::ToolStatus; + use crate::types::{ModelRef, ProjectInfo, ToolResult, ToolTime}; + use serde_json::json; + + #[test] + 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 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"); + } + + #[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, + }; + + // 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())) + ); + + // 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); + 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/event.rs b/packages/tui/src/event.rs index 466ee6fd..43ba3f10 100644 --- a/packages/tui/src/event.rs +++ b/packages/tui/src/event.rs @@ -10,13 +10,14 @@ use crossterm::event::{Event, KeyEvent, KeyEventKind, MouseEventKind}; use tokio::sync::mpsc; use crate::rpc::StreamEvent; -use crate::types::SessionInfo; +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>), } diff --git a/packages/tui/src/rpc/client.rs b/packages/tui/src/rpc/client.rs index 919c821d..6d0bafd9 100644 --- a/packages/tui/src/rpc/client.rs +++ b/packages/tui/src/rpc/client.rs @@ -11,9 +11,10 @@ use tokio_tungstenite::tungstenite::Message; use crate::rpc::protocol::{ ConfigureSessionPayload, CreateSessionPayload, ExitPayload, ModelRefPayload, - PromptSessionPayload, RpcAck, RpcRequest, RuntimePayload, ServerMessage, SessionIdPayload, + 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"; @@ -209,6 +210,19 @@ impl RpcClient { parse_session_info(&value) } + /// 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( @@ -575,6 +589,12 @@ fn parse_session_info(value: &Value) -> Result { .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(); @@ -794,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() { diff --git a/packages/tui/src/rpc/protocol.rs b/packages/tui/src/rpc/protocol.rs index d0573e17..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, @@ -139,3 +142,9 @@ 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/types.rs b/packages/tui/src/types.rs index 1aa324a5..96e7a006 100644 --- a/packages/tui/src/types.rs +++ b/packages/tui/src/types.rs @@ -1,4 +1,39 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelRef { + pub provider: String, + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectInfo { + pub id: String, + pub name: String, +} + +#[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, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionInfo { @@ -7,6 +42,93 @@ pub struct SessionInfo { 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, PartialEq, Eq, Serialize, Deserialize)] +pub struct HistoryEntry { + pub id: String, + #[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, 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, 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, +} + +#[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, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolTime { + pub start: u64, + pub end: u64, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]