mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cb987321a9
commit
4d0fe7d37e
Generated
+288
-169
@@ -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",
|
||||
|
||||
+4
-3
@@ -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"
|
||||
|
||||
@@ -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<String>),
|
||||
/// 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<MessageStatus>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::User,
|
||||
content: content.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::Agent,
|
||||
content: content.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> 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<ChatMessage>,
|
||||
/// Input composer.
|
||||
pub composer: ChatComposer,
|
||||
/// Approval overlay (if active).
|
||||
pub approval: Option<ApprovalOverlay>,
|
||||
/// Model selector overlay (if active).
|
||||
pub model_selector: Option<ModelSelectorOverlay>,
|
||||
/// Scroll offset for messages.
|
||||
pub scroll_offset: u16,
|
||||
/// Whether the app should quit.
|
||||
pub should_quit: bool,
|
||||
/// Pending approvals queue.
|
||||
pub pending_approvals: VecDeque<ApprovalRequest>,
|
||||
/// Current streaming response buffer.
|
||||
pub streaming_buffer: Option<String>,
|
||||
/// Status line message.
|
||||
pub status_message: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
// 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<String>) {
|
||||
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<String>) {
|
||||
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<ApprovalRequest> {
|
||||
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<String>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Current position in history (-1 = current input).
|
||||
history_index: Option<usize>,
|
||||
/// Saved current input when navigating history.
|
||||
saved_input: String,
|
||||
/// Completion candidates.
|
||||
completions: Vec<String>,
|
||||
/// Current completion index.
|
||||
completion_index: Option<usize>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut AppState,
|
||||
msg_tx: mpsc::Sender<IncomingMessage>,
|
||||
mut event_rx: mpsc::Receiver<AppEvent>,
|
||||
) -> 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<IncomingMessage>,
|
||||
) -> 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<IncomingMessage>,
|
||||
) -> 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<IncomingMessage>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppEvent>,
|
||||
/// Receiver end, taken when start() is called.
|
||||
event_rx: Arc<Mutex<Option<mpsc::Receiver<AppEvent>>>>,
|
||||
}
|
||||
|
||||
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<AppEvent> {
|
||||
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<MessageStream, ChannelError> {
|
||||
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<IncomingMessage>,
|
||||
event_rx: mpsc::Receiver<AppEvent>,
|
||||
) -> 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<AppEvent>,
|
||||
}
|
||||
|
||||
impl TuiLogWriter {
|
||||
pub fn new(tx: mpsc::Sender<AppEvent>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for TuiLogWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
description: impl Into<String>,
|
||||
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<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Line> = 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<Span> = 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);
|
||||
}
|
||||
+3
-5
@@ -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;
|
||||
|
||||
+189
-47
@@ -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<String>)> {
|
||||
if !line.starts_with('/') {
|
||||
return Ok((0, vec![]));
|
||||
}
|
||||
|
||||
let prefix = &line[..pos];
|
||||
let matches: Vec<String> = 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<String> {
|
||||
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<String>,
|
||||
/// Debug mode flag (shared with input thread).
|
||||
debug_mode: Arc<AtomicBool>,
|
||||
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||
is_streaming: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
+14
-82
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user