From 4d0fe7d37e3f44072d6136ec72678cf81a883be5 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 09:00:32 -0800 Subject: [PATCH] Replace TUI (ratatui) with REPL (rustyline + termimad) Drop the full Ratatui TUI in favor of a lighter REPL channel built on rustyline (line editing, history, tab-completion) and termimad (inline markdown rendering). Removes ratatui and crossterm event-stream deps, adds rustyline and termimad. Simplifies main.rs startup to use the REPL directly instead of the alternate-screen TUI. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 457 ++++++++++++++++++----------- Cargo.toml | 7 +- src/channels/cli/app.rs | 359 ---------------------- src/channels/cli/composer.rs | 318 -------------------- src/channels/cli/events.rs | 333 --------------------- src/channels/cli/mod.rs | 238 --------------- src/channels/cli/model_selector.rs | 156 ---------- src/channels/cli/overlay.rs | 145 --------- src/channels/cli/render.rs | 341 --------------------- src/channels/mod.rs | 8 +- src/channels/repl.rs | 236 ++++++++++++--- src/cli/mod.rs | 4 - src/main.rs | 96 +----- 13 files changed, 498 insertions(+), 2200 deletions(-) delete mode 100644 src/channels/cli/app.rs delete mode 100644 src/channels/cli/composer.rs delete mode 100644 src/channels/cli/events.rs delete mode 100644 src/channels/cli/mod.rs delete mode 100644 src/channels/cli/model_selector.rs delete mode 100644 src/channels/cli/overlay.rs delete mode 100644 src/channels/cli/render.rs diff --git a/Cargo.lock b/Cargo.lock index 655ae87f..596e78f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,21 +663,6 @@ dependencies = [ "winx", ] -[[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 = "cbc" version = "0.1.2" @@ -775,6 +760,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -790,20 +784,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[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 = "concurrent-queue" version = "2.5.0" @@ -819,6 +799,24 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "coolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" +dependencies = [ + "crossterm 0.29.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -969,6 +967,54 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm 0.29.0", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm 0.29.0", + "proc-macro2", + "quote", + "strict", + "syn 2.0.114", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -988,6 +1034,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1002,7 +1057,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", - "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -1011,6 +1065,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1040,38 +1112,14 @@ dependencies = [ "cipher", ] -[[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.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[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 2.0.114", + "darling_core", + "darling_macro", ] [[package]] @@ -1088,24 +1136,13 @@ dependencies = [ "syn 2.0.114", ] -[[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 2.0.114", -] - [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.114", ] @@ -1164,6 +1201,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "diff" version = "0.1.13" @@ -1265,6 +1324,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1310,6 +1378,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1347,6 +1421,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1689,8 +1769,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", "serde", ] @@ -2045,15 +2123,6 @@ 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 = "inout" version = "0.1.4" @@ -2064,19 +2133,6 @@ dependencies = [ "generic-array", ] -[[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 2.0.114", -] - [[package]] name = "io-extras" version = "0.18.4" @@ -2124,7 +2180,7 @@ dependencies = [ "bytes", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "deadpool-postgres", "dirs 6.0.0", "dotenvy", @@ -2138,12 +2194,12 @@ dependencies = [ "postgres-types", "pretty_assertions", "rand 0.8.5", - "ratatui", "refinery", "regex", "reqwest", "rust_decimal", "rust_decimal_macros", + "rustyline", "secrecy", "secret-service", "security-framework", @@ -2151,6 +2207,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "termimad", "testcontainers-modules", "thiserror 2.0.18", "tokio", @@ -2203,15 +2260,6 @@ dependencies = [ "either", ] -[[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" @@ -2258,6 +2306,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy-regex" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.114", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2317,6 +2388,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2332,15 +2409,6 @@ 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 = "lru-slab" version = "0.1.2" @@ -2417,6 +2485,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimad" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f" +dependencies = [ + "once_cell", +] + [[package]] name = "mio" version = "1.1.1" @@ -2429,6 +2506,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -2442,6 +2528,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2982,6 +3080,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -3041,27 +3149,6 @@ 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 0.13.0", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "rayon" version = "1.11.0" @@ -3371,6 +3458,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3469,6 +3565,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.30.1", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width 0.2.0", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ryu" version = "1.0.22" @@ -3702,7 +3832,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.114", @@ -3840,6 +3970,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + [[package]] name = "stringprep" version = "0.1.5" @@ -3880,28 +4016,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[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 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4000,6 +4114,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termimad" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror 2.0.18", + "unicode-width 0.1.14", +] + [[package]] name = "testcontainers" version = "0.23.3" @@ -4507,17 +4637,6 @@ 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 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-width" version = "0.1.14" @@ -4921,7 +5040,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools 0.12.1", + "itertools", "log", "object 0.36.7", "smallvec", @@ -5665,7 +5784,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", diff --git a/Cargo.toml b/Cargo.toml index 45524ee3..a9b8aceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,9 +48,10 @@ async-trait = "0.1" # CLI clap = { version = "4", features = ["derive", "env"] } -# TUI -ratatui = "0.29" -crossterm = { version = "0.28", features = ["event-stream"] } +# Terminal +crossterm = "0.28" +rustyline = { version = "17", features = ["derive", "with-file-history"] } +termimad = "0.34" # Channel integrations axum = "0.8" diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs deleted file mode 100644 index 8bae51bb..00000000 --- a/src/channels/cli/app.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Application state for the TUI. - -use std::collections::VecDeque; - -use crate::channels::cli::composer::ChatComposer; -use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest}; - -/// Events that can occur in the TUI. -#[derive(Debug, Clone)] -pub enum AppEvent { - /// Keyboard/mouse input event. - Input(crossterm::event::Event), - /// Response from the agent. - Response(String), - /// Tool execution started. - ToolStarted { name: String }, - /// Tool execution completed. - ToolCompleted { name: String, success: bool }, - /// Request approval for a tool. - ApprovalRequested(ApprovalRequest), - /// Streaming chunk received. - StreamChunk(String), - /// Log message from the application (shown in status line). - LogMessage(String), - /// Thinking/status message (shown in chat window). - ThinkingMessage(String), - /// Error message (shown in chat window). - ErrorMessage(String), - /// Available models fetched from API. - AvailableModels(Vec), - /// Force a redraw. - Redraw, - /// Quit the application. - Quit, -} - -/// Current input mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputMode { - /// Normal input mode. - Normal, - /// Editing input. - Editing, - /// Approval overlay is active. - Approval, - /// Model selector overlay is active. - ModelSelector, -} - -/// Message in the chat history. -#[derive(Debug, Clone)] -pub struct ChatMessage { - /// Who sent this message. - pub role: MessageRole, - /// The message content. - pub content: String, - /// Optional status indicator. - pub status: Option, -} - -/// Who sent a message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageRole { - User, - Agent, - System, -} - -/// Status of a message (for in-progress indicators). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageStatus { - Pending, - InProgress, - Complete, - Error, -} - -impl ChatMessage { - pub fn user(content: impl Into) -> Self { - Self { - role: MessageRole::User, - content: content.into(), - status: None, - } - } - - pub fn agent(content: impl Into) -> Self { - Self { - role: MessageRole::Agent, - content: content.into(), - status: None, - } - } - - pub fn system(content: impl Into) -> Self { - Self { - role: MessageRole::System, - content: content.into(), - status: None, - } - } - - pub fn with_status(mut self, status: MessageStatus) -> Self { - self.status = Some(status); - self - } -} - -/// Application state. -pub struct AppState { - /// Current input mode. - pub mode: InputMode, - /// Chat message history. - pub messages: Vec, - /// Input composer. - pub composer: ChatComposer, - /// Approval overlay (if active). - pub approval: Option, - /// Model selector overlay (if active). - pub model_selector: Option, - /// Scroll offset for messages. - pub scroll_offset: u16, - /// Whether the app should quit. - pub should_quit: bool, - /// Pending approvals queue. - pub pending_approvals: VecDeque, - /// Current streaming response buffer. - pub streaming_buffer: Option, - /// Status line message. - pub status_message: Option, - /// Whether Ctrl+D was pressed (waiting for second press to quit). - pub ctrl_d_pending: bool, - /// Currently selected model. - pub current_model: String, - /// Available models (fetched from API). - pub available_models: Vec, -} - -impl AppState { - /// Create a new app state. - pub fn new() -> Self { - // Load saved model from settings - let settings = crate::settings::Settings::load(); - let current_model = settings.model_or("claude-3-5-sonnet-20241022"); - - Self { - mode: InputMode::Editing, - messages: vec![ChatMessage::system( - "Welcome to IronClaw. Type a message or /help for commands.", - )], - composer: ChatComposer::new(), - approval: None, - model_selector: None, - scroll_offset: 0, - should_quit: false, - pending_approvals: VecDeque::new(), - streaming_buffer: None, - status_message: None, - ctrl_d_pending: false, - current_model, - available_models: Vec::new(), - } - } - - /// Show the model selector. - pub fn show_model_selector(&mut self) { - let request = ModelSelectorRequest { - current_model: self.current_model.clone(), - available_models: self.available_models.clone(), - }; - self.model_selector = Some(ModelSelectorOverlay::new(request)); - self.mode = InputMode::ModelSelector; - } - - /// Handle model selection. - pub fn handle_model_selection(&mut self, selected: Option) { - self.model_selector = None; - self.mode = InputMode::Editing; - - if let Some(model) = selected { - if model != self.current_model { - self.current_model = model.clone(); - // Save to settings - let mut settings = crate::settings::Settings::load(); - if let Err(e) = settings.set_model(&model) { - tracing::warn!("Failed to save model setting: {}", e); - } - self.messages.push(ChatMessage::system(format!( - "Switched to model: {}", - ModelSelectorOverlay::format_model_name(&model) - ))); - } - } - } - - /// Set available models (also updates selector if open). - pub fn set_available_models(&mut self, models: Vec) { - self.available_models = models.clone(); - - // Update the selector if it's currently open - if let Some(ref mut selector) = self.model_selector { - selector.request.available_models = models; - // Reset selection index if it's out of bounds - if selector.selection_index >= selector.request.available_models.len() { - selector.selection_index = 0; - } - } - } - - /// Add a user message to history. - pub fn add_user_message(&mut self, content: impl Into) { - self.messages.push(ChatMessage::user(content)); - self.scroll_to_bottom(); - } - - /// Add an agent response to history. - pub fn add_agent_message(&mut self, content: impl Into) { - // If we were streaming, finalize it - if self.streaming_buffer.is_some() { - self.streaming_buffer = None; - } - // Remove any pending thinking message before adding the response - self.clear_thinking(); - self.messages.push(ChatMessage::agent(content)); - self.scroll_to_bottom(); - } - - /// Add an error message to the chat. - pub fn add_error_message(&mut self, content: impl Into) { - self.messages.push( - ChatMessage::system(format!("Error: {}", content.into())) - .with_status(MessageStatus::Error), - ); - self.scroll_to_bottom(); - } - - /// Add or update a thinking/status message (shown as system message). - pub fn set_thinking(&mut self, content: impl Into) { - let content = content.into(); - // Check if last message is a thinking message (system with InProgress status) - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) { - last.content = content; - return; - } - } - // Add new thinking message - self.messages - .push(ChatMessage::system(content).with_status(MessageStatus::InProgress)); - self.scroll_to_bottom(); - } - - /// Clear any thinking/status message. - pub fn clear_thinking(&mut self) { - // Remove any thinking messages (system with InProgress status) - self.messages.retain(|msg| { - !(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress)) - }); - } - - /// Start streaming a response. - pub fn start_streaming(&mut self) { - self.streaming_buffer = Some(String::new()); - self.messages - .push(ChatMessage::agent("").with_status(MessageStatus::InProgress)); - } - - /// Append to the streaming buffer. - pub fn append_stream(&mut self, chunk: &str) { - if let Some(ref mut buffer) = self.streaming_buffer { - buffer.push_str(chunk); - // Update the last message - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.content = buffer.clone(); - } - } - } - } - - /// Finalize streaming. - pub fn finish_streaming(&mut self) { - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.status = Some(MessageStatus::Complete); - } - } - self.streaming_buffer = None; - } - - /// Show an approval request. - pub fn show_approval(&mut self, request: ApprovalRequest) { - self.approval = Some(ApprovalOverlay::new(request)); - self.mode = InputMode::Approval; - } - - /// Queue an approval request. - pub fn queue_approval(&mut self, request: ApprovalRequest) { - if self.approval.is_none() { - self.show_approval(request); - } else { - self.pending_approvals.push_back(request); - } - } - - /// Handle approval response. - pub fn handle_approval_response(&mut self, approved: bool) -> Option { - let request = self.approval.take().map(|o| o.request); - - // Show next pending approval if any - if let Some(next) = self.pending_approvals.pop_front() { - self.show_approval(next); - } else { - self.mode = InputMode::Editing; - } - - if approved { request } else { None } - } - - /// Clear all pending approvals. - pub fn clear_approvals(&mut self) { - self.approval = None; - self.pending_approvals.clear(); - self.mode = InputMode::Editing; - } - - /// Set the status message. - pub fn set_status(&mut self, message: impl Into) { - self.status_message = Some(message.into()); - } - - /// Clear the status message. - pub fn clear_status(&mut self) { - self.status_message = None; - } - - /// Scroll to the bottom of messages. - pub fn scroll_to_bottom(&mut self) { - // Will be calculated based on render area in render.rs - self.scroll_offset = 0; - } - - /// Scroll up. - pub fn scroll_up(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_add(amount); - } - - /// Scroll down. - pub fn scroll_down(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_sub(amount); - } -} - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} diff --git a/src/channels/cli/composer.rs b/src/channels/cli/composer.rs deleted file mode 100644 index 01e87730..00000000 --- a/src/channels/cli/composer.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Input composer with history and completion. - -use std::collections::VecDeque; - -/// Maximum number of history entries to keep. -const MAX_HISTORY: usize = 100; - -/// Available slash commands for completion. -const SLASH_COMMANDS: &[&str] = &[ - "/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit", -]; - -/// Chat input composer with history navigation and slash command completion. -pub struct ChatComposer { - /// Current input buffer. - buffer: String, - /// Cursor position in the buffer. - cursor: usize, - /// Input history. - history: VecDeque, - /// Current position in history (-1 = current input). - history_index: Option, - /// Saved current input when navigating history. - saved_input: String, - /// Completion candidates. - completions: Vec, - /// Current completion index. - completion_index: Option, -} - -impl ChatComposer { - /// Create a new composer. - pub fn new() -> Self { - Self { - buffer: String::new(), - cursor: 0, - history: VecDeque::with_capacity(MAX_HISTORY), - history_index: None, - saved_input: String::new(), - completions: Vec::new(), - completion_index: None, - } - } - - /// Get the current input buffer. - pub fn buffer(&self) -> &str { - &self.buffer - } - - /// Get the cursor position. - pub fn cursor(&self) -> usize { - self.cursor - } - - /// Check if the buffer is empty. - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } - - /// Insert a character at the cursor. - pub fn insert(&mut self, c: char) { - self.clear_completion(); - self.buffer.insert(self.cursor, c); - self.cursor += c.len_utf8(); - } - - /// Insert a string at the cursor. - pub fn insert_str(&mut self, s: &str) { - self.clear_completion(); - self.buffer.insert_str(self.cursor, s); - self.cursor += s.len(); - } - - /// Delete the character before the cursor (backspace). - pub fn backspace(&mut self) { - self.clear_completion(); - if self.cursor > 0 { - // Find the previous character boundary - let prev = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - self.buffer.drain(prev..self.cursor); - self.cursor = prev; - } - } - - /// Delete the character at the cursor (delete). - pub fn delete(&mut self) { - self.clear_completion(); - if self.cursor < self.buffer.len() { - // Find the next character boundary - let next = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - self.buffer.drain(self.cursor..next); - } - } - - /// Move cursor left. - pub fn move_left(&mut self) { - if self.cursor > 0 { - self.cursor = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } - } - - /// Move cursor right. - pub fn move_right(&mut self) { - if self.cursor < self.buffer.len() { - self.cursor = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - } - } - - /// Move cursor to start. - pub fn move_home(&mut self) { - self.cursor = 0; - } - - /// Move cursor to end. - pub fn move_end(&mut self) { - self.cursor = self.buffer.len(); - } - - /// Delete from cursor to end of line. - pub fn kill_line(&mut self) { - self.clear_completion(); - self.buffer.truncate(self.cursor); - } - - /// Delete from start to cursor. - pub fn kill_to_start(&mut self) { - self.clear_completion(); - self.buffer.drain(..self.cursor); - self.cursor = 0; - } - - /// Clear the entire buffer. - pub fn clear(&mut self) { - self.buffer.clear(); - self.cursor = 0; - self.clear_completion(); - } - - /// Submit the current input and return it. - pub fn submit(&mut self) -> String { - let input = std::mem::take(&mut self.buffer); - self.cursor = 0; - self.clear_completion(); - - // Add to history if non-empty and different from last entry - if !input.is_empty() && self.history.front() != Some(&input) { - self.history.push_front(input.clone()); - if self.history.len() > MAX_HISTORY { - self.history.pop_back(); - } - } - - self.history_index = None; - self.saved_input.clear(); - - input - } - - /// Navigate to previous history entry. - pub fn history_prev(&mut self) { - if self.history.is_empty() { - return; - } - - match self.history_index { - None => { - // Save current input and go to first history entry - self.saved_input = std::mem::take(&mut self.buffer); - self.history_index = Some(0); - self.buffer = self.history[0].clone(); - } - Some(i) if i + 1 < self.history.len() => { - self.history_index = Some(i + 1); - self.buffer = self.history[i + 1].clone(); - } - _ => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Navigate to next history entry. - pub fn history_next(&mut self) { - match self.history_index { - Some(0) => { - // Go back to saved input - self.history_index = None; - self.buffer = std::mem::take(&mut self.saved_input); - } - Some(i) => { - self.history_index = Some(i - 1); - self.buffer = self.history[i - 1].clone(); - } - None => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Attempt tab completion. - pub fn complete(&mut self) { - // Only complete slash commands for now - if !self.buffer.starts_with('/') { - return; - } - - if self.completions.is_empty() { - // Generate completions - let prefix = &self.buffer; - self.completions = SLASH_COMMANDS - .iter() - .filter(|cmd| cmd.starts_with(prefix)) - .map(|s| s.to_string()) - .collect(); - - if !self.completions.is_empty() { - self.completion_index = Some(0); - } - } else if let Some(i) = self.completion_index { - // Cycle through completions - self.completion_index = Some((i + 1) % self.completions.len()); - } - - // Apply completion - if let Some(i) = self.completion_index { - if let Some(completion) = self.completions.get(i) { - self.buffer = completion.clone(); - self.cursor = self.buffer.len(); - } - } - } - - /// Clear completion state. - fn clear_completion(&mut self) { - self.completions.clear(); - self.completion_index = None; - } - - /// Get current completion hint (for display). - pub fn completion_hint(&self) -> Option<&str> { - if let Some(i) = self.completion_index { - self.completions.get(i).map(|s| s.as_str()) - } else { - None - } - } - - /// Get the number of completions available. - pub fn completion_count(&self) -> usize { - self.completions.len() - } -} - -impl Default for ChatComposer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_insert_and_backspace() { - let mut composer = ChatComposer::new(); - composer.insert('h'); - composer.insert('i'); - assert_eq!(composer.buffer(), "hi"); - composer.backspace(); - assert_eq!(composer.buffer(), "h"); - } - - #[test] - fn test_history_navigation() { - let mut composer = ChatComposer::new(); - composer.insert_str("first"); - composer.submit(); - composer.insert_str("second"); - composer.submit(); - - composer.insert_str("current"); - composer.history_prev(); - assert_eq!(composer.buffer(), "second"); - composer.history_prev(); - assert_eq!(composer.buffer(), "first"); - composer.history_next(); - assert_eq!(composer.buffer(), "second"); - composer.history_next(); - assert_eq!(composer.buffer(), "current"); - } - - #[test] - fn test_completion() { - let mut composer = ChatComposer::new(); - composer.insert_str("/hel"); - composer.complete(); - assert_eq!(composer.buffer(), "/help"); - } -} diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs deleted file mode 100644 index 680a056b..00000000 --- a/src/channels/cli/events.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Event handling for the TUI. - -use std::io; -use std::time::Duration; - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::mpsc; - -use crate::channels::IncomingMessage; -use crate::channels::cli::app::{AppEvent, AppState, InputMode}; -use crate::channels::cli::render; - -/// Tick rate for the event loop (50ms = 20fps). -const TICK_RATE: Duration = Duration::from_millis(50); - -/// Run the main event loop. -pub fn run_event_loop( - terminal: &mut Terminal>, - app: &mut AppState, - msg_tx: mpsc::Sender, - mut event_rx: mpsc::Receiver, -) -> io::Result<()> { - loop { - // Render - terminal.draw(|f| render::render(f, app))?; - - // Check for quit - send shutdown signal and exit - if app.should_quit { - // Send a shutdown message so the agent loop knows to exit - let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown"); - let _ = msg_tx.blocking_send(shutdown_msg); - // Explicitly drop to close the channel - drop(msg_tx); - return Ok(()); - } - - // Poll for terminal events - if event::poll(TICK_RATE)? { - let evt = event::read()?; - if let Err(e) = handle_event(app, evt, &msg_tx) { - tracing::error!("Event handling error: {}", e); - } - } - - // Check for app events from agent (non-blocking) - while let Ok(app_event) = event_rx.try_recv() { - handle_app_event(app, app_event); - } - } -} - -/// Handle a crossterm event. -fn handle_event( - app: &mut AppState, - event: Event, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match event { - Event::Key(key) => handle_key(app, key, msg_tx), - Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here - Event::Resize(_, _) => Ok(()), // Terminal will handle resize - _ => Ok(()), - } -} - -/// Handle a key event. -fn handle_key( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - // Global keybindings - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => { - if app.mode == InputMode::Approval { - // Cancel all pending approvals - app.clear_approvals(); - } else { - // Quit - app.should_quit = true; - } - app.ctrl_d_pending = false; - return Ok(()); - } - KeyCode::Char('d') => { - if app.ctrl_d_pending { - // Second Ctrl+D, quit now - app.should_quit = true; - } else { - // First Ctrl+D, show hint - app.ctrl_d_pending = true; - app.set_status("Press Ctrl+D again to quit"); - } - return Ok(()); - } - _ => { - // Any other Ctrl+ combo clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - } - } else { - // Any non-Ctrl key clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - - match app.mode { - InputMode::Normal => handle_normal_mode(app, key), - InputMode::Editing => handle_editing_mode(app, key, msg_tx), - InputMode::Approval => handle_approval_mode(app, key), - InputMode::ModelSelector => handle_model_selector_mode(app, key), - } -} - -/// Handle keys in normal mode. -fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - match key.code { - KeyCode::Char('i') | KeyCode::Char('a') => { - app.mode = InputMode::Editing; - } - KeyCode::Char('q') => { - app.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - app.scroll_up(1); - } - KeyCode::Down | KeyCode::Char('j') => { - app.scroll_down(1); - } - KeyCode::PageUp => { - app.scroll_up(10); - } - KeyCode::PageDown => { - app.scroll_down(10); - } - KeyCode::Char('G') => { - app.scroll_to_bottom(); - } - _ => {} - } - Ok(()) -} - -/// Handle keys in editing mode. -fn handle_editing_mode( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match key.code { - KeyCode::Enter => { - if !app.composer.is_empty() { - let input = app.composer.submit(); - - // Handle /model command locally (TUI-specific) - if input.trim().eq_ignore_ascii_case("/model") { - app.show_model_selector(); - return Ok(()); - } - - app.add_user_message(&input); - - // Send message to agent - let msg = IncomingMessage::new("tui", "local-user", &input); - let _ = msg_tx.blocking_send(msg); - } - } - KeyCode::Esc => { - app.mode = InputMode::Normal; - } - KeyCode::Backspace => { - app.composer.backspace(); - } - KeyCode::Delete => { - app.composer.delete(); - } - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word left (simplified: just move to start) - app.composer.move_home(); - } else { - app.composer.move_left(); - } - } - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word right (simplified: just move to end) - app.composer.move_end(); - } else { - app.composer.move_right(); - } - } - KeyCode::Home => { - app.composer.move_home(); - } - KeyCode::End => { - app.composer.move_end(); - } - KeyCode::Up => { - app.composer.history_prev(); - } - KeyCode::Down => { - app.composer.history_next(); - } - KeyCode::Tab => { - app.composer.complete(); - } - KeyCode::Char(c) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - match c { - 'a' => app.composer.move_home(), - 'e' => app.composer.move_end(), - 'k' => app.composer.kill_line(), - 'u' => app.composer.kill_to_start(), - 'w' => { - // Delete word backwards (simplified: clear) - app.composer.clear(); - } - _ => {} - } - } else { - app.composer.insert(c); - } - } - _ => {} - } - Ok(()) -} - -/// Handle keys in approval mode. -fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.approval { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let (approved, _always) = overlay.confirm(); - app.handle_approval_response(approved); - // TODO: If always, remember to auto-approve this tool - } - KeyCode::Char(c) => { - if let Some(approved) = overlay.handle_shortcut(c) { - app.handle_approval_response(approved); - } - } - KeyCode::Esc => { - // Deny this approval - app.handle_approval_response(false); - } - _ => {} - } - } - Ok(()) -} - -/// Handle keys in model selector mode. -fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.model_selector { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let selected = overlay.selected_model().map(|s| s.to_string()); - app.handle_model_selection(selected); - } - KeyCode::Esc => { - // Cancel without changing model - app.handle_model_selection(None); - } - _ => {} - } - } - Ok(()) -} - -/// Handle an application event. -fn handle_app_event(app: &mut AppState, event: AppEvent) { - match event { - AppEvent::Response(content) => { - app.add_agent_message(content); - } - AppEvent::ToolStarted { name } => { - app.set_thinking(format!("⚙️ Running tool: {}...", name)); - } - AppEvent::ToolCompleted { name, success } => { - if success { - app.set_thinking(format!("✓ Tool {} completed", name)); - } else { - app.set_thinking(format!("✗ Tool {} failed", name)); - } - } - AppEvent::ApprovalRequested(request) => { - app.queue_approval(request); - } - AppEvent::StreamChunk(chunk) => { - if app.streaming_buffer.is_none() { - app.start_streaming(); - } - app.append_stream(&chunk); - } - AppEvent::Redraw => { - // Just triggers a redraw on next loop iteration - } - AppEvent::Quit => { - app.should_quit = true; - } - AppEvent::Input(_) => { - // Already handled directly - } - AppEvent::LogMessage(msg) => { - app.set_status(msg); - } - AppEvent::ThinkingMessage(msg) => { - app.set_thinking(msg); - } - AppEvent::ErrorMessage(msg) => { - app.add_error_message(msg); - } - AppEvent::AvailableModels(models) => { - app.set_available_models(models); - } - } -} diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs deleted file mode 100644 index afeb590e..00000000 --- a/src/channels/cli/mod.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Interactive TUI channel using Ratatui. -//! -//! Provides a rich terminal interface with: -//! - Input history navigation -//! - Slash command completion -//! - Approval overlays for tool execution -//! - Streaming response display - -mod app; -mod composer; -mod events; -mod model_selector; -mod overlay; -mod render; - -use std::io; -use std::sync::Arc; - -use async_trait::async_trait; -use crossterm::{ - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::{Mutex, mpsc}; -use tokio_stream::wrappers::ReceiverStream; - -use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -use crate::error::ChannelError; - -pub use app::{AppEvent, AppState, InputMode}; -pub use composer::ChatComposer; -pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -pub use overlay::{ApprovalOverlay, ApprovalRequest}; - -/// TUI channel for interactive terminal input with Ratatui. -pub struct TuiChannel { - /// Channel for sending events to the TUI (created upfront for logging). - event_tx: mpsc::Sender, - /// Receiver end, taken when start() is called. - event_rx: Arc>>>, -} - -impl TuiChannel { - /// Create a new TUI channel. - pub fn new() -> Self { - let (event_tx, event_rx) = mpsc::channel(64); - Self { - event_tx, - event_rx: Arc::new(Mutex::new(Some(event_rx))), - } - } - - /// Get a log writer that sends messages to the TUI status line. - /// Use this to redirect tracing output to the TUI. - pub fn log_writer(&self) -> TuiLogWriter { - TuiLogWriter::new(self.event_tx.clone()) - } - - /// Get a sender for sending events to the TUI. - /// Use this to send available models or other events from outside the channel. - pub fn event_sender(&self) -> mpsc::Sender { - self.event_tx.clone() - } -} - -impl Default for TuiChannel { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Channel for TuiChannel { - fn name(&self) -> &str { - "tui" - } - - async fn start(&self) -> Result { - let (msg_tx, msg_rx) = mpsc::channel(32); - - // Take the event receiver (can only start once) - let event_rx = { - let mut guard = self.event_rx.lock().await; - guard.take().ok_or_else(|| ChannelError::StartupFailed { - name: "tui".to_string(), - reason: "TUI channel already started".to_string(), - })? - }; - - tokio::task::spawn_blocking(move || { - if let Err(e) = run_tui(msg_tx, event_rx) { - // Try to restore terminal even on error - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - eprintln!("TUI error: {}", e); - } - }); - - Ok(Box::pin(ReceiverStream::new(msg_rx))) - } - - async fn respond( - &self, - _msg: &IncomingMessage, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn send_status( - &self, - status: StatusUpdate, - _metadata: &serde_json::Value, - ) -> Result<(), ChannelError> { - let event = match status { - StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)), - StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name }, - StatusUpdate::ToolCompleted { name, success } => { - AppEvent::ToolCompleted { name, success } - } - StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk), - StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg), - }; - self.event_tx - .send(event) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn broadcast( - &self, - _user_id: &str, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - // For TUI, broadcasts appear as regular agent responses with a notification indicator - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn health_check(&self) -> Result<(), ChannelError> { - // Channel is healthy if we haven't been closed - if self.event_tx.is_closed() { - Err(ChannelError::HealthCheckFailed { - name: "tui".to_string(), - }) - } else { - Ok(()) - } - } - - async fn shutdown(&self) -> Result<(), ChannelError> { - let _ = self.event_tx.send(AppEvent::Quit).await; - Ok(()) - } -} - -/// Run the TUI event loop (blocking). -fn run_tui( - msg_tx: mpsc::Sender, - event_rx: mpsc::Receiver, -) -> io::Result<()> { - // Setup terminal - // Note: We don't enable mouse capture so users can select text normally - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app state - let mut app = AppState::new(); - - // Run event loop - let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx); - - // Restore terminal - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - result -} - -/// TUI-compatible tracing writer that sends log messages to the TUI status line. -#[derive(Clone)] -pub struct TuiLogWriter { - tx: mpsc::Sender, -} - -impl TuiLogWriter { - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } -} - -impl std::io::Write for TuiLogWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if let Ok(s) = std::str::from_utf8(buf) { - let s = s.trim(); - if !s.is_empty() { - // Fire and forget - don't block on logging - let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string())); - } - } - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } -} diff --git a/src/channels/cli/model_selector.rs b/src/channels/cli/model_selector.rs deleted file mode 100644 index 23e7d477..00000000 --- a/src/channels/cli/model_selector.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Model selector overlay for switching LLM models. - -/// Request to show the model selector. -#[derive(Debug, Clone)] -pub struct ModelSelectorRequest { - /// Currently selected model. - pub current_model: String, - /// Available models to choose from. - pub available_models: Vec, -} - -/// Model selector overlay state. -#[derive(Debug, Clone)] -pub struct ModelSelectorOverlay { - /// The request that triggered this overlay. - pub request: ModelSelectorRequest, - /// Currently highlighted index. - pub selection_index: usize, -} - -impl ModelSelectorOverlay { - /// Create a new model selector overlay. - pub fn new(request: ModelSelectorRequest) -> Self { - // Find the current model in the list, default to 0 - let selection_index = request - .available_models - .iter() - .position(|m| m == &request.current_model) - .unwrap_or(0); - - Self { - request, - selection_index, - } - } - - /// Get the list of available models. - pub fn models(&self) -> &[String] { - &self.request.available_models - } - - /// Move selection up. - pub fn select_prev(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index > 0 { - self.selection_index -= 1; - } else { - // Wrap to bottom - self.selection_index = len - 1; - } - } - - /// Move selection down. - pub fn select_next(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index < len - 1 { - self.selection_index += 1; - } else { - // Wrap to top - self.selection_index = 0; - } - } - - /// Get the currently selected model name. - pub fn selected_model(&self) -> Option<&str> { - self.request - .available_models - .get(self.selection_index) - .map(|s| s.as_str()) - } - - /// Check if the selection is the current model. - pub fn is_current(&self) -> bool { - self.selected_model() == Some(&self.request.current_model) - } - - /// Format a model name for display (shorten long names). - pub fn format_model_name(model: &str) -> String { - // Shorten fireworks model names - if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") { - return format!("fireworks/{}", rest); - } - // Shorten other long prefixes - if let Some(rest) = model.strip_prefix("accounts/") { - return rest.to_string(); - } - model.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_model_selector_navigation() { - let request = ModelSelectorRequest { - current_model: "gpt-4o".to_string(), - available_models: vec![ - "claude-3-5-sonnet".to_string(), - "gpt-4o".to_string(), - "gpt-4o-mini".to_string(), - ], - }; - let mut overlay = ModelSelectorOverlay::new(request); - - // Should start at gpt-4o index (1) - assert_eq!(overlay.selected_model(), Some("gpt-4o")); - - // Navigate down - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - - // Navigate down (wrap) - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet")); - - // Navigate up - overlay.select_prev(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - } - - #[test] - fn test_format_model_name() { - assert_eq!( - ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"), - "claude-3-5-sonnet-20241022" - ); - assert_eq!( - ModelSelectorOverlay::format_model_name( - "fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct" - ), - "fireworks/llama-v3p1-405b-instruct" - ); - } - - #[test] - fn test_empty_models() { - let request = ModelSelectorRequest { - current_model: "unknown".to_string(), - available_models: vec![], - }; - let mut overlay = ModelSelectorOverlay::new(request); - assert_eq!(overlay.selected_model(), None); - - // Should not panic - overlay.select_next(); - overlay.select_prev(); - } -} diff --git a/src/channels/cli/overlay.rs b/src/channels/cli/overlay.rs deleted file mode 100644 index 1a8b4d97..00000000 --- a/src/channels/cli/overlay.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Approval overlay modal. - -use uuid::Uuid; - -/// A request for user approval before executing a tool. -#[derive(Debug, Clone)] -pub struct ApprovalRequest { - /// Unique ID for this request. - pub id: Uuid, - /// Name of the tool requesting approval. - pub tool_name: String, - /// Description of what the tool will do. - pub description: String, - /// Parameters being passed to the tool. - pub parameters: serde_json::Value, - /// Whether this is a destructive operation. - pub destructive: bool, -} - -impl ApprovalRequest { - /// Create a new approval request. - pub fn new( - tool_name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - Self { - id: Uuid::new_v4(), - tool_name: tool_name.into(), - description: description.into(), - parameters, - destructive: false, - } - } - - /// Mark as destructive operation. - pub fn destructive(mut self) -> Self { - self.destructive = true; - self - } -} - -/// Current selection in the approval overlay. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalSelection { - /// Yes, approve this action. - Yes, - /// No, deny this action. - No, - /// Always approve this tool (for this session). - Always, -} - -impl ApprovalSelection { - /// Get the next selection (cycling). - pub fn next(self) -> Self { - match self { - Self::Yes => Self::No, - Self::No => Self::Always, - Self::Always => Self::Yes, - } - } - - /// Get the previous selection (cycling). - pub fn prev(self) -> Self { - match self { - Self::Yes => Self::Always, - Self::No => Self::Yes, - Self::Always => Self::No, - } - } -} - -/// Approval overlay state. -pub struct ApprovalOverlay { - /// The request being shown. - pub request: ApprovalRequest, - /// Current selection. - pub selection: ApprovalSelection, -} - -impl ApprovalOverlay { - /// Create a new approval overlay. - pub fn new(request: ApprovalRequest) -> Self { - Self { - request, - selection: ApprovalSelection::Yes, - } - } - - /// Move selection left. - pub fn select_prev(&mut self) { - self.selection = self.selection.prev(); - } - - /// Move selection right. - pub fn select_next(&mut self) { - self.selection = self.selection.next(); - } - - /// Handle keyboard shortcut. - pub fn handle_shortcut(&mut self, c: char) -> Option { - match c.to_ascii_lowercase() { - 'y' => Some(true), - 'n' => Some(false), - 'a' => { - self.selection = ApprovalSelection::Always; - Some(true) - } - _ => None, - } - } - - /// Confirm the current selection. - pub fn confirm(&self) -> (bool, bool) { - match self.selection { - ApprovalSelection::Yes => (true, false), - ApprovalSelection::No => (false, false), - ApprovalSelection::Always => (true, true), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_approval_selection_cycle() { - let sel = ApprovalSelection::Yes; - assert_eq!(sel.next(), ApprovalSelection::No); - assert_eq!(sel.next().next(), ApprovalSelection::Always); - assert_eq!(sel.next().next().next(), ApprovalSelection::Yes); - } - - #[test] - fn test_approval_shortcuts() { - let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({})); - let mut overlay = ApprovalOverlay::new(request); - - assert_eq!(overlay.handle_shortcut('y'), Some(true)); - assert_eq!(overlay.handle_shortcut('n'), Some(false)); - assert_eq!(overlay.handle_shortcut('x'), None); - } -} diff --git a/src/channels/cli/render.rs b/src/channels/cli/render.rs deleted file mode 100644 index ba692260..00000000 --- a/src/channels/cli/render.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! TUI rendering with Ratatui. - -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; - -use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus}; -use crate::channels::cli::model_selector::ModelSelectorOverlay; -use crate::channels::cli::overlay::ApprovalSelection; - -/// Render the entire UI. -pub fn render(frame: &mut Frame, app: &AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // Messages - Constraint::Length(3), // Input - Constraint::Length(1), // Status - ]) - .split(frame.area()); - - render_messages(frame, app, chunks[0]); - render_input(frame, app, chunks[1]); - render_status(frame, app, chunks[2]); - - // Render approval overlay if active - if app.mode == InputMode::Approval { - render_approval_overlay(frame, app); - } -} - -/// Render the message history. -fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) { - // Build all lines from all messages - let mut lines: Vec = Vec::new(); - - for msg in &app.messages { - let (prefix, style) = match msg.role { - MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)), - MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)), - MessageRole::System => ( - "", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - }; - - let status_indicator = match msg.status { - Some(MessageStatus::Pending) => " ⏳", - Some(MessageStatus::InProgress) => " ⚙️", - Some(MessageStatus::Complete) => " ✓", - Some(MessageStatus::Error) => " ✗", - None => "", - }; - - // Split content by newlines and create a line for each - let content_lines: Vec<&str> = msg.content.lines().collect(); - for (i, line_text) in content_lines.iter().enumerate() { - if i == 0 { - // First line gets the prefix - let line_content = if status_indicator.is_empty() { - format!("{}{}", prefix, line_text) - } else if content_lines.len() == 1 { - format!("{}{}{}", prefix, line_text, status_indicator) - } else { - format!("{}{}", prefix, line_text) - }; - lines.push(Line::styled(line_content, style)); - } else if i == content_lines.len() - 1 && !status_indicator.is_empty() { - // Last line gets status indicator - lines.push(Line::styled( - format!("{}{}", line_text, status_indicator), - style, - )); - } else { - // Middle lines just get the content - lines.push(Line::styled(line_text.to_string(), style)); - } - } - - // Add empty line between messages for readability - lines.push(Line::from("")); - } - - // Calculate scroll - show most recent messages - let visible_height = area.height.saturating_sub(2) as usize; // Account for borders - let total_lines = lines.len(); - let scroll_offset = total_lines.saturating_sub(visible_height); - - let text = Text::from(lines); - let messages = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("Chat")) - .wrap(Wrap { trim: false }) - .scroll((scroll_offset as u16, 0)); - - frame.render_widget(messages, area); -} - -/// Render the input area (or model selector when in ModelSelector mode). -fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { - // In ModelSelector mode, render inline selector instead of input - if app.mode == InputMode::ModelSelector { - render_model_selector_inline(frame, app, area); - return; - } - - let input_style = match app.mode { - InputMode::Editing => Style::default().fg(Color::Yellow), - InputMode::Normal => Style::default(), - InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray), - }; - - let buffer = app.composer.buffer(); - let cursor = app.composer.cursor(); - - // Build the input text with cursor - let (before, after) = buffer.split_at(cursor.min(buffer.len())); - let cursor_char = after.chars().next().unwrap_or(' '); - let after_cursor = if after.is_empty() { - "" - } else { - &after[cursor_char.len_utf8()..] - }; - - let input = Paragraph::new(Line::from(vec![ - Span::raw(before), - Span::styled( - cursor_char.to_string(), - Style::default().bg(Color::White).fg(Color::Black), - ), - Span::raw(after_cursor), - ])) - .style(input_style) - .block(Block::default().borders(Borders::ALL).title("Input")); - - frame.render_widget(input, area); - - // Show cursor in editing mode - if app.mode == InputMode::Editing { - // Calculate cursor position accounting for the block border - let cursor_x = area.x + 1 + cursor as u16; - let cursor_y = area.y + 1; - frame.set_cursor_position((cursor_x, cursor_y)); - } -} - -/// Render inline model selector in the input area. -fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) { - let Some(ref overlay) = app.model_selector else { - return; - }; - - let models = overlay.models(); - - // Build horizontal list of models - let mut spans: Vec = Vec::new(); - - if models.is_empty() { - spans.push(Span::styled( - "Loading models...", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )); - } else { - for (i, model) in models.iter().enumerate() { - if i > 0 { - spans.push(Span::raw(" ")); - } - - let display_name = ModelSelectorOverlay::format_model_name(model); - let is_selected = i == overlay.selection_index; - let is_current = model == &overlay.request.current_model; - - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else if is_current { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if is_current { "●" } else { " " }; - spans.push(Span::styled(format!("{}{}", prefix, display_name), style)); - } - } - - let content = Paragraph::new(Line::from(spans)) - .block(Block::default().borders(Borders::ALL).title(Span::styled( - "Select Model", - Style::default().fg(Color::Cyan), - ))) - .scroll(( - 0, - calculate_model_scroll(overlay, area.width.saturating_sub(2)), - )); - - frame.render_widget(content, area); -} - -/// Calculate horizontal scroll offset to keep selected model visible. -fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 { - let models = overlay.models(); - if models.is_empty() { - return 0; - } - - // Estimate position of selected model (rough calculation) - let mut pos: u16 = 0; - for (i, model) in models.iter().enumerate() { - let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing - if i == overlay.selection_index { - // Check if selection is beyond visible area - if pos > visible_width { - return pos.saturating_sub(visible_width / 2); - } - return 0; - } - pos += name_len; - } - 0 -} - -/// Render the status line. -fn render_status(frame: &mut Frame, app: &AppState, area: Rect) { - let status_text = if let Some(ref msg) = app.status_message { - msg.clone() - } else { - match app.mode { - InputMode::Normal | InputMode::Editing => { - let model = ModelSelectorOverlay::format_model_name(&app.current_model); - format!("{} | /model to switch", model) - } - InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(), - InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(), - } - }; - - let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray)); - - frame.render_widget(status, area); -} - -/// Render the approval overlay. -fn render_approval_overlay(frame: &mut Frame, app: &AppState) { - let Some(ref overlay) = app.approval else { - return; - }; - - let area = frame.area(); - - // Calculate overlay size and position - let overlay_width = (area.width * 60 / 100).min(60); - let overlay_height = 12; - let overlay_x = (area.width - overlay_width) / 2; - let overlay_y = (area.height - overlay_height) / 2; - - let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height); - - // Clear the area behind the overlay - frame.render_widget(Clear, overlay_area); - - // Build overlay content - let title = if overlay.request.destructive { - "⚠️ Approval Required (Destructive)" - } else { - "Approval Required" - }; - - let title_style = if overlay.request.destructive { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - }; - - // Build the text content - let mut lines = vec![ - Line::from(vec![ - Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&overlay.request.tool_name), - ]), - Line::from(""), - Line::from(overlay.request.description.as_str()), - Line::from(""), - ]; - - // Add parameters preview (truncated) - let params_str = serde_json::to_string_pretty(&overlay.request.parameters) - .unwrap_or_else(|_| "{}".to_string()); - let params_preview: String = params_str.chars().take(100).collect(); - lines.push(Line::from(vec![ - Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(params_preview, Style::default().fg(Color::DarkGray)), - ])); - lines.push(Line::from("")); - - // Add selection buttons - let yes_style = if overlay.selection == ApprovalSelection::Yes { - Style::default().bg(Color::Green).fg(Color::Black) - } else { - Style::default().fg(Color::Green) - }; - - let no_style = if overlay.selection == ApprovalSelection::No { - Style::default().bg(Color::Red).fg(Color::Black) - } else { - Style::default().fg(Color::Red) - }; - - let always_style = if overlay.selection == ApprovalSelection::Always { - Style::default().bg(Color::Blue).fg(Color::Black) - } else { - Style::default().fg(Color::Blue) - }; - - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(" [Y]es ", yes_style), - Span::raw(" "), - Span::styled(" [N]o ", no_style), - Span::raw(" "), - Span::styled(" [A]lways ", always_style), - ])); - - let content = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(Span::styled(title, title_style)), - ) - .wrap(Wrap { trim: true }); - - frame.render_widget(content, overlay_area); -} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 550707d1..fc85436e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -9,9 +9,9 @@ //! ┌─────────────────────────────────────────────────────────────────────┐ //! │ ChannelManager │ //! │ │ -//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │ -//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +//! │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ +//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │ +//! │ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │ //! │ │ │ │ │ //! │ └─────────────────┴─────────────────┘ │ //! │ │ │ @@ -28,14 +28,12 @@ //! See the [`wasm`] module for details. mod channel; -pub mod cli; mod http; mod manager; mod repl; pub mod wasm; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -pub use cli::{AppEvent, TuiChannel}; pub use http::HttpChannel; pub use manager::ChannelManager; pub use repl::ReplChannel; diff --git a/src/channels/repl.rs b/src/channels/repl.rs index a498ff92..35fdcb07 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -1,6 +1,8 @@ -//! Interactive REPL channel for debugging and testing. +//! Interactive REPL channel with line editing and markdown rendering. //! -//! Provides a command-line interface for interacting with the agent. +//! Provides the primary CLI interface for interacting with the agent. +//! Uses rustyline for line editing, history, and tab-completion. +//! Uses termimad for rendering markdown responses inline. //! //! ## Commands //! @@ -14,23 +16,113 @@ //! - `/new` - Start a new thread //! - `yes`/`no`/`always` - Respond to tool approval prompts -use std::io::{self, BufRead, Write}; +use std::borrow::Cow; +use std::io::{self, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; +use rustyline::completion::Completer; +use rustyline::config::Config; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Editor, Helper}; +use termimad::MadSkin; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; -/// REPL channel for interactive agent debugging. +/// Slash commands available in the REPL. +const SLASH_COMMANDS: &[&str] = &[ + "/help", + "/quit", + "/exit", + "/debug", + "/undo", + "/redo", + "/clear", + "/compact", + "/new", + "/interrupt", +]; + +/// Rustyline helper for slash-command tab completion. +struct ReplHelper; + +impl Completer for ReplHelper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + if !line.starts_with('/') { + return Ok((0, vec![])); + } + + let prefix = &line[..pos]; + let matches: Vec = SLASH_COMMANDS + .iter() + .filter(|cmd| cmd.starts_with(prefix)) + .map(|cmd| cmd.to_string()) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for ReplHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + if !line.starts_with('/') || pos < line.len() { + return None; + } + + SLASH_COMMANDS + .iter() + .find(|cmd| cmd.starts_with(line) && **cmd != line) + .map(|cmd| cmd[line.len()..].to_string()) + } +} + +impl Highlighter for ReplHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Owned(format!("\x1b[90m{hint}\x1b[0m")) + } +} + +impl Validator for ReplHelper {} +impl Helper for ReplHelper {} + +/// Build a termimad skin with our color scheme. +fn make_skin() -> MadSkin { + let mut skin = MadSkin::default(); + skin.set_headers_fg(termimad::crossterm::style::Color::Yellow); + skin.bold.set_fg(termimad::crossterm::style::Color::White); + skin.italic + .set_fg(termimad::crossterm::style::Color::Magenta); + skin.inline_code + .set_fg(termimad::crossterm::style::Color::Green); + skin.code_block + .set_fg(termimad::crossterm::style::Color::Green); + skin +} + +/// REPL channel with line editing and markdown rendering. pub struct ReplChannel { /// Optional single message to send (for -m flag). single_message: Option, /// Debug mode flag (shared with input thread). debug_mode: Arc, + /// Whether we're currently streaming (chunks have been printed without a trailing newline). + is_streaming: Arc, } impl ReplChannel { @@ -39,6 +131,7 @@ impl ReplChannel { Self { single_message: None, debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -47,6 +140,7 @@ impl ReplChannel { Self { single_message: Some(message), debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -64,7 +158,7 @@ impl Default for ReplChannel { fn print_help() { println!( r#" -IronClaw REPL - Interactive debugging mode +IronClaw REPL Commands: /help Show this help message @@ -90,6 +184,14 @@ Tips: ); } +/// Get the history file path (~/.ironclaw/history). +fn history_path() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw") + .join("history") +} + #[async_trait] impl Channel for ReplChannel { fn name(&self) -> &str { @@ -102,38 +204,50 @@ impl Channel for ReplChannel { let debug_mode = Arc::clone(&self.debug_mode); std::thread::spawn(move || { - // If single message mode, send it and exit + // Single message mode: send it and return if let Some(msg) = single_message { let incoming = IncomingMessage::new("repl", "user", &msg); - if tx.blocking_send(incoming).is_err() { - return; - } - // Wait a bit for response, then the channel will close + let _ = tx.blocking_send(incoming); return; } - // Interactive REPL mode - let stdin = io::stdin(); - let mut stdout = io::stdout(); + // Set up rustyline + let config = Config::builder() + .history_ignore_dups(true) + .expect("valid config") + .auto_add_history(true) + .completion_type(CompletionType::List) + .build(); + + let mut rl = match Editor::with_config(config) { + Ok(editor) => editor, + Err(e) => { + eprintln!("Failed to initialize line editor: {e}"); + return; + } + }; + + rl.set_helper(Some(ReplHelper)); + + // Load history + let hist_path = history_path(); + if let Some(parent) = hist_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = rl.load_history(&hist_path); println!("IronClaw REPL - Type /help for commands, /quit to exit"); println!(); loop { - // Print prompt let prompt = if debug_mode.load(Ordering::Relaxed) { - "[debug] > " + "\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m " } else { - "> " + "\x1b[36m>\x1b[0m " }; - print!("{}", prompt); - let _ = stdout.flush(); - // Read line - let mut line = String::new(); - match stdin.lock().read_line(&mut line) { - Ok(0) => break, // EOF - Ok(_) => { + match rl.readline(prompt) { + Ok(line) => { let line = line.trim(); if line.is_empty() { continue; @@ -164,9 +278,26 @@ impl Channel for ReplChannel { break; } } - Err(_) => break, + Err(ReadlineError::Interrupted) => { + // Ctrl+C: send /interrupt + let msg = IncomingMessage::new("repl", "user", "/interrupt"); + if tx.blocking_send(msg).is_err() { + break; + } + } + Err(ReadlineError::Eof) => { + // Ctrl+D: quit + break; + } + Err(e) => { + eprintln!("Input error: {e}"); + break; + } } } + + // Save history on exit + let _ = rl.save_history(&history_path()); }); Ok(Box::pin(ReceiverStream::new(rx))) @@ -177,8 +308,23 @@ impl Channel for ReplChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { + // If we were streaming, the content was already printed via StreamChunk. + // Just finish the line and reset. + if self.is_streaming.swap(false, Ordering::Relaxed) { + println!(); + println!(); + return Ok(()); + } + + // Render markdown + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + println!(); - println!("{}", response.content); + print!("{text}"); println!(); Ok(()) } @@ -193,37 +339,27 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { if debug { - eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg); - } else { - eprint!("."); - let _ = io::stderr().flush(); + eprintln!("\x1b[90m[thinking] {msg}\x1b[0m"); } } StatusUpdate::ToolStarted { name } => { - if debug { - eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name); - } else { - eprintln!("\x1b[33m⚡ {}\x1b[0m", name); - } + eprintln!(" \x1b[33m>> {name}\x1b[0m"); } StatusUpdate::ToolCompleted { name, success } => { - if debug { - if success { - eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name); - } else { - eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name); - } - } else if !success { - eprintln!("\x1b[31m✗ {} failed\x1b[0m", name); + if success { + eprintln!(" \x1b[32m<< {name}\x1b[0m"); + } else { + eprintln!(" \x1b[31m<< {name} failed\x1b[0m"); } } StatusUpdate::StreamChunk(chunk) => { - print!("{}", chunk); + self.is_streaming.store(true, Ordering::Relaxed); + print!("{chunk}"); let _ = io::stdout().flush(); } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { - eprintln!("\x1b[90m[status] {}\x1b[0m", msg); + eprintln!("\x1b[90m[status] {msg}\x1b[0m"); } } } @@ -235,9 +371,15 @@ impl Channel for ReplChannel { _user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { - println!(); - println!("\x1b[36m[notification]\x1b[0m {}", response.content); - println!(); + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + + eprintln!("\x1b[36m[notification]\x1b[0m"); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + eprint!("{text}"); + eprintln!(); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fa3e9db1..e4344d21 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -41,10 +41,6 @@ pub struct Cli { #[arg(long, global = true)] pub no_db: bool, - /// Simple REPL mode without TUI (for testing) - #[arg(long, global = true)] - pub repl: bool, - /// Single message mode - send one message and exit #[arg(short, long, global = true)] pub message: Option, diff --git a/src/main.rs b/src/main.rs index 4e177154..14ea5eed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps}, channels::{ - AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel, + ChannelManager, HttpChannel, ReplChannel, wasm::{ RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer, @@ -38,7 +38,7 @@ use ironclaw::{ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - // Handle non-agent commands first (they don't need TUI/full setup) + // Handle non-agent commands first (they don't need full setup) match &cli.command { Some(Command::Tool(tool_cmd)) => { // Simple logging for CLI commands @@ -177,8 +177,7 @@ async fn main() -> anyhow::Result<()> { Err(e) => return Err(e.into()), }; - // Initialize session manager and authenticate BEFORE TUI setup - // This allows the auth menu to display cleanly without TUI interference + // Initialize session manager and authenticate before channel setup let session_config = SessionConfig { auth_base_url: config.llm.nearai.auth_base_url.clone(), session_path: config.llm.nearai.session_path.clone(), @@ -187,57 +186,24 @@ async fn main() -> anyhow::Result<()> { let session = create_session_manager(session_config).await; // Ensure we're authenticated before proceeding (may trigger login flow) - // This happens before TUI so the menu displays correctly session.ensure_authenticated().await?; - // Initialize tracing and channels based on mode + // Initialize tracing let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug")); - // Determine which mode to use: REPL, single message, or TUI - let use_repl = cli.repl || cli.message.is_some(); + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .init(); - // Create appropriate channel based on mode - let (tui_channel, tui_event_sender, repl_channel) = if use_repl { - // REPL mode - use simple stdin/stdout - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .init(); - - let repl = if let Some(ref msg) = cli.message { - ReplChannel::with_message(msg.clone()) - } else { - ReplChannel::new() - }; - - (None, None, Some(repl)) + // Create CLI channel + let repl_channel = if let Some(ref msg) = cli.message { + Some(ReplChannel::with_message(msg.clone())) } else if config.channels.cli.enabled { - // TUI mode - let channel = TuiChannel::new(); - let log_writer = channel.log_writer(); - let event_sender = channel.event_sender(); - - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(log_writer) - .without_time() - .with_target(false) - .with_level(true), - ) - .init(); - - (Some(channel), Some(event_sender), None) + Some(ReplChannel::new()) } else { - // No CLI - just logging - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .init(); - - (None, None, None) + None }; tracing::info!("Starting IronClaw..."); @@ -259,34 +225,6 @@ async fn main() -> anyhow::Result<()> { let llm = create_llm_provider(&config.llm, session.clone())?; tracing::info!("LLM provider initialized: {}", llm.model_name()); - // Fetch available models and send to TUI (async, non-blocking) - if let Some(ref event_tx) = tui_event_sender { - let llm_for_models = llm.clone(); - let event_tx = event_tx.clone(); - tokio::spawn(async move { - match llm_for_models.list_models().await { - Ok(models) if !models.is_empty() => { - let _ = event_tx.send(AppEvent::AvailableModels(models)).await; - } - Ok(_) => { - let _ = event_tx - .send(AppEvent::ErrorMessage( - "No models available from API".into(), - )) - .await; - } - Err(e) => { - let _ = event_tx - .send(AppEvent::ErrorMessage(format!( - "Failed to fetch models: {}", - e - ))) - .await; - } - } - }); - } - // Initialize safety layer let safety = Arc::new(SafetyLayer::new(&config.safety)); tracing::info!("Safety layer initialized"); @@ -529,7 +467,6 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); - // Add REPL channel if in REPL mode if let Some(repl) = repl_channel { channels.add(Box::new(repl)); if cli.message.is_some() { @@ -538,14 +475,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!("REPL mode enabled"); } } - // Add TUI channel if CLI is enabled (already created for logging hookup) - else if let Some(tui) = tui_channel { - channels.add(Box::new(tui)); - tracing::info!("TUI channel enabled"); - } // Add HTTP channel if configured and not CLI-only mode - if !cli.cli_only && !use_repl { + if !cli.cli_only { if let Some(ref http_config) = config.channels.http { channels.add(Box::new(HttpChannel::new(http_config.clone()))); tracing::info!(