From f461046c18151894c6c7344e1ebacfe3588ab9ae Mon Sep 17 00:00:00 2001 From: themartto Date: Thu, 21 May 2026 16:50:54 +0300 Subject: [PATCH 01/41] feat: update change log and bump Cargo.toml version --- CHANGELOG.md | 16 ++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e67f7..c24e8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.1.1] - 2026-05-21 + +### Fixed + +- **Tool call history gaps** — ACP sessions were silently dropping tool calls from stored history; all tool calls are now captured correctly. +- **Accurate status on replay** — Replayed tool calls now emit `InProgress` before resolving, matching the behaviour of live sessions. +- **Failed tool calls now surface as `Failed`** — Previously, tool failures were stored as plain text and replayed as `Completed`. The `is_error` flag is now persisted in `Message` and propagated through `StreamEvent::ToolResult` so both live and replayed paths emit `ToolCallStatus::Failed`. +- **Tool error logging** — Improved logging for tool call errors. + +### Improved + +- **LLM accuracy on failures** — `is_error` is forwarded to Anthropic's `tool_result` block, giving the model accurate signal when a tool has failed. +- Added `CHANGELOG.md`. +- Updated documentation for `is_error` and tool call history replay semantics. +- README updates. + ## [0.1.0] - 2026-05-15 First public release of openheim — a fast, multi-provider LLM agent runtime written in Rust. diff --git a/Cargo.toml b/Cargo.toml index 2b2bb3f..89e8da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openheim" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "A fast, multi-provider LLM agent runtime written in Rust" license = "MIT" From 2f6fa99f6bc66124ad995b13f253e1a43bc0aad7 Mon Sep 17 00:00:00 2001 From: themartto Date: Thu, 21 May 2026 16:54:52 +0300 Subject: [PATCH 02/41] feat: update Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c2e2e10..e84dcdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,7 +1440,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openheim" -version = "0.1.0" +version = "0.1.1" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", From 84c5461476314782a14849263dd27e0b786c94fe Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 08:52:01 +0300 Subject: [PATCH 03/41] feat: refactor TUI to use ratatui --- Cargo.lock | 318 +++++++++++++++++++++++- Cargo.toml | 2 + src/tui/app.rs | 461 +++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 604 +++++++++------------------------------------- src/tui/render.rs | 261 ++++++++++++++++++++ src/tui/types.rs | 31 +++ 6 files changed, 1175 insertions(+), 502 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/render.rs create mode 100644 src/tui/types.rs diff --git a/Cargo.lock b/Cargo.lock index e84dcdd..f936469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "strum", + "strum 0.28.0", "tracing", ] @@ -77,6 +77,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -259,6 +265,21 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.52" @@ -366,6 +387,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -415,6 +450,32 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "futures-core", + "mio 1.1.1", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -425,14 +486,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] @@ -448,13 +533,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn", ] @@ -546,6 +642,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -596,7 +698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -630,6 +732,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -835,6 +943,17 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1143,6 +1262,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.9.6" @@ -1163,6 +1291,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling 0.20.11", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1185,6 +1326,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1256,6 +1406,12 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1283,6 +1439,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1329,6 +1494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1449,10 +1615,12 @@ dependencies = [ "chrono", "clap", "colored", + "crossterm", "dirs", "futures", "notify", "once_cell", + "ratatui", "reqwest 0.12.28", "rmcp", "rustyline", @@ -1549,6 +1717,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.2" @@ -1700,6 +1874,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1887,7 +2082,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", @@ -1909,6 +2104,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1918,7 +2126,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -1978,7 +2186,7 @@ dependencies = [ "nix 0.28.0", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "windows-sys 0.52.0", ] @@ -2191,7 +2399,7 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn", @@ -2229,6 +2437,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.1.1", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2280,19 +2509,47 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", ] [[package]] @@ -2374,7 +2631,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2722,12 +2979,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2906,6 +3180,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2915,6 +3205,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 89e8da6..7b96f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } rustyline = "14" colored = "2" +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } notify = "6.1" walkdir = "2.5" once_cell = "1.19" diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..041c9f8 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,461 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use tokio::sync::mpsc; + +use crate::{ + config::{AgentConfig, AppConfig}, + rag::{ConversationMeta, RagContext, SkillsManager}, +}; + +use super::render; +use super::types::{AgentUpdate, ChatItem, Screen, Status}; + +pub(super) struct App { + pub(super) items: Vec, + pub(super) input: String, + pub(super) cursor: usize, + pub(super) scroll: usize, + pub(super) pinned: bool, + pub(super) spinner_frame: usize, + pub(super) status: Status, + pub(super) should_quit: bool, + screen: Screen, + agent_config: AgentConfig, + app_config: AppConfig, + skills: Vec, + sessions: Vec, + cached_lines: Vec>, + pub(super) cached_width: u16, + prompt_tx: mpsc::UnboundedSender, +} + +impl App { + pub(super) fn new( + agent_config: AgentConfig, + app_config: AppConfig, + skills: Vec, + prompt_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + items: Vec::new(), + input: String::new(), + cursor: 0, + scroll: 0, + pinned: true, + spinner_frame: 0, + status: Status::Idle, + should_quit: false, + screen: Screen::Welcome, + agent_config, + app_config, + skills, + sessions: Vec::new(), + cached_lines: Vec::new(), + cached_width: 0, + prompt_tx, + } + } + + pub(super) fn push(&mut self, item: ChatItem) { + self.items.push(item); + self.cached_width = 0; + } + + pub(super) fn handle_update(&mut self, update: AgentUpdate) { + match update { + AgentUpdate::TextChunk(text) => { + self.status = Status::Streaming; + match self.items.last_mut() { + Some(ChatItem::AssistantMessage(existing)) => existing.push_str(&text), + _ => self.items.push(ChatItem::AssistantMessage(text)), + } + self.cached_width = 0; + } + AgentUpdate::ToolCall { name, args } => { + self.status = Status::Thinking; + self.push(ChatItem::ToolCall { name, args }); + } + AgentUpdate::ToolResult { result, is_error } => { + self.push(ChatItem::ToolResult { result, is_error }); + } + AgentUpdate::Done => { + self.status = Status::Idle; + } + AgentUpdate::Error(e) => { + self.status = Status::Idle; + self.push(ChatItem::Err(e)); + } + } + } + + pub(super) fn handle_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.input.clear(); + self.cursor = 0; + } + KeyCode::Enter => { + if self.status != Status::Idle { + return; + } + let line = self.input.trim().to_string(); + if line.is_empty() { + return; + } + self.input.clear(); + self.cursor = 0; + self.screen = Screen::Chat; + if let Some(rest) = line.strip_prefix(':') { + self.handle_command(rest.trim()); + } else { + self.push(ChatItem::UserMessage(line.clone())); + self.status = Status::Thinking; + self.pinned = true; + let _ = self.prompt_tx.send(line); + } + } + KeyCode::Char(c) => { + self.input.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + KeyCode::Backspace => { + if self.cursor > 0 { + let prev = prev_char_boundary(&self.input, self.cursor); + self.input.drain(prev..self.cursor); + self.cursor = prev; + } + } + KeyCode::Delete => { + if self.cursor < self.input.len() { + let next = next_char_boundary(&self.input, self.cursor); + self.input.drain(self.cursor..next); + } + } + KeyCode::Left => { + if self.cursor > 0 { + self.cursor = prev_char_boundary(&self.input, self.cursor); + } + } + KeyCode::Right => { + if self.cursor < self.input.len() { + self.cursor = next_char_boundary(&self.input, self.cursor); + } + } + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.input.len(), + KeyCode::Up if self.screen == Screen::Chat => { + self.scroll = self.scroll.saturating_sub(1); + self.pinned = false; + } + KeyCode::Down if self.screen == Screen::Chat => { + self.scroll = self.scroll.saturating_add(1); + } + KeyCode::PageUp if self.screen == Screen::Chat => { + self.scroll = self.scroll.saturating_sub(20); + self.pinned = false; + } + KeyCode::PageDown if self.screen == Screen::Chat => { + self.scroll = self.scroll.saturating_add(20); + } + _ => {} + } + } + + fn handle_command(&mut self, cmd: &str) { + let mut parts = cmd.splitn(2, ' '); + let name = parts.next().unwrap_or(""); + let arg = parts.next().unwrap_or("").trim(); + match name { + "q" | "quit" => self.should_quit = true, + "help" => self.push(ChatItem::SystemInfo( + ":help show this\n\ + :q / :quit exit\n\ + :sessions list saved sessions\n\ + :open view session n (run :sessions first)\n\ + :config current config\n\ + :mcp MCP servers\n\ + :skills available skills\n\n\ + ↑/↓ scroll · PgUp/PgDn page · Ctrl+C quit" + .to_string(), + )), + "sessions" => match RagContext::new().and_then(|r| r.history.list_conversations()) { + Ok(metas) if metas.is_empty() => { + self.push(ChatItem::SystemInfo("no sessions yet".to_string())); + } + Ok(metas) => { + let mut lines = Vec::new(); + for (i, meta) in metas.iter().enumerate() { + let title = meta.title.as_deref().unwrap_or("(untitled)"); + let date = meta.updated_at.format("%Y-%m-%d %H:%M").to_string(); + let model = meta.model.as_deref().unwrap_or("?"); + lines.push(format!(" {} {} · {} · {}", i + 1, title, date, model)); + } + lines.push(String::new()); + lines.push(":open to view".to_string()); + self.sessions = metas; + self.push(ChatItem::SystemInfo(lines.join("\n"))); + } + Err(e) => self.push(ChatItem::Err(e.to_string())), + }, + "open" => { + if let Ok(n) = arg.parse::() { + if n == 0 || n > self.sessions.len() { + self.push(ChatItem::SystemInfo(format!( + "no session {n} (run :sessions first)" + ))); + } else { + let meta = self.sessions[n - 1].clone(); + self.open_session(&meta); + } + } else { + self.push(ChatItem::SystemInfo("usage: :open ".to_string())); + } + } + "config" => { + let ac = &self.agent_config; + let mut lines = vec![ + format!("Provider {}", ac.provider_name), + format!("Model {}", ac.model), + format!("Max iterations {}", ac.max_iterations), + format!("Timeout {}s", ac.timeout_secs), + ]; + if !self.app_config.providers.is_empty() { + lines.push(String::new()); + lines.push("Providers".to_string()); + for (pname, p) in &self.app_config.providers { + let suffix = if pname == &self.app_config.default_provider { + " (default)" + } else { + "" + }; + lines.push(format!(" {pname}{suffix} {}", p.default_model)); + } + } + if !self.app_config.mcp_servers.is_empty() { + lines.push(String::new()); + lines.push("MCP Servers".to_string()); + for sname in self.app_config.mcp_servers.keys() { + lines.push(format!(" {sname}")); + } + } + self.push(ChatItem::SystemInfo(lines.join("\n"))); + } + "mcp" => { + if self.app_config.mcp_servers.is_empty() { + self.push(ChatItem::SystemInfo( + "no MCP servers configured\n\ + add [mcp_servers.] to ~/.openheim/config.toml" + .to_string(), + )); + } else { + let mut lines = Vec::new(); + for (sname, server) in &self.app_config.mcp_servers { + lines.push(format!("● {sname}")); + if let Some(cmd) = &server.command { + let args_str = server.args.join(" "); + let cmd_line = if args_str.is_empty() { + cmd.clone() + } else { + format!("{cmd} {args_str}") + }; + lines.push(format!(" stdio {cmd_line}")); + } + if let Some(url) = &server.url { + lines.push(format!(" http {url}")); + } + } + self.push(ChatItem::SystemInfo(lines.join("\n"))); + } + } + "skills" => match SkillsManager::new().and_then(|m| m.list_skills()) { + Ok(names) if names.is_empty() => { + self.push(ChatItem::SystemInfo( + "no skills available\n\ + add .md files to ~/.openheim/skills/" + .to_string(), + )); + } + Ok(mut names) => { + names.push(String::new()); + names.push("activate with: openheim --skills ,...".to_string()); + self.push(ChatItem::SystemInfo(names.join("\n"))); + } + Err(e) => self.push(ChatItem::Err(e.to_string())), + }, + unknown => self.push(ChatItem::SystemInfo(format!( + ":{unknown}: unknown command (try :help)" + ))), + } + } + + fn open_session(&mut self, meta: &ConversationMeta) { + use crate::core::models::Role; + + let title = meta.title.as_deref().unwrap_or("(untitled)"); + self.push(ChatItem::SystemInfo(format!("─── {title}"))); + + match RagContext::new().and_then(|r| r.history.load_conversation(&meta.id)) { + Ok(conv) => { + for msg in &conv.messages { + match msg.role { + Role::System => {} + Role::User => { + if let Some(content) = &msg.content { + if !content.is_empty() { + self.push(ChatItem::UserMessage(content.clone())); + } + } + } + Role::Assistant => { + if let Some(content) = &msg.content { + if !content.is_empty() { + self.push(ChatItem::AssistantMessage(content.clone())); + } + } + if let Some(tool_calls) = &msg.tool_calls { + for tc in tool_calls { + self.push(ChatItem::ToolCall { + name: tc.function.name.clone(), + args: tc.function.arguments.clone(), + }); + } + } + } + Role::Tool => { + if let Some(content) = &msg.content { + self.push(ChatItem::ToolResult { + result: content.clone(), + is_error: msg.is_error, + }); + } + } + } + } + } + Err(e) => self.push(ChatItem::Err(e.to_string())), + } + + self.push(ChatItem::SystemInfo("───".to_string())); + } + + pub(super) fn draw(&mut self, f: &mut Frame) { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(3)]) + .split(area); + + let [status_area, content_area, input_area] = [chunks[0], chunks[1], chunks[2]]; + + self.draw_status_bar(f, status_area); + + if self.screen == Screen::Welcome { + let model = self.agent_config.model.clone(); + let provider = self.agent_config.provider_name.clone(); + let skills = self.skills.clone(); + render::render_welcome(f, content_area, &model, &provider, &skills); + } else { + self.draw_chat(f, content_area); + } + + let input = self.input.clone(); + render::render_input_bar(f, input_area, &input, self.cursor); + } + + fn draw_status_bar(&self, f: &mut Frame, area: ratatui::layout::Rect) { + let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let text = match &self.status { + Status::Idle => { + let model = &self.agent_config.model; + let provider = &self.agent_config.provider_name; + if self.skills.is_empty() { + format!(" {model} · {provider}") + } else { + format!(" {model} · {provider} · skills: {}", self.skills.join(", ")) + } + } + Status::Thinking => { + format!(" {} thinking…", spinner[self.spinner_frame % spinner.len()]) + } + Status::Streaming => { + format!(" {} streaming…", spinner[self.spinner_frame % spinner.len()]) + } + }; + f.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), + area, + ); + } + + fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { + let chat_w = area.width; + if self.cached_width != chat_w { + self.cached_lines = render::build_lines(&self.items, chat_w); + self.cached_width = chat_w; + } + + let total = self.cached_lines.len(); + let visible_h = area.height as usize; + let max_scroll = total.saturating_sub(visible_h); + + if self.pinned { + self.scroll = max_scroll; + } else { + self.scroll = self.scroll.min(max_scroll); + if self.scroll >= max_scroll { + self.pinned = true; + } + } + + let start = self.scroll; + let end = (start + visible_h).min(total); + let visible: Vec> = + if start < end { self.cached_lines[start..end].to_vec() } else { vec![] }; + + let scroll_hint = if !self.pinned && max_scroll > 0 { + format!(" {}% ↑ ", (self.scroll * 100) / max_scroll) + } else { + String::new() + }; + + use ratatui::widgets::{Block, Borders}; + let chat_block = Block::default() + .borders(Borders::NONE) + .title_bottom(Line::from( + Span::styled(scroll_hint, Style::default().fg(Color::DarkGray)), + )); + let chat_inner = chat_block.inner(area); + f.render_widget(chat_block, area); + f.render_widget(Paragraph::new(visible), chat_inner); + } +} + +fn prev_char_boundary(s: &str, pos: usize) -> usize { + let mut p = pos; + loop { + if p == 0 { + return 0; + } + p -= 1; + if s.is_char_boundary(p) { + return p; + } + } +} + +fn next_char_boundary(s: &str, pos: usize) -> usize { + let mut p = pos + 1; + while p <= s.len() && !s.is_char_boundary(p) { + p += 1; + } + p.min(s.len()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e8e4bcf..eb65b49 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,534 +1,156 @@ -use std::io::{Write, stdout}; +mod app; +mod render; +mod types; + +use std::io; use std::sync::Arc; +use std::time::Duration; -use agent_client_protocol::{ - ByteStreams, Client, SessionMessage, - schema::{ - ContentBlock, InitializeRequest, NewSessionRequest, ProtocolVersion, SessionNotification, - SessionUpdate, ToolCallStatus, - }, - util::MatchDispatch, +use agent_client_protocol::schema::{ContentBlock, SessionUpdate, ToolCallStatus}; +use crossterm::{ + event::{Event, EventStream}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use colored::Colorize; -use rustyline::DefaultEditor; -use rustyline::error::ReadlineError; +use futures::StreamExt; +use ratatui::{Terminal, backend::CrosstermBackend}; use tokio::sync::mpsc; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::{ - acp::{self, AgentState}, - config::{AgentConfig, AppConfig, load_config}, - rag::{ConversationMeta, RagContext, SkillsManager}, + client::OpenheimClient, + config::load_config, }; -#[derive(Debug, Clone)] -enum AgentUpdate { - TextChunk(String), - ToolCall { name: String, args: String }, - ToolResult(String), - Done, - Error(String), -} +use app::App; +use types::AgentUpdate; pub async fn run(skills: Vec) -> crate::error::Result<()> { let app_config = load_config()?; let agent_config = app_config.resolve(None)?; - let rag = RagContext::new()?; - let state = Arc::new(AgentState::new(agent_config.clone(), app_config.clone(), rag).await?); - - let (prompt_tx, prompt_rx) = mpsc::channel::(1); - let (update_tx, mut update_rx) = mpsc::channel::(64); - - let (server_half, client_half) = tokio::io::duplex(65536); - let (server_read, server_write) = tokio::io::split(server_half); - let (client_read, client_write) = tokio::io::split(client_half); - - let server_transport = ByteStreams::new(server_write.compat_write(), server_read.compat()); - let client_transport = ByteStreams::new(client_write.compat_write(), client_read.compat()); - - tokio::spawn(acp::serve(server_transport, state)); - tokio::spawn(run_acp_client( - client_transport, - prompt_rx, - update_tx, - skills.clone(), - )); - - let mut editor = DefaultEditor::new().map_err(|e| crate::error::Error::Other(e.to_string()))?; - - println!("{}", "openheim".yellow().bold()); - if skills.is_empty() { - println!("{}", "type a message or :help for commands".dimmed()); - } else { - println!("{}", "type a message or :help for commands".dimmed()); - println!(" {} {}", "skills:".dimmed(), skills.join(", ").yellow()); - } - println!(); - - let mut sessions: Vec = Vec::new(); - - loop { - let line = tokio::task::block_in_place(|| editor.readline("› ")); - match line { - Err(ReadlineError::Eof | ReadlineError::Interrupted) => break, - Err(e) => return Err(crate::error::Error::Other(e.to_string())), - Ok(line) => { - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - if let Some(rest) = line.strip_prefix(':') { - let mut parts = rest.trim().splitn(2, ' '); - let cmd = parts.next().unwrap_or(""); - let arg = parts.next().unwrap_or("").trim(); - if handle_command(cmd, arg, &agent_config, &app_config, &mut sessions) { - break; - } - } else { - let _ = editor.add_history_entry(&line); - prompt_tx.send(line).await.ok(); - stream_response(&mut update_rx).await; - } - } - } - } - Ok(()) -} - -fn handle_command( - cmd: &str, - arg: &str, - agent_config: &AgentConfig, - app_config: &AppConfig, - sessions: &mut Vec, -) -> bool { - match cmd { - "q" | "quit" => return true, - "sessions" => { - if let Ok(rag) = RagContext::new() - && let Ok(metas) = rag.history.list_conversations() - { - if metas.is_empty() { - println!("{}", " no sessions yet".dimmed()); - } else { - println!(); - for (i, meta) in metas.iter().enumerate() { - let title = meta.title.as_deref().unwrap_or("(untitled)"); - let date = meta.updated_at.format("%Y-%m-%d %H:%M").to_string(); - let model = meta.model.as_deref().unwrap_or("?"); - println!( - " {} {} · {} · {}", - format!("{}", i + 1).dimmed(), - title, - date.dimmed(), - model.dimmed(), - ); - } - *sessions = metas; - println!(); - println!("{}", " :open to load a session".dimmed()); - } - println!(); - } - } - "open" => { - if let Ok(n) = arg.parse::() { - if n == 0 || n > sessions.len() { - println!( - "{}", - format!(" no session {n} (run :sessions first)").red() - ); - } else { - open_session(&sessions[n - 1]); - } - } else { - println!("{}", " usage: :open ".dimmed()); - } - } - "config" => { - println!(); - let k = |s: &str| format!("{:<20}", s).dimmed().to_string(); - println!(" {} {}", k("Provider"), agent_config.provider_name); - println!(" {} {}", k("Model"), agent_config.model); - println!(" {} {}", k("Max iterations"), agent_config.max_iterations); - println!(" {} {}s", k("Timeout"), agent_config.timeout_secs); - if !app_config.providers.is_empty() { - println!(); - println!(" {}", "Providers".yellow()); - for (name, provider) in &app_config.providers { - let suffix = if name == &app_config.default_provider { - " (default)" - } else { - "" - }; - println!( - " {}{} {}", - name, - suffix, - provider.default_model.dimmed() - ); - } - } - if !app_config.mcp_servers.is_empty() { - println!(); - println!(" {}", "MCP Servers".yellow()); - for name in app_config.mcp_servers.keys() { - println!(" {name}"); - } - } - println!(); - println!(" {} ~/.openheim/config.toml", "edit config:".dimmed()); - println!(); - } - "mcp" => { - if app_config.mcp_servers.is_empty() { - println!("{}", " no MCP servers configured".dimmed()); - println!( - "{}", - " add [mcp_servers.] to ~/.openheim/config.toml".dimmed() - ); - } else { - for (name, server) in &app_config.mcp_servers { - println!(); - println!(" {} {}", "●".green(), name.bold()); - if let Some(cmd) = &server.command { - let args_str = server.args.join(" "); - let cmd_line = if args_str.is_empty() { - cmd.clone() - } else { - format!("{cmd} {args_str}") - }; - println!(" {} {}", "stdio".dimmed(), cmd_line.dimmed()); - } - if let Some(url) = &server.url { - println!(" {} {}", "http ".dimmed(), url.dimmed()); - } - for k in server.env.keys() { - println!(" {} {}", "env ".dimmed(), k.dimmed()); - } - } - } - println!(); - } - "skills" => { - match SkillsManager::new() { - Ok(mgr) => match mgr.list_skills() { - Ok(names) if names.is_empty() => { - println!("{}", " no skills available".dimmed()); - println!( - "{}", - " add .md files to ~/.openheim/skills/".dimmed() - ); - } - Ok(names) => { - println!(); - for name in &names { - println!(" {}", name); - } - println!(); - println!( - "{}", - " activate with: openheim --skills ,,...".dimmed() - ); - } - Err(e) => println!("{}", format!(" error listing skills: {e}").red()), - }, - Err(e) => println!("{}", format!(" error: {e}").red()), - } - println!(); - } - "help" => { - println!(); - let c = |s: &str| format!("{:<16}", s).bold().to_string(); - println!(" {} show this help", c(":help")); - println!(" {} quit openheim", c(":q / :quit")); - println!(" {} list saved sessions", c(":sessions")); - println!(" {} load session n", c(":open ")); - println!(" {} show current config", c(":config")); - println!(" {} show MCP servers", c(":mcp")); - println!(" {} list available skills", c(":skills")); - println!(); - } - unknown => { - println!( - "{}", - format!(":{unknown}: unknown command (try :help)").red() - ); - } - } - false -} + let client = OpenheimClient::builder() + .build() + .await + .map_err(|e| crate::error::Error::Other(e.to_string()))?; -fn open_session(meta: &ConversationMeta) { - use crate::core::models::Role; + let session = Arc::new( + client + .new_session() + .skills(skills.clone()) + .start() + .await + .map_err(|e| crate::error::Error::Other(e.to_string()))?, + ); - let title = meta.title.as_deref().unwrap_or("(untitled)"); - let divider = "─".repeat(52); - println!(); - println!(" {} {}", divider.dimmed(), title.bold()); - println!(); + let (update_tx, mut update_rx) = mpsc::unbounded_channel::(); + let (prompt_tx, mut prompt_rx) = mpsc::unbounded_channel::(); - if let Ok(rag) = RagContext::new() - && let Ok(conv) = rag.history.load_conversation(&meta.id) { - for msg in &conv.messages { - match msg.role { - Role::System => {} - Role::User => { - if let Some(content) = &msg.content - && !content.is_empty() - { - println!(" {}", "you".green().bold()); - for line in content.split('\n') { - println!(" {line}"); - } - println!(); - } - } - Role::Assistant => { - let mut printed_header = false; - if let Some(content) = &msg.content - && !content.is_empty() - { - println!(" {}", "openheim".yellow().bold()); - printed_header = true; - for line in content.split('\n') { - println!(" {line}"); - } - } - if let Some(tool_calls) = &msg.tool_calls { - for tc in tool_calls { - if !printed_header { - println!(" {}", "openheim".yellow().bold()); - printed_header = true; - } - let args = &tc.function.arguments; - let preview: String = args.chars().take(60).collect(); - let preview = if args.chars().count() > 60 { - format!("{preview}…") - } else { - preview - }; - println!( - " {} {} {}", - "⚙".cyan(), - tc.function.name.cyan().bold(), - preview.dimmed() - ); - } + let session = Arc::clone(&session); + let update_tx = update_tx.clone(); + tokio::spawn(async move { + while let Some(prompt) = prompt_rx.recv().await { + let tx_cb = update_tx.clone(); + let result = session + .prompt(&prompt, move |update| convert_update(&tx_cb, update)) + .await; + match result { + Ok(()) => { + let _ = update_tx.send(AgentUpdate::Done); } - if printed_header { - println!(); - } - } - Role::Tool => { - if let Some(content) = &msg.content { - let flat: String = content - .chars() - .take(100) - .collect::() - .replace('\n', " "); - let flat = flat.trim().to_string(); - if !flat.is_empty() { - println!(" {} {}", "→".dimmed(), flat.dimmed()); - } + Err(e) => { + let _ = update_tx.send(AgentUpdate::Error(e.to_string())); } } } - } + }); } - println!(" {}", divider.dimmed()); - println!(); -} - -async fn stream_response(update_rx: &mut mpsc::Receiver) { - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::time::Duration; + let mut app = App::new(agent_config, app_config, skills, prompt_tx); - let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_clone = Arc::clone(&stop_flag); + enable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen) + .map_err(|e| crate::error::Error::Other(e.to_string()))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = + Terminal::new(backend).map_err(|e| crate::error::Error::Other(e.to_string()))?; - let spinner = tokio::spawn(async move { - let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let mut i = 0usize; - tokio::time::sleep(Duration::from_millis(120)).await; - while !stop_clone.load(Ordering::Relaxed) { - print!("\r {} {}", frames[i % frames.len()], "thinking…".dimmed()); - let _ = stdout().flush(); - i += 1; - tokio::time::sleep(Duration::from_millis(80)).await; - } - print!("\r{}\r", " ".repeat(24)); - let _ = stdout().flush(); - }); + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original_hook(info); + })); - let mut agent_started = false; - let mut spinner = Some(spinner); + let mut events = EventStream::new(); + let mut tick = tokio::time::interval(Duration::from_millis(80)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - let update = update_rx.recv().await; + terminal + .draw(|f| app.draw(f)) + .map_err(|e| crate::error::Error::Other(e.to_string()))?; - if let Some(handle) = spinner.take() { - stop_flag.store(true, Ordering::Relaxed); - handle.await.ok(); + if app.should_quit { + break; } - match update { - Some(AgentUpdate::TextChunk(text)) => { - if !agent_started { - print!(" {} ", "openheim".yellow().bold()); - agent_started = true; + tokio::select! { + _ = tick.tick() => { + if app.status != types::Status::Idle { + app.spinner_frame = app.spinner_frame.wrapping_add(1); } - print!("{text}"); - let _ = stdout().flush(); } - Some(AgentUpdate::ToolCall { name, args }) => { - if !agent_started { - println!(" {}", "openheim".yellow().bold()); - agent_started = true; - } else { - println!(); + maybe = events.next() => { + match maybe { + Some(Ok(Event::Key(key))) => app.handle_key(key), + Some(Ok(Event::Resize(_, _))) => app.cached_width = 0, + Some(Err(_)) | None => break, + _ => {} } - let preview: String = args.chars().take(60).collect(); - let preview = if args.chars().count() > 60 { - format!("{preview}…") - } else { - preview - }; - println!( - " {} {} {}", - "⚙".cyan(), - name.cyan().bold(), - preview.dimmed() - ); } - Some(AgentUpdate::ToolResult(result)) => { - let flat: String = result - .chars() - .take(100) - .collect::() - .replace('\n', " "); - println!(" {} {}", "→".dimmed(), flat.trim().dimmed()); - } - Some(AgentUpdate::Done) | None => { - if agent_started { - println!(); - } - println!(); - break; - } - Some(AgentUpdate::Error(e)) => { - if agent_started { - println!(); - } - println!(" {} {}", "error".red().bold(), e); - println!(); - break; + Some(update) = update_rx.recv() => { + app.handle_update(update); } } } -} - -async fn run_acp_client( - transport: ByteStreams< - tokio_util::compat::Compat>, - tokio_util::compat::Compat>, - >, - mut prompt_rx: mpsc::Receiver, - update_tx: mpsc::Sender, - skills: Vec, -) { - let error_tx = update_tx.clone(); - let result = Client - .builder() - .connect_with(transport, async move |cx| { - cx.send_request(InitializeRequest::new(ProtocolVersion::V1)) - .block_task() - .await?; - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let request = if skills.is_empty() { - NewSessionRequest::new(cwd) - } else { - let mut meta = serde_json::Map::new(); - meta.insert("skills".to_string(), serde_json::json!(skills)); - NewSessionRequest::new(cwd).meta(meta) - }; + disable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; + execute!(terminal.backend_mut(), LeaveAlternateScreen) + .map_err(|e| crate::error::Error::Other(e.to_string()))?; + terminal + .show_cursor() + .map_err(|e| crate::error::Error::Other(e.to_string()))?; - cx.build_session_from(request) - .block_task() - .run_until(async move |mut session| { - while let Some(prompt) = prompt_rx.recv().await { - session.send_prompt(&prompt)?; - loop { - match session.read_update().await? { - SessionMessage::StopReason(_) => { - let _ = update_tx.send(AgentUpdate::Done).await; - break; - } - SessionMessage::SessionMessage(dispatch) => { - let tx = update_tx.clone(); - MatchDispatch::new(dispatch) - .if_notification(async move |notif: SessionNotification| { - match notif.update { - SessionUpdate::AgentMessageChunk(chunk) => { - if let ContentBlock::Text(t) = chunk.content { - let _ = tx - .send(AgentUpdate::TextChunk(t.text)) - .await; - } - } - SessionUpdate::ToolCall(tc) => { - let args = tc - .raw_input - .as_ref() - .map(|v| v.to_string()) - .unwrap_or_default(); - let _ = tx - .send(AgentUpdate::ToolCall { - name: tc.title.clone(), - args, - }) - .await; - } - SessionUpdate::ToolCallUpdate(tcu) => { - if matches!( - tcu.fields.status, - Some(ToolCallStatus::Completed) - | Some(ToolCallStatus::Failed) - ) { - let result = match tcu.fields.raw_output { - Some(serde_json::Value::String(s)) => s, - Some(v) => v.to_string(), - None => String::new(), - }; - let _ = tx - .send(AgentUpdate::ToolResult(result)) - .await; - } - } - _ => {} - } - Ok(()) - }) - .await - .otherwise_ignore()?; - } - _ => {} - } - } - } - Ok(()) - }) - .await - }) - .await; + Ok(()) +} - if let Err(e) = result { - tracing::error!("TUI ACP client error: {e}"); - let _ = error_tx.send(AgentUpdate::Error(e.to_string())).await; +fn convert_update(tx: &mpsc::UnboundedSender, update: SessionUpdate) { + match update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let ContentBlock::Text(t) = chunk.content { + let _ = tx.send(AgentUpdate::TextChunk(t.text)); + } + } + SessionUpdate::ToolCall(tc) => { + let args = tc.raw_input.as_ref().map(|v| v.to_string()).unwrap_or_default(); + let _ = tx.send(AgentUpdate::ToolCall { name: tc.title.clone(), args }); + } + SessionUpdate::ToolCallUpdate(tcu) => { + if matches!( + tcu.fields.status, + Some(ToolCallStatus::Completed) | Some(ToolCallStatus::Failed) + ) { + let is_error = matches!(tcu.fields.status, Some(ToolCallStatus::Failed)); + let result = match tcu.fields.raw_output { + Some(serde_json::Value::String(s)) => s, + Some(v) => v.to_string(), + None => String::new(), + }; + let _ = tx.send(AgentUpdate::ToolResult { result, is_error }); + } + } + _ => {} } } diff --git a/src/tui/render.rs b/src/tui/render.rs new file mode 100644 index 0000000..ebe738f --- /dev/null +++ b/src/tui/render.rs @@ -0,0 +1,261 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use super::types::ChatItem; + +pub(crate) fn build_lines(items: &[ChatItem], width: u16) -> Vec> { + let inner_w = width.saturating_sub(2) as usize; + let mut lines: Vec> = Vec::new(); + + for item in items { + match item { + ChatItem::UserMessage(text) => { + lines.extend(user_bubble(text, width)); + } + ChatItem::AssistantMessage(text) => { + for wl in word_wrap(text, inner_w) { + lines.push(Line::raw(format!(" {wl}"))); + } + lines.push(Line::default()); + } + ChatItem::ToolCall { name, args } => { + let used = 4 + name.chars().count(); + let preview_w = inner_w.saturating_sub(used + 1); + let preview: String = args.chars().take(preview_w).collect(); + let preview = if args.chars().count() > preview_w { + format!("{preview}…") + } else { + preview + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("⚙ ", Style::default().fg(Color::Cyan)), + Span::styled( + name.clone(), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(preview, Style::default().fg(Color::DarkGray)), + ])); + } + ChatItem::ToolResult { result, is_error } => { + let flat: String = + result.chars().take(200).collect::().replace('\n', " "); + let style = if *is_error { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("→ ", style), + Span::styled(flat.trim().to_string(), style), + ])); + } + ChatItem::SystemInfo(text) => { + for line in text.lines() { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(Color::DarkGray), + ))); + } + lines.push(Line::default()); + } + ChatItem::Err(text) => { + lines.push(Line::from(Span::styled( + format!(" error: {text}"), + Style::default().fg(Color::Red), + ))); + lines.push(Line::default()); + } + } + } + + lines +} + +// Right-aligned chat bubble for user messages. +// +// ╭──────────────────╮ +// │ list files in │ +// │ src/ │ +// ╰──────────────────╯ +fn user_bubble(text: &str, width: u16) -> Vec> { + let content_max = 50usize.min(width.saturating_sub(8) as usize).max(1); + + let wrapped = word_wrap(text, content_max); + let content_w = wrapped.iter().map(|l| l.chars().count()).max().unwrap_or(0).max(1); + + let border = Style::default().fg(Color::Green); + let mut out: Vec> = Vec::new(); + + out.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("╭{}╮", "─".repeat(content_w + 2)), border), + ])); + + for content_line in &wrapped { + let gap = " ".repeat(content_w - content_line.chars().count()); + out.push(Line::from(vec![ + Span::raw(" "), + Span::styled("│ ".to_string(), border), + Span::raw(content_line.clone()), + Span::raw(gap), + Span::styled(" │".to_string(), border), + ])); + } + + out.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("╰{}╯", "─".repeat(content_w + 2)), border), + ])); + + out.push(Line::default()); + out +} + +pub(crate) fn render_welcome( + f: &mut Frame, + area: Rect, + model: &str, + provider: &str, + skills: &[String], +) { + #[rustfmt::skip] + const LOGO: &[&str] = &[ + " ___ _ __ ___ _ __ | |__ ___(_)_ __ ___ ", + r" / _ \| '_ \ / _ \ '_ \ | '_ \ / _ \ | '_ ` _ \ ", + "| (_) | |_) | __/ | | || | | | __/ | | | | | |", + r" \___/| .__/ \___|_| |_||_| |_|\___|_|_| |_| |_|", + " |_| ", + ]; + + const COMMANDS: &[(&str, &str)] = &[ + (":help", "show all commands"), + (":config", "current config"), + (":sessions", "past sessions"), + (":skills", "available skills"), + (":mcp", "MCP servers"), + (":q", "quit"), + ]; + + let subtitle = if skills.is_empty() { + format!("{model} · {provider}") + } else { + format!("{model} · {provider} · skills: {}", skills.join(", ")) + }; + let hint = "type a message to start"; + + // logo + blank + subtitle + blank*2 + hint + blank + commands + let content_h = LOGO.len() + 1 + 1 + 2 + 1 + 1 + COMMANDS.len(); + let top_pad = (area.height as usize).saturating_sub(content_h) / 2; + let w = area.width as usize; + + let center = |text_w: usize| " ".repeat(w.saturating_sub(text_w) / 2); + + let mut lines: Vec> = (0..top_pad).map(|_| Line::default()).collect(); + + let logo_w = LOGO.iter().map(|l| l.chars().count()).max().unwrap_or(0); + let logo_pad = center(logo_w); + for &logo_line in LOGO { + lines.push(Line::styled( + format!("{logo_pad}{logo_line}"), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )); + } + + lines.push(Line::default()); + + let sub_pad = center(subtitle.chars().count()); + lines.push(Line::styled( + format!("{sub_pad}{subtitle}"), + Style::default().fg(Color::DarkGray), + )); + + lines.push(Line::default()); + lines.push(Line::default()); + + let hint_pad = center(hint.chars().count()); + lines.push(Line::styled( + format!("{hint_pad}{hint}"), + Style::default().fg(Color::DarkGray), + )); + + lines.push(Line::default()); + + let cmd_key_w = COMMANDS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0); + let cmd_desc_w = COMMANDS.iter().map(|(_, d)| d.chars().count()).max().unwrap_or(0); + let cmd_block_w = cmd_key_w + 6 + cmd_desc_w; + let cmd_pad = center(cmd_block_w); + + for &(key, desc) in COMMANDS { + let gap = " ".repeat(cmd_key_w - key.chars().count() + 6); + lines.push(Line::from(vec![ + Span::raw(cmd_pad.clone()), + Span::styled(key.to_string(), Style::default().fg(Color::White)), + Span::raw(gap), + Span::styled(desc.to_string(), Style::default().fg(Color::DarkGray)), + ])); + } + + f.render_widget(Paragraph::new(lines), area); +} + +pub(crate) fn render_input_bar(f: &mut Frame, area: Rect, input: &str, cursor: usize) { + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = block.inner(area); + f.render_widget(block, area); + + let prompt_prefix = " › "; + f.render_widget(Paragraph::new(format!("{prompt_prefix}{input}")), inner); + + let cursor_col = inner.x + + prompt_prefix.chars().count() as u16 + + input[..cursor].chars().count() as u16; + f.set_cursor_position(( + cursor_col.min(inner.x + inner.width.saturating_sub(1)), + inner.y, + )); +} + +// Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. +pub(crate) fn word_wrap(text: &str, width: usize) -> Vec { + if width == 0 { + return text.lines().map(String::from).collect(); + } + let mut out = Vec::new(); + for para in text.split('\n') { + if para.trim().is_empty() { + out.push(String::new()); + continue; + } + let mut current = String::new(); + let mut current_len = 0usize; + for word in para.split_whitespace() { + let wlen = word.chars().count(); + if current.is_empty() { + current.push_str(word); + current_len = wlen; + } else if current_len + 1 + wlen <= width { + current.push(' '); + current.push_str(word); + current_len += 1 + wlen; + } else { + out.push(current); + current = word.to_string(); + current_len = wlen; + } + } + if !current.is_empty() { + out.push(current); + } + } + out +} diff --git a/src/tui/types.rs b/src/tui/types.rs new file mode 100644 index 0000000..6cb36b8 --- /dev/null +++ b/src/tui/types.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Clone)] +pub(crate) enum AgentUpdate { + TextChunk(String), + ToolCall { name: String, args: String }, + ToolResult { result: String, is_error: bool }, + Done, + Error(String), +} + +#[derive(Debug, Clone)] +pub(crate) enum ChatItem { + UserMessage(String), + AssistantMessage(String), + ToolCall { name: String, args: String }, + ToolResult { result: String, is_error: bool }, + SystemInfo(String), + Err(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Status { + Idle, + Thinking, + Streaming, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Screen { + Welcome, + Chat, +} From 52fcd1af2f27489e2819e9cb02ecbc15760fb446 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 09:46:43 +0300 Subject: [PATCH 04/41] feat: move info panels to prompt --- src/tui/app.rs | 41 +++++++++++++++++------------------------ src/tui/render.rs | 23 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 041c9f8..7d4eb12 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -351,12 +351,10 @@ impl App { let area = f.area(); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(3)]) + .constraints([Constraint::Fill(1), Constraint::Length(3)]) .split(area); - let [status_area, content_area, input_area] = [chunks[0], chunks[1], chunks[2]]; - - self.draw_status_bar(f, status_area); + let [content_area, input_area] = [chunks[0], chunks[1]]; if self.screen == Screen::Welcome { let model = self.agent_config.model.clone(); @@ -367,32 +365,27 @@ impl App { self.draw_chat(f, content_area); } - let input = self.input.clone(); - render::render_input_bar(f, input_area, &input, self.cursor); - } - - fn draw_status_bar(&self, f: &mut Frame, area: ratatui::layout::Rect) { let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let text = match &self.status { - Status::Idle => { - let model = &self.agent_config.model; - let provider = &self.agent_config.provider_name; - if self.skills.is_empty() { - format!(" {model} · {provider}") - } else { - format!(" {model} · {provider} · skills: {}", self.skills.join(", ")) - } - } + let left_label = match &self.status { + Status::Idle => None, Status::Thinking => { - format!(" {} thinking…", spinner[self.spinner_frame % spinner.len()]) + Some(format!("{} thinking…", spinner[self.spinner_frame % spinner.len()])) } Status::Streaming => { - format!(" {} streaming…", spinner[self.spinner_frame % spinner.len()]) + Some(format!("{} streaming…", spinner[self.spinner_frame % spinner.len()])) } }; - f.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), - area, + let right_label = + format!("{} · {}", self.agent_config.provider_name, self.agent_config.model); + + let input = self.input.clone(); + render::render_input_bar( + f, + input_area, + &input, + self.cursor, + left_label.as_deref(), + &right_label, ); } diff --git a/src/tui/render.rs b/src/tui/render.rs index ebe738f..dba9958 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -206,10 +206,25 @@ pub(crate) fn render_welcome( f.render_widget(Paragraph::new(lines), area); } -pub(crate) fn render_input_bar(f: &mut Frame, area: Rect, input: &str, cursor: usize) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(Color::DarkGray)); +pub(crate) fn render_input_bar( + f: &mut Frame, + area: Rect, + input: &str, + cursor: usize, + left_label: Option<&str>, + right_label: &str, +) { + let dim = Style::default().fg(Color::DarkGray); + let mut block = Block::default().borders(Borders::TOP).border_style(dim); + + if let Some(left) = left_label { + block = block + .title_top(Line::from(Span::styled(format!("─── {left} "), dim)).left_aligned()); + } + block = block.title_top( + Line::from(Span::styled(format!(" {right_label} ───"), dim)).right_aligned(), + ); + let inner = block.inner(area); f.render_widget(block, area); From d9344ae0410e92d5e7168c3c92b7997bc4c7e68e Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 11:35:54 +0300 Subject: [PATCH 05/41] fix: resolve per-session llm client in acp_prompt acp_prompt was always using the global AgentState.llm regardless of the session's model override. Sessions created with a specific model were silently using the wrong client. Now builds a fresh client from the session config when provider or model differs from the default. --- src/acp/mod.rs | 26 +++++++++++++++++++++++++- src/client.rs | 9 +++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/acp/mod.rs b/src/acp/mod.rs index 45193e9..8db6c91 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -87,6 +87,22 @@ impl AgentState { Ok(session_key) } + pub async fn acp_update_session_model( + &self, + session_id: &str, + model: &str, + ) -> Result<(String, String)> { + let new_config = self.app_config.resolve(Some(model))?; + let provider_name = new_config.provider_name.clone(); + let model_name = new_config.model.clone(); + let mut sessions = self.sessions.write().await; + let s = sessions + .get_mut(session_id) + .ok_or_else(|| Error::Other(format!("session not found: {session_id}")))?; + s.config = new_config; + Ok((provider_name, model_name)) + } + pub async fn acp_prompt( &self, session_id: &str, @@ -101,8 +117,16 @@ impl AgentState { let s = sessions .get(session_id) .ok_or_else(|| Error::Other(format!("session not found: {session_id}")))?; + let llm = if s.config.provider_name != self.config.provider_name + || s.config.model != self.config.model + { + let http_client = crate::config::build_http_client(s.config.timeout_secs)?; + crate::config::create_client(&s.config, &http_client) + } else { + self.llm.clone() + }; ( - self.llm.clone(), + llm, self.executor.clone(), s.config.clone(), s.chat_id, diff --git a/src/client.rs b/src/client.rs index 8b98abd..7ad0c6a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -186,6 +186,15 @@ impl SessionHandle { .acp_prompt(&self.id, text.to_string(), on_update) .await } + + /// Switch the model for this session mid-conversation. + /// + /// The model must be listed under a provider in the config. Returns + /// `(provider_name, model_name)` on success; the next prompt will use + /// the new model while preserving conversation history. + pub async fn switch_model(&self, model: &str) -> Result<(String, String)> { + self.state.acp_update_session_model(&self.id, model).await + } } // ── OpenheimBuilder ─────────────────────────────────────────────────────────── From 2372d94698d2ce9db669ad3c63718e92918bcde4 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 11:36:00 +0300 Subject: [PATCH 06/41] feat: add :models command with interactive popup picker Opens a centered overlay listing all configured providers and models. Arrow keys navigate the list, Enter switches model mid-session (history preserved), Esc cancels. The status bar updates immediately on switch. Direct switch via :models still works without opening the popup. Also fixes cursor visibility (hidden while picker is open to prevent input artifacts) and scroll bounds (saturating arithmetic to prevent panic on navigation). --- src/tui/app.rs | 97 +++++++++++++++++++++++++++++++++++++++++++---- src/tui/mod.rs | 43 +++++++++++++++------ src/tui/render.rs | 96 ++++++++++++++++++++++++++++++++++++++++++---- src/tui/types.rs | 2 + 4 files changed, 211 insertions(+), 27 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 7d4eb12..75370ce 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -26,6 +26,7 @@ pub(super) struct App { pub(super) status: Status, pub(super) should_quit: bool, screen: Screen, + pre_picker_screen: Screen, agent_config: AgentConfig, app_config: AppConfig, skills: Vec, @@ -33,6 +34,9 @@ pub(super) struct App { cached_lines: Vec>, pub(super) cached_width: u16, prompt_tx: mpsc::UnboundedSender, + switch_model_tx: mpsc::UnboundedSender, + picker_items: Vec<(String, String)>, + picker_selected: usize, } impl App { @@ -41,6 +45,7 @@ impl App { app_config: AppConfig, skills: Vec, prompt_tx: mpsc::UnboundedSender, + switch_model_tx: mpsc::UnboundedSender, ) -> Self { Self { items: Vec::new(), @@ -52,6 +57,7 @@ impl App { status: Status::Idle, should_quit: false, screen: Screen::Welcome, + pre_picker_screen: Screen::Welcome, agent_config, app_config, skills, @@ -59,6 +65,9 @@ impl App { cached_lines: Vec::new(), cached_width: 0, prompt_tx, + switch_model_tx, + picker_items: Vec::new(), + picker_selected: 0, } } @@ -91,10 +100,46 @@ impl App { self.status = Status::Idle; self.push(ChatItem::Err(e)); } + AgentUpdate::ModelChanged { provider, model } => { + self.agent_config.provider_name = provider.clone(); + self.agent_config.model = model.clone(); + self.push(ChatItem::SystemInfo(format!("switched to {provider} / {model}"))); + } + } + } + + fn handle_picker_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up => { + self.picker_selected = self.picker_selected.saturating_sub(1); + } + KeyCode::Down => { + if !self.picker_items.is_empty() { + self.picker_selected = + (self.picker_selected + 1).min(self.picker_items.len() - 1); + } + } + KeyCode::Enter => { + if let Some((_, model)) = self.picker_items.get(self.picker_selected) { + let _ = self.switch_model_tx.send(model.clone()); + } + self.screen = Screen::Chat; + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} } } pub(super) fn handle_key(&mut self, key: KeyEvent) { + if self.screen == Screen::ModelPicker { + self.handle_picker_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -177,13 +222,15 @@ impl App { match name { "q" | "quit" => self.should_quit = true, "help" => self.push(ChatItem::SystemInfo( - ":help show this\n\ - :q / :quit exit\n\ - :sessions list saved sessions\n\ - :open view session n (run :sessions first)\n\ - :config current config\n\ - :mcp MCP servers\n\ - :skills available skills\n\n\ + ":help show this\n\ + :q / :quit exit\n\ + :sessions list saved sessions\n\ + :open view session n (run :sessions first)\n\ + :config current config\n\ + :models list available models\n\ + :models switch to model mid-session\n\ + :mcp MCP servers\n\ + :skills available skills\n\n\ ↑/↓ scroll · PgUp/PgDn page · Ctrl+C quit" .to_string(), )), @@ -276,6 +323,29 @@ impl App { self.push(ChatItem::SystemInfo(lines.join("\n"))); } } + "models" => { + if arg.is_empty() { + let info = self.app_config.models_info(); + let mut items: Vec<(String, String)> = info + .providers + .into_iter() + .flat_map(|(provider, p)| { + p.models.into_iter().map(move |m| (provider.clone(), m)) + }) + .collect(); + items.sort(); + let selected = items + .iter() + .position(|(_, m)| m == &self.agent_config.model) + .unwrap_or(0); + self.picker_items = items; + self.picker_selected = selected; + self.pre_picker_screen = self.screen; + self.screen = Screen::ModelPicker; + } else { + let _ = self.switch_model_tx.send(arg.to_string()); + } + } "skills" => match SkillsManager::new().and_then(|m| m.list_skills()) { Ok(names) if names.is_empty() => { self.push(ChatItem::SystemInfo( @@ -356,7 +426,13 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; - if self.screen == Screen::Welcome { + let bg_screen = if self.screen == Screen::ModelPicker { + self.pre_picker_screen + } else { + self.screen + }; + + if bg_screen == Screen::Welcome { let model = self.agent_config.model.clone(); let provider = self.agent_config.provider_name.clone(); let skills = self.skills.clone(); @@ -386,7 +462,12 @@ impl App { self.cursor, left_label.as_deref(), &right_label, + self.screen != Screen::ModelPicker, ); + + if self.screen == Screen::ModelPicker { + render::render_model_picker(f, area, &self.picker_items, self.picker_selected); + } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index eb65b49..b288d9f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -44,29 +44,50 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { let (update_tx, mut update_rx) = mpsc::unbounded_channel::(); let (prompt_tx, mut prompt_rx) = mpsc::unbounded_channel::(); + let (switch_model_tx, mut switch_model_rx) = mpsc::unbounded_channel::(); { let session = Arc::clone(&session); let update_tx = update_tx.clone(); tokio::spawn(async move { - while let Some(prompt) = prompt_rx.recv().await { - let tx_cb = update_tx.clone(); - let result = session - .prompt(&prompt, move |update| convert_update(&tx_cb, update)) - .await; - match result { - Ok(()) => { - let _ = update_tx.send(AgentUpdate::Done); + loop { + tokio::select! { + maybe_prompt = prompt_rx.recv() => { + match maybe_prompt { + Some(prompt) => { + let tx_cb = update_tx.clone(); + let result = session + .prompt(&prompt, move |update| convert_update(&tx_cb, update)) + .await; + match result { + Ok(()) => { let _ = update_tx.send(AgentUpdate::Done); } + Err(e) => { let _ = update_tx.send(AgentUpdate::Error(e.to_string())); } + } + } + None => break, + } } - Err(e) => { - let _ = update_tx.send(AgentUpdate::Error(e.to_string())); + maybe_model = switch_model_rx.recv() => { + match maybe_model { + Some(model) => { + match session.switch_model(&model).await { + Ok((provider, model)) => { + let _ = update_tx.send(AgentUpdate::ModelChanged { provider, model }); + } + Err(e) => { + let _ = update_tx.send(AgentUpdate::Error(e.to_string())); + } + } + } + None => break, + } } } } }); } - let mut app = App::new(agent_config, app_config, skills, prompt_tx); + let mut app = App::new(agent_config, app_config, skills, prompt_tx, switch_model_tx); enable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; let mut stdout = io::stdout(); diff --git a/src/tui/render.rs b/src/tui/render.rs index dba9958..391eb98 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph}, }; use super::types::ChatItem; @@ -213,6 +213,7 @@ pub(crate) fn render_input_bar( cursor: usize, left_label: Option<&str>, right_label: &str, + show_cursor: bool, ) { let dim = Style::default().fg(Color::DarkGray); let mut block = Block::default().borders(Borders::TOP).border_style(dim); @@ -231,13 +232,92 @@ pub(crate) fn render_input_bar( let prompt_prefix = " › "; f.render_widget(Paragraph::new(format!("{prompt_prefix}{input}")), inner); - let cursor_col = inner.x - + prompt_prefix.chars().count() as u16 - + input[..cursor].chars().count() as u16; - f.set_cursor_position(( - cursor_col.min(inner.x + inner.width.saturating_sub(1)), - inner.y, - )); + if show_cursor { + let cursor_col = inner.x + + prompt_prefix.chars().count() as u16 + + input[..cursor].chars().count() as u16; + f.set_cursor_position(( + cursor_col.min(inner.x + inner.width.saturating_sub(1)), + inner.y, + )); + } +} + +pub(crate) fn render_model_picker( + f: &mut Frame, + area: Rect, + items: &[(String, String)], + selected: usize, +) { + let max_label = items + .iter() + .map(|(p, m)| p.chars().count() + 2 + m.chars().count()) + .max() + .unwrap_or(20); + + let popup_w = ((max_label + 6) as u16).max(32).min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 2) as u16).max(5).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(Color::DarkGray); + let block = Block::default() + .title( + Line::from(Span::styled( + " models ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom( + Line::from(Span::styled(" ↑/↓ enter esc ", dim)).centered(), + ) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + let visible_h = inner.height as usize; + if visible_h == 0 { + return; + } + let start = selected.saturating_sub(visible_h.saturating_sub(1)); + let end = (start + visible_h).min(items.len()); + let start = start.min(end); + let inner_w = inner.width as usize; + + let lines: Vec> = items[start..end] + .iter() + .enumerate() + .map(|(i, (provider, model))| { + let idx = start + i; + if idx == selected { + let label = format!(" {provider} {model}"); + let truncated: String = label.chars().take(inner_w).collect(); + let padding = " ".repeat(inner_w.saturating_sub(truncated.chars().count())); + Line::styled( + format!("{truncated}{padding}"), + Style::default() + .fg(Color::Black) + .bg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled(provider.clone(), Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(model.clone(), Style::default().fg(Color::White)), + ]) + } + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); } // Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. diff --git a/src/tui/types.rs b/src/tui/types.rs index 6cb36b8..d6fc1d1 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -5,6 +5,7 @@ pub(crate) enum AgentUpdate { ToolResult { result: String, is_error: bool }, Done, Error(String), + ModelChanged { provider: String, model: String }, } #[derive(Debug, Clone)] @@ -28,4 +29,5 @@ pub(crate) enum Status { pub(crate) enum Screen { Welcome, Chat, + ModelPicker, } From b4800165fabd3d9541b65470480206bbcbb69e55 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 14:02:18 +0300 Subject: [PATCH 07/41] feat: update "openheim" text on welcome screen --- src/tui/render.rs | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index 391eb98..c13597c 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -126,14 +126,7 @@ pub(crate) fn render_welcome( provider: &str, skills: &[String], ) { - #[rustfmt::skip] - const LOGO: &[&str] = &[ - " ___ _ __ ___ _ __ | |__ ___(_)_ __ ___ ", - r" / _ \| '_ \ / _ \ '_ \ | '_ \ / _ \ | '_ ` _ \ ", - "| (_) | |_) | __/ | | || | | | __/ | | | | | |", - r" \___/| .__/ \___|_| |_||_| |_|\___|_|_| |_| |_|", - " |_| ", - ]; + const VERSION: &str = env!("CARGO_PKG_VERSION"); const COMMANDS: &[(&str, &str)] = &[ (":help", "show all commands"), @@ -151,23 +144,27 @@ pub(crate) fn render_welcome( }; let hint = "type a message to start"; - // logo + blank + subtitle + blank*2 + hint + blank + commands - let content_h = LOGO.len() + 1 + 1 + 2 + 1 + 1 + COMMANDS.len(); + // title + blank + subtitle + blank*2 + hint + blank + commands + let content_h = 1 + 1 + 1 + 2 + 1 + 1 + COMMANDS.len(); let top_pad = (area.height as usize).saturating_sub(content_h) / 2; let w = area.width as usize; let center = |text_w: usize| " ".repeat(w.saturating_sub(text_w) / 2); + let title = format!("openheim v{VERSION}"); + let title_pad = center(title.chars().count()); let mut lines: Vec> = (0..top_pad).map(|_| Line::default()).collect(); - - let logo_w = LOGO.iter().map(|l| l.chars().count()).max().unwrap_or(0); - let logo_pad = center(logo_w); - for &logo_line in LOGO { - lines.push(Line::styled( - format!("{logo_pad}{logo_line}"), + lines.push(Line::from(vec![ + Span::raw(title_pad), + Span::styled( + "openheim".to_string(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD), - )); - } + ), + Span::styled( + format!(" v{VERSION}"), + Style::default().fg(Color::DarkGray), + ), + ])); lines.push(Line::default()); From 58e82cd14c19e6b7e70fd53df7b8dca6c0a58f43 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 14:14:50 +0300 Subject: [PATCH 08/41] feat: refactor :config view --- src/tui/app.rs | 85 ++++++++++++++++++++++++++++++----------- src/tui/render.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++- src/tui/types.rs | 9 +++++ 3 files changed, 168 insertions(+), 22 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 75370ce..c97ece0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -14,7 +14,7 @@ use crate::{ }; use super::render; -use super::types::{AgentUpdate, ChatItem, Screen, Status}; +use super::types::{AgentUpdate, ChatItem, ConfigRow, Screen, Status}; pub(super) struct App { pub(super) items: Vec, @@ -37,6 +37,8 @@ pub(super) struct App { switch_model_tx: mpsc::UnboundedSender, picker_items: Vec<(String, String)>, picker_selected: usize, + config_rows: Vec, + config_scroll: usize, } impl App { @@ -68,6 +70,8 @@ impl App { switch_model_tx, picker_items: Vec::new(), picker_selected: 0, + config_rows: Vec::new(), + config_scroll: 0, } } @@ -108,6 +112,30 @@ impl App { } } + fn handle_config_viewer_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.config_scroll = self.config_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.config_scroll += 1; + } + KeyCode::PageUp => { + self.config_scroll = self.config_scroll.saturating_sub(5); + } + KeyCode::PageDown => { + self.config_scroll += 5; + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} + } + } + fn handle_picker_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -140,6 +168,10 @@ impl App { self.handle_picker_key(key); return; } + if self.screen == Screen::ConfigViewer { + self.handle_config_viewer_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -269,32 +301,41 @@ impl App { } "config" => { let ac = &self.agent_config; - let mut lines = vec![ - format!("Provider {}", ac.provider_name), - format!("Model {}", ac.model), - format!("Max iterations {}", ac.max_iterations), - format!("Timeout {}s", ac.timeout_secs), + let mut rows = vec![ + ConfigRow::Entry { key: "Provider".to_string(), val: ac.provider_name.clone() }, + ConfigRow::Entry { key: "Model".to_string(), val: ac.model.clone() }, + ConfigRow::Entry { + key: "Max iterations".to_string(), + val: ac.max_iterations.to_string(), + }, + ConfigRow::Entry { + key: "Timeout".to_string(), + val: format!("{}s", ac.timeout_secs), + }, ]; if !self.app_config.providers.is_empty() { - lines.push(String::new()); - lines.push("Providers".to_string()); + rows.push(ConfigRow::Blank); + rows.push(ConfigRow::Header("Providers".to_string())); for (pname, p) in &self.app_config.providers { - let suffix = if pname == &self.app_config.default_provider { - " (default)" + let label = if pname == &self.app_config.default_provider { + format!("{pname} (default)") } else { - "" + pname.clone() }; - lines.push(format!(" {pname}{suffix} {}", p.default_model)); + rows.push(ConfigRow::Entry { key: label, val: p.default_model.clone() }); } } if !self.app_config.mcp_servers.is_empty() { - lines.push(String::new()); - lines.push("MCP Servers".to_string()); + rows.push(ConfigRow::Blank); + rows.push(ConfigRow::Header("MCP Servers".to_string())); for sname in self.app_config.mcp_servers.keys() { - lines.push(format!(" {sname}")); + rows.push(ConfigRow::Item(sname.clone())); } } - self.push(ChatItem::SystemInfo(lines.join("\n"))); + self.config_rows = rows; + self.config_scroll = 0; + self.pre_picker_screen = self.screen; + self.screen = Screen::ConfigViewer; } "mcp" => { if self.app_config.mcp_servers.is_empty() { @@ -426,10 +467,9 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; - let bg_screen = if self.screen == Screen::ModelPicker { - self.pre_picker_screen - } else { - self.screen + let bg_screen = match self.screen { + Screen::ModelPicker | Screen::ConfigViewer => self.pre_picker_screen, + s => s, }; if bg_screen == Screen::Welcome { @@ -462,12 +502,15 @@ impl App { self.cursor, left_label.as_deref(), &right_label, - self.screen != Screen::ModelPicker, + self.screen != Screen::ModelPicker && self.screen != Screen::ConfigViewer, ); if self.screen == Screen::ModelPicker { render::render_model_picker(f, area, &self.picker_items, self.picker_selected); } + if self.screen == Screen::ConfigViewer { + render::render_config_viewer(f, area, &self.config_rows, self.config_scroll); + } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { diff --git a/src/tui/render.rs b/src/tui/render.rs index c13597c..be0596b 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph}, }; -use super::types::ChatItem; +use super::types::{ChatItem, ConfigRow}; pub(crate) fn build_lines(items: &[ChatItem], width: u16) -> Vec> { let inner_w = width.saturating_sub(2) as usize; @@ -317,6 +317,100 @@ pub(crate) fn render_model_picker( f.render_widget(Paragraph::new(lines), inner); } +pub(crate) fn render_config_viewer( + f: &mut Frame, + area: Rect, + rows: &[ConfigRow], + scroll: usize, +) { + let entry_key_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Entry { key, .. } = r { Some(key.chars().count()) } else { None } + }) + .max() + .unwrap_or(10); + let entry_val_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Entry { val, .. } = r { Some(val.chars().count()) } else { None } + }) + .max() + .unwrap_or(10); + let item_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Item(s) = r { Some(s.chars().count() + 4) } else { None } + }) + .max() + .unwrap_or(0); + let header_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Header(h) = r { Some(h.chars().count() + 2) } else { None } + }) + .max() + .unwrap_or(0); + let content_w = (entry_key_w + 4 + entry_val_w).max(item_w).max(header_w); + + let popup_w = ((content_w + 6) as u16).max(36).min(area.width.saturating_sub(4)); + let popup_h = ((rows.len() + 2) as u16).max(6).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(Color::DarkGray); + let block = Block::default() + .title( + Line::from(Span::styled( + " config ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom(Line::from(Span::styled(" ↑/↓ esc ", dim)).centered()) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + let visible_h = inner.height as usize; + if visible_h == 0 { + return; + } + let scroll = scroll.min(rows.len().saturating_sub(visible_h)); + let end = (scroll + visible_h).min(rows.len()); + + let lines: Vec> = rows[scroll..end] + .iter() + .map(|row| match row { + ConfigRow::Blank => Line::default(), + ConfigRow::Header(h) => Line::from(Span::styled( + format!(" {h}"), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + ConfigRow::Entry { key, val } => { + let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); + Line::from(vec![ + Span::raw(" "), + Span::styled(key.clone(), Style::default().fg(Color::DarkGray)), + Span::raw(gap), + Span::styled(val.clone(), Style::default().fg(Color::White)), + ]) + } + ConfigRow::Item(s) => Line::from(vec![ + Span::raw(" "), + Span::styled(s.clone(), Style::default().fg(Color::White)), + ]), + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + // Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. pub(crate) fn word_wrap(text: &str, width: usize) -> Vec { if width == 0 { diff --git a/src/tui/types.rs b/src/tui/types.rs index d6fc1d1..f0f5a78 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -30,4 +30,13 @@ pub(crate) enum Screen { Welcome, Chat, ModelPicker, + ConfigViewer, +} + +#[derive(Debug, Clone)] +pub(crate) enum ConfigRow { + Blank, + Header(String), + Entry { key: String, val: String }, + Item(String), } From f1bed8112cbaecbcbaf7e58be2c63ae18160218e Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 14:42:44 +0300 Subject: [PATCH 09/41] fix: restore session as active when opening from history --- src/client.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/client.rs b/src/client.rs index 7ad0c6a..9bbb5b3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -195,6 +195,23 @@ impl SessionHandle { pub async fn switch_model(&self, model: &str) -> Result<(String, String)> { self.state.acp_update_session_model(&self.id, model).await } + + /// Restore a persisted session as the active session for this handle. + /// + /// Registers the conversation in the agent state so subsequent `prompt` + /// calls continue from its history. Pass a no-op callback — the TUI + /// already replays history visually before calling this. + pub async fn restore( + &self, + session_id: &str, + cwd: std::path::PathBuf, + ) -> Result { + self.state.acp_load_session(session_id, cwd, |_| {}).await?; + Ok(SessionHandle { + id: session_id.to_string(), + state: Arc::clone(&self.state), + }) + } } // ── OpenheimBuilder ─────────────────────────────────────────────────────────── From 9058460dc9345baa4d050087732addc06d7bd613 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 14:42:53 +0300 Subject: [PATCH 10/41] refactor: replace :sessions text list with interactive picker --- src/tui/app.rs | 83 ++++++++++++++++++++++++++-------------- src/tui/mod.rs | 70 ++++++++++++++++++++++++++-------- src/tui/render.rs | 97 +++++++++++++++++++++++++++++++++++++++++++++++ src/tui/types.rs | 1 + 4 files changed, 207 insertions(+), 44 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index c97ece0..9e9abb6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -35,6 +35,7 @@ pub(super) struct App { pub(super) cached_width: u16, prompt_tx: mpsc::UnboundedSender, switch_model_tx: mpsc::UnboundedSender, + switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, picker_items: Vec<(String, String)>, picker_selected: usize, config_rows: Vec, @@ -48,6 +49,7 @@ impl App { skills: Vec, prompt_tx: mpsc::UnboundedSender, switch_model_tx: mpsc::UnboundedSender, + switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, ) -> Self { Self { items: Vec::new(), @@ -68,6 +70,7 @@ impl App { cached_width: 0, prompt_tx, switch_model_tx, + switch_session_tx, picker_items: Vec::new(), picker_selected: 0, config_rows: Vec::new(), @@ -112,6 +115,33 @@ impl App { } } + fn handle_session_picker_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up => { + self.picker_selected = self.picker_selected.saturating_sub(1); + } + KeyCode::Down => { + if !self.sessions.is_empty() { + self.picker_selected = + (self.picker_selected + 1).min(self.sessions.len() - 1); + } + } + KeyCode::Enter => { + if let Some(meta) = self.sessions.get(self.picker_selected).cloned() { + self.screen = Screen::Chat; + self.open_session(&meta); + } + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} + } + } + fn handle_config_viewer_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -172,6 +202,10 @@ impl App { self.handle_config_viewer_key(key); return; } + if self.screen == Screen::SessionPicker { + self.handle_session_picker_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -256,8 +290,7 @@ impl App { "help" => self.push(ChatItem::SystemInfo( ":help show this\n\ :q / :quit exit\n\ - :sessions list saved sessions\n\ - :open view session n (run :sessions first)\n\ + :sessions browse and restore saved sessions\n\ :config current config\n\ :models list available models\n\ :models switch to model mid-session\n\ @@ -271,34 +304,13 @@ impl App { self.push(ChatItem::SystemInfo("no sessions yet".to_string())); } Ok(metas) => { - let mut lines = Vec::new(); - for (i, meta) in metas.iter().enumerate() { - let title = meta.title.as_deref().unwrap_or("(untitled)"); - let date = meta.updated_at.format("%Y-%m-%d %H:%M").to_string(); - let model = meta.model.as_deref().unwrap_or("?"); - lines.push(format!(" {} {} · {} · {}", i + 1, title, date, model)); - } - lines.push(String::new()); - lines.push(":open to view".to_string()); self.sessions = metas; - self.push(ChatItem::SystemInfo(lines.join("\n"))); + self.picker_selected = 0; + self.pre_picker_screen = self.screen; + self.screen = Screen::SessionPicker; } Err(e) => self.push(ChatItem::Err(e.to_string())), }, - "open" => { - if let Ok(n) = arg.parse::() { - if n == 0 || n > self.sessions.len() { - self.push(ChatItem::SystemInfo(format!( - "no session {n} (run :sessions first)" - ))); - } else { - let meta = self.sessions[n - 1].clone(); - self.open_session(&meta); - } - } else { - self.push(ChatItem::SystemInfo("usage: :open ".to_string())); - } - } "config" => { let ac = &self.agent_config; let mut rows = vec![ @@ -455,7 +467,13 @@ impl App { Err(e) => self.push(ChatItem::Err(e.to_string())), } - self.push(ChatItem::SystemInfo("───".to_string())); + self.push(ChatItem::SystemInfo("─── session restored".to_string())); + + let cwd = meta + .cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let _ = self.switch_session_tx.send((meta.id.to_string(), cwd)); } pub(super) fn draw(&mut self, f: &mut Frame) { @@ -468,7 +486,9 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; let bg_screen = match self.screen { - Screen::ModelPicker | Screen::ConfigViewer => self.pre_picker_screen, + Screen::ModelPicker | Screen::ConfigViewer | Screen::SessionPicker => { + self.pre_picker_screen + } s => s, }; @@ -502,7 +522,9 @@ impl App { self.cursor, left_label.as_deref(), &right_label, - self.screen != Screen::ModelPicker && self.screen != Screen::ConfigViewer, + self.screen != Screen::ModelPicker + && self.screen != Screen::ConfigViewer + && self.screen != Screen::SessionPicker, ); if self.screen == Screen::ModelPicker { @@ -511,6 +533,9 @@ impl App { if self.screen == Screen::ConfigViewer { render::render_config_viewer(f, area, &self.config_rows, self.config_scroll); } + if self.screen == Screen::SessionPicker { + render::render_session_picker(f, area, &self.sessions, self.picker_selected); + } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b288d9f..c977821 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3,12 +3,14 @@ mod render; mod types; use std::io; -use std::sync::Arc; use std::time::Duration; use agent_client_protocol::schema::{ContentBlock, SessionUpdate, ToolCallStatus}; use crossterm::{ - event::{Event, EventStream}, + event::{ + Event, EventStream, KeyEventKind, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -33,23 +35,23 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { .await .map_err(|e| crate::error::Error::Other(e.to_string()))?; - let session = Arc::new( - client - .new_session() - .skills(skills.clone()) - .start() - .await - .map_err(|e| crate::error::Error::Other(e.to_string()))?, - ); + let session = client + .new_session() + .skills(skills.clone()) + .start() + .await + .map_err(|e| crate::error::Error::Other(e.to_string()))?; let (update_tx, mut update_rx) = mpsc::unbounded_channel::(); let (prompt_tx, mut prompt_rx) = mpsc::unbounded_channel::(); let (switch_model_tx, mut switch_model_rx) = mpsc::unbounded_channel::(); + let (switch_session_tx, mut switch_session_rx) = + mpsc::unbounded_channel::<(String, std::path::PathBuf)>(); { - let session = Arc::clone(&session); let update_tx = update_tx.clone(); tokio::spawn(async move { + let mut session = session; loop { tokio::select! { maybe_prompt = prompt_rx.recv() => { @@ -82,25 +84,58 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { None => break, } } + maybe_switch = switch_session_rx.recv() => { + match maybe_switch { + Some((session_id, cwd)) => { + match session.restore(&session_id, cwd).await { + Ok(restored) => { session = restored; } + Err(e) => { + let _ = update_tx.send(AgentUpdate::Error(e.to_string())); + } + } + } + None => break, + } + } } } }); } - let mut app = App::new(agent_config, app_config, skills, prompt_tx, switch_model_tx); + let mut app = App::new(agent_config, app_config, skills, prompt_tx, switch_model_tx, switch_session_tx); enable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen) .map_err(|e| crate::error::Error::Other(e.to_string()))?; + + // Enable keyboard enhancement on supporting terminals so that arrow-key + // escape sequences (\x1b[B etc.) are never ambiguously split into a + // spurious Esc + characters, which caused `[B` to appear in the input. + let kbd_enhanced = + crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false); + if kbd_enhanced { + execute!( + stdout, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES, + ) + ) + .ok(); + } + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).map_err(|e| crate::error::Error::Other(e.to_string()))?; let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - let _ = disable_raw_mode(); + if kbd_enhanced { + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); + } let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = disable_raw_mode(); original_hook(info); })); @@ -125,7 +160,9 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } maybe = events.next() => { match maybe { - Some(Ok(Event::Key(key))) => app.handle_key(key), + Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => { + app.handle_key(key); + } Some(Ok(Event::Resize(_, _))) => app.cached_width = 0, Some(Err(_)) | None => break, _ => {} @@ -137,9 +174,12 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } } - disable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; + if kbd_enhanced { + execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags).ok(); + } execute!(terminal.backend_mut(), LeaveAlternateScreen) .map_err(|e| crate::error::Error::Other(e.to_string()))?; + disable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; terminal .show_cursor() .map_err(|e| crate::error::Error::Other(e.to_string()))?; diff --git a/src/tui/render.rs b/src/tui/render.rs index be0596b..5af9c51 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -317,6 +317,103 @@ pub(crate) fn render_model_picker( f.render_widget(Paragraph::new(lines), inner); } +pub(crate) fn render_session_picker( + f: &mut Frame, + area: Rect, + items: &[crate::rag::ConversationMeta], + selected: usize, +) { + if items.is_empty() { + return; + } + + let max_title = items + .iter() + .map(|m| m.title.as_deref().unwrap_or("(untitled)").chars().count()) + .max() + .unwrap_or(10); + + let max_meta = items + .iter() + .map(|m| { + let date = m.updated_at.format("%Y-%m-%d %H:%M").to_string(); + let model = m.model.as_deref().unwrap_or("?"); + date.len() + 5 + model.len() + }) + .max() + .unwrap_or(20); + + let content_w = max_title + 4 + max_meta; + let popup_w = ((content_w + 6) as u16).max(40).min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 2) as u16).max(5).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(Color::DarkGray); + let block = Block::default() + .title( + Line::from(Span::styled( + " sessions ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom(Line::from(Span::styled(" ↑/↓ enter esc ", dim)).centered()) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + let visible_h = inner.height as usize; + if visible_h == 0 { + return; + } + + let start = selected.saturating_sub(visible_h.saturating_sub(1)); + let end = (start + visible_h).min(items.len()); + let start = start.min(end); + let inner_w = inner.width as usize; + + let lines: Vec> = items[start..end] + .iter() + .enumerate() + .map(|(i, meta)| { + let idx = start + i; + let title = meta.title.as_deref().unwrap_or("(untitled)").to_string(); + let date = meta.updated_at.format("%Y-%m-%d %H:%M").to_string(); + let model = meta.model.as_deref().unwrap_or("?").to_string(); + let meta_str = format!("{date} · {model}"); + + if idx == selected { + let label = format!(" {title} {meta_str}"); + let truncated: String = label.chars().take(inner_w).collect(); + let padding = " ".repeat(inner_w.saturating_sub(truncated.chars().count())); + Line::styled( + format!("{truncated}{padding}"), + Style::default() + .fg(Color::Black) + .bg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + let gap = " ".repeat(max_title.saturating_sub(title.chars().count()) + 4); + Line::from(vec![ + Span::raw(" "), + Span::styled(title, Style::default().fg(Color::White)), + Span::raw(gap), + Span::styled(meta_str, Style::default().fg(Color::DarkGray)), + ]) + } + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + pub(crate) fn render_config_viewer( f: &mut Frame, area: Rect, diff --git a/src/tui/types.rs b/src/tui/types.rs index f0f5a78..825af64 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -31,6 +31,7 @@ pub(crate) enum Screen { Chat, ModelPicker, ConfigViewer, + SessionPicker, } #[derive(Debug, Clone)] From d4a71de24d6423b3b616c9c9cdb20e76d62cf6da Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 21:58:13 +0300 Subject: [PATCH 11/41] feat: refactor skills view --- src/tui/app.rs | 54 ++++++++++++++++++++++++++++++++++++------- src/tui/render.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++ src/tui/types.rs | 1 + 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 9e9abb6..f4856b6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -40,6 +40,8 @@ pub(super) struct App { picker_selected: usize, config_rows: Vec, config_scroll: usize, + skills_items: Vec, + skills_scroll: usize, } impl App { @@ -75,6 +77,8 @@ impl App { picker_selected: 0, config_rows: Vec::new(), config_scroll: 0, + skills_items: Vec::new(), + skills_scroll: 0, } } @@ -166,6 +170,30 @@ impl App { } } + fn handle_skills_viewer_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.skills_scroll = self.skills_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.skills_scroll += 1; + } + KeyCode::PageUp => { + self.skills_scroll = self.skills_scroll.saturating_sub(5); + } + KeyCode::PageDown => { + self.skills_scroll += 5; + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} + } + } + fn handle_picker_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -206,6 +234,10 @@ impl App { self.handle_session_picker_key(key); return; } + if self.screen == Screen::SkillsViewer { + self.handle_skills_viewer_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -407,10 +439,11 @@ impl App { .to_string(), )); } - Ok(mut names) => { - names.push(String::new()); - names.push("activate with: openheim --skills ,...".to_string()); - self.push(ChatItem::SystemInfo(names.join("\n"))); + Ok(names) => { + self.skills_items = names; + self.skills_scroll = 0; + self.pre_picker_screen = self.screen; + self.screen = Screen::SkillsViewer; } Err(e) => self.push(ChatItem::Err(e.to_string())), }, @@ -486,9 +519,10 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; let bg_screen = match self.screen { - Screen::ModelPicker | Screen::ConfigViewer | Screen::SessionPicker => { - self.pre_picker_screen - } + Screen::ModelPicker + | Screen::ConfigViewer + | Screen::SessionPicker + | Screen::SkillsViewer => self.pre_picker_screen, s => s, }; @@ -524,7 +558,8 @@ impl App { &right_label, self.screen != Screen::ModelPicker && self.screen != Screen::ConfigViewer - && self.screen != Screen::SessionPicker, + && self.screen != Screen::SessionPicker + && self.screen != Screen::SkillsViewer, ); if self.screen == Screen::ModelPicker { @@ -536,6 +571,9 @@ impl App { if self.screen == Screen::SessionPicker { render::render_session_picker(f, area, &self.sessions, self.picker_selected); } + if self.screen == Screen::SkillsViewer { + render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll); + } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { diff --git a/src/tui/render.rs b/src/tui/render.rs index 5af9c51..8c88ac9 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -508,6 +508,64 @@ pub(crate) fn render_config_viewer( f.render_widget(Paragraph::new(lines), inner); } +pub(crate) fn render_skills_viewer( + f: &mut Frame, + area: Rect, + items: &[String], + scroll: usize, +) { + let max_w = items.iter().map(|s| s.chars().count()).max().unwrap_or(10); + let content_w = max_w + 4; + let popup_w = ((content_w + 6) as u16).max(36).min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 4) as u16).max(6).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(Color::DarkGray); + let block = Block::default() + .title( + Line::from(Span::styled( + " skills ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom( + Line::from(Span::styled( + " activate: openheim --skills ,... · ↑/↓ esc ", + dim, + )) + .centered(), + ) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + let visible_h = inner.height as usize; + if visible_h == 0 || items.is_empty() { + return; + } + let scroll = scroll.min(items.len().saturating_sub(visible_h)); + let end = (scroll + visible_h).min(items.len()); + + let lines: Vec> = items[scroll..end] + .iter() + .map(|name| { + Line::from(vec![ + Span::raw(" "), + Span::styled(name.clone(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + // Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. pub(crate) fn word_wrap(text: &str, width: usize) -> Vec { if width == 0 { diff --git a/src/tui/types.rs b/src/tui/types.rs index 825af64..b371bd4 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -32,6 +32,7 @@ pub(crate) enum Screen { ModelPicker, ConfigViewer, SessionPicker, + SkillsViewer, } #[derive(Debug, Clone)] From c4f29c1fd996866350924ba7531ccea3b6ebf553 Mon Sep 17 00:00:00 2001 From: themartto Date: Fri, 22 May 2026 23:36:19 +0300 Subject: [PATCH 12/41] feat: refactor mcp view --- src/tui/app.rs | 68 +++++++++++++++++++++++++++++++----- src/tui/render.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++ src/tui/types.rs | 1 + 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index f4856b6..9f97b74 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -42,6 +42,8 @@ pub(super) struct App { config_scroll: usize, skills_items: Vec, skills_scroll: usize, + mcp_rows: Vec, + mcp_scroll: usize, } impl App { @@ -79,6 +81,8 @@ impl App { config_scroll: 0, skills_items: Vec::new(), skills_scroll: 0, + mcp_rows: Vec::new(), + mcp_scroll: 0, } } @@ -194,6 +198,30 @@ impl App { } } + fn handle_mcp_viewer_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up | KeyCode::Char('k') => { + self.mcp_scroll = self.mcp_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.mcp_scroll += 1; + } + KeyCode::PageUp => { + self.mcp_scroll = self.mcp_scroll.saturating_sub(5); + } + KeyCode::PageDown => { + self.mcp_scroll += 5; + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} + } + } + fn handle_picker_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -238,6 +266,10 @@ impl App { self.handle_skills_viewer_key(key); return; } + if self.screen == Screen::McpViewer { + self.handle_mcp_viewer_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -389,23 +421,36 @@ impl App { .to_string(), )); } else { - let mut lines = Vec::new(); - for (sname, server) in &self.app_config.mcp_servers { - lines.push(format!("● {sname}")); + let mut rows = Vec::new(); + let mut iter = self.app_config.mcp_servers.iter().peekable(); + while let Some((sname, server)) = iter.next() { + rows.push(ConfigRow::Header(sname.clone())); if let Some(cmd) = &server.command { let args_str = server.args.join(" "); - let cmd_line = if args_str.is_empty() { + let val = if args_str.is_empty() { cmd.clone() } else { format!("{cmd} {args_str}") }; - lines.push(format!(" stdio {cmd_line}")); + rows.push(ConfigRow::Entry { + key: "stdio".to_string(), + val, + }); } if let Some(url) = &server.url { - lines.push(format!(" http {url}")); + rows.push(ConfigRow::Entry { + key: "http".to_string(), + val: url.clone(), + }); + } + if iter.peek().is_some() { + rows.push(ConfigRow::Blank); } } - self.push(ChatItem::SystemInfo(lines.join("\n"))); + self.mcp_rows = rows; + self.mcp_scroll = 0; + self.pre_picker_screen = self.screen; + self.screen = Screen::McpViewer; } } "models" => { @@ -522,7 +567,8 @@ impl App { Screen::ModelPicker | Screen::ConfigViewer | Screen::SessionPicker - | Screen::SkillsViewer => self.pre_picker_screen, + | Screen::SkillsViewer + | Screen::McpViewer => self.pre_picker_screen, s => s, }; @@ -559,7 +605,8 @@ impl App { self.screen != Screen::ModelPicker && self.screen != Screen::ConfigViewer && self.screen != Screen::SessionPicker - && self.screen != Screen::SkillsViewer, + && self.screen != Screen::SkillsViewer + && self.screen != Screen::McpViewer, ); if self.screen == Screen::ModelPicker { @@ -574,6 +621,9 @@ impl App { if self.screen == Screen::SkillsViewer { render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll); } + if self.screen == Screen::McpViewer { + render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll); + } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { diff --git a/src/tui/render.rs b/src/tui/render.rs index 8c88ac9..97696d6 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -508,6 +508,93 @@ pub(crate) fn render_config_viewer( f.render_widget(Paragraph::new(lines), inner); } +pub(crate) fn render_mcp_viewer( + f: &mut Frame, + area: Rect, + rows: &[ConfigRow], + scroll: usize, +) { + let entry_key_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Entry { key, .. } = r { Some(key.chars().count()) } else { None } + }) + .max() + .unwrap_or(5); + let entry_val_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Entry { val, .. } = r { Some(val.chars().count()) } else { None } + }) + .max() + .unwrap_or(20); + let header_w = rows + .iter() + .filter_map(|r| { + if let ConfigRow::Header(h) = r { Some(h.chars().count() + 2) } else { None } + }) + .max() + .unwrap_or(0); + let content_w = (entry_key_w + 4 + entry_val_w).max(header_w); + + let popup_w = ((content_w + 6) as u16).max(40).min(area.width.saturating_sub(4)); + let popup_h = ((rows.len() + 2) as u16).max(6).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(Color::DarkGray); + let block = Block::default() + .title( + Line::from(Span::styled( + " mcp servers ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom(Line::from(Span::styled(" ↑/↓ esc ", dim)).centered()) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + let visible_h = inner.height as usize; + if visible_h == 0 { + return; + } + let scroll = scroll.min(rows.len().saturating_sub(visible_h)); + let end = (scroll + visible_h).min(rows.len()); + + let lines: Vec> = rows[scroll..end] + .iter() + .map(|row| match row { + ConfigRow::Blank => Line::default(), + ConfigRow::Header(h) => Line::from(Span::styled( + format!(" {h}"), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + ConfigRow::Entry { key, val } => { + let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); + Line::from(vec![ + Span::raw(" "), + Span::styled(key.clone(), Style::default().fg(Color::DarkGray)), + Span::raw(gap), + Span::styled(val.clone(), Style::default().fg(Color::White)), + ]) + } + ConfigRow::Item(s) => Line::from(vec![ + Span::raw(" "), + Span::styled(s.clone(), Style::default().fg(Color::White)), + ]), + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + pub(crate) fn render_skills_viewer( f: &mut Frame, area: Rect, diff --git a/src/tui/types.rs b/src/tui/types.rs index b371bd4..fc21385 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -33,6 +33,7 @@ pub(crate) enum Screen { ConfigViewer, SessionPicker, SkillsViewer, + McpViewer, } #[derive(Debug, Clone)] From 9fac876fbccd79e91abf4da6161c9c593df91a66 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:14:54 +0300 Subject: [PATCH 13/41] fix(tools): execute command error handling --- src/tools/execute_command.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tools/execute_command.rs b/src/tools/execute_command.rs index 0dcc483..a4a89f7 100644 --- a/src/tools/execute_command.rs +++ b/src/tools/execute_command.rs @@ -72,10 +72,10 @@ impl ToolHandler for ExecuteCommandTool { if output.status.success() { Ok(stdout) } else { - Ok(format!( + Err(Error::ToolExecutionError(format!( "Command failed:\nStdout: {}\nStderr: {}", stdout, stderr - )) + ))) } } } @@ -101,11 +101,12 @@ mod tests { } #[tokio::test] - async fn execute_returns_output_for_failing_command() { + async fn execute_errors_for_failing_command() { let tool = ExecuteCommandTool; let args = r#"{"command": "ls /nonexistent_dir_12345"}"#; - let result = tool.execute(args).await.unwrap(); - assert!(result.contains("Command failed:")); + let result = tool.execute(args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Command failed:")); } #[tokio::test] From 3de96123014c374665c83dfa169629f99752b16d Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:15:18 +0300 Subject: [PATCH 14/41] feat: implement themes --- src/client.rs | 1 + src/config/client.rs | 1 + src/config/resolve.rs | 2 + src/config/types.rs | 2 + src/tui/app.rs | 133 +++++++++++++++++++++++++++++--- src/tui/render.rs | 175 ++++++++++++++++++++++++++++++++++-------- src/tui/types.rs | 1 + 7 files changed, 270 insertions(+), 45 deletions(-) diff --git a/src/client.rs b/src/client.rs index 9bbb5b3..9548dac 100644 --- a/src/client.rs +++ b/src/client.rs @@ -373,6 +373,7 @@ fn build_programmatic( let app_config = AppConfig { default_provider: provider.clone(), max_iterations: max_iter, + theme_color: None, providers, mcp_servers: BTreeMap::new(), }; diff --git a/src/config/client.rs b/src/config/client.rs index ad553fb..dab187a 100644 --- a/src/config/client.rs +++ b/src/config/client.rs @@ -118,6 +118,7 @@ mod tests { AppConfig { default_provider: "openai".into(), max_iterations: 10, + theme_color: None, providers, mcp_servers: BTreeMap::new(), } diff --git a/src/config/resolve.rs b/src/config/resolve.rs index 916db3d..7913fd2 100644 --- a/src/config/resolve.rs +++ b/src/config/resolve.rs @@ -118,6 +118,7 @@ mod tests { AppConfig { default_provider: "openai".into(), max_iterations: 5, + theme_color: None, providers, mcp_servers: BTreeMap::new(), } @@ -157,6 +158,7 @@ mod tests { let config = AppConfig { default_provider: "nonexistent".into(), max_iterations: 10, + theme_color: None, providers: BTreeMap::new(), mcp_servers: BTreeMap::new(), }; diff --git a/src/config/types.rs b/src/config/types.rs index ba75f00..e3cfaf3 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -33,6 +33,8 @@ pub struct AppConfig { #[serde(default = "default_max_iterations")] pub max_iterations: usize, #[serde(default)] + pub theme_color: Option, + #[serde(default)] pub providers: BTreeMap, #[serde(default)] pub mcp_servers: BTreeMap, diff --git a/src/tui/app.rs b/src/tui/app.rs index 9f97b74..3e0908b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -8,6 +8,41 @@ use ratatui::{ }; use tokio::sync::mpsc; +fn save_theme_to_config(name: &str) -> crate::error::Result<()> { + let path = crate::config::config_path()?; + let contents = std::fs::read_to_string(&path)?; + let new_line = format!("theme_color = \"{name}\""); + let has_theme = contents.lines().any(|l| l.trim_start().starts_with("theme_color")); + let updated: String = if has_theme { + contents + .lines() + .map(|l| { + if l.trim_start().starts_with("theme_color") { + new_line.clone() + } else { + l.to_string() + } + }) + .collect::>() + .join("\n") + } else { + let mut lines: Vec = contents.lines().map(String::from).collect(); + let insert_pos = lines + .iter() + .rposition(|l| { + l.trim_start().starts_with("max_iterations") + || l.trim_start().starts_with("default_provider") + }) + .map(|i| i + 1) + .unwrap_or(0); + lines.insert(insert_pos, new_line); + lines.join("\n") + }; + let trailing = if contents.ends_with('\n') { "\n" } else { "" }; + std::fs::write(&path, format!("{updated}{trailing}"))?; + Ok(()) +} + use crate::{ config::{AgentConfig, AppConfig}, rag::{ConversationMeta, RagContext, SkillsManager}, @@ -44,6 +79,9 @@ pub(super) struct App { skills_scroll: usize, mcp_rows: Vec, mcp_scroll: usize, + theme_color: Color, + theme_color_name: String, + theme_selected: usize, } impl App { @@ -55,6 +93,8 @@ impl App { switch_model_tx: mpsc::UnboundedSender, switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, ) -> Self { + let theme_name = app_config.theme_color.as_deref().unwrap_or("dark_gray").to_string(); + let theme_color = render::theme_color(&theme_name); Self { items: Vec::new(), input: String::new(), @@ -83,6 +123,9 @@ impl App { skills_scroll: 0, mcp_rows: Vec::new(), mcp_scroll: 0, + theme_color, + theme_color_name: theme_name, + theme_selected: 0, } } @@ -222,6 +265,43 @@ impl App { } } + fn handle_theme_picker_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Up => { + self.theme_selected = self.theme_selected.saturating_sub(1); + } + KeyCode::Down => { + self.theme_selected = + (self.theme_selected + 1).min(render::THEME_COLORS.len() - 1); + } + KeyCode::Enter => { + let name = render::THEME_COLORS[self.theme_selected].to_string(); + self.screen = Screen::Chat; + self.apply_theme(&name); + } + KeyCode::Esc => { + self.screen = self.pre_picker_screen; + } + _ => {} + } + } + + fn apply_theme(&mut self, name: &str) { + self.theme_color = render::theme_color(name); + self.theme_color_name = name.to_string(); + self.cached_width = 0; + self.app_config.theme_color = Some(name.to_string()); + match save_theme_to_config(name) { + Ok(()) => self.push(ChatItem::SystemInfo(format!("theme set to {name}"))), + Err(e) => self.push(ChatItem::SystemInfo(format!( + "theme set to {name} (could not save: {e})" + ))), + } + } + fn handle_picker_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -270,6 +350,10 @@ impl App { self.handle_mcp_viewer_key(key); return; } + if self.screen == Screen::ThemePicker { + self.handle_theme_picker_key(key); + return; + } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -359,7 +443,9 @@ impl App { :models list available models\n\ :models switch to model mid-session\n\ :mcp MCP servers\n\ - :skills available skills\n\n\ + :skills available skills\n\ + :theme change accent color\n\ + :theme apply color directly\n\n\ ↑/↓ scroll · PgUp/PgDn page · Ctrl+C quit" .to_string(), )), @@ -492,6 +578,23 @@ impl App { } Err(e) => self.push(ChatItem::Err(e.to_string())), }, + "theme" => { + if arg.is_empty() { + self.theme_selected = render::THEME_COLORS + .iter() + .position(|&n| n == self.theme_color_name) + .unwrap_or(0); + self.pre_picker_screen = self.screen; + self.screen = Screen::ThemePicker; + } else if render::THEME_COLORS.contains(&arg) { + self.apply_theme(arg); + } else { + self.push(ChatItem::SystemInfo(format!( + ":{arg}: unknown theme (available: {})", + render::THEME_COLORS.join(", ") + ))); + } + } unknown => self.push(ChatItem::SystemInfo(format!( ":{unknown}: unknown command (try :help)" ))), @@ -568,7 +671,8 @@ impl App { | Screen::ConfigViewer | Screen::SessionPicker | Screen::SkillsViewer - | Screen::McpViewer => self.pre_picker_screen, + | Screen::McpViewer + | Screen::ThemePicker => self.pre_picker_screen, s => s, }; @@ -576,7 +680,7 @@ impl App { let model = self.agent_config.model.clone(); let provider = self.agent_config.provider_name.clone(); let skills = self.skills.clone(); - render::render_welcome(f, content_area, &model, &provider, &skills); + render::render_welcome(f, content_area, &model, &provider, &skills, self.theme_color); } else { self.draw_chat(f, content_area); } @@ -595,6 +699,7 @@ impl App { format!("{} · {}", self.agent_config.provider_name, self.agent_config.model); let input = self.input.clone(); + let theme = self.theme_color; render::render_input_bar( f, input_area, @@ -606,30 +711,36 @@ impl App { && self.screen != Screen::ConfigViewer && self.screen != Screen::SessionPicker && self.screen != Screen::SkillsViewer - && self.screen != Screen::McpViewer, + && self.screen != Screen::McpViewer + && self.screen != Screen::ThemePicker, + theme, ); if self.screen == Screen::ModelPicker { - render::render_model_picker(f, area, &self.picker_items, self.picker_selected); + render::render_model_picker(f, area, &self.picker_items, self.picker_selected, theme); } if self.screen == Screen::ConfigViewer { - render::render_config_viewer(f, area, &self.config_rows, self.config_scroll); + render::render_config_viewer(f, area, &self.config_rows, self.config_scroll, theme); } if self.screen == Screen::SessionPicker { - render::render_session_picker(f, area, &self.sessions, self.picker_selected); + render::render_session_picker(f, area, &self.sessions, self.picker_selected, theme); } if self.screen == Screen::SkillsViewer { - render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll); + render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll, theme); } if self.screen == Screen::McpViewer { - render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll); + render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll, theme); + } + if self.screen == Screen::ThemePicker { + let current = self.theme_color_name.clone(); + render::render_theme_picker(f, area, self.theme_selected, ¤t, theme); } } fn draw_chat(&mut self, f: &mut Frame, area: ratatui::layout::Rect) { let chat_w = area.width; if self.cached_width != chat_w { - self.cached_lines = render::build_lines(&self.items, chat_w); + self.cached_lines = render::build_lines(&self.items, chat_w, self.theme_color); self.cached_width = chat_w; } @@ -661,7 +772,7 @@ impl App { let chat_block = Block::default() .borders(Borders::NONE) .title_bottom(Line::from( - Span::styled(scroll_hint, Style::default().fg(Color::DarkGray)), + Span::styled(scroll_hint, Style::default().fg(self.theme_color)), )); let chat_inner = chat_block.inner(area); f.render_widget(chat_block, area); diff --git a/src/tui/render.rs b/src/tui/render.rs index 97696d6..14c1bfd 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -8,18 +8,48 @@ use ratatui::{ use super::types::{ChatItem, ConfigRow}; -pub(crate) fn build_lines(items: &[ChatItem], width: u16) -> Vec> { +pub(crate) const THEME_COLORS: &[&str] = &[ + "white", + "gray", + "blue", + "cyan", + "magenta", + "green", + "yellow", + "red", + "pink", +]; + +pub(crate) fn theme_color(name: &str) -> Color { + match name { + "white" => Color::White, + "gray" => Color::DarkGray, + "blue" => Color::Blue, + "cyan" => Color::Cyan, + "magenta" => Color::LightMagenta, + "green" => Color::Green, + "yellow" => Color::Yellow, + "red" => Color::Red, + "pink" => Color::LightRed, + _ => Color::White, + } +} + +pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec> { let inner_w = width.saturating_sub(2) as usize; let mut lines: Vec> = Vec::new(); - for item in items { + for (idx, item) in items.iter().enumerate() { match item { ChatItem::UserMessage(text) => { - lines.extend(user_bubble(text, width)); + lines.extend(user_bubble(text, width, theme)); } ChatItem::AssistantMessage(text) => { for wl in word_wrap(text, inner_w) { - lines.push(Line::raw(format!(" {wl}"))); + lines.push(Line::from(Span::styled( + format!(" {wl}"), + Style::default().fg(Color::White), + ))); } lines.push(Line::default()); } @@ -32,36 +62,36 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16) -> Vec> } else { preview }; + let call_color = match items.get(idx + 1) { + Some(ChatItem::ToolResult { is_error: false, .. }) => Color::Green, + Some(ChatItem::ToolResult { is_error: true, .. }) => Color::Red, + _ => theme, + }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("⚙ ", Style::default().fg(Color::Cyan)), + Span::styled("⚙ ", Style::default().fg(call_color)), Span::styled( name.clone(), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default().fg(call_color).add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled(preview, Style::default().fg(Color::DarkGray)), ])); } - ChatItem::ToolResult { result, is_error } => { + ChatItem::ToolResult { result, .. } => { let flat: String = result.chars().take(200).collect::().replace('\n', " "); - let style = if *is_error { - Style::default().fg(Color::Red) - } else { - Style::default().fg(Color::DarkGray) - }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("→ ", style), - Span::styled(flat.trim().to_string(), style), + Span::styled("→ ", Style::default().fg(Color::DarkGray)), + Span::styled(flat.trim().to_string(), Style::default().fg(Color::DarkGray)), ])); } ChatItem::SystemInfo(text) => { for line in text.lines() { lines.push(Line::from(Span::styled( format!(" {line}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(theme), ))); } lines.push(Line::default()); @@ -85,13 +115,14 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16) -> Vec> // │ list files in │ // │ src/ │ // ╰──────────────────╯ -fn user_bubble(text: &str, width: u16) -> Vec> { +fn user_bubble(text: &str, width: u16, theme: Color) -> Vec> { let content_max = 50usize.min(width.saturating_sub(8) as usize).max(1); let wrapped = word_wrap(text, content_max); let content_w = wrapped.iter().map(|l| l.chars().count()).max().unwrap_or(0).max(1); - let border = Style::default().fg(Color::Green); + let border = Style::default().fg(theme); + let text_style = Style::default().fg(theme); let mut out: Vec> = Vec::new(); out.push(Line::from(vec![ @@ -104,8 +135,8 @@ fn user_bubble(text: &str, width: u16) -> Vec> { out.push(Line::from(vec![ Span::raw(" "), Span::styled("│ ".to_string(), border), - Span::raw(content_line.clone()), - Span::raw(gap), + Span::styled(content_line.clone(), text_style), + Span::styled(gap, text_style), Span::styled(" │".to_string(), border), ])); } @@ -125,6 +156,7 @@ pub(crate) fn render_welcome( model: &str, provider: &str, skills: &[String], + theme: Color, ) { const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -134,6 +166,7 @@ pub(crate) fn render_welcome( (":sessions", "past sessions"), (":skills", "available skills"), (":mcp", "MCP servers"), + (":theme", "change accent color"), (":q", "quit"), ]; @@ -162,7 +195,7 @@ pub(crate) fn render_welcome( ), Span::styled( format!(" v{VERSION}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(theme), ), ])); @@ -171,7 +204,7 @@ pub(crate) fn render_welcome( let sub_pad = center(subtitle.chars().count()); lines.push(Line::styled( format!("{sub_pad}{subtitle}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(theme), )); lines.push(Line::default()); @@ -180,7 +213,7 @@ pub(crate) fn render_welcome( let hint_pad = center(hint.chars().count()); lines.push(Line::styled( format!("{hint_pad}{hint}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(theme), )); lines.push(Line::default()); @@ -196,7 +229,7 @@ pub(crate) fn render_welcome( Span::raw(cmd_pad.clone()), Span::styled(key.to_string(), Style::default().fg(Color::White)), Span::raw(gap), - Span::styled(desc.to_string(), Style::default().fg(Color::DarkGray)), + Span::styled(desc.to_string(), Style::default().fg(theme)), ])); } @@ -211,8 +244,9 @@ pub(crate) fn render_input_bar( left_label: Option<&str>, right_label: &str, show_cursor: bool, + theme: Color, ) { - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let mut block = Block::default().borders(Borders::TOP).border_style(dim); if let Some(left) = left_label { @@ -227,7 +261,11 @@ pub(crate) fn render_input_bar( f.render_widget(block, area); let prompt_prefix = " › "; - f.render_widget(Paragraph::new(format!("{prompt_prefix}{input}")), inner); + f.render_widget( + Paragraph::new(format!("{prompt_prefix}{input}")) + .style(Style::default().fg(Color::White)), + inner, + ); if show_cursor { let cursor_col = inner.x @@ -245,6 +283,7 @@ pub(crate) fn render_model_picker( area: Rect, items: &[(String, String)], selected: usize, + theme: Color, ) { let max_label = items .iter() @@ -260,7 +299,7 @@ pub(crate) fn render_model_picker( f.render_widget(Clear, popup_rect); - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let block = Block::default() .title( Line::from(Span::styled( @@ -306,7 +345,7 @@ pub(crate) fn render_model_picker( } else { Line::from(vec![ Span::raw(" "), - Span::styled(provider.clone(), Style::default().fg(Color::DarkGray)), + Span::styled(provider.clone(), Style::default().fg(theme)), Span::raw(" "), Span::styled(model.clone(), Style::default().fg(Color::White)), ]) @@ -322,6 +361,7 @@ pub(crate) fn render_session_picker( area: Rect, items: &[crate::rag::ConversationMeta], selected: usize, + theme: Color, ) { if items.is_empty() { return; @@ -352,7 +392,7 @@ pub(crate) fn render_session_picker( f.render_widget(Clear, popup_rect); - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let block = Block::default() .title( Line::from(Span::styled( @@ -405,7 +445,7 @@ pub(crate) fn render_session_picker( Span::raw(" "), Span::styled(title, Style::default().fg(Color::White)), Span::raw(gap), - Span::styled(meta_str, Style::default().fg(Color::DarkGray)), + Span::styled(meta_str, Style::default().fg(theme)), ]) } }) @@ -419,6 +459,7 @@ pub(crate) fn render_config_viewer( area: Rect, rows: &[ConfigRow], scroll: usize, + theme: Color, ) { let entry_key_w = rows .iter() @@ -458,7 +499,7 @@ pub(crate) fn render_config_viewer( f.render_widget(Clear, popup_rect); - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let block = Block::default() .title( Line::from(Span::styled( @@ -493,7 +534,7 @@ pub(crate) fn render_config_viewer( let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); Line::from(vec![ Span::raw(" "), - Span::styled(key.clone(), Style::default().fg(Color::DarkGray)), + Span::styled(key.clone(), Style::default().fg(theme)), Span::raw(gap), Span::styled(val.clone(), Style::default().fg(Color::White)), ]) @@ -513,6 +554,7 @@ pub(crate) fn render_mcp_viewer( area: Rect, rows: &[ConfigRow], scroll: usize, + theme: Color, ) { let entry_key_w = rows .iter() @@ -545,7 +587,7 @@ pub(crate) fn render_mcp_viewer( f.render_widget(Clear, popup_rect); - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let block = Block::default() .title( Line::from(Span::styled( @@ -580,7 +622,7 @@ pub(crate) fn render_mcp_viewer( let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); Line::from(vec![ Span::raw(" "), - Span::styled(key.clone(), Style::default().fg(Color::DarkGray)), + Span::styled(key.clone(), Style::default().fg(theme)), Span::raw(gap), Span::styled(val.clone(), Style::default().fg(Color::White)), ]) @@ -600,6 +642,7 @@ pub(crate) fn render_skills_viewer( area: Rect, items: &[String], scroll: usize, + theme: Color, ) { let max_w = items.iter().map(|s| s.chars().count()).max().unwrap_or(10); let content_w = max_w + 4; @@ -611,7 +654,7 @@ pub(crate) fn render_skills_viewer( f.render_widget(Clear, popup_rect); - let dim = Style::default().fg(Color::DarkGray); + let dim = Style::default().fg(theme); let block = Block::default() .title( Line::from(Span::styled( @@ -653,6 +696,70 @@ pub(crate) fn render_skills_viewer( f.render_widget(Paragraph::new(lines), inner); } +pub(crate) fn render_theme_picker( + f: &mut Frame, + area: Rect, + selected: usize, + current_name: &str, + theme: Color, +) { + let max_w = THEME_COLORS.iter().map(|n| n.len()).max().unwrap_or(10); + let popup_w = ((max_w + 8) as u16).max(24).min(area.width.saturating_sub(4)); + let popup_h = (THEME_COLORS.len() as u16 + 2).max(5).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let dim = Style::default().fg(theme); + let block = Block::default() + .title( + Line::from(Span::styled( + " theme ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )) + .centered(), + ) + .title_bottom(Line::from(Span::styled(" ↑/↓ enter esc ", dim)).centered()) + .borders(Borders::ALL) + .border_style(dim); + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + + if inner.height == 0 { + return; + } + + let lines: Vec> = THEME_COLORS + .iter() + .enumerate() + .map(|(i, &name)| { + let color = theme_color(name); + let is_selected = i == selected; + let is_current = name == current_name; + let marker = if is_current { "·" } else { " " }; + if is_selected { + Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::White)), + Span::styled( + format!("{name} {marker}"), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{name} {marker}"), Style::default().fg(color)), + ]) + } + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + // Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. pub(crate) fn word_wrap(text: &str, width: usize) -> Vec { if width == 0 { diff --git a/src/tui/types.rs b/src/tui/types.rs index fc21385..2eacb39 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -34,6 +34,7 @@ pub(crate) enum Screen { SessionPicker, SkillsViewer, McpViewer, + ThemePicker, } #[derive(Debug, Clone)] From a4d9028e7701dd0a72acd79ebc3ea78235e14188 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:31:58 +0300 Subject: [PATCH 15/41] fix: lint & clippy --- src/tui/app.rs | 78 ++++++++++++------ src/tui/mod.rs | 32 +++++--- src/tui/render.rs | 198 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 212 insertions(+), 96 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 3e0908b..87413e6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -12,7 +12,9 @@ fn save_theme_to_config(name: &str) -> crate::error::Result<()> { let path = crate::config::config_path()?; let contents = std::fs::read_to_string(&path)?; let new_line = format!("theme_color = \"{name}\""); - let has_theme = contents.lines().any(|l| l.trim_start().starts_with("theme_color")); + let has_theme = contents + .lines() + .any(|l| l.trim_start().starts_with("theme_color")); let updated: String = if has_theme { contents .lines() @@ -93,7 +95,11 @@ impl App { switch_model_tx: mpsc::UnboundedSender, switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, ) -> Self { - let theme_name = app_config.theme_color.as_deref().unwrap_or("dark_gray").to_string(); + let theme_name = app_config + .theme_color + .as_deref() + .unwrap_or("dark_gray") + .to_string(); let theme_color = render::theme_color(&theme_name); Self { items: Vec::new(), @@ -161,7 +167,9 @@ impl App { AgentUpdate::ModelChanged { provider, model } => { self.agent_config.provider_name = provider.clone(); self.agent_config.model = model.clone(); - self.push(ChatItem::SystemInfo(format!("switched to {provider} / {model}"))); + self.push(ChatItem::SystemInfo(format!( + "switched to {provider} / {model}" + ))); } } } @@ -176,8 +184,7 @@ impl App { } KeyCode::Down => { if !self.sessions.is_empty() { - self.picker_selected = - (self.picker_selected + 1).min(self.sessions.len() - 1); + self.picker_selected = (self.picker_selected + 1).min(self.sessions.len() - 1); } } KeyCode::Enter => { @@ -274,8 +281,7 @@ impl App { self.theme_selected = self.theme_selected.saturating_sub(1); } KeyCode::Down => { - self.theme_selected = - (self.theme_selected + 1).min(render::THEME_COLORS.len() - 1); + self.theme_selected = (self.theme_selected + 1).min(render::THEME_COLORS.len() - 1); } KeyCode::Enter => { let name = render::THEME_COLORS[self.theme_selected].to_string(); @@ -464,8 +470,14 @@ impl App { "config" => { let ac = &self.agent_config; let mut rows = vec![ - ConfigRow::Entry { key: "Provider".to_string(), val: ac.provider_name.clone() }, - ConfigRow::Entry { key: "Model".to_string(), val: ac.model.clone() }, + ConfigRow::Entry { + key: "Provider".to_string(), + val: ac.provider_name.clone(), + }, + ConfigRow::Entry { + key: "Model".to_string(), + val: ac.model.clone(), + }, ConfigRow::Entry { key: "Max iterations".to_string(), val: ac.max_iterations.to_string(), @@ -484,7 +496,10 @@ impl App { } else { pname.clone() }; - rows.push(ConfigRow::Entry { key: label, val: p.default_model.clone() }); + rows.push(ConfigRow::Entry { + key: label, + val: p.default_model.clone(), + }); } } if !self.app_config.mcp_servers.is_empty() { @@ -680,7 +695,14 @@ impl App { let model = self.agent_config.model.clone(); let provider = self.agent_config.provider_name.clone(); let skills = self.skills.clone(); - render::render_welcome(f, content_area, &model, &provider, &skills, self.theme_color); + render::render_welcome( + f, + content_area, + &model, + &provider, + &skills, + self.theme_color, + ); } else { self.draw_chat(f, content_area); } @@ -688,15 +710,19 @@ impl App { let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let left_label = match &self.status { Status::Idle => None, - Status::Thinking => { - Some(format!("{} thinking…", spinner[self.spinner_frame % spinner.len()])) - } - Status::Streaming => { - Some(format!("{} streaming…", spinner[self.spinner_frame % spinner.len()])) - } + Status::Thinking => Some(format!( + "{} thinking…", + spinner[self.spinner_frame % spinner.len()] + )), + Status::Streaming => Some(format!( + "{} streaming…", + spinner[self.spinner_frame % spinner.len()] + )), }; - let right_label = - format!("{} · {}", self.agent_config.provider_name, self.agent_config.model); + let right_label = format!( + "{} · {}", + self.agent_config.provider_name, self.agent_config.model + ); let input = self.input.clone(); let theme = self.theme_color; @@ -759,8 +785,11 @@ impl App { let start = self.scroll; let end = (start + visible_h).min(total); - let visible: Vec> = - if start < end { self.cached_lines[start..end].to_vec() } else { vec![] }; + let visible: Vec> = if start < end { + self.cached_lines[start..end].to_vec() + } else { + vec![] + }; let scroll_hint = if !self.pinned && max_scroll > 0 { format!(" {}% ↑ ", (self.scroll * 100) / max_scroll) @@ -771,9 +800,10 @@ impl App { use ratatui::widgets::{Block, Borders}; let chat_block = Block::default() .borders(Borders::NONE) - .title_bottom(Line::from( - Span::styled(scroll_hint, Style::default().fg(self.theme_color)), - )); + .title_bottom(Line::from(Span::styled( + scroll_hint, + Style::default().fg(self.theme_color), + ))); let chat_inner = chat_block.inner(area); f.render_widget(chat_block, area); f.render_widget(Paragraph::new(visible), chat_inner); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c977821..0ea4149 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -8,8 +8,8 @@ use std::time::Duration; use agent_client_protocol::schema::{ContentBlock, SessionUpdate, ToolCallStatus}; use crossterm::{ event::{ - Event, EventStream, KeyEventKind, KeyboardEnhancementFlags, - PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + Event, EventStream, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, @@ -18,10 +18,7 @@ use futures::StreamExt; use ratatui::{Terminal, backend::CrosstermBackend}; use tokio::sync::mpsc; -use crate::{ - client::OpenheimClient, - config::load_config, -}; +use crate::{client::OpenheimClient, config::load_config}; use app::App; use types::AgentUpdate; @@ -102,7 +99,14 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { }); } - let mut app = App::new(agent_config, app_config, skills, prompt_tx, switch_model_tx, switch_session_tx); + let mut app = App::new( + agent_config, + app_config, + skills, + prompt_tx, + switch_model_tx, + switch_session_tx, + ); enable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; let mut stdout = io::stdout(); @@ -112,8 +116,7 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { // Enable keyboard enhancement on supporting terminals so that arrow-key // escape sequences (\x1b[B etc.) are never ambiguously split into a // spurious Esc + characters, which caused `[B` to appear in the input. - let kbd_enhanced = - crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false); + let kbd_enhanced = crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false); if kbd_enhanced { execute!( stdout, @@ -195,8 +198,15 @@ fn convert_update(tx: &mpsc::UnboundedSender, update: SessionUpdate } } SessionUpdate::ToolCall(tc) => { - let args = tc.raw_input.as_ref().map(|v| v.to_string()).unwrap_or_default(); - let _ = tx.send(AgentUpdate::ToolCall { name: tc.title.clone(), args }); + let args = tc + .raw_input + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_default(); + let _ = tx.send(AgentUpdate::ToolCall { + name: tc.title.clone(), + args, + }); } SessionUpdate::ToolCallUpdate(tcu) => { if matches!( diff --git a/src/tui/render.rs b/src/tui/render.rs index 14c1bfd..19f85ae 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -9,15 +9,7 @@ use ratatui::{ use super::types::{ChatItem, ConfigRow}; pub(crate) const THEME_COLORS: &[&str] = &[ - "white", - "gray", - "blue", - "cyan", - "magenta", - "green", - "yellow", - "red", - "pink", + "white", "gray", "blue", "cyan", "magenta", "green", "yellow", "red", "pink", ]; pub(crate) fn theme_color(name: &str) -> Color { @@ -63,7 +55,9 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec Color::Green, + Some(ChatItem::ToolResult { + is_error: false, .. + }) => Color::Green, Some(ChatItem::ToolResult { is_error: true, .. }) => Color::Red, _ => theme, }; @@ -79,12 +73,18 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { - let flat: String = - result.chars().take(200).collect::().replace('\n', " "); + let flat: String = result + .chars() + .take(200) + .collect::() + .replace('\n', " "); lines.push(Line::from(vec![ Span::raw(" "), Span::styled("→ ", Style::default().fg(Color::DarkGray)), - Span::styled(flat.trim().to_string(), Style::default().fg(Color::DarkGray)), + Span::styled( + flat.trim().to_string(), + Style::default().fg(Color::DarkGray), + ), ])); } ChatItem::SystemInfo(text) => { @@ -119,7 +119,12 @@ fn user_bubble(text: &str, width: u16, theme: Color) -> Vec> { let content_max = 50usize.min(width.saturating_sub(8) as usize).max(1); let wrapped = word_wrap(text, content_max); - let content_w = wrapped.iter().map(|l| l.chars().count()).max().unwrap_or(0).max(1); + let content_w = wrapped + .iter() + .map(|l| l.chars().count()) + .max() + .unwrap_or(0) + .max(1); let border = Style::default().fg(theme); let text_style = Style::default().fg(theme); @@ -191,12 +196,11 @@ pub(crate) fn render_welcome( Span::raw(title_pad), Span::styled( "openheim".to_string(), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" v{VERSION}"), - Style::default().fg(theme), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), ), + Span::styled(format!(" v{VERSION}"), Style::default().fg(theme)), ])); lines.push(Line::default()); @@ -218,8 +222,16 @@ pub(crate) fn render_welcome( lines.push(Line::default()); - let cmd_key_w = COMMANDS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0); - let cmd_desc_w = COMMANDS.iter().map(|(_, d)| d.chars().count()).max().unwrap_or(0); + let cmd_key_w = COMMANDS + .iter() + .map(|(k, _)| k.chars().count()) + .max() + .unwrap_or(0); + let cmd_desc_w = COMMANDS + .iter() + .map(|(_, d)| d.chars().count()) + .max() + .unwrap_or(0); let cmd_block_w = cmd_key_w + 6 + cmd_desc_w; let cmd_pad = center(cmd_block_w); @@ -236,6 +248,7 @@ pub(crate) fn render_welcome( f.render_widget(Paragraph::new(lines), area); } +#[allow(clippy::too_many_arguments)] pub(crate) fn render_input_bar( f: &mut Frame, area: Rect, @@ -250,27 +263,24 @@ pub(crate) fn render_input_bar( let mut block = Block::default().borders(Borders::TOP).border_style(dim); if let Some(left) = left_label { - block = block - .title_top(Line::from(Span::styled(format!("─── {left} "), dim)).left_aligned()); + block = + block.title_top(Line::from(Span::styled(format!("─── {left} "), dim)).left_aligned()); } - block = block.title_top( - Line::from(Span::styled(format!(" {right_label} ───"), dim)).right_aligned(), - ); + block = block + .title_top(Line::from(Span::styled(format!(" {right_label} ───"), dim)).right_aligned()); let inner = block.inner(area); f.render_widget(block, area); let prompt_prefix = " › "; f.render_widget( - Paragraph::new(format!("{prompt_prefix}{input}")) - .style(Style::default().fg(Color::White)), + Paragraph::new(format!("{prompt_prefix}{input}")).style(Style::default().fg(Color::White)), inner, ); if show_cursor { - let cursor_col = inner.x - + prompt_prefix.chars().count() as u16 - + input[..cursor].chars().count() as u16; + let cursor_col = + inner.x + prompt_prefix.chars().count() as u16 + input[..cursor].chars().count() as u16; f.set_cursor_position(( cursor_col.min(inner.x + inner.width.saturating_sub(1)), inner.y, @@ -291,8 +301,12 @@ pub(crate) fn render_model_picker( .max() .unwrap_or(20); - let popup_w = ((max_label + 6) as u16).max(32).min(area.width.saturating_sub(4)); - let popup_h = ((items.len() + 2) as u16).max(5).min(area.height.saturating_sub(4)); + let popup_w = ((max_label + 6) as u16) + .max(32) + .min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 2) as u16) + .max(5) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -304,13 +318,13 @@ pub(crate) fn render_model_picker( .title( Line::from(Span::styled( " models ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) - .title_bottom( - Line::from(Span::styled(" ↑/↓ enter esc ", dim)).centered(), - ) + .title_bottom(Line::from(Span::styled(" ↑/↓ enter esc ", dim)).centered()) .borders(Borders::ALL) .border_style(dim); @@ -384,8 +398,12 @@ pub(crate) fn render_session_picker( .unwrap_or(20); let content_w = max_title + 4 + max_meta; - let popup_w = ((content_w + 6) as u16).max(40).min(area.width.saturating_sub(4)); - let popup_h = ((items.len() + 2) as u16).max(5).min(area.height.saturating_sub(4)); + let popup_w = ((content_w + 6) as u16) + .max(40) + .min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 2) as u16) + .max(5) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -397,7 +415,9 @@ pub(crate) fn render_session_picker( .title( Line::from(Span::styled( " sessions ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) @@ -464,35 +484,55 @@ pub(crate) fn render_config_viewer( let entry_key_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Entry { key, .. } = r { Some(key.chars().count()) } else { None } + if let ConfigRow::Entry { key, .. } = r { + Some(key.chars().count()) + } else { + None + } }) .max() .unwrap_or(10); let entry_val_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Entry { val, .. } = r { Some(val.chars().count()) } else { None } + if let ConfigRow::Entry { val, .. } = r { + Some(val.chars().count()) + } else { + None + } }) .max() .unwrap_or(10); let item_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Item(s) = r { Some(s.chars().count() + 4) } else { None } + if let ConfigRow::Item(s) = r { + Some(s.chars().count() + 4) + } else { + None + } }) .max() .unwrap_or(0); let header_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Header(h) = r { Some(h.chars().count() + 2) } else { None } + if let ConfigRow::Header(h) = r { + Some(h.chars().count() + 2) + } else { + None + } }) .max() .unwrap_or(0); let content_w = (entry_key_w + 4 + entry_val_w).max(item_w).max(header_w); - let popup_w = ((content_w + 6) as u16).max(36).min(area.width.saturating_sub(4)); - let popup_h = ((rows.len() + 2) as u16).max(6).min(area.height.saturating_sub(4)); + let popup_w = ((content_w + 6) as u16) + .max(36) + .min(area.width.saturating_sub(4)); + let popup_h = ((rows.len() + 2) as u16) + .max(6) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -504,7 +544,9 @@ pub(crate) fn render_config_viewer( .title( Line::from(Span::styled( " config ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) @@ -528,7 +570,9 @@ pub(crate) fn render_config_viewer( ConfigRow::Blank => Line::default(), ConfigRow::Header(h) => Line::from(Span::styled( format!(" {h}"), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )), ConfigRow::Entry { key, val } => { let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); @@ -559,28 +603,44 @@ pub(crate) fn render_mcp_viewer( let entry_key_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Entry { key, .. } = r { Some(key.chars().count()) } else { None } + if let ConfigRow::Entry { key, .. } = r { + Some(key.chars().count()) + } else { + None + } }) .max() .unwrap_or(5); let entry_val_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Entry { val, .. } = r { Some(val.chars().count()) } else { None } + if let ConfigRow::Entry { val, .. } = r { + Some(val.chars().count()) + } else { + None + } }) .max() .unwrap_or(20); let header_w = rows .iter() .filter_map(|r| { - if let ConfigRow::Header(h) = r { Some(h.chars().count() + 2) } else { None } + if let ConfigRow::Header(h) = r { + Some(h.chars().count() + 2) + } else { + None + } }) .max() .unwrap_or(0); let content_w = (entry_key_w + 4 + entry_val_w).max(header_w); - let popup_w = ((content_w + 6) as u16).max(40).min(area.width.saturating_sub(4)); - let popup_h = ((rows.len() + 2) as u16).max(6).min(area.height.saturating_sub(4)); + let popup_w = ((content_w + 6) as u16) + .max(40) + .min(area.width.saturating_sub(4)); + let popup_h = ((rows.len() + 2) as u16) + .max(6) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -592,7 +652,9 @@ pub(crate) fn render_mcp_viewer( .title( Line::from(Span::styled( " mcp servers ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) @@ -616,7 +678,9 @@ pub(crate) fn render_mcp_viewer( ConfigRow::Blank => Line::default(), ConfigRow::Header(h) => Line::from(Span::styled( format!(" {h}"), - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )), ConfigRow::Entry { key, val } => { let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); @@ -646,8 +710,12 @@ pub(crate) fn render_skills_viewer( ) { let max_w = items.iter().map(|s| s.chars().count()).max().unwrap_or(10); let content_w = max_w + 4; - let popup_w = ((content_w + 6) as u16).max(36).min(area.width.saturating_sub(4)); - let popup_h = ((items.len() + 4) as u16).max(6).min(area.height.saturating_sub(4)); + let popup_w = ((content_w + 6) as u16) + .max(36) + .min(area.width.saturating_sub(4)); + let popup_h = ((items.len() + 4) as u16) + .max(6) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -659,7 +727,9 @@ pub(crate) fn render_skills_viewer( .title( Line::from(Span::styled( " skills ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) @@ -704,8 +774,12 @@ pub(crate) fn render_theme_picker( theme: Color, ) { let max_w = THEME_COLORS.iter().map(|n| n.len()).max().unwrap_or(10); - let popup_w = ((max_w + 8) as u16).max(24).min(area.width.saturating_sub(4)); - let popup_h = (THEME_COLORS.len() as u16 + 2).max(5).min(area.height.saturating_sub(4)); + let popup_w = ((max_w + 8) as u16) + .max(24) + .min(area.width.saturating_sub(4)); + let popup_h = (THEME_COLORS.len() as u16 + 2) + .max(5) + .min(area.height.saturating_sub(4)); let x = area.x + (area.width.saturating_sub(popup_w)) / 2; let y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup_rect = Rect::new(x, y, popup_w, popup_h); @@ -717,7 +791,9 @@ pub(crate) fn render_theme_picker( .title( Line::from(Span::styled( " theme ", - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), )) .centered(), ) From 60d06a681793389896d3db4c4b9bae6e193e3844 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:53:05 +0300 Subject: [PATCH 16/41] =?UTF-8?q?fix(tui):=20default=20theme=20"dark=5Fgra?= =?UTF-8?q?y"=20=E2=86=92=20"gray"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 87413e6..af51630 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -98,7 +98,7 @@ impl App { let theme_name = app_config .theme_color .as_deref() - .unwrap_or("dark_gray") + .unwrap_or("gray") .to_string(); let theme_color = render::theme_color(&theme_name); Self { From f0c2d7ce89b5f0aa3aec9f99e11c623cfe49b6ff Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:54:42 +0300 Subject: [PATCH 17/41] refactor(tui): Screen::is_overlay(), push_screen helper, match-based dispatch --- src/tui/app.rs | 139 +++++++++++++++++------------------------------ src/tui/types.rs | 14 +++++ 2 files changed, 65 insertions(+), 88 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index af51630..bde536c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Style}, text::{Line, Span}, - widgets::Paragraph, + widgets::{Block, Borders, Paragraph}, }; use tokio::sync::mpsc; @@ -308,7 +308,7 @@ impl App { } } - fn handle_picker_key(&mut self, key: KeyEvent) { + fn handle_model_picker_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; @@ -335,30 +335,20 @@ impl App { } } + fn push_screen(&mut self, next: Screen) { + self.pre_picker_screen = self.screen; + self.screen = next; + } + pub(super) fn handle_key(&mut self, key: KeyEvent) { - if self.screen == Screen::ModelPicker { - self.handle_picker_key(key); - return; - } - if self.screen == Screen::ConfigViewer { - self.handle_config_viewer_key(key); - return; - } - if self.screen == Screen::SessionPicker { - self.handle_session_picker_key(key); - return; - } - if self.screen == Screen::SkillsViewer { - self.handle_skills_viewer_key(key); - return; - } - if self.screen == Screen::McpViewer { - self.handle_mcp_viewer_key(key); - return; - } - if self.screen == Screen::ThemePicker { - self.handle_theme_picker_key(key); - return; + match self.screen { + Screen::ModelPicker => { self.handle_model_picker_key(key); return; } + Screen::ConfigViewer => { self.handle_config_viewer_key(key); return; } + Screen::SessionPicker => { self.handle_session_picker_key(key); return; } + Screen::SkillsViewer => { self.handle_skills_viewer_key(key); return; } + Screen::McpViewer => { self.handle_mcp_viewer_key(key); return; } + Screen::ThemePicker => { self.handle_theme_picker_key(key); return; } + Screen::Welcome | Screen::Chat => {} } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -462,8 +452,7 @@ impl App { Ok(metas) => { self.sessions = metas; self.picker_selected = 0; - self.pre_picker_screen = self.screen; - self.screen = Screen::SessionPicker; + self.push_screen(Screen::SessionPicker); } Err(e) => self.push(ChatItem::Err(e.to_string())), }, @@ -511,8 +500,7 @@ impl App { } self.config_rows = rows; self.config_scroll = 0; - self.pre_picker_screen = self.screen; - self.screen = Screen::ConfigViewer; + self.push_screen(Screen::ConfigViewer); } "mcp" => { if self.app_config.mcp_servers.is_empty() { @@ -550,8 +538,7 @@ impl App { } self.mcp_rows = rows; self.mcp_scroll = 0; - self.pre_picker_screen = self.screen; - self.screen = Screen::McpViewer; + self.push_screen(Screen::McpViewer); } } "models" => { @@ -571,8 +558,7 @@ impl App { .unwrap_or(0); self.picker_items = items; self.picker_selected = selected; - self.pre_picker_screen = self.screen; - self.screen = Screen::ModelPicker; + self.push_screen(Screen::ModelPicker); } else { let _ = self.switch_model_tx.send(arg.to_string()); } @@ -588,8 +574,7 @@ impl App { Ok(names) => { self.skills_items = names; self.skills_scroll = 0; - self.pre_picker_screen = self.screen; - self.screen = Screen::SkillsViewer; + self.push_screen(Screen::SkillsViewer); } Err(e) => self.push(ChatItem::Err(e.to_string())), }, @@ -599,8 +584,7 @@ impl App { .iter() .position(|&n| n == self.theme_color_name) .unwrap_or(0); - self.pre_picker_screen = self.screen; - self.screen = Screen::ThemePicker; + self.push_screen(Screen::ThemePicker); } else if render::THEME_COLORS.contains(&arg) { self.apply_theme(arg); } else { @@ -681,85 +665,65 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; - let bg_screen = match self.screen { - Screen::ModelPicker - | Screen::ConfigViewer - | Screen::SessionPicker - | Screen::SkillsViewer - | Screen::McpViewer - | Screen::ThemePicker => self.pre_picker_screen, - s => s, - }; + let bg_screen = if self.screen.is_overlay() { self.pre_picker_screen } else { self.screen }; if bg_screen == Screen::Welcome { - let model = self.agent_config.model.clone(); - let provider = self.agent_config.provider_name.clone(); - let skills = self.skills.clone(); render::render_welcome( f, content_area, - &model, - &provider, - &skills, + &self.agent_config.model, + &self.agent_config.provider_name, + &self.skills, self.theme_color, ); } else { self.draw_chat(f, content_area); } - let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let frame = SPINNER[self.spinner_frame % SPINNER.len()]; let left_label = match &self.status { Status::Idle => None, - Status::Thinking => Some(format!( - "{} thinking…", - spinner[self.spinner_frame % spinner.len()] - )), - Status::Streaming => Some(format!( - "{} streaming…", - spinner[self.spinner_frame % spinner.len()] - )), + Status::Thinking => Some(format!("{frame} thinking…")), + Status::Streaming => Some(format!("{frame} streaming…")), }; let right_label = format!( "{} · {}", self.agent_config.provider_name, self.agent_config.model ); - let input = self.input.clone(); let theme = self.theme_color; render::render_input_bar( f, input_area, - &input, + &self.input, self.cursor, left_label.as_deref(), &right_label, - self.screen != Screen::ModelPicker - && self.screen != Screen::ConfigViewer - && self.screen != Screen::SessionPicker - && self.screen != Screen::SkillsViewer - && self.screen != Screen::McpViewer - && self.screen != Screen::ThemePicker, + !self.screen.is_overlay(), theme, ); - if self.screen == Screen::ModelPicker { - render::render_model_picker(f, area, &self.picker_items, self.picker_selected, theme); - } - if self.screen == Screen::ConfigViewer { - render::render_config_viewer(f, area, &self.config_rows, self.config_scroll, theme); - } - if self.screen == Screen::SessionPicker { - render::render_session_picker(f, area, &self.sessions, self.picker_selected, theme); - } - if self.screen == Screen::SkillsViewer { - render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll, theme); - } - if self.screen == Screen::McpViewer { - render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll, theme); - } - if self.screen == Screen::ThemePicker { - let current = self.theme_color_name.clone(); - render::render_theme_picker(f, area, self.theme_selected, ¤t, theme); + match self.screen { + Screen::ModelPicker => { + render::render_model_picker(f, area, &self.picker_items, self.picker_selected, theme); + } + Screen::ConfigViewer => { + render::render_config_viewer(f, area, &self.config_rows, self.config_scroll, theme); + } + Screen::SessionPicker => { + render::render_session_picker(f, area, &self.sessions, self.picker_selected, theme); + } + Screen::SkillsViewer => { + render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll, theme); + } + Screen::McpViewer => { + render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll, theme); + } + Screen::ThemePicker => { + render::render_theme_picker(f, area, self.theme_selected, &self.theme_color_name, theme); + } + Screen::Welcome | Screen::Chat => {} } } @@ -797,7 +761,6 @@ impl App { String::new() }; - use ratatui::widgets::{Block, Borders}; let chat_block = Block::default() .borders(Borders::NONE) .title_bottom(Line::from(Span::styled( diff --git a/src/tui/types.rs b/src/tui/types.rs index 2eacb39..45a4912 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -37,6 +37,20 @@ pub(crate) enum Screen { ThemePicker, } +impl Screen { + pub(crate) fn is_overlay(self) -> bool { + matches!( + self, + Screen::ModelPicker + | Screen::ConfigViewer + | Screen::SessionPicker + | Screen::SkillsViewer + | Screen::McpViewer + | Screen::ThemePicker + ) + } +} + #[derive(Debug, Clone)] pub(crate) enum ConfigRow { Blank, From 892cf0ce732bfe1c8e7d7b323fa8ad534fe87d8f Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:55:04 +0300 Subject: [PATCH 18/41] refactor(tui): extract handle_scroll_key, replace triplicated scroll handlers --- src/tui/app.rs | 78 ++++++++++++-------------------------------------- 1 file changed, 19 insertions(+), 59 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index bde536c..a9aead0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -200,76 +200,36 @@ impl App { } } - fn handle_config_viewer_key(&mut self, key: KeyEvent) { + fn handle_scroll_key( + key: KeyEvent, + scroll: &mut usize, + should_quit: &mut bool, + screen: &mut Screen, + prev: Screen, + ) { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - self.config_scroll = self.config_scroll.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.config_scroll += 1; - } - KeyCode::PageUp => { - self.config_scroll = self.config_scroll.saturating_sub(5); - } - KeyCode::PageDown => { - self.config_scroll += 5; - } - KeyCode::Esc => { - self.screen = self.pre_picker_screen; + *should_quit = true; } + KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => *scroll = scroll.saturating_add(1), + KeyCode::PageUp => *scroll = scroll.saturating_sub(5), + KeyCode::PageDown => *scroll = scroll.saturating_add(5), + KeyCode::Esc => *screen = prev, _ => {} } } + fn handle_config_viewer_key(&mut self, key: KeyEvent) { + Self::handle_scroll_key(key, &mut self.config_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); + } + fn handle_skills_viewer_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - self.skills_scroll = self.skills_scroll.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.skills_scroll += 1; - } - KeyCode::PageUp => { - self.skills_scroll = self.skills_scroll.saturating_sub(5); - } - KeyCode::PageDown => { - self.skills_scroll += 5; - } - KeyCode::Esc => { - self.screen = self.pre_picker_screen; - } - _ => {} - } + Self::handle_scroll_key(key, &mut self.skills_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); } fn handle_mcp_viewer_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - self.mcp_scroll = self.mcp_scroll.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - self.mcp_scroll += 1; - } - KeyCode::PageUp => { - self.mcp_scroll = self.mcp_scroll.saturating_sub(5); - } - KeyCode::PageDown => { - self.mcp_scroll += 5; - } - KeyCode::Esc => { - self.screen = self.pre_picker_screen; - } - _ => {} - } + Self::handle_scroll_key(key, &mut self.mcp_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); } fn handle_theme_picker_key(&mut self, key: KeyEvent) { From 8000ced34cd9d8f139d9949da79f5c58312b5d07 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:56:29 +0300 Subject: [PATCH 19/41] refactor(tui): merge config/mcp viewers into shared helper, extract centered_popup --- src/tui/render.rs | 198 +++++++++------------------------------------- 1 file changed, 37 insertions(+), 161 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index 19f85ae..70dcd1c 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -27,6 +27,12 @@ pub(crate) fn theme_color(name: &str) -> Color { } } +fn centered_popup(area: Rect, w: u16, h: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + Rect::new(x, y, w, h) +} + pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec> { let inner_w = width.saturating_sub(2) as usize; let mut lines: Vec> = Vec::new(); @@ -307,9 +313,7 @@ pub(crate) fn render_model_picker( let popup_h = ((items.len() + 2) as u16) .max(5) .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); + let popup_rect = centered_popup(area, popup_w, popup_h); f.render_widget(Clear, popup_rect); @@ -404,9 +408,7 @@ pub(crate) fn render_session_picker( let popup_h = ((items.len() + 2) as u16) .max(5) .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); + let popup_rect = centered_popup(area, popup_w, popup_h); f.render_widget(Clear, popup_rect); @@ -481,116 +483,7 @@ pub(crate) fn render_config_viewer( scroll: usize, theme: Color, ) { - let entry_key_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Entry { key, .. } = r { - Some(key.chars().count()) - } else { - None - } - }) - .max() - .unwrap_or(10); - let entry_val_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Entry { val, .. } = r { - Some(val.chars().count()) - } else { - None - } - }) - .max() - .unwrap_or(10); - let item_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Item(s) = r { - Some(s.chars().count() + 4) - } else { - None - } - }) - .max() - .unwrap_or(0); - let header_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Header(h) = r { - Some(h.chars().count() + 2) - } else { - None - } - }) - .max() - .unwrap_or(0); - let content_w = (entry_key_w + 4 + entry_val_w).max(item_w).max(header_w); - - let popup_w = ((content_w + 6) as u16) - .max(36) - .min(area.width.saturating_sub(4)); - let popup_h = ((rows.len() + 2) as u16) - .max(6) - .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); - - f.render_widget(Clear, popup_rect); - - let dim = Style::default().fg(theme); - let block = Block::default() - .title( - Line::from(Span::styled( - " config ", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )) - .centered(), - ) - .title_bottom(Line::from(Span::styled(" ↑/↓ esc ", dim)).centered()) - .borders(Borders::ALL) - .border_style(dim); - - let inner = block.inner(popup_rect); - f.render_widget(block, popup_rect); - - let visible_h = inner.height as usize; - if visible_h == 0 { - return; - } - let scroll = scroll.min(rows.len().saturating_sub(visible_h)); - let end = (scroll + visible_h).min(rows.len()); - - let lines: Vec> = rows[scroll..end] - .iter() - .map(|row| match row { - ConfigRow::Blank => Line::default(), - ConfigRow::Header(h) => Line::from(Span::styled( - format!(" {h}"), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )), - ConfigRow::Entry { key, val } => { - let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); - Line::from(vec![ - Span::raw(" "), - Span::styled(key.clone(), Style::default().fg(theme)), - Span::raw(gap), - Span::styled(val.clone(), Style::default().fg(Color::White)), - ]) - } - ConfigRow::Item(s) => Line::from(vec![ - Span::raw(" "), - Span::styled(s.clone(), Style::default().fg(Color::White)), - ]), - }) - .collect(); - - f.render_widget(Paragraph::new(lines), inner); + render_rows_popup(f, area, rows, scroll, " config ", 36, " ", theme); } pub(crate) fn render_mcp_viewer( @@ -600,50 +493,37 @@ pub(crate) fn render_mcp_viewer( scroll: usize, theme: Color, ) { - let entry_key_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Entry { key, .. } = r { - Some(key.chars().count()) - } else { - None - } - }) - .max() - .unwrap_or(5); - let entry_val_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Entry { val, .. } = r { - Some(val.chars().count()) - } else { - None - } - }) - .max() - .unwrap_or(20); - let header_w = rows - .iter() - .filter_map(|r| { - if let ConfigRow::Header(h) = r { - Some(h.chars().count() + 2) - } else { - None + render_rows_popup(f, area, rows, scroll, " mcp servers ", 40, " ", theme); +} + +fn render_rows_popup( + f: &mut Frame, + area: Rect, + rows: &[ConfigRow], + scroll: usize, + title: &'static str, + min_popup_w: u16, + entry_prefix: &'static str, + theme: Color, +) { + let (entry_key_w, entry_val_w, item_w, header_w) = + rows.iter().fold((0, 0, 0, 0), |(ek, ev, iw, hw), row| match row { + ConfigRow::Entry { key, val } => { + (ek.max(key.chars().count()), ev.max(val.chars().count()), iw, hw) } - }) - .max() - .unwrap_or(0); - let content_w = (entry_key_w + 4 + entry_val_w).max(header_w); + ConfigRow::Item(s) => (ek, ev, iw.max(s.chars().count() + 4), hw), + ConfigRow::Header(h) => (ek, ev, iw, hw.max(h.chars().count() + 2)), + ConfigRow::Blank => (ek, ev, iw, hw), + }); + let content_w = (entry_key_w + 4 + entry_val_w).max(item_w).max(header_w); let popup_w = ((content_w + 6) as u16) - .max(40) + .max(min_popup_w) .min(area.width.saturating_sub(4)); let popup_h = ((rows.len() + 2) as u16) .max(6) .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); + let popup_rect = centered_popup(area, popup_w, popup_h); f.render_widget(Clear, popup_rect); @@ -651,7 +531,7 @@ pub(crate) fn render_mcp_viewer( let block = Block::default() .title( Line::from(Span::styled( - " mcp servers ", + title, Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), @@ -685,7 +565,7 @@ pub(crate) fn render_mcp_viewer( ConfigRow::Entry { key, val } => { let gap = " ".repeat(entry_key_w.saturating_sub(key.chars().count()) + 2); Line::from(vec![ - Span::raw(" "), + Span::raw(entry_prefix), Span::styled(key.clone(), Style::default().fg(theme)), Span::raw(gap), Span::styled(val.clone(), Style::default().fg(Color::White)), @@ -716,9 +596,7 @@ pub(crate) fn render_skills_viewer( let popup_h = ((items.len() + 4) as u16) .max(6) .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); + let popup_rect = centered_popup(area, popup_w, popup_h); f.render_widget(Clear, popup_rect); @@ -780,9 +658,7 @@ pub(crate) fn render_theme_picker( let popup_h = (THEME_COLORS.len() as u16 + 2) .max(5) .min(area.height.saturating_sub(4)); - let x = area.x + (area.width.saturating_sub(popup_w)) / 2; - let y = area.y + (area.height.saturating_sub(popup_h)) / 2; - let popup_rect = Rect::new(x, y, popup_w, popup_h); + let popup_rect = centered_popup(area, popup_w, popup_h); f.render_widget(Clear, popup_rect); From 8d85959edbbdbd2f9c2f970591e5bfd67e5ccc07 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:57:22 +0300 Subject: [PATCH 20/41] refactor(tui): extract highlight_row helper, deduplicate picker selection rendering --- src/tui/render.rs | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index 70dcd1c..5519a40 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -33,6 +33,18 @@ fn centered_popup(area: Rect, w: u16, h: u16) -> Rect { Rect::new(x, y, w, h) } +fn highlight_row(label: &str, inner_w: usize) -> Line<'static> { + let truncated: String = label.chars().take(inner_w).collect(); + let padding = " ".repeat(inner_w.saturating_sub(truncated.chars().count())); + Line::styled( + format!("{truncated}{padding}"), + Style::default() + .fg(Color::Black) + .bg(Color::White) + .add_modifier(Modifier::BOLD), + ) +} + pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec> { let inner_w = width.saturating_sub(2) as usize; let mut lines: Vec> = Vec::new(); @@ -350,16 +362,7 @@ pub(crate) fn render_model_picker( .map(|(i, (provider, model))| { let idx = start + i; if idx == selected { - let label = format!(" {provider} {model}"); - let truncated: String = label.chars().take(inner_w).collect(); - let padding = " ".repeat(inner_w.saturating_sub(truncated.chars().count())); - Line::styled( - format!("{truncated}{padding}"), - Style::default() - .fg(Color::Black) - .bg(Color::White) - .add_modifier(Modifier::BOLD), - ) + highlight_row(&format!(" {provider} {model}"), inner_w) } else { Line::from(vec![ Span::raw(" "), @@ -451,16 +454,7 @@ pub(crate) fn render_session_picker( let meta_str = format!("{date} · {model}"); if idx == selected { - let label = format!(" {title} {meta_str}"); - let truncated: String = label.chars().take(inner_w).collect(); - let padding = " ".repeat(inner_w.saturating_sub(truncated.chars().count())); - Line::styled( - format!("{truncated}{padding}"), - Style::default() - .fg(Color::Black) - .bg(Color::White) - .add_modifier(Modifier::BOLD), - ) + highlight_row(&format!(" {title} {meta_str}"), inner_w) } else { let gap = " ".repeat(max_title.saturating_sub(title.chars().count()) + 4); Line::from(vec![ From 0c6de3f502d3212d274a1ca1897c736cf2744958 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:57:55 +0300 Subject: [PATCH 21/41] refactor(tui): replace hand-rolled char boundary fns with str::floor/ceil_char_boundary --- src/tui/app.rs | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index a9aead0..94f1844 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -344,25 +344,25 @@ impl App { } KeyCode::Backspace => { if self.cursor > 0 { - let prev = prev_char_boundary(&self.input, self.cursor); + let prev = self.input.floor_char_boundary(self.cursor - 1); self.input.drain(prev..self.cursor); self.cursor = prev; } } KeyCode::Delete => { if self.cursor < self.input.len() { - let next = next_char_boundary(&self.input, self.cursor); + let next = self.input.ceil_char_boundary(self.cursor + 1); self.input.drain(self.cursor..next); } } KeyCode::Left => { if self.cursor > 0 { - self.cursor = prev_char_boundary(&self.input, self.cursor); + self.cursor = self.input.floor_char_boundary(self.cursor - 1); } } KeyCode::Right => { if self.cursor < self.input.len() { - self.cursor = next_char_boundary(&self.input, self.cursor); + self.cursor = self.input.ceil_char_boundary(self.cursor + 1); } } KeyCode::Home => self.cursor = 0, @@ -733,23 +733,3 @@ impl App { } } -fn prev_char_boundary(s: &str, pos: usize) -> usize { - let mut p = pos; - loop { - if p == 0 { - return 0; - } - p -= 1; - if s.is_char_boundary(p) { - return p; - } - } -} - -fn next_char_boundary(s: &str, pos: usize) -> usize { - let mut p = pos + 1; - while p <= s.len() && !s.is_char_boundary(p) { - p += 1; - } - p.min(s.len()) -} From d88025e06f855e411fde0fa016509d327470c4e4 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:58:46 +0300 Subject: [PATCH 22/41] chore(tui): drop map_err boilerplate on IO errors, use ? directly --- src/tui/mod.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0ea4149..b32b558 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -108,10 +108,9 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { switch_session_tx, ); - enable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; + enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen) - .map_err(|e| crate::error::Error::Other(e.to_string()))?; + execute!(stdout, EnterAlternateScreen)?; // Enable keyboard enhancement on supporting terminals so that arrow-key // escape sequences (\x1b[B etc.) are never ambiguously split into a @@ -129,8 +128,7 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } let backend = CrosstermBackend::new(stdout); - let mut terminal = - Terminal::new(backend).map_err(|e| crate::error::Error::Other(e.to_string()))?; + let mut terminal = Terminal::new(backend)?; let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { @@ -147,9 +145,7 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - terminal - .draw(|f| app.draw(f)) - .map_err(|e| crate::error::Error::Other(e.to_string()))?; + terminal.draw(|f| app.draw(f))?; if app.should_quit { break; @@ -180,12 +176,9 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { if kbd_enhanced { execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags).ok(); } - execute!(terminal.backend_mut(), LeaveAlternateScreen) - .map_err(|e| crate::error::Error::Other(e.to_string()))?; - disable_raw_mode().map_err(|e| crate::error::Error::Other(e.to_string()))?; - terminal - .show_cursor() - .map_err(|e| crate::error::Error::Other(e.to_string()))?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + disable_raw_mode()?; + terminal.show_cursor()?; Ok(()) } From 557dfc2b5f81a3e4ae2f5c97c6efc38f4675fd58 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 08:59:58 +0300 Subject: [PATCH 23/41] chore(tui): move imports before functions, make word_wrap private, render ToolResult errors in red --- src/tui/app.rs | 16 ++++++++-------- src/tui/render.rs | 17 +++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 94f1844..2d3b7f2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -8,6 +8,14 @@ use ratatui::{ }; use tokio::sync::mpsc; +use crate::{ + config::{AgentConfig, AppConfig}, + rag::{ConversationMeta, RagContext, SkillsManager}, +}; + +use super::render; +use super::types::{AgentUpdate, ChatItem, ConfigRow, Screen, Status}; + fn save_theme_to_config(name: &str) -> crate::error::Result<()> { let path = crate::config::config_path()?; let contents = std::fs::read_to_string(&path)?; @@ -45,14 +53,6 @@ fn save_theme_to_config(name: &str) -> crate::error::Result<()> { Ok(()) } -use crate::{ - config::{AgentConfig, AppConfig}, - rag::{ConversationMeta, RagContext, SkillsManager}, -}; - -use super::render; -use super::types::{AgentUpdate, ChatItem, ConfigRow, Screen, Status}; - pub(super) struct App { pub(super) items: Vec, pub(super) input: String, diff --git a/src/tui/render.rs b/src/tui/render.rs index 5519a40..88b6305 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -90,19 +90,17 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { + ChatItem::ToolResult { result, is_error } => { let flat: String = result .chars() .take(200) - .collect::() - .replace('\n', " "); + .map(|c| if c == '\n' { ' ' } else { c }) + .collect(); + let color = if *is_error { Color::Red } else { Color::DarkGray }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("→ ", Style::default().fg(Color::DarkGray)), - Span::styled( - flat.trim().to_string(), - Style::default().fg(Color::DarkGray), - ), + Span::styled("→ ", Style::default().fg(color)), + Span::styled(flat.trim().to_string(), Style::default().fg(color)), ])); } ChatItem::SystemInfo(text) => { @@ -706,8 +704,7 @@ pub(crate) fn render_theme_picker( f.render_widget(Paragraph::new(lines), inner); } -// Word-wraps `text` to `width` chars, preserving newlines as paragraph breaks. -pub(crate) fn word_wrap(text: &str, width: usize) -> Vec { +fn word_wrap(text: &str, width: usize) -> Vec { if width == 0 { return text.lines().map(String::from).collect(); } From edfde7be8bcb14447167386fc6b4abbf45950e43 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 09:04:38 +0300 Subject: [PATCH 24/41] revert(tui): keep ToolResult output dark gray regardless of error status --- src/tui/render.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index 88b6305..e9ab562 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -90,17 +90,16 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { + ChatItem::ToolResult { result, .. } => { let flat: String = result .chars() .take(200) .map(|c| if c == '\n' { ' ' } else { c }) .collect(); - let color = if *is_error { Color::Red } else { Color::DarkGray }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("→ ", Style::default().fg(color)), - Span::styled(flat.trim().to_string(), Style::default().fg(color)), + Span::styled("→ ", Style::default().fg(Color::DarkGray)), + Span::styled(flat.trim().to_string(), Style::default().fg(Color::DarkGray)), ])); } ChatItem::SystemInfo(text) => { From 63855d11f7fb7463a6e0ce566d745a433beba93a Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 10:47:14 +0300 Subject: [PATCH 25/41] fix: lint & clippy --- Cargo.toml | 2 +- src/transport/ws.rs | 11 +++-- src/tui/app.rs | 101 ++++++++++++++++++++++++++++++++++---------- src/tui/render.rs | 26 ++++++++---- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7b96f1c..eb035e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ homepage = "https://openheim.io" documentation = "https://docs.rs/openheim" keywords = ["agent", "llm", "ai", "mcp", "async"] categories = ["command-line-utilities", "asynchronous"] -rust-version = "1.85" +rust-version = "1.91" [package.metadata.docs.rs] rustdoc-args = ["--cfg", "docsrs"] diff --git a/src/transport/ws.rs b/src/transport/ws.rs index 2b7bb70..1d67591 100644 --- a/src/transport/ws.rs +++ b/src/transport/ws.rs @@ -473,12 +473,11 @@ fn validate_path_opt(workspace: &Option, path: &str) -> Option } else { if let Ok(cwd) = std::env::current_dir() { let from_cwd = cwd.join(&requested); - if from_cwd.exists() { - if let Ok(c) = from_cwd.canonicalize() { - if c.starts_with(&workspace_canonical) { - return Some(c); - } - } + if from_cwd.exists() + && let Ok(c) = from_cwd.canonicalize() + && c.starts_with(&workspace_canonical) + { + return Some(c); } } // Fallback: treat path as relative to the workspace root diff --git a/src/tui/app.rs b/src/tui/app.rs index 2d3b7f2..cb8d019 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -221,15 +221,33 @@ impl App { } fn handle_config_viewer_key(&mut self, key: KeyEvent) { - Self::handle_scroll_key(key, &mut self.config_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); + Self::handle_scroll_key( + key, + &mut self.config_scroll, + &mut self.should_quit, + &mut self.screen, + self.pre_picker_screen, + ); } fn handle_skills_viewer_key(&mut self, key: KeyEvent) { - Self::handle_scroll_key(key, &mut self.skills_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); + Self::handle_scroll_key( + key, + &mut self.skills_scroll, + &mut self.should_quit, + &mut self.screen, + self.pre_picker_screen, + ); } fn handle_mcp_viewer_key(&mut self, key: KeyEvent) { - Self::handle_scroll_key(key, &mut self.mcp_scroll, &mut self.should_quit, &mut self.screen, self.pre_picker_screen); + Self::handle_scroll_key( + key, + &mut self.mcp_scroll, + &mut self.should_quit, + &mut self.screen, + self.pre_picker_screen, + ); } fn handle_theme_picker_key(&mut self, key: KeyEvent) { @@ -302,12 +320,30 @@ impl App { pub(super) fn handle_key(&mut self, key: KeyEvent) { match self.screen { - Screen::ModelPicker => { self.handle_model_picker_key(key); return; } - Screen::ConfigViewer => { self.handle_config_viewer_key(key); return; } - Screen::SessionPicker => { self.handle_session_picker_key(key); return; } - Screen::SkillsViewer => { self.handle_skills_viewer_key(key); return; } - Screen::McpViewer => { self.handle_mcp_viewer_key(key); return; } - Screen::ThemePicker => { self.handle_theme_picker_key(key); return; } + Screen::ModelPicker => { + self.handle_model_picker_key(key); + return; + } + Screen::ConfigViewer => { + self.handle_config_viewer_key(key); + return; + } + Screen::SessionPicker => { + self.handle_session_picker_key(key); + return; + } + Screen::SkillsViewer => { + self.handle_skills_viewer_key(key); + return; + } + Screen::McpViewer => { + self.handle_mcp_viewer_key(key); + return; + } + Screen::ThemePicker => { + self.handle_theme_picker_key(key); + return; + } Screen::Welcome | Screen::Chat => {} } match key.code { @@ -572,17 +608,17 @@ impl App { match msg.role { Role::System => {} Role::User => { - if let Some(content) = &msg.content { - if !content.is_empty() { - self.push(ChatItem::UserMessage(content.clone())); - } + if let Some(content) = &msg.content + && !content.is_empty() + { + self.push(ChatItem::UserMessage(content.clone())); } } Role::Assistant => { - if let Some(content) = &msg.content { - if !content.is_empty() { - self.push(ChatItem::AssistantMessage(content.clone())); - } + if let Some(content) = &msg.content + && !content.is_empty() + { + self.push(ChatItem::AssistantMessage(content.clone())); } if let Some(tool_calls) = &msg.tool_calls { for tc in tool_calls { @@ -625,7 +661,11 @@ impl App { let [content_area, input_area] = [chunks[0], chunks[1]]; - let bg_screen = if self.screen.is_overlay() { self.pre_picker_screen } else { self.screen }; + let bg_screen = if self.screen.is_overlay() { + self.pre_picker_screen + } else { + self.screen + }; if bg_screen == Screen::Welcome { render::render_welcome( @@ -666,7 +706,13 @@ impl App { match self.screen { Screen::ModelPicker => { - render::render_model_picker(f, area, &self.picker_items, self.picker_selected, theme); + render::render_model_picker( + f, + area, + &self.picker_items, + self.picker_selected, + theme, + ); } Screen::ConfigViewer => { render::render_config_viewer(f, area, &self.config_rows, self.config_scroll, theme); @@ -675,13 +721,25 @@ impl App { render::render_session_picker(f, area, &self.sessions, self.picker_selected, theme); } Screen::SkillsViewer => { - render::render_skills_viewer(f, area, &self.skills_items, self.skills_scroll, theme); + render::render_skills_viewer( + f, + area, + &self.skills_items, + self.skills_scroll, + theme, + ); } Screen::McpViewer => { render::render_mcp_viewer(f, area, &self.mcp_rows, self.mcp_scroll, theme); } Screen::ThemePicker => { - render::render_theme_picker(f, area, self.theme_selected, &self.theme_color_name, theme); + render::render_theme_picker( + f, + area, + self.theme_selected, + &self.theme_color_name, + theme, + ); } Screen::Welcome | Screen::Chat => {} } @@ -732,4 +790,3 @@ impl App { f.render_widget(Paragraph::new(visible), chat_inner); } } - diff --git a/src/tui/render.rs b/src/tui/render.rs index e9ab562..7c4868c 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -99,7 +99,10 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { @@ -487,6 +490,7 @@ pub(crate) fn render_mcp_viewer( render_rows_popup(f, area, rows, scroll, " mcp servers ", 40, " ", theme); } +#[allow(clippy::too_many_arguments)] fn render_rows_popup( f: &mut Frame, area: Rect, @@ -498,14 +502,18 @@ fn render_rows_popup( theme: Color, ) { let (entry_key_w, entry_val_w, item_w, header_w) = - rows.iter().fold((0, 0, 0, 0), |(ek, ev, iw, hw), row| match row { - ConfigRow::Entry { key, val } => { - (ek.max(key.chars().count()), ev.max(val.chars().count()), iw, hw) - } - ConfigRow::Item(s) => (ek, ev, iw.max(s.chars().count() + 4), hw), - ConfigRow::Header(h) => (ek, ev, iw, hw.max(h.chars().count() + 2)), - ConfigRow::Blank => (ek, ev, iw, hw), - }); + rows.iter() + .fold((0, 0, 0, 0), |(ek, ev, iw, hw), row| match row { + ConfigRow::Entry { key, val } => ( + ek.max(key.chars().count()), + ev.max(val.chars().count()), + iw, + hw, + ), + ConfigRow::Item(s) => (ek, ev, iw.max(s.chars().count() + 4), hw), + ConfigRow::Header(h) => (ek, ev, iw, hw.max(h.chars().count() + 2)), + ConfigRow::Blank => (ek, ev, iw, hw), + }); let content_w = (entry_key_w + 4 + entry_val_w).max(item_w).max(header_w); let popup_w = ((content_w + 6) as u16) From 7ea1fa5382fe17e6515f1f591b0c1b864b302fe2 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:26:26 +0300 Subject: [PATCH 26/41] fix(acp): load full provider config in acp_session_load --- src/acp/mod.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/acp/mod.rs b/src/acp/mod.rs index 8db6c91..f11f11a 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -244,12 +244,22 @@ impl AgentState { .map_err(|e| Error::Other(e.to_string()))??; let mut session_config = self.config.clone(); - if let Some(model) = &conversation.meta.model { + if let Some(provider_name) = &conversation.meta.provider { + if let Some(provider_cfg) = self.app_config.providers.get(provider_name) { + session_config.provider_name = provider_name.clone(); + session_config.api_base = provider_cfg.api_base.clone(); + session_config.api_key = provider_cfg.resolve_api_key(); + session_config.timeout_secs = provider_cfg.timeout_secs.unwrap_or(120); + session_config.max_tokens = provider_cfg.max_tokens; + session_config.model = conversation + .meta + .model + .clone() + .unwrap_or_else(|| provider_cfg.default_model.clone()); + } + } else if let Some(model) = &conversation.meta.model { session_config.model = model.clone(); } - if let Some(provider) = &conversation.meta.provider { - session_config.provider_name = provider.clone(); - } self.sessions.write().await.insert( session_id.to_string(), From f56e1405cbfb49636dbc301ff98dacf783ae5701 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:33:19 +0300 Subject: [PATCH 27/41] fix: full (provider, model) pair on model switch --- src/acp/mod.rs | 3 ++- src/client.rs | 6 ++++-- src/config/resolve.rs | 20 ++++++++++++++++++++ src/tui/app.rs | 21 ++++++++++++++++----- src/tui/mod.rs | 6 +++--- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/acp/mod.rs b/src/acp/mod.rs index f11f11a..c6d16ae 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -90,9 +90,10 @@ impl AgentState { pub async fn acp_update_session_model( &self, session_id: &str, + provider: &str, model: &str, ) -> Result<(String, String)> { - let new_config = self.app_config.resolve(Some(model))?; + let new_config = self.app_config.resolve_with_provider(provider, model)?; let provider_name = new_config.provider_name.clone(); let model_name = new_config.model.clone(); let mut sessions = self.sessions.write().await; diff --git a/src/client.rs b/src/client.rs index 9548dac..cdb96ac 100644 --- a/src/client.rs +++ b/src/client.rs @@ -192,8 +192,10 @@ impl SessionHandle { /// The model must be listed under a provider in the config. Returns /// `(provider_name, model_name)` on success; the next prompt will use /// the new model while preserving conversation history. - pub async fn switch_model(&self, model: &str) -> Result<(String, String)> { - self.state.acp_update_session_model(&self.id, model).await + pub async fn switch_model(&self, provider: &str, model: &str) -> Result<(String, String)> { + self.state + .acp_update_session_model(&self.id, provider, model) + .await } /// Restore a persisted session as the active session for this handle. diff --git a/src/config/resolve.rs b/src/config/resolve.rs index 7913fd2..a872560 100644 --- a/src/config/resolve.rs +++ b/src/config/resolve.rs @@ -53,6 +53,26 @@ impl AppConfig { }) } + pub fn resolve_with_provider(&self, provider_name: &str, model: &str) -> Result { + let provider = self.providers.get(provider_name).ok_or_else(|| { + Error::config(format!( + "Provider '{}' not found in config. Available providers: {}", + provider_name, + self.provider_names() + )) + })?; + validate_provider(provider_name, provider)?; + Ok(AgentConfig { + provider_name: provider_name.to_string(), + api_base: provider.api_base.clone(), + api_key: provider.resolve_api_key(), + model: model.to_string(), + max_iterations: self.max_iterations, + timeout_secs: provider.timeout_secs.unwrap_or(120), + max_tokens: provider.max_tokens, + }) + } + fn resolve_model(&self, model_name: &str) -> Result { for (name, provider) in &self.providers { if provider.models.contains(&model_name.to_string()) { diff --git a/src/tui/app.rs b/src/tui/app.rs index cb8d019..6abe477 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -71,7 +71,7 @@ pub(super) struct App { cached_lines: Vec>, pub(super) cached_width: u16, prompt_tx: mpsc::UnboundedSender, - switch_model_tx: mpsc::UnboundedSender, + switch_model_tx: mpsc::UnboundedSender<(String, String)>, switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, picker_items: Vec<(String, String)>, picker_selected: usize, @@ -92,7 +92,7 @@ impl App { app_config: AppConfig, skills: Vec, prompt_tx: mpsc::UnboundedSender, - switch_model_tx: mpsc::UnboundedSender, + switch_model_tx: mpsc::UnboundedSender<(String, String)>, switch_session_tx: mpsc::UnboundedSender<(String, std::path::PathBuf)>, ) -> Self { let theme_name = app_config @@ -301,8 +301,10 @@ impl App { } } KeyCode::Enter => { - if let Some((_, model)) = self.picker_items.get(self.picker_selected) { - let _ = self.switch_model_tx.send(model.clone()); + if let Some((provider, model)) = self.picker_items.get(self.picker_selected) { + let _ = self + .switch_model_tx + .send((provider.clone(), model.clone())); } self.screen = Screen::Chat; } @@ -556,7 +558,16 @@ impl App { self.picker_selected = selected; self.push_screen(Screen::ModelPicker); } else { - let _ = self.switch_model_tx.send(arg.to_string()); + match self.app_config.resolve(Some(arg)) { + Ok(config) => { + let _ = self + .switch_model_tx + .send((config.provider_name, config.model)); + } + Err(e) => { + self.push(ChatItem::SystemInfo(format!("unknown model: {e}"))); + } + } } } "skills" => match SkillsManager::new().and_then(|m| m.list_skills()) { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b32b558..deb19d4 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -41,7 +41,7 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { let (update_tx, mut update_rx) = mpsc::unbounded_channel::(); let (prompt_tx, mut prompt_rx) = mpsc::unbounded_channel::(); - let (switch_model_tx, mut switch_model_rx) = mpsc::unbounded_channel::(); + let (switch_model_tx, mut switch_model_rx) = mpsc::unbounded_channel::<(String, String)>(); let (switch_session_tx, mut switch_session_rx) = mpsc::unbounded_channel::<(String, std::path::PathBuf)>(); @@ -68,8 +68,8 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } maybe_model = switch_model_rx.recv() => { match maybe_model { - Some(model) => { - match session.switch_model(&model).await { + Some((provider, model)) => { + match session.switch_model(&provider, &model).await { Ok((provider, model)) => { let _ = update_tx.send(AgentUpdate::ModelChanged { provider, model }); } From 2bb98191a846e4c4a2b227893983b287e80d4476 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:37:55 +0300 Subject: [PATCH 28/41] fix(tui): clear current session on restore session --- src/tui/app.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tui/app.rs b/src/tui/app.rs index 6abe477..8e570dd 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -610,6 +610,11 @@ impl App { fn open_session(&mut self, meta: &ConversationMeta) { use crate::core::models::Role; + self.items.clear(); + self.status = Status::Idle; + self.scroll = 0; + self.pinned = true; + let title = meta.title.as_deref().unwrap_or("(untitled)"); self.push(ChatItem::SystemInfo(format!("─── {title}"))); From b3080a3f42bd95d5687e46c80f9d63eb3ffcf503 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:42:11 +0300 Subject: [PATCH 29/41] feat: add TerminalGuard RAII --- src/tui/mod.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index deb19d4..5dd4677 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -7,6 +7,7 @@ use std::time::Duration; use agent_client_protocol::schema::{ContentBlock, SessionUpdate, ToolCallStatus}; use crossterm::{ + cursor::Show, event::{ Event, EventStream, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, @@ -23,6 +24,21 @@ use crate::{client::OpenheimClient, config::load_config}; use app::App; use types::AgentUpdate; +struct TerminalGuard { + kbd_enhanced: bool, +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if self.kbd_enhanced { + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); + } + let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), Show); + } +} + pub async fn run(skills: Vec) -> crate::error::Result<()> { let app_config = load_config()?; let agent_config = app_config.resolve(None)?; @@ -129,14 +145,10 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + let _guard = TerminalGuard { kbd_enhanced }; let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if kbd_enhanced { - let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); - } - let _ = execute!(io::stdout(), LeaveAlternateScreen); - let _ = disable_raw_mode(); original_hook(info); })); @@ -173,13 +185,6 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } } - if kbd_enhanced { - execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags).ok(); - } - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - disable_raw_mode()?; - terminal.show_cursor()?; - Ok(()) } From 0d576895e141de1d30c382edf077f006547fd976 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:44:37 +0300 Subject: [PATCH 30/41] fix: messages word wrap --- src/tui/render.rs | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/tui/render.rs b/src/tui/render.rs index 7c4868c..3455a68 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -56,10 +56,21 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { for wl in word_wrap(text, inner_w) { - lines.push(Line::from(Span::styled( - format!(" {wl}"), - Style::default().fg(Color::White), - ))); + if wl.chars().count() <= inner_w { + lines.push(Line::from(Span::styled( + format!(" {wl}"), + Style::default().fg(Color::White), + ))); + } else { + let chars: Vec = wl.chars().collect(); + for chunk in chars.chunks(inner_w.max(1)) { + let s: String = chunk.iter().collect(); + lines.push(Line::from(Span::styled( + format!(" {s}"), + Style::default().fg(Color::White), + ))); + } + } } lines.push(Line::default()); } @@ -136,7 +147,20 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec Vec> { let content_max = 50usize.min(width.saturating_sub(8) as usize).max(1); - let wrapped = word_wrap(text, content_max); + let wrapped = { + let mut out = Vec::new(); + for line in word_wrap(text, content_max) { + if line.chars().count() <= content_max { + out.push(line); + } else { + let chars: Vec = line.chars().collect(); + for chunk in chars.chunks(content_max) { + out.push(chunk.iter().collect()); + } + } + } + out + }; let content_w = wrapped .iter() .map(|l| l.chars().count()) From 3373eca1a4ef0dca46b90b39451b2cf4db2e8317 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 16:46:42 +0300 Subject: [PATCH 31/41] fix: fmt --- src/tui/app.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 8e570dd..092984a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -302,9 +302,7 @@ impl App { } KeyCode::Enter => { if let Some((provider, model)) = self.picker_items.get(self.picker_selected) { - let _ = self - .switch_model_tx - .send((provider.clone(), model.clone())); + let _ = self.switch_model_tx.send((provider.clone(), model.clone())); } self.screen = Screen::Chat; } From ed4e1f4340e775aeab5f5c7c2c9a912f76cb9989 Mon Sep 17 00:00:00 2001 From: themartto Date: Sat, 23 May 2026 23:54:19 +0300 Subject: [PATCH 32/41] fix: warn when restored session provider is not configured --- src/acp/mod.rs | 8 ++++++++ src/tui/app.rs | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/src/acp/mod.rs b/src/acp/mod.rs index c6d16ae..8ec21c9 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -257,6 +257,14 @@ impl AgentState { .model .clone() .unwrap_or_else(|| provider_cfg.default_model.clone()); + } else { + let warning = format!( + "[warning] Provider '{}' from this session is not configured. Falling back to the default provider '{}'.", + provider_name, session_config.provider_name + ); + on_update(SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::from(warning), + ))); } } else if let Some(model) = &conversation.meta.model { session_config.model = model.clone(); diff --git a/src/tui/app.rs b/src/tui/app.rs index 092984a..55b4243 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -657,6 +657,15 @@ impl App { Err(e) => self.push(ChatItem::Err(e.to_string())), } + if let Some(provider_name) = &meta.provider { + if !self.app_config.providers.contains_key(provider_name.as_str()) { + self.push(ChatItem::SystemInfo(format!( + "warning: provider '{}' is not configured; using default provider instead.", + provider_name + ))); + } + } + self.push(ChatItem::SystemInfo("─── session restored".to_string())); let cwd = meta From 61ec0de2844a8ce3ab80824563aae37f9ad5aa11 Mon Sep 17 00:00:00 2001 From: themartto Date: Sun, 24 May 2026 00:00:25 +0300 Subject: [PATCH 33/41] fix: add model-whitelist check in `resolve_with_provide --- src/config/resolve.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/config/resolve.rs b/src/config/resolve.rs index a872560..36243f0 100644 --- a/src/config/resolve.rs +++ b/src/config/resolve.rs @@ -62,6 +62,14 @@ impl AppConfig { )) })?; validate_provider(provider_name, provider)?; + if !provider.models.is_empty() && !provider.models.contains(&model.to_string()) { + return Err(Error::config(format!( + "Model '{}' is not allowed for provider '{}'. Allowed models: [{}]", + model, + provider_name, + provider.models.join(", ") + ))); + } Ok(AgentConfig { provider_name: provider_name.to_string(), api_base: provider.api_base.clone(), @@ -225,4 +233,33 @@ mod tests { let p = provider_with_base("https://api.example.com"); assert!(validate_provider("test", &p).is_ok()); } + + #[test] + fn resolve_with_provider_rejects_unlisted_model() { + let config = sample_config(); + let err = config + .resolve_with_provider("openai", "gpt-99") + .unwrap_err(); + assert!(err.to_string().contains("gpt-99")); + assert!(err.to_string().contains("openai")); + } + + #[test] + fn resolve_with_provider_accepts_listed_model() { + let config = sample_config(); + let agent = config + .resolve_with_provider("openai", "gpt-3.5-turbo") + .unwrap(); + assert_eq!(agent.model, "gpt-3.5-turbo"); + } + + #[test] + fn resolve_with_provider_allows_any_model_when_list_empty() { + let mut config = sample_config(); + config.providers.get_mut("openai").unwrap().models.clear(); + let agent = config + .resolve_with_provider("openai", "any-future-model") + .unwrap(); + assert_eq!(agent.model, "any-future-model"); + } } From acd30eb9910d42c3f0ff26a2282ff42e2881c0a7 Mon Sep 17 00:00:00 2001 From: themartto Date: Sun, 24 May 2026 00:01:52 +0300 Subject: [PATCH 34/41] fix: fmt --- src/tui/app.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 55b4243..ac58cd0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -658,7 +658,11 @@ impl App { } if let Some(provider_name) = &meta.provider { - if !self.app_config.providers.contains_key(provider_name.as_str()) { + if !self + .app_config + .providers + .contains_key(provider_name.as_str()) + { self.push(ChatItem::SystemInfo(format!( "warning: provider '{}' is not configured; using default provider instead.", provider_name From ac71bd244ff8d319a085a675b4aee1086dcbbe3e Mon Sep 17 00:00:00 2001 From: themartto Date: Sun, 24 May 2026 00:05:19 +0300 Subject: [PATCH 35/41] fix: clippy --- src/tui/app.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index ac58cd0..af524dc 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -657,8 +657,8 @@ impl App { Err(e) => self.push(ChatItem::Err(e.to_string())), } - if let Some(provider_name) = &meta.provider { - if !self + if let Some(provider_name) = &meta.provider + && !self .app_config .providers .contains_key(provider_name.as_str()) @@ -668,7 +668,6 @@ impl App { provider_name ))); } - } self.push(ChatItem::SystemInfo("─── session restored".to_string())); From 90bb3a9882bf352c22e64983888e0395673a91f3 Mon Sep 17 00:00:00 2001 From: themartto Date: Sun, 24 May 2026 00:06:57 +0300 Subject: [PATCH 36/41] fix: fmt --- src/tui/app.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index af524dc..ecc079f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -662,12 +662,12 @@ impl App { .app_config .providers .contains_key(provider_name.as_str()) - { - self.push(ChatItem::SystemInfo(format!( - "warning: provider '{}' is not configured; using default provider instead.", - provider_name - ))); - } + { + self.push(ChatItem::SystemInfo(format!( + "warning: provider '{}' is not configured; using default provider instead.", + provider_name + ))); + } self.push(ChatItem::SystemInfo("─── session restored".to_string())); From 04dfbc41a8ebdd60ed8b20b4cffcfd630ad0fa42 Mon Sep 17 00:00:00 2001 From: themartto Date: Wed, 27 May 2026 05:45:04 +0300 Subject: [PATCH 37/41] fix(mcp): kill child process on drop --- src/mcp/client.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 8f1a2fd..ec39d61 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -38,6 +38,7 @@ impl McpClient { } else if let Some(ref command) = config.command { let mut cmd = tokio::process::Command::new(command); cmd.args(&config.args); + cmd.kill_on_drop(true); for (k, v) in &config.env { cmd.env(k, v); } From 6a1e33355b37543d5cd23772010fedf65f4562d7 Mon Sep 17 00:00:00 2001 From: themartto Date: Wed, 27 May 2026 05:45:12 +0300 Subject: [PATCH 38/41] feat: real token streaming and thinking display for all LLM providers --- src/acp/mod.rs | 17 ++- src/core/agent.rs | 65 ++++++-- src/core/llm/anthropic.rs | 242 ++++++++++++++++++++++++++++-- src/core/llm/gemini.rs | 153 ++++++++++++++++--- src/core/llm/mod.rs | 29 ++++ src/core/llm/openai.rs | 187 ++++++++++++++++++++++- src/core/llm/openai_compatible.rs | 24 ++- src/core/llm/retry.rs | 12 +- src/core/models.rs | 3 + src/tui/app.rs | 8 + src/tui/mod.rs | 23 ++- src/tui/render.rs | 16 ++ src/tui/types.rs | 2 + 13 files changed, 725 insertions(+), 56 deletions(-) diff --git a/src/acp/mod.rs b/src/acp/mod.rs index 8ec21c9..9690d51 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -13,8 +13,8 @@ use agent_client_protocol::{ InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, SessionCapabilities, SessionInfo, SessionListCapabilities, SessionNotification, - SessionUpdate, StopReason, ToolCall as AcpToolCall, ToolCallStatus, ToolCallUpdate, - ToolCallUpdateFields, + SessionUpdate, StopReason, TextContent, ToolCall as AcpToolCall, ToolCallStatus, + ToolCallUpdate, ToolCallUpdateFields, }, util::internal_error, }; @@ -160,6 +160,19 @@ impl AgentState { ContentBlock::from(content), ))); } + StreamEvent::ThinkingContent { content } => { + // Tunnel thinking through ContentBlock::Text using a meta tag so + // it survives the ACP layer (ContentBlock has no Thinking variant). + let mut meta = serde_json::Map::new(); + meta.insert( + "kind".to_string(), + serde_json::Value::String("thinking".to_string()), + ); + let text = TextContent::new(content).meta(meta); + on_update(SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::Text(text), + ))); + } StreamEvent::ToolCall { tool_name, arguments, diff --git a/src/core/agent.rs b/src/core/agent.rs index 886c3c8..c3b3dd5 100644 --- a/src/core/agent.rs +++ b/src/core/agent.rs @@ -1,14 +1,14 @@ use std::sync::Arc; +use tokio::sync::mpsc; + use crate::config::AgentConfig; -use crate::core::llm::LlmClient; +use crate::core::llm::{LlmChunk, LlmClient}; use crate::core::models::*; use crate::error::Result; use crate::rag::PromptBuilder; use crate::tools::ToolExecutor; -/// Sends a request to the LLM, optionally applying a [`PromptBuilder`] to inject -/// skill-based system content before the message history. async fn call_llm( llm: &Arc, messages: &[Message], @@ -24,6 +24,22 @@ async fn call_llm( } } +async fn call_llm_streaming( + llm: &Arc, + messages: &[Message], + tools: &[Tool], + prompt_builder: Option<&PromptBuilder>, + chunk_tx: mpsc::UnboundedSender, +) -> Result { + match prompt_builder { + Some(builder) => { + let built = builder.build(messages); + llm.send_streaming(&built, tools, chunk_tx).await + } + None => llm.send_streaming(messages, tools, chunk_tx).await, + } +} + /// Core agent loop: repeatedly calls the LLM and executes tool calls until a /// final text response with `finish_reason == "stop"` is produced or /// `config.max_iterations` is reached. @@ -58,7 +74,40 @@ where }); } - let choice = call_llm(llm, messages, &tools, prompt_builder).await?; + let choice = if callback.is_some() { + let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::(); + let choice_fut = + call_llm_streaming(llm, messages, &tools, prompt_builder, chunk_tx); + tokio::pin!(choice_fut); + + let mut maybe_choice: Option> = None; + loop { + tokio::select! { + result = &mut choice_fut, if maybe_choice.is_none() => { + maybe_choice = Some(result); + } + maybe_chunk = chunk_rx.recv() => { + match maybe_chunk { + Some(LlmChunk::Text(text)) => { + if let Some(cb) = callback.as_mut() { + cb(StreamEvent::LlmResponse { content: text }); + } + } + Some(LlmChunk::Thinking(thought)) => { + if let Some(cb) = callback.as_mut() { + cb(StreamEvent::ThinkingContent { content: thought }); + } + } + None => break, + } + } + } + } + maybe_choice + .unwrap_or_else(|| Err(crate::error::Error::Other("stream ended prematurely".into())))? + } else { + call_llm(llm, messages, &tools, prompt_builder).await? + }; messages.push(choice.message.clone()); if let Some(tool_calls) = &choice.message.tool_calls { @@ -108,12 +157,8 @@ where tool_calls: Some(tool_results), }); } else if let Some(content) = &choice.message.content { - if let Some(cb) = callback.as_mut() { - cb(StreamEvent::LlmResponse { - content: content.clone(), - }); - } - + // LlmResponse chunks already fired per-token from the streaming select + // loop above; just record the final text here. final_response = content.clone(); steps.push(AgentStep { diff --git a/src/core/llm/anthropic.rs b/src/core/llm/anthropic.rs index 3a75053..df06465 100644 --- a/src/core/llm/anthropic.rs +++ b/src/core/llm/anthropic.rs @@ -2,11 +2,12 @@ use async_trait::async_trait; use reqwest::Client as ReqwestClient; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tokio::sync::mpsc; use crate::core::models::{Choice, FunctionCall, Message, Role, Tool, ToolCall}; use crate::error::{Error, Result}; -use super::LlmClient; +use super::{LlmChunk, LlmClient}; const ANTHROPIC_VERSION: &str = "2023-06-01"; const DEFAULT_MAX_TOKENS: u32 = 4096; @@ -48,11 +49,21 @@ impl AnthropicClient { struct AnthropicRequest { model: String, max_tokens: u32, + stream: bool, #[serde(skip_serializing_if = "Option::is_none")] system: Option, messages: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, +} + +#[derive(Debug, Serialize)] +struct AnthropicThinkingConfig { + #[serde(rename = "type")] + thinking_type: &'static str, + budget_tokens: u32, } #[derive(Debug, Serialize)] @@ -244,28 +255,51 @@ fn convert_response(resp: AnthropicResponse) -> Choice { } } +fn extract_system(messages: &[Message]) -> Option { + let parts: Vec<&str> = messages + .iter() + .filter(|m| m.role == Role::System) + .filter_map(|m| m.content.as_deref()) + .collect(); + if parts.is_empty() { None } else { Some(parts.join("\n\n")) } +} + +/// Returns a thinking config if the model supports extended thinking and +/// `max_tokens` is large enough to accommodate a reasonable budget. +fn thinking_config(model: &str, max_tokens: u32) -> Option { + let supported = model.contains("claude-3-7") + || (model.starts_with("claude-") && model.contains("-4-")); + if !supported || max_tokens < 2048 { + return None; + } + // Leave at least 1024 tokens for the actual response. + let budget = 5000u32.min(max_tokens - 1024); + Some(AnthropicThinkingConfig { + thinking_type: "enabled", + budget_tokens: budget, + }) +} + +/// Returns the interleaved-thinking beta header value for models that need it. +fn thinking_beta(model: &str) -> Option<&'static str> { + if model.contains("claude-3-7") { + Some("interleaved-thinking-2025-05-14") + } else { + None + } +} + #[async_trait] impl LlmClient for AnthropicClient { async fn send(&self, messages: &[Message], tools: &[Tool]) -> Result { - let system: Option = { - let parts: Vec<&str> = messages - .iter() - .filter(|m| m.role == Role::System) - .filter_map(|m| m.content.as_deref()) - .collect(); - if parts.is_empty() { - None - } else { - Some(parts.join("\n\n")) - } - }; - let request = AnthropicRequest { model: self.model.clone(), max_tokens: self.max_tokens, - system, + stream: false, + system: extract_system(messages), messages: convert_messages(messages)?, tools: convert_tools(tools), + thinking: None, }; let endpoint = format!("{}/messages", self.api_base.trim_end_matches('/')); @@ -295,6 +329,184 @@ impl LlmClient for AnthropicClient { Ok(convert_response(anthropic_response)) } + + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + let thinking = thinking_config(&self.model, self.max_tokens); + + let request = AnthropicRequest { + model: self.model.clone(), + max_tokens: self.max_tokens, + stream: true, + system: extract_system(messages), + messages: convert_messages(messages)?, + tools: convert_tools(tools), + thinking, + }; + + let endpoint = format!("{}/messages", self.api_base.trim_end_matches('/')); + + let mut req = self + .client + .post(&endpoint) + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("Content-Type", "application/json"); + + if let Some(beta) = thinking_beta(&self.model) { + req = req.header("anthropic-beta", beta); + } + + let response = req + .json(&request) + .send() + .await + .map_err(Error::ReqwestError)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".into()); + return Err(Error::HttpError { status, body }); + } + + // Parse SSE stream. + let mut buf = String::new(); + let mut current_block_type: Option = None; + let mut current_tool_id: Option = None; + let mut current_tool_name: Option = None; + let mut current_tool_json = String::new(); + let mut text_content = String::new(); + let mut tool_calls: Vec = Vec::new(); + let mut stop_reason: Option = None; + + let mut response = response; + while let Some(chunk) = response.chunk().await.map_err(Error::ReqwestError)? { + buf.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(nl_pos) = buf.find('\n') { + let line = buf[..nl_pos].trim_end_matches('\r').to_string(); + buf.drain(..=nl_pos); + + let data = match line.strip_prefix("data: ") { + Some(d) => d.trim(), + None => continue, + }; + + if data == "[DONE]" || data.is_empty() { + continue; + } + + let event: Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(_) => continue, + }; + + match event["type"].as_str().unwrap_or("") { + "content_block_start" => { + let block_type = event["content_block"]["type"] + .as_str() + .unwrap_or("") + .to_string(); + if block_type == "tool_use" { + current_tool_id = + event["content_block"]["id"].as_str().map(String::from); + current_tool_name = + event["content_block"]["name"].as_str().map(String::from); + current_tool_json.clear(); + } + current_block_type = Some(block_type); + } + "content_block_delta" => { + let delta = &event["delta"]; + match delta["type"].as_str().unwrap_or("") { + "text_delta" => { + if let Some(text) = delta["text"].as_str() { + text_content.push_str(text); + let _ = chunk_tx.send(LlmChunk::Text(text.to_string())); + } + } + "thinking_delta" => { + if let Some(thinking) = delta["thinking"].as_str() { + let _ = + chunk_tx.send(LlmChunk::Thinking(thinking.to_string())); + } + } + "input_json_delta" => { + if let Some(partial) = delta["partial_json"].as_str() { + current_tool_json.push_str(partial); + } + } + _ => {} + } + } + "content_block_stop" => { + if current_block_type.as_deref() == Some("tool_use") { + if let (Some(id), Some(name)) = + (current_tool_id.take(), current_tool_name.take()) + { + tool_calls.push(ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name, + arguments: current_tool_json.clone(), + }, + }); + } + current_tool_json.clear(); + } + current_block_type = None; + } + "message_delta" => { + if let Some(reason) = event["delta"]["stop_reason"].as_str() { + stop_reason = Some(reason.to_string()); + } + } + "error" => { + let msg = event["error"]["message"] + .as_str() + .unwrap_or("unknown streaming error") + .to_string(); + return Err(Error::Other(format!("Anthropic streaming error: {msg}"))); + } + _ => {} + } + } + } + + let finish_reason = match stop_reason.as_deref() { + Some("tool_use") => Some("tool_calls".to_string()), + Some("end_turn") => Some("stop".to_string()), + other => other.map(|s| s.to_string()), + }; + + Ok(Choice { + message: Message { + role: Role::Assistant, + content: if text_content.is_empty() { + None + } else { + Some(text_content) + }, + tool_calls: if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + }, + tool_call_id: None, + tool_name: None, + is_error: false, + }, + finish_reason, + }) + } } #[cfg(test)] diff --git a/src/core/llm/gemini.rs b/src/core/llm/gemini.rs index 7f1089e..6045766 100644 --- a/src/core/llm/gemini.rs +++ b/src/core/llm/gemini.rs @@ -2,11 +2,12 @@ use async_trait::async_trait; use reqwest::Client as ReqwestClient; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tokio::sync::mpsc; use crate::core::models::{Choice, FunctionCall, Message, Role, Tool, ToolCall}; use crate::error::{Error, Result}; -use super::LlmClient; +use super::{LlmClient, LlmChunk}; #[derive(Clone)] pub struct GeminiClient { @@ -280,28 +281,30 @@ fn convert_response(resp: GeminiResponse) -> Result { }) } +fn gemini_system_instruction(messages: &[Message]) -> Option { + let parts: Vec<&str> = messages + .iter() + .filter(|m| m.role == Role::System) + .filter_map(|m| m.content.as_deref()) + .collect(); + if parts.is_empty() { + None + } else { + Some(GeminiContent { + role: "user".to_string(), + parts: vec![GeminiPart { + text: Some(parts.join("\n\n")), + function_call: None, + function_response: None, + }], + }) + } +} + #[async_trait] impl LlmClient for GeminiClient { async fn send(&self, messages: &[Message], tools: &[Tool]) -> Result { - let system_instruction: Option = { - let parts: Vec<&str> = messages - .iter() - .filter(|m| m.role == Role::System) - .filter_map(|m| m.content.as_deref()) - .collect(); - if parts.is_empty() { - None - } else { - Some(GeminiContent { - role: "user".to_string(), - parts: vec![GeminiPart { - text: Some(parts.join("\n\n")), - function_call: None, - function_response: None, - }], - }) - } - }; + let system_instruction = gemini_system_instruction(messages); let request = GeminiRequest { contents: convert_messages(messages)?, @@ -341,6 +344,116 @@ impl LlmClient for GeminiClient { convert_response(gemini_response) } + + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + let request = GeminiRequest { + contents: convert_messages(messages)?, + tools: convert_tools(tools), + generation_config: self.max_tokens.map(|t| GeminiGenerationConfig { + max_output_tokens: t, + }), + system_instruction: gemini_system_instruction(messages), + }; + + let endpoint = format!( + "{}/models/{}:streamGenerateContent", + self.api_base.trim_end_matches('/'), + self.model + ); + + let mut response = self + .client + .post(&endpoint) + .query(&[("key", &self.api_key), ("alt", &"sse".to_string())]) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(Error::ReqwestError)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".into()); + return Err(Error::HttpError { status, body }); + } + + let mut text_buf = String::new(); + let mut tool_calls: Vec = Vec::new(); + let mut finish_reason: Option = None; + let mut line_buf = String::new(); + + while let Some(bytes) = response.chunk().await.map_err(Error::ReqwestError)? { + line_buf.push_str(&String::from_utf8_lossy(&bytes)); + + loop { + let Some(pos) = line_buf.find('\n') else { break }; + let line = line_buf[..pos].trim_end_matches('\r').to_string(); + line_buf = line_buf[pos + 1..].to_string(); + + if line.is_empty() || line.starts_with(':') { + continue; + } + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + + let Ok(event) = serde_json::from_str::(data) else { + continue; + }; + + let Some(candidate) = event.candidates.into_iter().next() else { + continue; + }; + + if let Some(fr) = candidate.finish_reason { + finish_reason = Some(match fr.as_str() { + "STOP" => "stop".to_string(), + "MAX_TOKENS" => "length".to_string(), + other => other.to_lowercase(), + }); + } + + for part in candidate.content.parts { + if let Some(text) = part.text { + if !text.is_empty() { + text_buf.push_str(&text); + let _ = chunk_tx.send(LlmChunk::Text(text)); + } + } + if let Some(fc) = part.function_call { + tool_calls.push(ToolCall { + id: format!("call_{}", tool_calls.len()), + call_type: "function".to_string(), + function: FunctionCall { + name: fc.name, + arguments: serde_json::to_string(&fc.args).unwrap_or_default(), + }, + }); + } + } + } + } + + Ok(Choice { + message: Message { + role: Role::Assistant, + content: if text_buf.is_empty() { None } else { Some(text_buf) }, + tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, + tool_call_id: None, + tool_name: None, + is_error: false, + }, + finish_reason, + }) + } } #[cfg(test)] diff --git a/src/core/llm/mod.rs b/src/core/llm/mod.rs index 8c209ac..2f019d7 100644 --- a/src/core/llm/mod.rs +++ b/src/core/llm/mod.rs @@ -5,10 +5,20 @@ mod openai_compatible; mod retry; use async_trait::async_trait; +use tokio::sync::mpsc; use crate::core::models::{Choice, Message, Tool}; use crate::error::Result; +/// A single streaming chunk produced during an LLM call. +#[derive(Debug)] +pub enum LlmChunk { + /// A token or partial text from the model's response. + Text(String), + /// A chunk from the model's extended thinking (reasoning). + Thinking(String), +} + /// Abstraction over a chat-completion API. /// /// Implement this trait to add a custom provider. The built-in implementations @@ -18,6 +28,25 @@ use crate::error::Result; pub trait LlmClient: Send + Sync { /// Send a chat request and return the first choice from the provider. async fn send(&self, messages: &[Message], tools: &[Tool]) -> Result; + + /// Streaming variant: sends [`LlmChunk`]s to `chunk_tx` as they arrive, + /// then returns the complete [`Choice`] once the response is finished. + /// + /// The default implementation calls [`LlmClient::send`] and emits the full + /// response content as a single [`LlmChunk::Text`]. Override this to enable + /// real token-by-token streaming. + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + let choice = self.send(messages, tools).await?; + if let Some(ref content) = choice.message.content { + let _ = chunk_tx.send(LlmChunk::Text(content.clone())); + } + Ok(choice) + } } pub use anthropic::AnthropicClient; diff --git a/src/core/llm/openai.rs b/src/core/llm/openai.rs index c672c84..055f153 100644 --- a/src/core/llm/openai.rs +++ b/src/core/llm/openai.rs @@ -1,10 +1,13 @@ use async_trait::async_trait; use reqwest::Client as ReqwestClient; +use tokio::sync::mpsc; -use crate::core::models::{ChatRequest, ChatResponse, Choice, Message, Tool}; +use crate::core::models::{ + ChatRequest, ChatResponse, Choice, FunctionCall, Message, Role, Tool, ToolCall, +}; use crate::error::{Error, Result}; -use super::LlmClient; +use super::{LlmClient, LlmChunk}; #[derive(Clone)] pub struct OpenAiClient { @@ -78,6 +81,167 @@ pub(super) async fn send_openai_style( .ok_or_else(|| Error::ApiError("No response from LLM".to_string())) } +pub(super) async fn send_openai_style_streaming( + client: &ReqwestClient, + api_base: &str, + api_key: &str, + model: &str, + max_tokens: Option, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, +) -> Result { + let request = ChatRequest { + model: model.to_string(), + messages: messages.to_vec(), + tools: tools.to_vec(), + max_tokens, + }; + + let mut body = + serde_json::to_value(&request).map_err(|e| Error::ParseError(e.to_string()))?; + body["stream"] = serde_json::Value::Bool(true); + + let endpoint = format!("{}/chat/completions", api_base.trim_end_matches('/')); + + let mut response = client + .post(&endpoint) + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(Error::ReqwestError)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".into()); + return Err(Error::HttpError { status, body }); + } + + struct ToolCallAcc { + id: String, + name: String, + args: String, + } + + let mut text_buf = String::new(); + let mut tool_acc: Vec = Vec::new(); + let mut finish_reason: Option = None; + let mut line_buf = String::new(); + let mut done = false; + + while !done { + let Some(bytes) = response.chunk().await.map_err(Error::ReqwestError)? else { + break; + }; + line_buf.push_str(&String::from_utf8_lossy(&bytes)); + + loop { + let Some(pos) = line_buf.find('\n') else { break }; + let line = line_buf[..pos].trim_end_matches('\r').to_string(); + line_buf = line_buf[pos + 1..].to_string(); + + if line.is_empty() || line.starts_with(':') { + continue; + } + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + + if data == "[DONE]" { + done = true; + break; + } + + let Ok(event) = serde_json::from_str::(data) else { + continue; + }; + + let choice = &event["choices"][0]; + + if let Some(fr) = choice["finish_reason"].as_str() { + finish_reason = Some(fr.to_string()); + } + + let delta = &choice["delta"]; + + if let Some(reasoning) = delta["reasoning_content"].as_str() { + if !reasoning.is_empty() { + let _ = chunk_tx.send(LlmChunk::Thinking(reasoning.to_string())); + } + } + + if let Some(content) = delta["content"].as_str() { + if !content.is_empty() { + text_buf.push_str(content); + let _ = chunk_tx.send(LlmChunk::Text(content.to_string())); + } + } + + if let Some(tcs) = delta["tool_calls"].as_array() { + for tc in tcs { + let idx = tc["index"].as_u64().unwrap_or(0) as usize; + while tool_acc.len() <= idx { + tool_acc.push(ToolCallAcc { + id: String::new(), + name: String::new(), + args: String::new(), + }); + } + if let Some(id) = tc["id"].as_str() { + tool_acc[idx].id = id.to_string(); + } + if let Some(name) = tc["function"]["name"].as_str() { + tool_acc[idx].name.push_str(name); + } + if let Some(args) = tc["function"]["arguments"].as_str() { + tool_acc[idx].args.push_str(args); + } + } + } + } + } + + let content = if text_buf.is_empty() { None } else { Some(text_buf) }; + let tool_calls: Vec = tool_acc + .into_iter() + .enumerate() + .filter(|(_, tc)| !tc.name.is_empty()) + .map(|(i, tc)| ToolCall { + id: if tc.id.is_empty() { + format!("call_{i}") + } else { + tc.id + }, + call_type: "function".to_string(), + function: FunctionCall { + name: tc.name, + arguments: tc.args, + }, + }) + .collect(); + + Ok(Choice { + message: Message { + role: Role::Assistant, + content, + tool_calls: if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + }, + tool_call_id: None, + tool_name: None, + is_error: false, + }, + finish_reason, + }) +} + #[async_trait] impl LlmClient for OpenAiClient { async fn send(&self, messages: &[Message], tools: &[Tool]) -> Result { @@ -92,4 +256,23 @@ impl LlmClient for OpenAiClient { ) .await } + + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + send_openai_style_streaming( + &self.client, + &self.api_base, + &self.api_key, + &self.model, + self.max_tokens, + messages, + tools, + chunk_tx, + ) + .await + } } diff --git a/src/core/llm/openai_compatible.rs b/src/core/llm/openai_compatible.rs index 925c176..d3bbf60 100644 --- a/src/core/llm/openai_compatible.rs +++ b/src/core/llm/openai_compatible.rs @@ -1,11 +1,12 @@ use async_trait::async_trait; use reqwest::Client as ReqwestClient; +use tokio::sync::mpsc; use crate::core::models::{Choice, Message, Tool}; use crate::error::Result; -use super::LlmClient; -use super::openai::send_openai_style; +use super::{LlmClient, LlmChunk}; +use super::openai::{send_openai_style, send_openai_style_streaming}; #[derive(Clone)] pub struct OpenAiCompatibleClient { @@ -48,4 +49,23 @@ impl LlmClient for OpenAiCompatibleClient { ) .await } + + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + send_openai_style_streaming( + &self.client, + &self.api_base, + &self.api_key, + &self.model, + self.max_tokens, + messages, + tools, + chunk_tx, + ) + .await + } } diff --git a/src/core/llm/retry.rs b/src/core/llm/retry.rs index 00722dc..1ed1c39 100644 --- a/src/core/llm/retry.rs +++ b/src/core/llm/retry.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use tokio::sync::mpsc; use tokio::time::sleep; -use super::LlmClient; +use super::{LlmChunk, LlmClient}; use crate::core::models::{Choice, Message, Tool}; use crate::error::Result; @@ -44,6 +45,15 @@ impl LlmClient for RetryClient { } unreachable!("loop always returns via Ok or Err arm") } + + async fn send_streaming( + &self, + messages: &[Message], + tools: &[Tool], + chunk_tx: mpsc::UnboundedSender, + ) -> Result { + self.inner.send_streaming(messages, tools, chunk_tx).await + } } #[cfg(test)] diff --git a/src/core/models.rs b/src/core/models.rs index ea03a6c..6bb067c 100644 --- a/src/core/models.rs +++ b/src/core/models.rs @@ -179,6 +179,9 @@ pub enum StreamEvent { /// A chunk of text from the LLM. #[serde(rename = "llm_response")] LlmResponse { content: String }, + /// A chunk of the model's internal reasoning (extended thinking). + #[serde(rename = "thinking_content")] + ThinkingContent { content: String }, /// The agent has finished; `final_response` is the complete answer. #[serde(rename = "finished")] Finished { diff --git a/src/tui/app.rs b/src/tui/app.rs index ecc079f..7b64307 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -150,6 +150,14 @@ impl App { } self.cached_width = 0; } + AgentUpdate::ThinkingChunk(text) => { + self.status = Status::Streaming; + match self.items.last_mut() { + Some(ChatItem::Thinking(existing)) => existing.push_str(&text), + _ => self.items.push(ChatItem::Thinking(text)), + } + self.cached_width = 0; + } AgentUpdate::ToolCall { name, args } => { self.status = Status::Thinking; self.push(ChatItem::ToolCall { name, args }); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5dd4677..6201920 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -61,7 +61,7 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { let (switch_session_tx, mut switch_session_rx) = mpsc::unbounded_channel::<(String, std::path::PathBuf)>(); - { + let agent_handle = { let update_tx = update_tx.clone(); tokio::spawn(async move { let mut session = session; @@ -112,8 +112,8 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } } } - }); - } + }) + }; let mut app = App::new( agent_config, @@ -185,6 +185,11 @@ pub async fn run(skills: Vec) -> crate::error::Result<()> { } } + // Drop app first so all channel senders close, signaling the agent task to exit. + drop(app); + agent_handle.abort(); + let _ = agent_handle.await; + Ok(()) } @@ -192,7 +197,17 @@ fn convert_update(tx: &mpsc::UnboundedSender, update: SessionUpdate match update { SessionUpdate::AgentMessageChunk(chunk) => { if let ContentBlock::Text(t) = chunk.content { - let _ = tx.send(AgentUpdate::TextChunk(t.text)); + let is_thinking = t + .meta + .as_ref() + .and_then(|m| m.get("kind")) + .and_then(|v| v.as_str()) + .map_or(false, |s| s == "thinking"); + if is_thinking { + let _ = tx.send(AgentUpdate::ThinkingChunk(t.text)); + } else { + let _ = tx.send(AgentUpdate::TextChunk(t.text)); + } } } SessionUpdate::ToolCall(tc) => { diff --git a/src/tui/render.rs b/src/tui/render.rs index 3455a68..8e6b70e 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -74,6 +74,22 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec { + // Show thinking as a dimmed, indented block prefixed with a marker. + let prefix_len = 4; // " ≫ " length + let wrap_w = inner_w.saturating_sub(prefix_len); + for (i, wl) in word_wrap(text, wrap_w).iter().enumerate() { + let prefix = if i == 0 { " ≫ " } else { " " }; + lines.push(Line::from(vec![ + Span::styled( + prefix.to_string(), + Style::default().fg(Color::DarkGray), + ), + Span::styled(wl.clone(), Style::default().fg(Color::DarkGray)), + ])); + } + lines.push(Line::default()); + } ChatItem::ToolCall { name, args } => { let used = 4 + name.chars().count(); let preview_w = inner_w.saturating_sub(used + 1); diff --git a/src/tui/types.rs b/src/tui/types.rs index 45a4912..b0d2aee 100644 --- a/src/tui/types.rs +++ b/src/tui/types.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone)] pub(crate) enum AgentUpdate { TextChunk(String), + ThinkingChunk(String), ToolCall { name: String, args: String }, ToolResult { result: String, is_error: bool }, Done, @@ -12,6 +13,7 @@ pub(crate) enum AgentUpdate { pub(crate) enum ChatItem { UserMessage(String), AssistantMessage(String), + Thinking(String), ToolCall { name: String, args: String }, ToolResult { result: String, is_error: bool }, SystemInfo(String), From 64eb56d1a204eadd3c7bf212a867f84725532d9b Mon Sep 17 00:00:00 2001 From: themartto Date: Wed, 27 May 2026 05:47:20 +0300 Subject: [PATCH 39/41] chore: fmt & clippy --- src/core/agent.rs | 10 ++++++---- src/core/llm/anthropic.rs | 13 ++++++++----- src/core/llm/gemini.rs | 18 ++++++++++++++---- src/core/llm/openai.rs | 15 ++++++++++----- src/core/llm/openai_compatible.rs | 2 +- src/tui/render.rs | 5 +---- 6 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/core/agent.rs b/src/core/agent.rs index c3b3dd5..2b0f8cf 100644 --- a/src/core/agent.rs +++ b/src/core/agent.rs @@ -76,8 +76,7 @@ where let choice = if callback.is_some() { let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::(); - let choice_fut = - call_llm_streaming(llm, messages, &tools, prompt_builder, chunk_tx); + let choice_fut = call_llm_streaming(llm, messages, &tools, prompt_builder, chunk_tx); tokio::pin!(choice_fut); let mut maybe_choice: Option> = None; @@ -103,8 +102,11 @@ where } } } - maybe_choice - .unwrap_or_else(|| Err(crate::error::Error::Other("stream ended prematurely".into())))? + maybe_choice.unwrap_or_else(|| { + Err(crate::error::Error::Other( + "stream ended prematurely".into(), + )) + })? } else { call_llm(llm, messages, &tools, prompt_builder).await? }; diff --git a/src/core/llm/anthropic.rs b/src/core/llm/anthropic.rs index df06465..fef6600 100644 --- a/src/core/llm/anthropic.rs +++ b/src/core/llm/anthropic.rs @@ -261,14 +261,18 @@ fn extract_system(messages: &[Message]) -> Option { .filter(|m| m.role == Role::System) .filter_map(|m| m.content.as_deref()) .collect(); - if parts.is_empty() { None } else { Some(parts.join("\n\n")) } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } } /// Returns a thinking config if the model supports extended thinking and /// `max_tokens` is large enough to accommodate a reasonable budget. fn thinking_config(model: &str, max_tokens: u32) -> Option { - let supported = model.contains("claude-3-7") - || (model.starts_with("claude-") && model.contains("-4-")); + let supported = + model.contains("claude-3-7") || (model.starts_with("claude-") && model.contains("-4-")); if !supported || max_tokens < 2048 { return None; } @@ -434,8 +438,7 @@ impl LlmClient for AnthropicClient { } "thinking_delta" => { if let Some(thinking) = delta["thinking"].as_str() { - let _ = - chunk_tx.send(LlmChunk::Thinking(thinking.to_string())); + let _ = chunk_tx.send(LlmChunk::Thinking(thinking.to_string())); } } "input_json_delta" => { diff --git a/src/core/llm/gemini.rs b/src/core/llm/gemini.rs index 6045766..d4fcb25 100644 --- a/src/core/llm/gemini.rs +++ b/src/core/llm/gemini.rs @@ -7,7 +7,7 @@ use tokio::sync::mpsc; use crate::core::models::{Choice, FunctionCall, Message, Role, Tool, ToolCall}; use crate::error::{Error, Result}; -use super::{LlmClient, LlmChunk}; +use super::{LlmChunk, LlmClient}; #[derive(Clone)] pub struct GeminiClient { @@ -394,7 +394,9 @@ impl LlmClient for GeminiClient { line_buf.push_str(&String::from_utf8_lossy(&bytes)); loop { - let Some(pos) = line_buf.find('\n') else { break }; + let Some(pos) = line_buf.find('\n') else { + break; + }; let line = line_buf[..pos].trim_end_matches('\r').to_string(); line_buf = line_buf[pos + 1..].to_string(); @@ -445,8 +447,16 @@ impl LlmClient for GeminiClient { Ok(Choice { message: Message { role: Role::Assistant, - content: if text_buf.is_empty() { None } else { Some(text_buf) }, - tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, + content: if text_buf.is_empty() { + None + } else { + Some(text_buf) + }, + tool_calls: if tool_calls.is_empty() { + None + } else { + Some(tool_calls) + }, tool_call_id: None, tool_name: None, is_error: false, diff --git a/src/core/llm/openai.rs b/src/core/llm/openai.rs index 055f153..517777c 100644 --- a/src/core/llm/openai.rs +++ b/src/core/llm/openai.rs @@ -7,7 +7,7 @@ use crate::core::models::{ }; use crate::error::{Error, Result}; -use super::{LlmClient, LlmChunk}; +use super::{LlmChunk, LlmClient}; #[derive(Clone)] pub struct OpenAiClient { @@ -98,8 +98,7 @@ pub(super) async fn send_openai_style_streaming( max_tokens, }; - let mut body = - serde_json::to_value(&request).map_err(|e| Error::ParseError(e.to_string()))?; + let mut body = serde_json::to_value(&request).map_err(|e| Error::ParseError(e.to_string()))?; body["stream"] = serde_json::Value::Bool(true); let endpoint = format!("{}/chat/completions", api_base.trim_end_matches('/')); @@ -141,7 +140,9 @@ pub(super) async fn send_openai_style_streaming( line_buf.push_str(&String::from_utf8_lossy(&bytes)); loop { - let Some(pos) = line_buf.find('\n') else { break }; + let Some(pos) = line_buf.find('\n') else { + break; + }; let line = line_buf[..pos].trim_end_matches('\r').to_string(); line_buf = line_buf[pos + 1..].to_string(); @@ -206,7 +207,11 @@ pub(super) async fn send_openai_style_streaming( } } - let content = if text_buf.is_empty() { None } else { Some(text_buf) }; + let content = if text_buf.is_empty() { + None + } else { + Some(text_buf) + }; let tool_calls: Vec = tool_acc .into_iter() .enumerate() diff --git a/src/core/llm/openai_compatible.rs b/src/core/llm/openai_compatible.rs index d3bbf60..077c9cc 100644 --- a/src/core/llm/openai_compatible.rs +++ b/src/core/llm/openai_compatible.rs @@ -5,8 +5,8 @@ use tokio::sync::mpsc; use crate::core::models::{Choice, Message, Tool}; use crate::error::Result; -use super::{LlmClient, LlmChunk}; use super::openai::{send_openai_style, send_openai_style_streaming}; +use super::{LlmChunk, LlmClient}; #[derive(Clone)] pub struct OpenAiCompatibleClient { diff --git a/src/tui/render.rs b/src/tui/render.rs index 8e6b70e..6ee81a1 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -81,10 +81,7 @@ pub(crate) fn build_lines(items: &[ChatItem], width: u16, theme: Color) -> Vec Date: Wed, 27 May 2026 05:53:31 +0300 Subject: [PATCH 40/41] fix: clippy --- src/core/llm/gemini.rs | 5 ++--- src/core/llm/openai.rs | 11 +++++------ src/tui/mod.rs | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/core/llm/gemini.rs b/src/core/llm/gemini.rs index d4fcb25..520bfbb 100644 --- a/src/core/llm/gemini.rs +++ b/src/core/llm/gemini.rs @@ -424,12 +424,11 @@ impl LlmClient for GeminiClient { } for part in candidate.content.parts { - if let Some(text) = part.text { - if !text.is_empty() { + if let Some(text) = part.text + && !text.is_empty() { text_buf.push_str(&text); let _ = chunk_tx.send(LlmChunk::Text(text)); } - } if let Some(fc) = part.function_call { tool_calls.push(ToolCall { id: format!("call_{}", tool_calls.len()), diff --git a/src/core/llm/openai.rs b/src/core/llm/openai.rs index 517777c..80275f5 100644 --- a/src/core/llm/openai.rs +++ b/src/core/llm/openai.rs @@ -81,6 +81,7 @@ pub(super) async fn send_openai_style( .ok_or_else(|| Error::ApiError("No response from LLM".to_string())) } +#[allow(clippy::too_many_arguments)] pub(super) async fn send_openai_style_streaming( client: &ReqwestClient, api_base: &str, @@ -170,18 +171,16 @@ pub(super) async fn send_openai_style_streaming( let delta = &choice["delta"]; - if let Some(reasoning) = delta["reasoning_content"].as_str() { - if !reasoning.is_empty() { + if let Some(reasoning) = delta["reasoning_content"].as_str() + && !reasoning.is_empty() { let _ = chunk_tx.send(LlmChunk::Thinking(reasoning.to_string())); } - } - if let Some(content) = delta["content"].as_str() { - if !content.is_empty() { + if let Some(content) = delta["content"].as_str() + && !content.is_empty() { text_buf.push_str(content); let _ = chunk_tx.send(LlmChunk::Text(content.to_string())); } - } if let Some(tcs) = delta["tool_calls"].as_array() { for tc in tcs { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 6201920..0048a33 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -201,8 +201,7 @@ fn convert_update(tx: &mpsc::UnboundedSender, update: SessionUpdate .meta .as_ref() .and_then(|m| m.get("kind")) - .and_then(|v| v.as_str()) - .map_or(false, |s| s == "thinking"); + .and_then(|v| v.as_str()) == Some("thinking"); if is_thinking { let _ = tx.send(AgentUpdate::ThinkingChunk(t.text)); } else { From eb59ab9cb40c73100dc761b4e09d8481690a9dea Mon Sep 17 00:00:00 2001 From: themartto Date: Wed, 27 May 2026 06:01:47 +0300 Subject: [PATCH 41/41] chore: fmt --- src/core/llm/gemini.rs | 9 +++++---- src/core/llm/openai.rs | 16 +++++++++------- src/tui/mod.rs | 3 ++- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/core/llm/gemini.rs b/src/core/llm/gemini.rs index 520bfbb..23cac70 100644 --- a/src/core/llm/gemini.rs +++ b/src/core/llm/gemini.rs @@ -425,10 +425,11 @@ impl LlmClient for GeminiClient { for part in candidate.content.parts { if let Some(text) = part.text - && !text.is_empty() { - text_buf.push_str(&text); - let _ = chunk_tx.send(LlmChunk::Text(text)); - } + && !text.is_empty() + { + text_buf.push_str(&text); + let _ = chunk_tx.send(LlmChunk::Text(text)); + } if let Some(fc) = part.function_call { tool_calls.push(ToolCall { id: format!("call_{}", tool_calls.len()), diff --git a/src/core/llm/openai.rs b/src/core/llm/openai.rs index 80275f5..6aca4bb 100644 --- a/src/core/llm/openai.rs +++ b/src/core/llm/openai.rs @@ -172,15 +172,17 @@ pub(super) async fn send_openai_style_streaming( let delta = &choice["delta"]; if let Some(reasoning) = delta["reasoning_content"].as_str() - && !reasoning.is_empty() { - let _ = chunk_tx.send(LlmChunk::Thinking(reasoning.to_string())); - } + && !reasoning.is_empty() + { + let _ = chunk_tx.send(LlmChunk::Thinking(reasoning.to_string())); + } if let Some(content) = delta["content"].as_str() - && !content.is_empty() { - text_buf.push_str(content); - let _ = chunk_tx.send(LlmChunk::Text(content.to_string())); - } + && !content.is_empty() + { + text_buf.push_str(content); + let _ = chunk_tx.send(LlmChunk::Text(content.to_string())); + } if let Some(tcs) = delta["tool_calls"].as_array() { for tc in tcs { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0048a33..5ce735e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -201,7 +201,8 @@ fn convert_update(tx: &mpsc::UnboundedSender, update: SessionUpdate .meta .as_ref() .and_then(|m| m.get("kind")) - .and_then(|v| v.as_str()) == Some("thinking"); + .and_then(|v| v.as_str()) + == Some("thinking"); if is_thinking { let _ = tx.send(AgentUpdate::ThinkingChunk(t.text)); } else {