diff --git a/Cargo.lock b/Cargo.lock index 61d5565d..df76544d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,12 +690,27 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "castaway" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.55" @@ -996,6 +1011,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway 0.2.4", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.36" @@ -1171,6 +1200,32 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1266,6 +1321,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.13.4" @@ -1308,6 +1373,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.13.4" @@ -1341,6 +1419,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.114", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1841,7 +1930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -2248,7 +2337,7 @@ dependencies = [ "gix-worktree-stream", "parking_lot", "regex", - "signal-hook", + "signal-hook 0.4.3", "smallvec", "thiserror 2.0.18", ] @@ -2621,7 +2710,7 @@ dependencies = [ "itoa", "libc", "memmap2", - "rustix", + "rustix 1.1.3", "smallvec", "thiserror 2.0.18", ] @@ -2774,7 +2863,7 @@ dependencies = [ "gix-command", "gix-config-value", "parking_lot", - "rustix", + "rustix 1.1.3", "thiserror 2.0.18", ] @@ -2951,7 +3040,7 @@ dependencies = [ "gix-fs", "libc", "parking_lot", - "signal-hook", + "signal-hook 0.4.3", "signal-hook-registry", "tempfile", ] @@ -3698,6 +3787,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.0" @@ -3718,6 +3816,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "instant" version = "0.1.13" @@ -3785,7 +3896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" dependencies = [ "async-channel", - "castaway", + "castaway 0.1.2", "crossbeam-utils", "curl", "curl-sys", @@ -4065,6 +4176,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -4126,6 +4243,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4366,6 +4492,7 @@ dependencies = [ "moltis-sessions", "moltis-skills", "moltis-tools", + "moltis-tui", "open", "reqwest 0.12.28", "secrecy 0.8.0", @@ -4913,6 +5040,33 @@ dependencies = [ "uuid", ] +[[package]] +name = "moltis-tui" +version = "0.9.10" +dependencies = [ + "crossterm", + "futures", + "moltis-config", + "moltis-protocol", + "pulldown-cmark", + "ratatui", + "reqwest 0.12.28", + "rustls", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "secrecy 0.8.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tokio-tungstenite 0.26.2", + "tracing", + "tui-textarea", + "url", + "uuid", +] + [[package]] name = "moltis-voice" version = "0.9.10" @@ -5316,6 +5470,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.1.1" @@ -5830,6 +5990,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[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 = "raw-cpuid" version = "11.6.0" @@ -6220,6 +6401,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -6229,7 +6423,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -6700,6 +6894,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook" version = "0.4.3" @@ -6710,6 +6914,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7078,6 +7293,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7299,7 +7536,7 @@ dependencies = [ "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -7309,7 +7546,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-sys 0.60.2", ] @@ -7516,7 +7753,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.26.2", ] @@ -7760,6 +8001,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -7772,6 +8024,8 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", "utf-8", @@ -7854,6 +8108,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -8217,7 +8494,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix", + "rustix 1.1.3", "winsafe", ] @@ -8228,7 +8505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix", + "rustix 1.1.3", "winsafe", ] @@ -8821,7 +9098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ece1c587..6c74a6f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/skills", "crates/telegram", "crates/tools", + "crates/tui", "crates/voice", ] resolver = "2" @@ -69,6 +70,11 @@ tracing-opentelemetry = "0.30" tracing-subscriber = { features = ["env-filter", "json"], version = "0.3" } # CLI clap = { features = ["derive", "env"], version = "4" } +# Terminal UI +crossterm = { features = ["event-stream"], version = "0.28" } +ratatui = "0.29" +rpassword = "5" +tui-textarea = "0.7" # Database sqlx = { features = ["migrate", "runtime-tokio", "sqlite"], version = "0.8" } # HTTP client @@ -111,7 +117,8 @@ teloxide = { features = ["macros"], version = "0.13" } axum-server = { features = ["tls-rustls"], version = "0.7" } rcgen = "0.13" rustls = { features = ["ring"], version = "0.23" } -rustls-pemfile = "2" +rustls-native-certs = "0.8" +rustls-pemfile = "2" time = "0.3" tokio-rustls = "0.26" # Cryptography / authentication @@ -182,6 +189,7 @@ moltis-sessions = { path = "crates/sessions" } moltis-skills = { path = "crates/skills" } moltis-telegram = { path = "crates/telegram" } moltis-tools = { path = "crates/tools" } +moltis-tui = { path = "crates/tui" } moltis-voice = { path = "crates/voice" } [profile.release] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4493b525..34887cac 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -37,6 +37,7 @@ anyhow = { workspace = true } clap = { workspace = true } dotenvy = { workspace = true } moltis-agents = { workspace = true } +moltis-tui = { optional = true, workspace = true } moltis-browser = { workspace = true } moltis-common = { workspace = true } moltis-config = { workspace = true } @@ -78,6 +79,7 @@ default = [ "qmd", "tailscale", "tls", + "tui", "voice", "web-ui", ] @@ -93,6 +95,7 @@ push-notifications = ["moltis-gateway/push-notifications"] qmd = ["moltis-gateway/qmd"] tailscale = ["moltis-gateway/tailscale"] tls = ["moltis-gateway/tls"] +tui = ["dep:moltis-tui"] voice = ["moltis-gateway/voice"] web-ui = ["moltis-gateway/web-ui"] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8e5f0ae0..9fbd2858 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -145,6 +145,17 @@ enum Commands { /// Install the Moltis CA certificate into the system trust store. #[cfg(feature = "tls")] TrustCa, + /// Launch the terminal user interface (connects to a running gateway). + #[cfg(feature = "tui")] + Tui { + /// Gateway WebSocket URL (e.g. wss://localhost:9433/ws/chat). + /// When omitted, derived from moltis.toml server config. + #[arg(long, env = "MOLTIS_URL")] + url: Option, + /// API key for authentication. + #[arg(long, env = "MOLTIS_API_KEY")] + api_key: Option, + }, } #[derive(Subcommand)] @@ -184,7 +195,10 @@ enum SkillAction { /// Initialise tracing and optionally attach a [`LogBroadcastLayer`] that /// captures events into an in-memory ring buffer for the web UI. -fn init_telemetry(cli: &Cli, log_buffer: Option) { +/// +/// When `tui_mode` is true, logs are written to `tui.log` inside the data +/// directory instead of stderr (which would corrupt the ratatui display). +fn init_telemetry(cli: &Cli, log_buffer: Option, tui_mode: bool) { // Start with user-specified or default log level let base_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&cli.log_level)); @@ -209,7 +223,23 @@ fn init_telemetry(cli: &Cli, log_buffer: Option) { // Optionally attach the in-memory capture layer. let log_layer = log_buffer.map(LogBroadcastLayer::new); - if cli.json_logs { + if tui_mode { + // TUI mode: redirect logs to a file so they don't corrupt the terminal. + let log_path = moltis_config::data_dir().join("tui.log"); + let log_file = std::fs::File::create(&log_path).unwrap_or_else(|e| { + panic!("failed to create TUI log file {}: {e}", log_path.display()) + }); + registry + .with( + fmt::layer() + .with_writer(std::sync::Mutex::new(log_file)) + .with_target(true) + .with_thread_ids(false) + .with_ansi(false), + ) + .with(log_layer) + .init(); + } else if cli.json_logs { registry .with(fmt::layer().json().with_target(true).with_thread_ids(false)) .with(log_layer) @@ -315,7 +345,12 @@ async fn main() -> anyhow::Result<()> { None }; - init_telemetry(&cli, log_buffer.clone()); + #[cfg(feature = "tui")] + let tui_mode = matches!(cli.command, Some(Commands::Tui { .. })); + #[cfg(not(feature = "tui"))] + let tui_mode = false; + + init_telemetry(&cli, log_buffer.clone(), tui_mode); info!(version = env!("CARGO_PKG_VERSION"), "moltis starting"); @@ -403,6 +438,12 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Hooks { action }) => hooks_commands::handle_hooks(action).await, #[cfg(feature = "tls")] Some(Commands::TrustCa) => trust_ca().await, + #[cfg(feature = "tui")] + Some(Commands::Tui { url, api_key }) => { + moltis_tui::run_tui(url.as_deref(), api_key.as_deref()) + .await + .map_err(Into::into) + }, Some(_) => { eprintln!("command not yet implemented"); Ok(()) diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 00000000..4c82a208 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,38 @@ +[package] +description = "Terminal user interface for Moltis gateway" +edition.workspace = true +name = "moltis-tui" +version.workspace = true + +[dependencies] +crossterm = { workspace = true } +futures = { workspace = true } +moltis-config = { workspace = true } +moltis-protocol = { workspace = true } +pulldown-cmark = { workspace = true } +ratatui = { workspace = true } +reqwest = { workspace = true } +rustls = { workspace = true } +rustls-native-certs = { workspace = true } +rustls-pemfile = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } +tracing = { workspace = true } +tui-textarea = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio-test = { workspace = true } + +[features] +default = [] +metrics = [] +tracing = [] + +[lints] +workspace = true diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs new file mode 100644 index 00000000..f3c83e17 --- /dev/null +++ b/crates/tui/src/app.rs @@ -0,0 +1,2033 @@ +mod onboarding; + +use { + crate::{ + Error, + connection::{ConnectionEvent, ConnectionManager}, + events, + onboarding::{OnboardingState, ProviderEntry, parse_providers}, + rpc::RpcClient, + state::{ + AppState, DisplayMessage, InputMode, MainTab, MessageRole, ModelSwitchItem, + ModelSwitcherState, Panel, SessionEntry, SlashMenuItem, TokenUsage, + }, + ui::{self, status_bar::ConnectionDisplay, theme::Theme}, + }, + crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}, + futures::StreamExt, + moltis_protocol::ConnectAuth, + ratatui::DefaultTerminal, + serde_json::Value, + std::{collections::HashSet, sync::Arc, time::Duration}, + tokio::sync::mpsc, + tracing::{debug, warn}, + tui_textarea::TextArea, +}; + +/// Events that drive the application state machine. +#[derive(Debug)] +pub enum AppEvent { + /// Terminal key press. + Key(KeyEvent), + /// Terminal resize or focus-regained — forces a full redraw. + Redraw, + /// Periodic tick for animations/status updates. + Tick, + /// Connection lifecycle event. + Connection(ConnectionEvent), + /// Initial data loaded from gateway (non-blocking). + InitialData(InitialData), + /// System message generated by an async task. + SystemMessage(String), + /// Context payload returned by `/context`. + ContextData(Value), +} + +/// Data loaded from the gateway after a successful connection. +#[derive(Debug, Default)] +pub struct InitialData { + pub sessions: Option>, + pub messages: Option>, + pub active_session: Option, + pub model: Option, + pub provider: Option, + pub token_usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SlashCommand { + name: String, + args: String, +} + +#[derive(Debug, Clone, Copy)] +struct SlashCommandSpec { + name: &'static str, + description: &'static str, +} + +const SLASH_COMMANDS: [SlashCommandSpec; 5] = [ + SlashCommandSpec { + name: "clear", + description: "Clear conversation history", + }, + SlashCommandSpec { + name: "compact", + description: "Summarize conversation to save tokens", + }, + SlashCommandSpec { + name: "context", + description: "Show session context and project info", + }, + SlashCommandSpec { + name: "sh", + description: "Enter command mode (/sh off or Esc to exit)", + }, + SlashCommandSpec { + name: "help", + description: "Show slash command list", + }, +]; + +/// Top-level application. +pub struct App { + state: AppState, + onboarding: Option, + model_switcher: Option, + onboarding_check_pending: bool, + connection_display: ConnectionDisplay, + connection: Option>, + should_quit: bool, + url: String, + auth: ConnectAuth, + theme: Theme, +} + +impl App { + pub fn new(url: String, auth: ConnectAuth) -> Self { + Self { + state: AppState::default(), + onboarding: None, + model_switcher: None, + onboarding_check_pending: true, + connection_display: ConnectionDisplay::Connecting, + connection: None, + should_quit: false, + url, + auth, + theme: Theme::default(), + } + } + + /// Main event loop: reads terminal events, dispatches, and re-renders. + pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<(), Error> { + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + + // Spawn terminal event reader + let term_tx = event_tx.clone(); + tokio::spawn(async move { + let mut reader = EventStream::new(); + while let Some(Ok(event)) = reader.next().await { + let app_event = match event { + Event::Key(key) => AppEvent::Key(key), + Event::Resize(..) | Event::FocusGained => AppEvent::Redraw, + _ => continue, + }; + if term_tx.send(app_event).is_err() { + break; + } + } + }); + + // Spawn tick timer (60ms for smooth streaming) + let tick_tx = event_tx.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(60)); + loop { + interval.tick().await; + if tick_tx.send(AppEvent::Tick).is_err() { + break; + } + } + }); + + // Spawn connection manager + let (conn_event_tx, mut conn_event_rx) = mpsc::unbounded_channel::(); + let connection = Arc::new(ConnectionManager::spawn( + self.url.clone(), + self.auth.clone(), + conn_event_tx, + )); + let rpc = Arc::new(RpcClient::new(Arc::clone(&connection))); + self.connection = Some(Arc::clone(&connection)); + + // Forward connection events to main event loop + let conn_fwd_tx = event_tx.clone(); + let rpc_resolver = Arc::clone(&rpc); + tokio::spawn(async move { + while let Some(event) = conn_event_rx.recv().await { + match event { + ConnectionEvent::Frame(text) => { + // Resolve RPC responses off the UI thread so `rpc.call()` + // can complete even while the app loop is busy. + if let Ok(response) = + serde_json::from_str::(&text) + && response.r#type == "res" + { + rpc_resolver.resolve_response(response).await; + continue; + } + + if conn_fwd_tx + .send(AppEvent::Connection(ConnectionEvent::Frame(text))) + .is_err() + { + break; + } + }, + other => { + if conn_fwd_tx.send(AppEvent::Connection(other)).is_err() { + break; + } + }, + } + } + }); + + // Text input area + let mut textarea = TextArea::default(); + textarea.set_placeholder_text("Type a message..."); + + // Main loop + while !self.should_quit { + if self.state.dirty { + terminal.draw(|frame| { + ui::draw( + frame, + &self.state, + self.onboarding.as_ref(), + self.model_switcher.as_ref(), + self.onboarding_check_pending, + &self.connection_display, + &mut textarea, + &self.theme, + ); + })?; + self.state.dirty = false; + } + + if let Some(event) = event_rx.recv().await { + self.handle_event(event, &rpc, &event_tx, &mut textarea) + .await; + } + } + + Ok(()) + } + + async fn handle_event( + &mut self, + event: AppEvent, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + textarea: &mut TextArea<'_>, + ) { + match event { + AppEvent::Key(key) => self.handle_key(key, rpc, event_tx, textarea).await, + AppEvent::Redraw => { + self.state.dirty = true; + }, + AppEvent::Tick => { + // Re-render on tick if streaming (for spinner animation) + if self.state.is_streaming() || self.state.pending_approval.is_some() { + self.state.dirty = true; + } + }, + AppEvent::Connection(conn_event) => { + self.handle_connection_event(conn_event, rpc, event_tx) + .await; + }, + AppEvent::InitialData(data) => { + self.apply_initial_data(data); + }, + AppEvent::SystemMessage(content) => { + self.push_system_message(content); + }, + AppEvent::ContextData(payload) => { + self.apply_context_snapshot(&payload); + self.push_system_message(format_context_summary(&payload)); + }, + } + } + + async fn handle_connection_event( + &mut self, + event: ConnectionEvent, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + ) { + match event { + ConnectionEvent::Connected(hello_ok) => { + self.connection_display = ConnectionDisplay::Connected; + self.state.server_version = Some(hello_ok.server.version.clone()); + self.state.dirty = true; + + if self.initialize_onboarding(rpc).await { + // Load sessions and history in background (non-blocking). + spawn_initial_data_load(Arc::clone(rpc), event_tx.clone()); + } + self.onboarding_check_pending = false; + self.state.dirty = true; + }, + ConnectionEvent::Disconnected => { + self.connection_display = ConnectionDisplay::Disconnected; + self.state.active_run_id = None; + self.state.thinking_active = false; + self.onboarding_check_pending = false; + self.state.dirty = true; + }, + ConnectionEvent::Error(msg) => { + self.connection_display = ConnectionDisplay::Disconnected; + self.onboarding_check_pending = false; + // Provide actionable hints for common errors. + let content = if msg.contains("authentication failed") { + "Authentication failed. Run the gateway's web UI to complete \ + setup, or pass --api-key." + .into() + } else { + format!("Connection error: {msg}") + }; + self.state.messages.push(DisplayMessage { + role: MessageRole::System, + content, + tool_calls: Vec::new(), + thinking: None, + }); + self.state.dirty = true; + }, + ConnectionEvent::Frame(text) => { + self.handle_frame(&text); + }, + } + } + + fn handle_frame(&mut self, text: &str) { + // Try as event frame + if let Ok(event) = serde_json::from_str::(text) + && event.r#type == "event" + { + let payload = event.payload.unwrap_or(Value::Null); + events::handle_event(&mut self.state, &event.event, &payload); + } + } + + fn apply_initial_data(&mut self, data: InitialData) { + if let Some(sessions) = data.sessions { + self.state.sessions = sessions; + } + if let Some(messages) = data.messages { + self.state.messages = messages; + } + if let Some(session) = data.active_session { + self.state.active_session = session; + } + if data.model.is_some() { + self.state.model = data.model; + } + if data.provider.is_some() { + self.state.provider = data.provider; + } + if let Some(usage) = data.token_usage { + self.state.token_usage = usage; + } + self.state.dirty = true; + } + + fn push_system_message(&mut self, content: String) { + self.state.messages.push(DisplayMessage { + role: MessageRole::System, + content, + tool_calls: Vec::new(), + thinking: None, + }); + self.state.scroll_to_bottom(); + self.state.dirty = true; + } + + fn apply_context_snapshot(&mut self, payload: &Value) { + if let Some(session) = payload.get("session") { + if let Some(key) = session.get("key").and_then(Value::as_str) { + self.state.active_session = key.to_string(); + } + self.state.model = session + .get("model") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + self.state.provider = session + .get("provider") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + } + self.state.token_usage = parse_token_usage(payload); + } + + fn clear_slash_menu(&mut self) { + if self.state.slash_menu_items.is_empty() && self.state.slash_menu_selected == 0 { + return; + } + self.state.slash_menu_items.clear(); + self.state.slash_menu_selected = 0; + self.state.dirty = true; + } + + fn update_slash_menu(&mut self, textarea: &TextArea<'_>) { + let text = textarea.lines().join("\n"); + let menu = build_slash_menu_items(&text); + if menu.is_empty() { + self.clear_slash_menu(); + return; + } + + let previous_name = self + .state + .slash_menu_items + .get(self.state.slash_menu_selected) + .map(|item| item.name.clone()); + self.state.slash_menu_items = menu; + self.state.slash_menu_selected = previous_name + .and_then(|name| { + self.state + .slash_menu_items + .iter() + .position(|item| item.name == name) + }) + .unwrap_or(0); + self.state.dirty = true; + } + + fn move_slash_menu_selection(&mut self, forward: bool) { + let len = self.state.slash_menu_items.len(); + if len == 0 { + self.state.slash_menu_selected = 0; + return; + } + if forward { + self.state.slash_menu_selected = (self.state.slash_menu_selected + 1) % len; + } else { + self.state.slash_menu_selected = (self.state.slash_menu_selected + len - 1) % len; + } + self.state.dirty = true; + } + + async fn apply_slash_menu_selection( + &mut self, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + textarea: &mut TextArea<'_>, + ) { + let command_name = self + .state + .slash_menu_items + .get(self.state.slash_menu_selected) + .map(|item| item.name.clone()); + let Some(command_name) = command_name else { + return; + }; + let command = format!("/{command_name}"); + if self.handle_slash_command(&command, rpc, event_tx).await { + *textarea = TextArea::default(); + textarea.set_placeholder_text("Type a message..."); + self.clear_slash_menu(); + self.state.dirty = true; + } + } + + async fn handle_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + textarea: &mut TextArea<'_>, + ) { + if let Some(onboarding) = self.onboarding.as_ref() { + let modal_open = onboarding_modal_open(onboarding); + if should_quit_onboarding(key, modal_open) { + self.should_quit = true; + return; + } + } + + if self.onboarding_check_pending { + if should_quit_onboarding(key, false) { + self.should_quit = true; + } + return; + } + + if self.model_switcher.is_some() { + self.handle_model_switcher_key(key, rpc).await; + return; + } + + match self.state.input_mode { + InputMode::Normal => self.handle_normal_key(key, rpc, textarea).await, + InputMode::Insert => self.handle_insert_key(key, rpc, event_tx, textarea).await, + InputMode::Command => self.handle_command_key(key, rpc), + } + } + + async fn handle_normal_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if self.onboarding.is_some() { + self.handle_onboarding_normal_key(key, rpc, textarea).await; + return; + } + + match (key.code, key.modifiers) { + // Quit + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + if self.state.is_streaming() { + // Abort current stream + rpc.fire_and_forget( + "chat.abort", + serde_json::json!({"sessionKey": self.state.active_session}), + ); + self.state.active_run_id = None; + self.state.thinking_active = false; + self.state.dirty = true; + } else { + self.should_quit = true; + } + }, + (KeyCode::Char('q'), _) => { + if self.state.pending_approval.is_none() { + self.should_quit = true; + } + }, + + // Enter insert mode + (KeyCode::Char('i') | KeyCode::Char('a'), _) => { + self.state.input_mode = InputMode::Insert; + self.state.dirty = true; + }, + + // Enter command mode + (KeyCode::Char(':'), _) => { + self.state.input_mode = InputMode::Command; + self.state.command_buffer.clear(); + self.state.dirty = true; + }, + + // Model/provider switcher + (KeyCode::Char('m'), KeyModifiers::NONE) => { + self.open_model_switcher(rpc).await; + }, + + // Scrolling + (KeyCode::Char('j') | KeyCode::Down, _) => { + self.state.scroll_down(1); + }, + (KeyCode::Char('k') | KeyCode::Up, _) => { + self.state.scroll_up(1); + }, + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + self.state.scroll_down(10); + }, + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.state.scroll_up(10); + }, + (KeyCode::Char('g'), _) => { + // Scroll to top + self.state.scroll_offset = usize::MAX; + self.state.dirty = true; + }, + (KeyCode::Char('G'), KeyModifiers::SHIFT) | (KeyCode::End, _) => { + self.state.scroll_to_bottom(); + }, + + // Toggle sidebar + (KeyCode::Char('b'), KeyModifiers::CONTROL) => { + self.state.sidebar_visible = !self.state.sidebar_visible; + self.state.dirty = true; + }, + + // Tab: cycle focus (Chat tab only) + (KeyCode::Tab, _) => { + if matches!(self.state.active_tab, MainTab::Chat) { + self.state.active_panel = match self.state.active_panel { + Panel::Chat => Panel::Sessions, + Panel::Sessions => Panel::Chat, + }; + if self.state.active_panel == Panel::Sessions { + self.state.sidebar_visible = true; + } + } + self.state.dirty = true; + }, + + // Tab navigation: 1-4 switch tabs + (KeyCode::Char('1'), _) if self.state.pending_approval.is_none() => { + self.state.active_tab = MainTab::Chat; + self.state.dirty = true; + }, + (KeyCode::Char('2'), _) if self.state.pending_approval.is_none() => { + self.state.active_tab = MainTab::Settings; + self.state.dirty = true; + }, + (KeyCode::Char('3'), _) if self.state.pending_approval.is_none() => { + self.state.active_tab = MainTab::Projects; + self.state.dirty = true; + }, + (KeyCode::Char('4'), _) if self.state.pending_approval.is_none() => { + self.state.active_tab = MainTab::Crons; + self.state.dirty = true; + }, + + // Approval handling + (KeyCode::Char('y'), _) => { + if let Some(approval) = self.state.pending_approval.take() { + rpc.fire_and_forget( + "exec.approval.resolve", + serde_json::json!({ + "requestId": approval.request_id, + "decision": "approved" + }), + ); + self.state.dirty = true; + } + }, + (KeyCode::Char('n'), _) => { + if let Some(approval) = self.state.pending_approval.take() { + rpc.fire_and_forget( + "exec.approval.resolve", + serde_json::json!({ + "requestId": approval.request_id, + "decision": "denied" + }), + ); + self.state.dirty = true; + } + }, + + _ => {}, + } + } + + async fn open_model_switcher(&mut self, rpc: &Arc) { + let (providers_res, models_res) = tokio::join!( + rpc.call("providers.available", serde_json::json!({})), + rpc.call("models.list", serde_json::json!({})), + ); + + let providers = providers_res + .ok() + .map(|payload| parse_providers(&payload)) + .unwrap_or_default(); + let models = models_res + .ok() + .map(|payload| parse_model_list(&payload)) + .unwrap_or_default(); + let items = build_model_switch_items(&providers, &models); + + if items.is_empty() { + self.state.messages.push(DisplayMessage { + role: MessageRole::System, + content: "No configured providers with visible models. Configure a provider first." + .to_string(), + tool_calls: Vec::new(), + thinking: None, + }); + self.state.dirty = true; + return; + } + + let current_provider = self + .state + .provider + .as_deref() + .unwrap_or_default() + .to_string(); + let current_model = self.state.model.as_deref().unwrap_or_default().to_string(); + let selected = items + .iter() + .position(|item| { + item.model_id == current_model + && (current_provider.is_empty() + || provider_names_match(&item.provider_name, ¤t_provider) + || item + .provider_display + .eq_ignore_ascii_case(¤t_provider)) + }) + .or_else(|| items.iter().position(|item| item.model_id == current_model)) + .unwrap_or(0); + + self.model_switcher = Some(ModelSwitcherState { + query: String::new(), + selected, + items, + error_message: None, + }); + self.state.dirty = true; + } + + async fn handle_model_switcher_key(&mut self, key: KeyEvent, rpc: &Arc) { + if is_force_quit_key(key) { + self.should_quit = true; + return; + } + + match key.code { + KeyCode::Esc => { + self.model_switcher = None; + self.state.dirty = true; + return; + }, + KeyCode::Enter => { + self.apply_model_switch_selection(rpc).await; + return; + }, + _ => {}, + } + + let Some(switcher) = self.model_switcher.as_mut() else { + return; + }; + + match (key.code, key.modifiers) { + (KeyCode::Backspace, _) => { + switcher.query.pop(); + switcher.error_message = None; + sync_model_switcher_selection(switcher); + self.state.dirty = true; + }, + (KeyCode::Char('j') | KeyCode::Down, _) => { + move_model_switcher_selection(switcher, true); + self.state.dirty = true; + }, + (KeyCode::Char('k') | KeyCode::Up, _) => { + move_model_switcher_selection(switcher, false); + self.state.dirty = true; + }, + (KeyCode::Char(c), modifiers) + if !c.is_control() + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + switcher.query.push(c); + switcher.error_message = None; + switcher.reset_selection_to_visible(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn apply_model_switch_selection(&mut self, rpc: &Arc) { + let selected = self.model_switcher.as_ref().and_then(|switcher| { + let filtered = switcher.filtered_indices(); + if filtered.is_empty() { + return None; + } + + let selected_index = if filtered.contains(&switcher.selected) { + switcher.selected + } else { + filtered[0] + }; + + switcher.items.get(selected_index).cloned() + }); + + let Some(selected) = selected else { + if let Some(switcher) = self.model_switcher.as_mut() { + switcher.error_message = Some("No model matches the current search.".to_string()); + } + self.state.dirty = true; + return; + }; + + let result = rpc + .call( + "sessions.patch", + serde_json::json!({ + "key": self.state.active_session, + "model": selected.model_id, + }), + ) + .await; + + match result { + Ok(_) => { + self.state.model = Some(selected.model_id.clone()); + self.state.provider = Some(selected.provider_name.clone()); + if let Some(session) = self + .state + .sessions + .iter_mut() + .find(|session| session.key == self.state.active_session) + { + session.model = Some(selected.model_id); + } + self.model_switcher = None; + self.state.dirty = true; + }, + Err(error) => { + if let Some(switcher) = self.model_switcher.as_mut() { + switcher.error_message = Some(format!("Failed to switch model: {error}")); + } + self.state.dirty = true; + }, + } + } + + async fn handle_insert_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + textarea: &mut TextArea<'_>, + ) { + if self.onboarding.is_some() { + self.handle_onboarding_insert_key(key, rpc, textarea).await; + return; + } + + if !self.state.slash_menu_items.is_empty() { + match (key.code, key.modifiers) { + (KeyCode::Up, _) => { + self.move_slash_menu_selection(false); + return; + }, + (KeyCode::Down, _) => { + self.move_slash_menu_selection(true); + return; + }, + (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Tab, _) => { + self.apply_slash_menu_selection(rpc, event_tx, textarea) + .await; + return; + }, + (KeyCode::Esc, _) => { + self.clear_slash_menu(); + return; + }, + _ => {}, + } + } + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + if self.state.shell_mode_enabled && textarea.lines().join("\n").trim().is_empty() { + self.state.shell_mode_enabled = false; + self.push_system_message("Command mode disabled.".to_string()); + self.clear_slash_menu(); + return; + } + self.state.input_mode = InputMode::Normal; + self.clear_slash_menu(); + self.state.dirty = true; + }, + (KeyCode::Enter, KeyModifiers::NONE) => { + // Send message + let text: String = textarea.lines().join("\n"); + let trimmed = text.trim(); + if !trimmed.is_empty() { + if self.handle_slash_command(trimmed, rpc, event_tx).await { + *textarea = TextArea::default(); + textarea.set_placeholder_text("Type a message..."); + self.clear_slash_menu(); + self.state.dirty = true; + return; + } + + self.state.add_user_message(trimmed.to_owned()); + self.state.scroll_to_bottom(); + + let outbound = rewrite_for_shell_mode(trimmed, self.state.shell_mode_enabled); + rpc.fire_and_forget("chat.send", serde_json::json!({"text": outbound})); + + // Clear textarea, stay in Insert mode + *textarea = TextArea::default(); + textarea.set_placeholder_text("Type a message..."); + self.clear_slash_menu(); + } + self.state.dirty = true; + }, + (KeyCode::Enter, KeyModifiers::SHIFT) => { + // Insert newline + textarea.insert_newline(); + self.update_slash_menu(textarea); + self.state.dirty = true; + }, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + if self.state.is_streaming() { + rpc.fire_and_forget( + "chat.abort", + serde_json::json!({"sessionKey": self.state.active_session}), + ); + self.state.active_run_id = None; + self.state.thinking_active = false; + } else { + self.state.input_mode = InputMode::Normal; + } + self.clear_slash_menu(); + self.state.dirty = true; + }, + _ => { + // Forward to textarea + textarea.input(key); + self.update_slash_menu(textarea); + self.state.dirty = true; + }, + } + } + + async fn handle_slash_command( + &mut self, + text: &str, + rpc: &Arc, + event_tx: &mpsc::UnboundedSender, + ) -> bool { + let Some(command) = parse_slash_command(text) else { + return false; + }; + if !should_handle_slash_locally(&command) { + return false; + } + + match command.name.as_str() { + "clear" => { + rpc.fire_and_forget("chat.clear", serde_json::json!({})); + self.state.messages.clear(); + self.state.stream_buffer.clear(); + self.state.active_run_id = None; + self.state.thinking_active = false; + self.state.thinking_text.clear(); + self.state.token_usage.session_input = 0; + self.state.token_usage.session_output = 0; + self.state.scroll_to_bottom(); + }, + "compact" => { + self.push_system_message("Compacting conversation...".to_string()); + let rpc = Arc::clone(rpc); + let event_tx = event_tx.clone(); + tokio::spawn(async move { + match rpc.call("chat.compact", serde_json::json!({})).await { + Ok(_) => { + let _ = event_tx.send(AppEvent::SystemMessage( + "Conversation compacted.".to_string(), + )); + spawn_initial_data_load(Arc::clone(&rpc), event_tx.clone()); + }, + Err(error) => { + let _ = event_tx + .send(AppEvent::SystemMessage(format!("Compact failed: {error}"))); + }, + } + }); + }, + "context" => { + self.push_system_message("Loading context...".to_string()); + let rpc = Arc::clone(rpc); + let event_tx = event_tx.clone(); + tokio::spawn(async move { + match rpc.call("chat.context", serde_json::json!({})).await { + Ok(payload) => { + let _ = event_tx.send(AppEvent::ContextData(payload)); + }, + Err(error) => { + let _ = event_tx + .send(AppEvent::SystemMessage(format!("Context failed: {error}"))); + }, + } + }); + }, + "help" => { + self.push_system_message(slash_help_text().to_string()); + }, + "sh" => { + let normalized = command.args.trim().to_ascii_lowercase(); + if normalized == "off" || normalized == "exit" { + self.state.shell_mode_enabled = false; + self.push_system_message("Command mode disabled.".to_string()); + } else { + self.state.shell_mode_enabled = true; + self.push_system_message( + "Command mode enabled. Plain messages run as `/sh `. Exit with `/sh off`." + .to_string(), + ); + } + }, + _ => { + return false; + }, + } + + true + } + + fn handle_command_key(&mut self, key: KeyEvent, rpc: &Arc) { + match key.code { + KeyCode::Esc => { + self.state.input_mode = InputMode::Normal; + self.state.command_buffer.clear(); + self.state.dirty = true; + }, + KeyCode::Enter => { + let cmd = std::mem::take(&mut self.state.command_buffer); + self.execute_command(&cmd, rpc); + self.state.input_mode = InputMode::Normal; + self.state.dirty = true; + }, + KeyCode::Backspace => { + self.state.command_buffer.pop(); + self.state.dirty = true; + }, + KeyCode::Char(c) => { + self.state.command_buffer.push(c); + self.state.dirty = true; + }, + _ => {}, + } + } + + fn execute_command(&mut self, cmd: &str, rpc: &Arc) { + let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect(); + match parts.first().copied() { + Some("q" | "quit") => self.should_quit = true, + Some("clear") => { + rpc.fire_and_forget("chat.clear", serde_json::json!({})); + self.state.messages.clear(); + }, + Some("model") => { + if let Some(model_id) = parts.get(1) { + rpc.fire_and_forget( + "sessions.patch", + serde_json::json!({ + "key": self.state.active_session, + "model": model_id + }), + ); + self.state.model = Some(model_id.to_string()); + } + }, + Some("session") => { + if let Some(key) = parts.get(1) { + rpc.fire_and_forget("sessions.switch", serde_json::json!({"key": key})); + self.state.active_session = key.to_string(); + self.state.messages.clear(); + } + }, + _ => { + self.state.messages.push(DisplayMessage { + role: MessageRole::System, + content: format!("Unknown command: {cmd}"), + tool_calls: Vec::new(), + thinking: None, + }); + }, + } + } +} + +fn onboarding_modal_open(onboarding: &OnboardingState) -> bool { + onboarding.llm.configuring.is_some() + || onboarding.channel.configuring + || onboarding.editing.is_some() +} + +fn should_quit_onboarding(key: KeyEvent, modal_open: bool) -> bool { + is_force_quit_key(key) || (key.code == KeyCode::Esc && !modal_open) +} + +fn is_force_quit_key(key: KeyEvent) -> bool { + key.code == KeyCode::Char('q') + || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) +} + +#[derive(Debug, Clone)] +struct ModelCatalogEntry { + provider_name: String, + model_id: String, + model_display: String, +} + +fn parse_model_list(payload: &Value) -> Vec { + payload + .as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + let model_id = row.get("id").and_then(Value::as_str)?.trim(); + if model_id.is_empty() { + return None; + } + + let provider_name = row + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .filter(|provider| !provider.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| infer_provider_from_model_id(model_id).map(ToOwned::to_owned)) + .unwrap_or_default(); + if provider_name.is_empty() { + return None; + } + + let model_display = row + .get("displayName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|display| !display.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| fallback_model_display(model_id)); + + Some(ModelCatalogEntry { + provider_name, + model_id: model_id.to_string(), + model_display, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn build_model_switch_items( + providers: &[ProviderEntry], + models: &[ModelCatalogEntry], +) -> Vec { + let mut seen = HashSet::new(); + let mut items = Vec::new(); + + for provider in providers.iter().filter(|provider| provider.configured) { + let mut has_live_models = false; + + for model in models + .iter() + .filter(|model| provider_names_match(&provider.name, &model.provider_name)) + { + has_live_models = true; + push_model_switch_item( + &mut items, + &mut seen, + provider, + &model.model_id, + &model.model_display, + ); + } + + if has_live_models { + continue; + } + + for model_id in &provider.models { + if model_id.trim().is_empty() { + continue; + } + push_model_switch_item( + &mut items, + &mut seen, + provider, + model_id, + &fallback_model_display(model_id), + ); + } + } + + items +} + +fn push_model_switch_item( + items: &mut Vec, + seen: &mut HashSet<(String, String)>, + provider: &ProviderEntry, + model_id: &str, + model_display: &str, +) { + let normalized_provider = normalize_provider_name(&provider.name); + let normalized_model = model_id.trim().to_ascii_lowercase(); + if normalized_provider.is_empty() || normalized_model.is_empty() { + return; + } + + if !seen.insert((normalized_provider, normalized_model)) { + return; + } + + items.push(ModelSwitchItem { + provider_name: provider.name.clone(), + provider_display: provider.display_name.clone(), + model_id: model_id.trim().to_string(), + model_display: if model_display.trim().is_empty() { + fallback_model_display(model_id) + } else { + model_display.trim().to_string() + }, + }); +} + +fn sync_model_switcher_selection(switcher: &mut ModelSwitcherState) { + let filtered = switcher.filtered_indices(); + if filtered.is_empty() { + switcher.selected = 0; + return; + } + if !filtered.contains(&switcher.selected) { + switcher.selected = filtered[0]; + } +} + +fn move_model_switcher_selection(switcher: &mut ModelSwitcherState, forward: bool) { + let filtered = switcher.filtered_indices(); + if filtered.is_empty() { + switcher.selected = 0; + return; + } + + let current_pos = filtered + .iter() + .position(|index| *index == switcher.selected) + .unwrap_or(0); + let next_pos = if forward { + (current_pos + 1).min(filtered.len().saturating_sub(1)) + } else { + current_pos.saturating_sub(1) + }; + switcher.selected = filtered[next_pos]; +} + +fn infer_provider_from_model_id(model_id: &str) -> Option<&str> { + model_id + .split_once("::") + .or_else(|| model_id.split_once('/')) + .or_else(|| model_id.split_once(':')) + .map(|(provider, _)| provider) +} + +fn fallback_model_display(model_id: &str) -> String { + model_id + .split_once("::") + .or_else(|| model_id.split_once('/')) + .or_else(|| model_id.split_once(':')) + .map(|(_, model)| model.to_string()) + .unwrap_or_else(|| model_id.to_string()) +} + +fn provider_names_match(left: &str, right: &str) -> bool { + normalize_provider_name(left) == normalize_provider_name(right) +} + +fn normalize_provider_name(name: &str) -> String { + let normalized = name.trim().to_ascii_lowercase(); + match normalized.as_str() { + "z.ai" => "zai".to_string(), + other => other.to_string(), + } +} + +fn build_slash_menu_items(text: &str) -> Vec { + if !text.starts_with('/') || text.chars().any(char::is_whitespace) { + return Vec::new(); + } + let filter = text.to_ascii_lowercase(); + SLASH_COMMANDS + .iter() + .filter_map(|spec| { + let command = format!("/{}", spec.name); + if command.starts_with(&filter) { + Some(SlashMenuItem { + name: spec.name.to_string(), + description: spec.description.to_string(), + }) + } else { + None + } + }) + .collect() +} + +fn parse_slash_command(text: &str) -> Option { + let body = text.strip_prefix('/')?.trim(); + if body.is_empty() { + return None; + } + + let (name, args) = match body.split_once(char::is_whitespace) { + Some((name, rest)) => (name, rest.trim()), + None => (body, ""), + }; + if name.is_empty() { + return None; + } + + Some(SlashCommand { + name: name.to_ascii_lowercase(), + args: args.to_string(), + }) +} + +fn is_sh_local_toggle(args: &str) -> bool { + let trimmed = args.trim(); + trimmed.is_empty() || matches!(trimmed.to_ascii_lowercase().as_str(), "on" | "off" | "exit") +} + +fn should_handle_slash_locally(command: &SlashCommand) -> bool { + match command.name.as_str() { + "clear" | "compact" | "context" | "help" => true, + "sh" => is_sh_local_toggle(&command.args), + _ => false, + } +} + +fn rewrite_for_shell_mode(text: &str, shell_mode_enabled: bool) -> String { + if !shell_mode_enabled { + return text.to_string(); + } + + if let Some(command) = parse_slash_command(text) + && command.name == "sh" + { + return text.to_string(); + } + + format!("/sh {text}") +} + +fn slash_help_text() -> &'static str { + concat!( + "Slash commands:\n", + "/clear clear session history\n", + "/compact summarize history to save tokens\n", + "/context show session and project context\n", + "/sh enable command mode (/sh off to exit)\n", + "/help show this list" + ) +} + +fn parse_token_usage(payload: &Value) -> TokenUsage { + let usage = payload + .get("tokenUsage") + .or_else(|| payload.get("usage")) + .unwrap_or(&Value::Null); + TokenUsage { + session_input: usage + .get("inputTokens") + .or_else(|| usage.get("sessionInputTokens")) + .and_then(Value::as_u64) + .unwrap_or(0), + session_output: usage + .get("outputTokens") + .or_else(|| usage.get("sessionOutputTokens")) + .and_then(Value::as_u64) + .unwrap_or(0), + context_window: usage + .get("contextWindow") + .or_else(|| payload.get("contextWindow")) + .and_then(Value::as_u64) + .unwrap_or(0), + } +} + +fn parse_context_initial_data(data: &mut InitialData, payload: &Value) { + if let Some(session) = payload.get("session") { + data.active_session = session + .get("key") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + data.model = session + .get("model") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + data.provider = session + .get("provider") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + } + data.token_usage = Some(parse_token_usage(payload)); +} + +fn format_context_summary(payload: &Value) -> String { + let session = payload.get("session").unwrap_or(&Value::Null); + let project = payload.get("project").unwrap_or(&Value::Null); + let supports_tools = payload + .get("supportsTools") + .and_then(Value::as_bool) + .unwrap_or(true); + let token_usage = payload.get("tokenUsage").unwrap_or(&Value::Null); + let sandbox = payload.get("sandbox").unwrap_or(&Value::Null); + let execution = payload.get("execution").unwrap_or(&Value::Null); + + let session_key = session + .get("key") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let message_count = session + .get("messageCount") + .and_then(Value::as_u64) + .unwrap_or(0); + let model = session + .get("model") + .and_then(Value::as_str) + .unwrap_or("default"); + let provider = session + .get("provider") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let label = session.get("label").and_then(Value::as_str).unwrap_or(""); + + let mut lines = vec![ + "Context".to_string(), + "Session:".to_string(), + format!("- Key: {session_key}"), + format!("- Messages: {message_count}"), + format!("- Model: {model}"), + format!("- Provider: {provider}"), + format!( + "- Tool support: {}", + if supports_tools { + "enabled" + } else { + "disabled" + } + ), + ]; + if !label.is_empty() { + lines.push(format!("- Label: {label}")); + } + + lines.push(String::new()); + lines.push("Project:".to_string()); + if project.is_null() { + lines.push("- No project bound to this session.".to_string()); + } else { + let project_name = project + .get("label") + .and_then(Value::as_str) + .unwrap_or("(unnamed)"); + lines.push(format!("- Name: {project_name}")); + if let Some(directory) = project.get("directory").and_then(Value::as_str) + && !directory.is_empty() + { + lines.push(format!("- Directory: {directory}")); + } + if let Some(system_prompt) = project.get("systemPrompt").and_then(Value::as_str) + && !system_prompt.is_empty() + { + lines.push(format!( + "- System prompt: {} chars", + system_prompt.chars().count() + )); + } + let context_files = project + .get("contextFiles") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if context_files.is_empty() { + lines.push("- Context files: none".to_string()); + } else { + lines.push(format!("- Context files: {}", context_files.len())); + for file in context_files.iter().take(8) { + let path = file + .get("path") + .and_then(Value::as_str) + .unwrap_or("(unknown)"); + let size = file.get("size").and_then(Value::as_u64).unwrap_or(0); + lines.push(format!(" - {path} ({})", format_bytes(size))); + } + if context_files.len() > 8 { + lines.push(format!(" - ... {} more", context_files.len() - 8)); + } + } + } + + lines.push(String::new()); + lines.push("Tools:".to_string()); + if !supports_tools { + lines.push("- Disabled for current model.".to_string()); + } else { + let tools = payload + .get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if tools.is_empty() { + lines.push("- No tools registered.".to_string()); + } else { + let tool_names = tools + .iter() + .take(8) + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>() + .join(", "); + lines.push(format!("- {} tool(s): {tool_names}", tools.len())); + if tools.len() > 8 { + lines.push(format!("- ... {} more", tools.len() - 8)); + } + } + + let skills = payload + .get("skills") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !skills.is_empty() { + let skill_names = skills + .iter() + .take(8) + .filter_map(|skill| skill.get("name").and_then(Value::as_str)) + .collect::>() + .join(", "); + lines.push(format!("- Skills: {skill_names}")); + if skills.len() > 8 { + lines.push(format!("- Skills: ... {} more", skills.len() - 8)); + } + } + + let mcp_disabled = payload + .get("mcpDisabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let mcp_servers = payload + .get("mcpServers") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if mcp_disabled { + lines.push("- MCP: disabled for this session.".to_string()); + } else { + let running = mcp_servers + .iter() + .filter(|server| server.get("state").and_then(Value::as_str) == Some("running")) + .filter_map(|server| server.get("name").and_then(Value::as_str)) + .collect::>(); + if running.is_empty() { + lines.push("- MCP: no running servers.".to_string()); + } else { + lines.push(format!("- MCP running: {}", running.join(", "))); + } + } + } + + lines.push(String::new()); + lines.push("Execution:".to_string()); + let command_route = execution + .get("mode") + .and_then(Value::as_str) + .map(|mode| { + let label = if mode == "sandbox" { + "sandboxed" + } else { + "host" + }; + match execution.get("promptSymbol").and_then(Value::as_str) { + Some(symbol) if !symbol.is_empty() => format!("{label} ({symbol})"), + _ => label.to_string(), + } + }) + .unwrap_or_else(|| "unknown".to_string()); + lines.push(format!("- Command route: {command_route}")); + lines.push(format!( + "- Sandbox enabled: {}", + if sandbox + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "yes" + } else { + "no" + } + )); + if let Some(backend) = sandbox.get("backend").and_then(Value::as_str) + && !backend.is_empty() + { + lines.push(format!("- Sandbox backend: {backend}")); + } + if let Some(image) = sandbox.get("image").and_then(Value::as_str) + && !image.is_empty() + { + lines.push(format!("- Sandbox image: {image}")); + } + if let Some(container) = sandbox.get("containerName").and_then(Value::as_str) + && !container.is_empty() + { + lines.push(format!("- Container: {container}")); + } + + let session_input = token_usage + .get("inputTokens") + .and_then(Value::as_u64) + .unwrap_or(0); + let session_output = token_usage + .get("outputTokens") + .and_then(Value::as_u64) + .unwrap_or(0); + let session_total = token_usage + .get("total") + .and_then(Value::as_u64) + .unwrap_or(session_input.saturating_add(session_output)); + let current_input = token_usage + .get("currentInputTokens") + .and_then(Value::as_u64) + .unwrap_or(session_input); + let current_output = token_usage + .get("currentOutputTokens") + .and_then(Value::as_u64) + .unwrap_or(0); + let current_total = token_usage + .get("currentTotal") + .and_then(Value::as_u64) + .unwrap_or(current_input.saturating_add(current_output)); + let estimated_next_input = token_usage + .get("estimatedNextInputTokens") + .and_then(Value::as_u64) + .unwrap_or(current_input); + let context_window = token_usage + .get("contextWindow") + .and_then(Value::as_u64) + .unwrap_or(0); + + lines.push(String::new()); + lines.push("Tokens:".to_string()); + lines.push(format!( + "- Session: in {}, out {}, total {}", + ui::common::format_count(session_input), + ui::common::format_count(session_output), + ui::common::format_count(session_total) + )); + lines.push(format!( + "- Current request: in {}, out {}, total {}", + ui::common::format_count(current_input), + ui::common::format_count(current_output), + ui::common::format_count(current_total) + )); + lines.push(format!( + "- Estimated next input: {}", + ui::common::format_count(estimated_next_input) + )); + if context_window > 0 { + let pct_used = ((estimated_next_input as f64 / context_window as f64) * 100.0).round(); + lines.push(format!( + "- Context usage: {}% of {}", + pct_used.clamp(0.0, 100.0) as u64, + ui::common::format_count(context_window) + )); + } + + lines.join("\n") +} + +fn format_bytes(size: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if size >= GB { + format!("{:.1} GB", size as f64 / GB as f64) + } else if size >= MB { + format!("{:.1} MB", size as f64 / MB as f64) + } else if size >= KB { + format!("{:.1} KB", size as f64 / KB as f64) + } else { + format!("{size} B") + } +} + +/// Load initial data (sessions, history, context) in a background task. +/// Results are sent back to the event loop via `event_tx`. +fn spawn_initial_data_load(rpc: Arc, event_tx: mpsc::UnboundedSender) { + tokio::spawn(async move { + let mut data = InitialData::default(); + + // Run all 3 RPC calls concurrently. + let (sessions_res, history_res, context_res) = tokio::join!( + rpc.call("sessions.list", serde_json::json!({})), + rpc.call("chat.history", serde_json::json!({})), + rpc.call("chat.context", serde_json::json!({})), + ); + + // Parse sessions + if let Ok(sessions) = sessions_res { + if let Some(arr) = sessions.as_array() { + data.sessions = Some( + arr.iter() + .filter_map(|s| { + let key = s.get("key").and_then(|v| v.as_str())?; + Some(SessionEntry { + key: key.into(), + label: s.get("label").and_then(|v| v.as_str()).map(String::from), + model: s.get("model").and_then(|v| v.as_str()).map(String::from), + message_count: s + .get("message_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + replying: s + .get("replying") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }) + }) + .collect(), + ); + } + } else if let Err(e) = sessions_res { + warn!(error = %e, "failed to load sessions"); + } + + // Parse chat history + if let Ok(history) = history_res { + if let Some(arr) = history.as_array() { + data.messages = Some( + arr.iter() + .filter_map(|msg| { + let role = msg.get("role").and_then(|v| v.as_str())?; + let content = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let role = match role { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + _ => MessageRole::System, + }; + Some(DisplayMessage { + role, + content, + tool_calls: Vec::new(), + thinking: None, + }) + }) + .collect(), + ); + } + } else if let Err(e) = history_res { + warn!(error = %e, "failed to load chat history"); + } + + // Parse context + if let Ok(ctx) = context_res { + parse_context_initial_data(&mut data, &ctx); + } else if let Err(e) = context_res { + debug!(error = %e, "failed to load context"); + } + + let _ = event_tx.send(AppEvent::InitialData(data)); + }); +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{ + onboarding::{OnboardingState, ProviderEntry}, + state::ModelSwitcherState, + }, + }; + + #[test] + fn onboarding_escape_quits_only_without_modal() { + assert!(should_quit_onboarding( + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + false + )); + assert!(!should_quit_onboarding( + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + true + )); + } + + #[test] + fn onboarding_force_quit_keys_always_quit() { + assert!(should_quit_onboarding( + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + false + )); + assert!(should_quit_onboarding( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + true + )); + assert!(!should_quit_onboarding( + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + false + )); + } + + #[test] + fn channel_config_modal_counts_as_open_modal() { + let mut onboarding = OnboardingState::new(false, false, true, None); + onboarding.channel.configuring = true; + assert!(onboarding_modal_open(&onboarding)); + } + + #[test] + fn parse_model_list_uses_provider_field_and_fallback_display() { + let payload = serde_json::json!([ + { + "id": "openai/gpt-5", + "provider": "openai", + "displayName": "GPT-5" + }, + { + "id": "anthropic::claude-sonnet-4", + "displayName": "" + } + ]); + + let parsed = parse_model_list(&payload); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].provider_name, "openai"); + assert_eq!(parsed[0].model_display, "GPT-5"); + assert_eq!(parsed[1].provider_name, "anthropic"); + assert_eq!(parsed[1].model_display, "claude-sonnet-4"); + } + + #[test] + fn build_model_switch_items_uses_live_models_then_provider_saved_models() { + let providers = vec![ + ProviderEntry { + name: "openai".into(), + display_name: "OpenAI".into(), + auth_type: "api-key".into(), + configured: true, + default_base_url: None, + base_url: None, + models: vec!["openai/gpt-4.1".into()], + requires_model: false, + key_optional: false, + }, + ProviderEntry { + name: "anthropic".into(), + display_name: "Anthropic".into(), + auth_type: "api-key".into(), + configured: true, + default_base_url: None, + base_url: None, + models: vec!["anthropic/claude-sonnet-4".into()], + requires_model: false, + key_optional: false, + }, + ProviderEntry { + name: "openrouter".into(), + display_name: "OpenRouter".into(), + auth_type: "api-key".into(), + configured: false, + default_base_url: None, + base_url: None, + models: vec!["openrouter/meta-llama-3.1-8b-instruct".into()], + requires_model: false, + key_optional: false, + }, + ]; + + let models = vec![ModelCatalogEntry { + provider_name: "openai".into(), + model_id: "openai/gpt-5".into(), + model_display: "GPT-5".into(), + }]; + + let items = build_model_switch_items(&providers, &models); + assert_eq!(items.len(), 2); + assert_eq!(items[0].provider_name, "openai"); + assert_eq!(items[0].model_id, "openai/gpt-5"); + assert_eq!(items[1].provider_name, "anthropic"); + assert_eq!(items[1].model_id, "anthropic/claude-sonnet-4"); + } + + #[test] + fn provider_alias_matching_supports_zai() { + assert!(provider_names_match("z.ai", "zai")); + assert!(provider_names_match("ZAI", "z.ai")); + } + + #[test] + fn model_switcher_selection_stays_in_filtered_list() { + let mut switcher = ModelSwitcherState { + query: "claude".into(), + selected: 0, + items: vec![ + ModelSwitchItem { + provider_name: "openai".into(), + provider_display: "OpenAI".into(), + model_id: "openai/gpt-5".into(), + model_display: "GPT-5".into(), + }, + ModelSwitchItem { + provider_name: "anthropic".into(), + provider_display: "Anthropic".into(), + model_id: "anthropic/claude-sonnet-4".into(), + model_display: "Claude Sonnet 4".into(), + }, + ], + error_message: None, + }; + + sync_model_switcher_selection(&mut switcher); + assert_eq!(switcher.selected, 1); + + move_model_switcher_selection(&mut switcher, true); + assert_eq!(switcher.selected, 1); + } + + #[test] + fn parse_slash_command_extracts_name_and_args() { + let command = match parse_slash_command("/context") { + Some(command) => command, + None => panic!("command should parse"), + }; + assert_eq!(command.name, "context"); + assert_eq!(command.args, ""); + + let command = match parse_slash_command("/SH echo hello") { + Some(command) => command, + None => panic!("command should parse"), + }; + assert_eq!(command.name, "sh"); + assert_eq!(command.args, "echo hello"); + + assert!(parse_slash_command("context").is_none()); + assert!(parse_slash_command("/").is_none()); + } + + #[test] + fn slash_menu_matches_prefix_without_whitespace() { + let all = build_slash_menu_items("/"); + assert_eq!(all.len(), 5); + + let filtered = build_slash_menu_items("/co"); + let names = filtered + .iter() + .map(|item| item.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["compact", "context"]); + + assert!(build_slash_menu_items("/co arg").is_empty()); + assert!(build_slash_menu_items("context").is_empty()); + } + + #[test] + fn slash_local_routing_matches_web_behavior() { + assert!(should_handle_slash_locally(&SlashCommand { + name: "clear".to_string(), + args: String::new(), + })); + assert!(should_handle_slash_locally(&SlashCommand { + name: "context".to_string(), + args: String::new(), + })); + assert!(should_handle_slash_locally(&SlashCommand { + name: "sh".to_string(), + args: "off".to_string(), + })); + assert!(!should_handle_slash_locally(&SlashCommand { + name: "sh".to_string(), + args: "uname -a".to_string(), + })); + } + + #[test] + fn shell_mode_rewrite_prefixes_non_sh_messages() { + assert_eq!(rewrite_for_shell_mode("echo hi", true), "/sh echo hi"); + assert_eq!(rewrite_for_shell_mode("/clear", true), "/sh /clear"); + assert_eq!(rewrite_for_shell_mode("/sh uname -a", true), "/sh uname -a"); + assert_eq!(rewrite_for_shell_mode("echo hi", false), "echo hi"); + } + + #[test] + fn parse_context_initial_data_reads_token_usage() { + let payload = serde_json::json!({ + "session": { + "key": "main", + "model": "openai/gpt-5", + "provider": "openai" + }, + "tokenUsage": { + "inputTokens": 1200, + "outputTokens": 300, + "contextWindow": 200000 + } + }); + let mut data = InitialData::default(); + parse_context_initial_data(&mut data, &payload); + + assert_eq!(data.active_session.as_deref(), Some("main")); + assert_eq!(data.model.as_deref(), Some("openai/gpt-5")); + assert_eq!(data.provider.as_deref(), Some("openai")); + assert_eq!( + data.token_usage.as_ref().map(|usage| usage.session_input), + Some(1200) + ); + assert_eq!( + data.token_usage.as_ref().map(|usage| usage.session_output), + Some(300) + ); + assert_eq!( + data.token_usage.as_ref().map(|usage| usage.context_window), + Some(200000) + ); + } + + #[test] + fn format_context_summary_includes_core_sections() { + let payload = serde_json::json!({ + "session": { + "key": "main", + "messageCount": 5, + "model": "openai/gpt-5", + "provider": "openai" + }, + "project": { + "label": "repo", + "directory": "/tmp/repo", + "contextFiles": [ + { "path": "README.md", "size": 1024 } + ] + }, + "tools": [ + { "name": "exec" }, + { "name": "fs_read" } + ], + "skills": [ + { "name": "skill-installer" } + ], + "mcpServers": [ + { "name": "filesystem", "state": "running" } + ], + "sandbox": { + "enabled": true, + "backend": "docker" + }, + "execution": { + "mode": "sandbox", + "promptSymbol": "#" + }, + "tokenUsage": { + "inputTokens": 1000, + "outputTokens": 500, + "total": 1500, + "currentInputTokens": 900, + "currentOutputTokens": 100, + "currentTotal": 1000, + "estimatedNextInputTokens": 950, + "contextWindow": 10000 + } + }); + + let summary = format_context_summary(&payload); + assert!(summary.contains("Context")); + assert!(summary.contains("Session:")); + assert!(summary.contains("Project:")); + assert!(summary.contains("Tools:")); + assert!(summary.contains("Execution:")); + assert!(summary.contains("Tokens:")); + assert!(summary.contains("MCP running: filesystem")); + } +} diff --git a/crates/tui/src/app/onboarding.rs b/crates/tui/src/app/onboarding.rs new file mode 100644 index 00000000..7c9817ff --- /dev/null +++ b/crates/tui/src/app/onboarding.rs @@ -0,0 +1,2109 @@ +use { + super::{App, InitialData}, + crate::{ + onboarding::{ + AuthStatus, ChannelProvider, EditTarget, OnboardingState, OnboardingStep, + ProviderConfigurePhase, ProviderConfigureState, ProviderEntry, SecurityState, + configured_provider_badges, parse_channels, parse_identity, parse_local_backend_note, + parse_local_models, parse_local_recommended_backend, parse_model_options, + parse_providers, parse_voice_providers, supports_endpoint, + }, + rpc::RpcClient, + state::{DisplayMessage, InputMode, MessageRole, SessionEntry}, + }, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, + serde_json::Value, + std::{collections::BTreeSet, sync::Arc}, + tui_textarea::TextArea, + url::{Host, Url}, +}; + +impl App { + pub(super) async fn initialize_onboarding(&mut self, rpc: &Arc) -> bool { + let onboarded = rpc + .call("wizard.status", serde_json::json!({})) + .await + .ok() + .and_then(|status| status.get("onboarded").and_then(|v| v.as_bool())) + .unwrap_or(false); + + if onboarded { + self.onboarding = None; + return true; + } + + let auth_status = self.fetch_auth_status().await.ok(); + let auth_needed = auth_status.as_ref().is_some_and(|status| { + status.setup_required || (status.auth_disabled && !status.localhost_only) + }); + let auth_skippable = auth_status + .as_ref() + .is_some_and(|status| !status.setup_required); + + let voice_payload = rpc + .call("voice.providers.all", serde_json::json!({})) + .await + .ok(); + let voice_available = voice_payload.is_some(); + + let mut onboarding = OnboardingState::new( + auth_needed, + auth_skippable, + voice_available, + auth_status.as_ref(), + ); + + if let Ok(identity) = rpc.call("agent.identity.get", serde_json::json!({})).await { + onboarding.identity = parse_identity(&identity); + } + + if let Ok(providers) = rpc.call("providers.available", serde_json::json!({})).await { + onboarding.llm.providers = parse_providers(&providers); + } + + if let Some(voice) = voice_payload.as_ref() { + onboarding.voice.providers = parse_voice_providers(voice); + onboarding.voice.available = true; + } + + self.onboarding = Some(onboarding); + self.state.sidebar_visible = false; + self.state.input_mode = InputMode::Normal; + self.state.dirty = true; + false + } + + async fn fetch_auth_status(&self) -> Result { + let base = http_base_url_from_ws(&self.url) + .ok_or_else(|| "unable to derive HTTP base URL from gateway URL".to_string())?; + let endpoint = format!("{base}/api/auth/status"); + + let response = reqwest::Client::new() + .get(&endpoint) + .send() + .await + .map_err(|error| format!("failed to fetch auth status: {error}"))?; + + if !response.status().is_success() { + return Err(format!( + "auth status endpoint returned {}", + response.status() + )); + } + + let payload = response + .json::() + .await + .map_err(|error| format!("invalid auth status response: {error}"))?; + + Ok(AuthStatus { + setup_required: payload + .get("setup_required") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + setup_complete: payload + .get("setup_complete") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + auth_disabled: payload + .get("auth_disabled") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + setup_code_required: payload + .get("setup_code_required") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + localhost_only: payload + .get("localhost_only") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + webauthn_available: payload + .get("webauthn_available") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + }) + } + + pub(super) async fn handle_onboarding_normal_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { + self.should_quit = true; + return; + } + + let Some(step) = self.onboarding.as_ref().map(OnboardingState::current_step) else { + return; + }; + + match step { + OnboardingStep::Security => { + self.handle_security_step_key(key, rpc, textarea).await; + }, + OnboardingStep::Llm => { + self.handle_llm_step_key(key, rpc, textarea).await; + }, + OnboardingStep::Voice => { + self.handle_voice_step_key(key, rpc, textarea).await; + }, + OnboardingStep::Channel => { + self.handle_channel_step_key(key, rpc, textarea).await; + }, + OnboardingStep::Identity => { + self.handle_identity_step_key(key, rpc, textarea).await; + }, + OnboardingStep::Summary => { + self.handle_summary_step_key(key, rpc).await; + }, + } + + // Auto-refresh summary when navigating into the Summary step. + if step != OnboardingStep::Summary + && self + .onboarding + .as_ref() + .is_some_and(|o| o.current_step() == OnboardingStep::Summary) + { + self.refresh_summary(rpc).await; + } + } + + async fn handle_security_step_key( + &mut self, + key: KeyEvent, + _rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if self + .onboarding + .as_ref() + .is_none_or(|onboarding| onboarding.busy) + { + return; + } + + if key.code == KeyCode::Char('c') { + self.submit_security_step().await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let max_index = onboarding.security.visible_fields().saturating_sub(1); + onboarding.security.field_index = + (onboarding.security.field_index + 1).min(max_index); + self.state.dirty = true; + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.security.field_index = onboarding.security.field_index.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') | KeyCode::Enter => { + let target = security_edit_target(&onboarding.security); + self.start_onboarding_edit(target, textarea); + }, + KeyCode::Char('s') => { + if onboarding.security.skippable || onboarding.security.localhost_only { + onboarding.clear_messages(); + onboarding.go_next(); + self.state.dirty = true; + } + }, + KeyCode::Char('b') => { + onboarding.clear_messages(); + onboarding.go_back(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn submit_security_step(&mut self) { + let ( + setup_complete, + setup_code_required, + setup_code, + password, + confirm_password, + localhost_only, + skippable, + ) = { + let Some(onboarding) = self.onboarding.as_ref() else { + return; + }; + ( + onboarding.security.setup_complete, + onboarding.security.setup_code_required, + onboarding.security.setup_code.clone(), + onboarding.security.password.clone(), + onboarding.security.confirm_password.clone(), + onboarding.security.localhost_only, + onboarding.security.skippable, + ) + }; + + if setup_complete { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.clear_messages(); + onboarding.go_next(); + self.state.dirty = true; + } + return; + } + + if password.len() < 8 && !(localhost_only && password.is_empty() && skippable) { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Password must be at least 8 characters."); + self.state.dirty = true; + } + return; + } + if !password.is_empty() && password != confirm_password { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Passwords do not match."); + self.state.dirty = true; + } + return; + } + if setup_code_required && setup_code.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Setup code is required."); + self.state.dirty = true; + } + return; + } + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = true; + onboarding.clear_messages(); + } + self.state.dirty = true; + + let result = self + .perform_auth_setup(password, setup_code_required.then_some(setup_code)) + .await; + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = false; + match result { + Ok(message) => { + onboarding.set_status(message); + onboarding.go_next(); + }, + Err(error) => onboarding.set_error(error), + } + self.state.dirty = true; + } + } + + async fn perform_auth_setup( + &mut self, + password: String, + setup_code: Option, + ) -> Result { + let base = http_base_url_from_ws(&self.url) + .ok_or_else(|| "unable to derive HTTP URL for auth setup".to_string())?; + let endpoint = format!("{base}/api/auth/setup"); + + let mut body = serde_json::Map::new(); + if !password.is_empty() { + body.insert("password".into(), serde_json::json!(password)); + } + if let Some(code) = setup_code { + body.insert("setup_code".into(), serde_json::json!(code)); + } + + let response = reqwest::Client::new() + .post(&endpoint) + .json(&Value::Object(body)) + .send() + .await + .map_err(|error| format!("failed to call auth setup endpoint: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_else(|_| String::new()); + if detail.trim().is_empty() { + return Err(format!("setup failed ({status})")); + } + return Err(format!("setup failed ({status}): {detail}")); + } + + if !password.is_empty() { + self.auth.password = Some(password); + } + + Ok("Security setup completed.".into()) + } + + async fn handle_llm_step_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if self + .onboarding + .as_ref() + .is_some_and(|onboarding| onboarding.llm.configuring.is_some()) + { + self.handle_llm_config_key(key, rpc, textarea).await; + return; + } + + if key.code == KeyCode::Char('r') { + self.refresh_onboarding_providers(rpc).await; + return; + } + + if matches!(key.code, KeyCode::Char('e') | KeyCode::Enter) { + let provider = self + .onboarding + .as_ref() + .and_then(|onboarding| { + onboarding + .llm + .providers + .get(onboarding.llm.selected_provider) + }) + .cloned(); + if let Some(provider) = provider { + self.open_llm_provider_config(provider, rpc).await; + } + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !onboarding.llm.providers.is_empty() { + let next = onboarding.llm.selected_provider.saturating_add(1); + onboarding.llm.selected_provider = next.min(onboarding.llm.providers.len() - 1); + self.state.dirty = true; + } + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.llm.selected_provider = + onboarding.llm.selected_provider.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('c') => { + onboarding.clear_messages(); + onboarding.go_next(); + self.state.dirty = true; + }, + KeyCode::Char('s') => { + onboarding.clear_messages(); + onboarding.go_next(); + self.state.dirty = true; + }, + KeyCode::Char('b') => { + onboarding.clear_messages(); + onboarding.go_back(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn handle_llm_config_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + let phase = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .map(|config| config.phase.clone()); + let Some(phase) = phase else { + return; + }; + + match phase { + ProviderConfigurePhase::Form => { + if key.code == KeyCode::Char('v') { + self.save_llm_provider_config(rpc).await; + return; + } + if key.code == KeyCode::Char('m') { + self.open_llm_provider_model_select(rpc).await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + let Some(config) = onboarding.llm.configuring.as_mut() else { + return; + }; + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let max_index = config.visible_fields().saturating_sub(1); + config.field_index = (config.field_index + 1).min(max_index); + self.state.dirty = true; + }, + KeyCode::Char('k') | KeyCode::Up => { + config.field_index = config.field_index.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') | KeyCode::Enter => { + if let Some(target) = provider_edit_target(config) { + self.start_onboarding_edit(target, textarea); + } + }, + KeyCode::Esc => { + onboarding.llm.configuring = None; + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + }, + ProviderConfigurePhase::ModelSelect { .. } => { + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + let Some(config) = onboarding.llm.configuring.as_mut() else { + return; + }; + let ProviderConfigurePhase::ModelSelect { + models, + selected, + cursor, + } = &mut config.phase + else { + return; + }; + + if key.code == KeyCode::Enter { + self.save_llm_selected_models(rpc).await; + return; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !models.is_empty() { + *cursor = cursor.saturating_add(1).min(models.len() - 1); + self.state.dirty = true; + } + }, + KeyCode::Char('k') | KeyCode::Up => { + *cursor = cursor.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char(' ') => { + if let Some(model) = models.get(*cursor) { + if !selected.insert(model.id.clone()) { + selected.remove(&model.id); + } + self.state.dirty = true; + } + }, + KeyCode::Esc => { + onboarding.llm.configuring = None; + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + }, + ProviderConfigurePhase::OAuth { .. } => { + if matches!(key.code, KeyCode::Char('p') | KeyCode::Enter) { + self.poll_oauth_provider_status(rpc).await; + return; + } + + if key.code == KeyCode::Esc + && let Some(onboarding) = self.onboarding.as_mut() + { + onboarding.llm.configuring = None; + onboarding.clear_messages(); + self.state.dirty = true; + } + }, + ProviderConfigurePhase::Local { .. } => { + if key.code == KeyCode::Enter { + self.configure_local_provider_model(rpc).await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + let Some(config) = onboarding.llm.configuring.as_mut() else { + return; + }; + let ProviderConfigurePhase::Local { models, cursor, .. } = &mut config.phase else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !models.is_empty() { + *cursor = cursor.saturating_add(1).min(models.len() - 1); + self.state.dirty = true; + } + }, + KeyCode::Char('k') | KeyCode::Up => { + *cursor = cursor.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Esc => { + onboarding.llm.configuring = None; + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + }, + } + } + + async fn open_llm_provider_config(&mut self, provider: ProviderEntry, rpc: &Arc) { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.clear_messages(); + onboarding.busy = true; + self.state.dirty = true; + } + + let result = match provider.auth_type.as_str() { + "api-key" if provider.configured => { + self.start_api_key_model_select(provider, rpc).await + }, + "api-key" => { + let endpoint = provider + .base_url + .clone() + .or(provider.default_base_url.clone()) + .unwrap_or_default(); + let model = provider.models.first().cloned().unwrap_or_default(); + Ok(ProviderConfigureState { + provider_name: provider.name, + provider_display_name: provider.display_name, + auth_type: provider.auth_type, + requires_model: provider.requires_model, + key_optional: provider.key_optional, + field_index: 0, + api_key: String::new(), + endpoint, + model, + phase: ProviderConfigurePhase::Form, + }) + }, + "oauth" => self.start_oauth_config(provider, rpc).await, + "local" => self.start_local_config(provider, rpc).await, + other => Err(format!("Unsupported provider auth type: {other}")), + }; + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = false; + match result { + Ok(configure) => { + onboarding.llm.configuring = Some(configure); + onboarding.clear_messages(); + }, + Err(error) => onboarding.set_error(error), + } + self.state.dirty = true; + } + } + + async fn start_api_key_model_select( + &self, + provider: ProviderEntry, + rpc: &Arc, + ) -> Result { + let endpoint = provider + .base_url + .clone() + .or(provider.default_base_url.clone()) + .unwrap_or_default(); + let current_model = provider.models.first().cloned().unwrap_or_default(); + + let models_rpc = rpc.call("models.list", serde_json::json!({})).await; + let mut models = match models_rpc { + Ok(payload) => parse_provider_models_from_list(&payload, &provider.name), + Err(error) => { + if provider.models.is_empty() { + return Err(error.to_string()); + } + provider + .models + .iter() + .map(|id| crate::onboarding::ModelOption { + id: id.clone(), + display_name: id.clone(), + supports_tools: false, + }) + .collect() + }, + }; + if models.is_empty() && !provider.models.is_empty() { + models = provider + .models + .iter() + .map(|id| crate::onboarding::ModelOption { + id: id.clone(), + display_name: id.clone(), + supports_tools: false, + }) + .collect(); + } + if models.is_empty() { + return Err(format!( + "No models available for {}. Try refreshing providers first.", + provider.display_name + )); + } + + let (selected, cursor) = + select_models_for_picker(&models, &provider.models, ¤t_model); + + Ok(ProviderConfigureState { + provider_name: provider.name, + provider_display_name: provider.display_name, + auth_type: provider.auth_type, + requires_model: provider.requires_model, + key_optional: provider.key_optional, + field_index: 0, + api_key: String::new(), + endpoint, + model: current_model, + phase: ProviderConfigurePhase::ModelSelect { + models, + selected, + cursor, + }, + }) + } + + async fn start_oauth_config( + &self, + provider: ProviderEntry, + rpc: &Arc, + ) -> Result { + let mut params = serde_json::json!({ + "provider": provider.name, + }); + if let Some(base) = http_base_url_from_ws(&self.url) { + params["redirectUri"] = serde_json::json!(format!("{base}/auth/callback")); + } + + let payload = rpc + .call("providers.oauth.start", params) + .await + .map_err(|error| error.to_string())?; + + if payload + .get("alreadyAuthenticated") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(ProviderConfigureState { + provider_name: provider.name, + provider_display_name: provider.display_name, + auth_type: provider.auth_type, + requires_model: false, + key_optional: false, + field_index: 0, + api_key: String::new(), + endpoint: String::new(), + model: String::new(), + phase: ProviderConfigurePhase::OAuth { + auth_url: None, + verification_uri: None, + user_code: None, + }, + }); + } + + let auth_url = payload + .get("authUrl") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + let verification_uri = payload + .get("verificationUriComplete") + .or_else(|| payload.get("verificationUri")) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + let user_code = payload + .get("userCode") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + if auth_url.is_none() && verification_uri.is_none() && user_code.is_none() { + return Err("OAuth start did not return authentication instructions.".into()); + } + + Ok(ProviderConfigureState { + provider_name: provider.name, + provider_display_name: provider.display_name, + auth_type: provider.auth_type, + requires_model: false, + key_optional: false, + field_index: 0, + api_key: String::new(), + endpoint: String::new(), + model: String::new(), + phase: ProviderConfigurePhase::OAuth { + auth_url, + verification_uri, + user_code, + }, + }) + } + + async fn start_local_config( + &self, + provider: ProviderEntry, + rpc: &Arc, + ) -> Result { + let system = rpc + .call("providers.local.system_info", serde_json::json!({})) + .await + .map_err(|error| format!("failed to fetch local system info: {error}"))?; + let backend = parse_local_recommended_backend(&system); + let note = parse_local_backend_note(&system); + + let models = rpc + .call("providers.local.models", serde_json::json!({})) + .await + .map_err(|error| format!("failed to fetch local model list: {error}"))?; + let parsed_models = parse_local_models(&models, &backend); + + if parsed_models.is_empty() { + return Err(format!("No local models available for backend {backend}.")); + } + + Ok(ProviderConfigureState { + provider_name: provider.name, + provider_display_name: provider.display_name, + auth_type: provider.auth_type, + requires_model: false, + key_optional: false, + field_index: 0, + api_key: String::new(), + endpoint: String::new(), + model: String::new(), + phase: ProviderConfigurePhase::Local { + backend, + models: parsed_models, + cursor: 0, + note, + }, + }) + } + + async fn refresh_onboarding_providers(&mut self, rpc: &Arc) { + let result = rpc.call("providers.available", serde_json::json!({})).await; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(payload) => { + onboarding.llm.providers = parse_providers(&payload); + if onboarding.llm.providers.is_empty() { + onboarding.llm.selected_provider = 0; + } else if onboarding.llm.selected_provider >= onboarding.llm.providers.len() { + onboarding.llm.selected_provider = onboarding.llm.providers.len() - 1; + } + onboarding.set_status("Providers refreshed."); + }, + Err(error) => onboarding.set_error(format!("Failed to load providers: {error}")), + } + self.state.dirty = true; + } + } + + async fn save_llm_provider_config(&mut self, rpc: &Arc) { + let Some(config) = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .cloned() + else { + return; + }; + + if config.auth_type != "api-key" { + return; + } + + let provider_name = config.provider_name.clone(); + + let api_key_value = if config.api_key.trim().is_empty() { + config.provider_name.clone() + } else { + config.api_key.trim().to_string() + }; + + if !config.key_optional && config.api_key.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("API key is required."); + self.state.dirty = true; + } + return; + } + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = true; + onboarding.clear_messages(); + self.state.dirty = true; + } + + let mut validate_payload = serde_json::json!({ + "provider": config.provider_name, + "apiKey": api_key_value, + }); + if !config.endpoint.trim().is_empty() { + validate_payload["baseUrl"] = serde_json::json!(config.endpoint.trim()); + } + if !config.model.trim().is_empty() { + validate_payload["model"] = serde_json::json!(config.model.trim()); + } + + let result = async { + let validation = rpc + .call("providers.validate_key", validate_payload.clone()) + .await + .map_err(|error| error.to_string())?; + + let valid = validation + .get("valid") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if !valid { + let error = validation + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or("Validation failed"); + return Err(error.to_string()); + } + + let mut save_payload = serde_json::json!({ + "provider": config.provider_name, + "apiKey": api_key_value, + }); + if !config.endpoint.trim().is_empty() { + save_payload["baseUrl"] = serde_json::json!(config.endpoint.trim()); + } + if !config.model.trim().is_empty() { + save_payload["model"] = serde_json::json!(config.model.trim()); + } + + rpc.call("providers.save_key", save_payload) + .await + .map_err(|error| error.to_string())?; + + let models = parse_model_options(validation.get("models").unwrap_or(&Value::Null)); + Ok(models) + } + .await; + + let mut refresh_providers = false; + let mut model_to_save = None::; + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = false; + match result { + Ok(models) => { + if !models.is_empty() { + let (selected, cursor) = + select_models_for_picker(&models, &[], &config.model); + onboarding.llm.configuring = Some(ProviderConfigureState { + phase: ProviderConfigurePhase::ModelSelect { + models, + selected, + cursor, + }, + ..config + }); + onboarding.set_status("Choose preferred models."); + } else { + if config.requires_model && !config.model.trim().is_empty() { + model_to_save = Some(config.model.trim().to_string()); + } + onboarding.llm.configuring = None; + onboarding.set_status("Provider saved."); + refresh_providers = true; + } + }, + Err(error) => onboarding.set_error(error), + } + self.state.dirty = true; + } + + if let Some(model_id) = model_to_save { + let _ = rpc + .call( + "providers.save_models", + serde_json::json!({ + "provider": provider_name, + "models": [model_id], + }), + ) + .await; + } + + if refresh_providers { + self.refresh_onboarding_providers(rpc).await; + } + } + + async fn save_llm_selected_models(&mut self, rpc: &Arc) { + let Some((provider_name, selected_models)) = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .and_then(|config| { + if let ProviderConfigurePhase::ModelSelect { selected, .. } = &config.phase { + Some((config.provider_name.clone(), selected.clone())) + } else { + None + } + }) + else { + return; + }; + + let models = selected_models.into_iter().collect::>(); + + let result = rpc + .call( + "providers.save_models", + serde_json::json!({ + "provider": provider_name, + "models": models, + }), + ) + .await; + + let mut refresh_providers = false; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.llm.configuring = None; + onboarding.set_status("Model preferences saved."); + refresh_providers = true; + }, + Err(error) => onboarding.set_error(format!("Failed to save models: {error}")), + } + self.state.dirty = true; + } + + if refresh_providers { + self.refresh_onboarding_providers(rpc).await; + } + } + + async fn open_llm_provider_model_select(&mut self, rpc: &Arc) { + let Some((provider_name, provider_display_name, current_model, saved_models)) = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .and_then(|config| { + if config.auth_type != "api-key" + || !matches!(config.phase, ProviderConfigurePhase::Form) + { + return None; + } + + let saved_models = self + .onboarding + .as_ref() + .and_then(|onboarding| { + onboarding + .llm + .providers + .iter() + .find(|provider| provider.name == config.provider_name) + }) + .map(|provider| provider.models.clone()) + .unwrap_or_default(); + + Some(( + config.provider_name.clone(), + config.provider_display_name.clone(), + config.model.clone(), + saved_models, + )) + }) + else { + return; + }; + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = true; + onboarding.clear_messages(); + self.state.dirty = true; + } + + let result = rpc + .call("models.list", serde_json::json!({})) + .await + .map(|payload| parse_provider_models_from_list(&payload, &provider_name)) + .map_err(|error| error.to_string()); + + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.busy = false; + match result { + Ok(models) => { + if models.is_empty() { + onboarding.set_error( + "No models available yet. Validate/save first with v, then choose models.", + ); + self.state.dirty = true; + return; + } + + let Some(config) = onboarding.llm.configuring.as_mut() else { + self.state.dirty = true; + return; + }; + if config.provider_name != provider_name + || !matches!(config.phase, ProviderConfigurePhase::Form) + { + self.state.dirty = true; + return; + } + + let (selected, cursor) = + select_models_for_picker(&models, &saved_models, ¤t_model); + + config.phase = ProviderConfigurePhase::ModelSelect { + models, + selected, + cursor, + }; + onboarding.set_status(format!( + "Select preferred models for {}.", + provider_display_name + )); + }, + Err(error) => onboarding.set_error(format!("Failed to load models: {error}")), + } + self.state.dirty = true; + } + } + + async fn poll_oauth_provider_status(&mut self, rpc: &Arc) { + let provider_name = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .map(|config| config.provider_name.clone()); + let Some(provider_name) = provider_name else { + return; + }; + + let result = rpc + .call( + "providers.oauth.status", + serde_json::json!({ "provider": provider_name }), + ) + .await; + + let mut refresh_providers = false; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(payload) => { + if payload + .get("authenticated") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + onboarding.llm.configuring = None; + onboarding.set_status("OAuth provider authenticated."); + refresh_providers = true; + } else { + onboarding.set_status("OAuth still pending."); + } + }, + Err(error) => onboarding.set_error(format!("OAuth status failed: {error}")), + } + self.state.dirty = true; + } + + if refresh_providers { + self.refresh_onboarding_providers(rpc).await; + } + } + + async fn configure_local_provider_model(&mut self, rpc: &Arc) { + let Some((model_id, backend)) = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.llm.configuring.as_ref()) + .and_then(|config| { + if let ProviderConfigurePhase::Local { + backend, + models, + cursor, + .. + } = &config.phase + { + models + .get(*cursor) + .map(|model| (model.id.clone(), backend.clone())) + } else { + None + } + }) + else { + return; + }; + + let result = rpc + .call( + "providers.local.configure", + serde_json::json!({ + "modelId": model_id, + "backend": backend, + }), + ) + .await; + + let mut refresh_providers = false; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.llm.configuring = None; + onboarding.set_status("Local provider configured."); + refresh_providers = true; + }, + Err(error) => onboarding.set_error(format!("Local model setup failed: {error}")), + } + self.state.dirty = true; + } + + if refresh_providers { + self.refresh_onboarding_providers(rpc).await; + } + } + + async fn handle_voice_step_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + match key.code { + KeyCode::Char('t') => { + self.toggle_selected_voice_provider(rpc).await; + return; + }, + KeyCode::Char('v') => { + self.save_selected_voice_key(rpc).await; + return; + }, + KeyCode::Char('r') => { + self.refresh_voice_providers(rpc).await; + return; + }, + _ => {}, + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !onboarding.voice.providers.is_empty() { + let next = onboarding.voice.selected_provider.saturating_add(1); + onboarding.voice.selected_provider = + next.min(onboarding.voice.providers.len() - 1); + self.state.dirty = true; + } + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.voice.selected_provider = + onboarding.voice.selected_provider.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') => { + self.start_onboarding_edit(EditTarget::VoiceApiKey, textarea); + }, + KeyCode::Char('c') => { + onboarding.go_next(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + KeyCode::Char('s') => { + onboarding.go_next(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + KeyCode::Char('b') => { + onboarding.go_back(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn refresh_voice_providers(&mut self, rpc: &Arc) { + let result = rpc.call("voice.providers.all", serde_json::json!({})).await; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(payload) => { + onboarding.voice.providers = parse_voice_providers(&payload); + if onboarding.voice.providers.is_empty() { + onboarding.voice.selected_provider = 0; + } else if onboarding.voice.selected_provider >= onboarding.voice.providers.len() + { + onboarding.voice.selected_provider = + onboarding.voice.providers.len().saturating_sub(1); + } + onboarding.set_status("Voice providers refreshed."); + }, + Err(error) => { + onboarding.set_error(format!("Failed to load voice providers: {error}")) + }, + } + self.state.dirty = true; + } + } + + async fn toggle_selected_voice_provider(&mut self, rpc: &Arc) { + let Some(provider) = self + .onboarding + .as_ref() + .and_then(|onboarding| { + onboarding + .voice + .providers + .get(onboarding.voice.selected_provider) + }) + .cloned() + else { + return; + }; + + if !provider.available { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Selected voice provider is not available."); + self.state.dirty = true; + } + return; + } + + let result = rpc + .call( + "voice.provider.toggle", + serde_json::json!({ + "provider": provider.id, + "enabled": !provider.enabled, + "type": provider.provider_type, + }), + ) + .await; + + let mut refresh_voice = false; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.set_status("Voice provider updated."); + refresh_voice = true; + }, + Err(error) => onboarding.set_error(format!("Voice toggle failed: {error}")), + } + self.state.dirty = true; + } + + if refresh_voice { + self.refresh_voice_providers(rpc).await; + } + } + + async fn save_selected_voice_key(&mut self, rpc: &Arc) { + let Some((provider_id, key)) = self + .onboarding + .as_ref() + .and_then(|onboarding| { + onboarding + .voice + .providers + .get(onboarding.voice.selected_provider) + }) + .map(|provider| { + ( + provider.id.clone(), + self.onboarding + .as_ref() + .map(|onboarding| onboarding.voice.pending_api_key.clone()) + .unwrap_or_default(), + ) + }) + else { + return; + }; + + if key.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Voice API key cannot be empty."); + self.state.dirty = true; + } + return; + } + + let result = rpc + .call( + "voice.config.save_key", + serde_json::json!({ + "provider": provider_id, + "api_key": key.trim(), + }), + ) + .await; + + let mut refresh_voice = false; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.voice.pending_api_key.clear(); + onboarding.set_status("Voice API key saved."); + refresh_voice = true; + }, + Err(error) => onboarding.set_error(format!("Failed to save voice key: {error}")), + } + self.state.dirty = true; + } + + if refresh_voice { + self.refresh_voice_providers(rpc).await; + } + } + + async fn handle_channel_step_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if self + .onboarding + .as_ref() + .is_some_and(|onboarding| onboarding.channel.configuring) + { + self.handle_channel_config_key(key, rpc, textarea).await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let next = onboarding.channel.selected_provider.saturating_add(1); + onboarding.channel.selected_provider = next.min(ChannelProvider::ALL.len() - 1); + self.state.dirty = true; + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.channel.selected_provider = + onboarding.channel.selected_provider.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') | KeyCode::Enter => { + let provider = ChannelProvider::from_index(onboarding.channel.selected_provider); + if provider.available() { + onboarding.channel.configuring = true; + onboarding.channel.field_index = 0; + onboarding.clear_messages(); + } else { + onboarding + .set_status(format!("{} onboarding is coming soon.", provider.name())); + } + self.state.dirty = true; + }, + KeyCode::Char('c') => { + if onboarding.channel.connected { + onboarding.go_next(); + onboarding.clear_messages(); + } else { + onboarding.set_error("Connect a channel first, or press s to skip."); + } + self.state.dirty = true; + }, + KeyCode::Char('s') => { + onboarding.go_next(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + KeyCode::Char('b') => { + onboarding.go_back(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn handle_channel_config_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if key.code == KeyCode::Char('x') { + self.connect_telegram_channel(rpc).await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + if ChannelProvider::from_index(onboarding.channel.selected_provider) + != ChannelProvider::Telegram + { + onboarding.channel.configuring = false; + onboarding.set_error("Selected channel cannot be configured yet."); + self.state.dirty = true; + return; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + onboarding.channel.field_index = (onboarding.channel.field_index + 1).min(3); + self.state.dirty = true; + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.channel.field_index = onboarding.channel.field_index.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') | KeyCode::Enter => { + if let Some(target) = channel_edit_target(onboarding.channel.field_index) { + self.start_onboarding_edit(target, textarea); + } else if onboarding.channel.field_index == 2 { + onboarding.channel.dm_policy = next_dm_policy(&onboarding.channel.dm_policy); + self.state.dirty = true; + } + }, + KeyCode::Char('[') | KeyCode::Left if onboarding.channel.field_index == 2 => { + onboarding.channel.dm_policy = previous_dm_policy(&onboarding.channel.dm_policy); + self.state.dirty = true; + }, + KeyCode::Char(']') | KeyCode::Right if onboarding.channel.field_index == 2 => { + onboarding.channel.dm_policy = next_dm_policy(&onboarding.channel.dm_policy); + self.state.dirty = true; + }, + KeyCode::Esc => { + onboarding.channel.configuring = false; + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn connect_telegram_channel(&mut self, rpc: &Arc) { + let Some(channel) = self + .onboarding + .as_ref() + .map(|onboarding| onboarding.channel.clone()) + else { + return; + }; + + if channel.account_id.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Bot username is required."); + self.state.dirty = true; + } + return; + } + if channel.token.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Bot token is required."); + self.state.dirty = true; + } + return; + } + + let allowlist = channel + .allowlist + .lines() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| entry.trim_start_matches('@').to_string()) + .collect::>(); + + let payload = serde_json::json!({ + "type": "telegram", + "account_id": channel.account_id.trim(), + "config": { + "token": channel.token.trim(), + "dm_policy": channel.dm_policy, + "mention_mode": "mention", + "allowlist": allowlist, + } + }); + + let result = rpc.call("channels.add", payload).await; + + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.channel.connected = true; + onboarding.channel.connected_name = channel.account_id.trim().to_string(); + onboarding.channel.configuring = false; + onboarding.set_status("Telegram bot connected."); + }, + Err(error) => { + onboarding.set_error(format!("Failed to connect Telegram bot: {error}")) + }, + } + self.state.dirty = true; + } + } + + async fn handle_identity_step_key( + &mut self, + key: KeyEvent, + rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + if key.code == KeyCode::Char('c') { + self.submit_identity_step(rpc).await; + return; + } + + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + onboarding.identity.field_index = (onboarding.identity.field_index + 1).min(4); + self.state.dirty = true; + }, + KeyCode::Char('k') | KeyCode::Up => { + onboarding.identity.field_index = onboarding.identity.field_index.saturating_sub(1); + self.state.dirty = true; + }, + KeyCode::Char('e') | KeyCode::Enter => { + if let Some(target) = identity_edit_target(onboarding.identity.field_index) { + self.start_onboarding_edit(target, textarea); + } + }, + KeyCode::Char('b') => { + onboarding.go_back(); + onboarding.clear_messages(); + self.state.dirty = true; + }, + _ => {}, + } + } + + async fn submit_identity_step(&mut self, rpc: &Arc) { + let Some(identity) = self + .onboarding + .as_ref() + .map(|onboarding| onboarding.identity.clone()) + else { + return; + }; + + if identity.user_name.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Your name is required."); + self.state.dirty = true; + } + return; + } + if identity.agent_name.trim().is_empty() { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.set_error("Agent name is required."); + self.state.dirty = true; + } + return; + } + + let payload = serde_json::json!({ + "name": identity.agent_name.trim(), + "emoji": identity.emoji.trim(), + "creature": identity.creature.trim(), + "vibe": identity.vibe.trim(), + "user_name": identity.user_name.trim(), + }); + + let result = rpc.call("agent.identity.update", payload).await; + if let Some(onboarding) = self.onboarding.as_mut() { + match result { + Ok(_) => { + onboarding.set_status("Identity saved."); + onboarding.go_next(); + }, + Err(error) => onboarding.set_error(format!("Failed to save identity: {error}")), + } + self.state.dirty = true; + } + } + + async fn handle_summary_step_key(&mut self, key: KeyEvent, rpc: &Arc) { + match key.code { + KeyCode::Char('r') => { + self.refresh_summary(rpc).await; + }, + KeyCode::Char('b') => { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.go_back(); + onboarding.clear_messages(); + self.state.dirty = true; + } + }, + KeyCode::Char('f') | KeyCode::Char('c') | KeyCode::Enter => { + self.finish_onboarding(rpc).await; + }, + _ => {}, + } + } + + async fn refresh_summary(&mut self, rpc: &Arc) { + let (identity_res, providers_res, channels_res, voice_res) = tokio::join!( + rpc.call("agent.identity.get", serde_json::json!({})), + rpc.call("providers.available", serde_json::json!({})), + rpc.call("channels.status", serde_json::json!({})), + rpc.call("voice.providers.all", serde_json::json!({})), + ); + + if let Some(onboarding) = self.onboarding.as_mut() { + let mut summary = onboarding.summary.clone(); + + if let Ok(identity) = identity_res { + let user = identity + .get("user_name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = identity + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let emoji = identity + .get("emoji") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + if !user.is_empty() && !name.is_empty() { + summary.identity_line = Some(format!("You: {user} Agent: {emoji} {name}")); + } else { + summary.identity_line = None; + } + } + + if let Ok(providers) = providers_res { + let parsed = parse_providers(&providers); + summary.provider_badges = configured_provider_badges(&parsed); + } + + if let Ok(channels) = channels_res { + summary.channels = parse_channels(&channels); + } + + if let Ok(voice) = voice_res { + let providers = parse_voice_providers(&voice); + summary.voice_enabled = providers + .iter() + .filter(|provider| provider.enabled) + .map(|provider| provider.name.clone()) + .collect::>(); + } + + onboarding.summary = summary; + onboarding.set_status("Summary refreshed."); + self.state.dirty = true; + } + } + + async fn finish_onboarding(&mut self, rpc: &Arc) { + self.onboarding = None; + self.state.input_mode = InputMode::Insert; + self.state.sidebar_visible = true; + self.state.dirty = true; + + self.load_initial_data_now(rpc).await; + } + + async fn load_initial_data_now(&mut self, rpc: &Arc) { + let (sessions_res, history_res, context_res) = tokio::join!( + rpc.call("sessions.list", serde_json::json!({})), + rpc.call("chat.history", serde_json::json!({})), + rpc.call("chat.context", serde_json::json!({})), + ); + + let mut data = InitialData::default(); + if let Ok(sessions) = sessions_res + && let Some(arr) = sessions.as_array() + { + data.sessions = Some( + arr.iter() + .filter_map(|s| { + let key = s.get("key").and_then(|v| v.as_str())?; + Some(SessionEntry { + key: key.into(), + label: s + .get("label") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + model: s + .get("model") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + message_count: s + .get("message_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + replying: s.get("replying").and_then(|v| v.as_bool()).unwrap_or(false), + }) + }) + .collect(), + ); + } + + if let Ok(history) = history_res + && let Some(arr) = history.as_array() + { + data.messages = Some( + arr.iter() + .filter_map(|msg| { + let role = msg.get("role").and_then(|v| v.as_str())?; + let content = msg + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let role = match role { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + _ => MessageRole::System, + }; + Some(DisplayMessage { + role, + content, + tool_calls: Vec::new(), + thinking: None, + }) + }) + .collect(), + ); + } + + if let Ok(context) = context_res { + super::parse_context_initial_data(&mut data, &context); + } + + self.apply_initial_data(data); + } + + fn start_onboarding_edit(&mut self, target: EditTarget, textarea: &mut TextArea<'_>) { + let Some(onboarding) = self.onboarding.as_mut() else { + return; + }; + + let current = onboarding.begin_edit(target); + *textarea = TextArea::from(vec![current]); + textarea.set_placeholder_text(target.placeholder()); + self.state.input_mode = InputMode::Insert; + self.state.dirty = true; + } + + fn cancel_onboarding_edit(&mut self, textarea: &mut TextArea<'_>) { + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.cancel_edit(); + } + *textarea = TextArea::default(); + textarea.set_placeholder_text("Press 'e' to edit selected field"); + self.state.input_mode = InputMode::Normal; + self.state.dirty = true; + } + + pub(super) async fn handle_onboarding_insert_key( + &mut self, + key: KeyEvent, + _rpc: &Arc, + textarea: &mut TextArea<'_>, + ) { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => self.cancel_onboarding_edit(textarea), + (KeyCode::Enter, KeyModifiers::NONE) => { + let target = self + .onboarding + .as_ref() + .and_then(|onboarding| onboarding.editing); + if let Some(target) = target { + let value = textarea.lines().join("\n"); + if let Some(onboarding) = self.onboarding.as_mut() { + onboarding.commit_edit(target, value); + } + } + *textarea = TextArea::default(); + textarea.set_placeholder_text("Press 'e' to edit selected field"); + self.state.input_mode = InputMode::Normal; + self.state.dirty = true; + }, + (KeyCode::Enter, KeyModifiers::SHIFT) => { + textarea.insert_newline(); + self.state.dirty = true; + }, + _ => { + textarea.input(key); + self.state.dirty = true; + }, + } + } +} + +fn security_edit_target(security: &SecurityState) -> EditTarget { + if security.setup_code_required { + return match security.field_index { + 0 => EditTarget::SecuritySetupCode, + 1 => EditTarget::SecurityPassword, + _ => EditTarget::SecurityConfirmPassword, + }; + } + + match security.field_index { + 0 => EditTarget::SecurityPassword, + _ => EditTarget::SecurityConfirmPassword, + } +} + +fn provider_edit_target(config: &ProviderConfigureState) -> Option { + match config.field_index { + 0 => Some(EditTarget::ProviderApiKey), + 1 if supports_endpoint(&config.provider_name) => Some(EditTarget::ProviderEndpoint), + 1 if config.requires_model => Some(EditTarget::ProviderModel), + 2 if supports_endpoint(&config.provider_name) && config.requires_model => { + Some(EditTarget::ProviderModel) + }, + _ => None, + } +} + +fn channel_edit_target(field_index: usize) -> Option { + match field_index { + 0 => Some(EditTarget::ChannelAccountId), + 1 => Some(EditTarget::ChannelToken), + 3 => Some(EditTarget::ChannelAllowlist), + _ => None, + } +} + +fn identity_edit_target(field_index: usize) -> Option { + match field_index { + 0 => Some(EditTarget::IdentityUserName), + 1 => Some(EditTarget::IdentityAgentName), + 2 => Some(EditTarget::IdentityEmoji), + 3 => Some(EditTarget::IdentityCreature), + 4 => Some(EditTarget::IdentityVibe), + _ => None, + } +} + +fn previous_dm_policy(current: &str) -> String { + const OPTIONS: [&str; 3] = ["allowlist", "open", "disabled"]; + let idx = OPTIONS + .iter() + .position(|value| *value == current) + .unwrap_or(0); + let previous = if idx == 0 { + OPTIONS.len() - 1 + } else { + idx - 1 + }; + OPTIONS[previous].to_string() +} + +fn next_dm_policy(current: &str) -> String { + const OPTIONS: [&str; 3] = ["allowlist", "open", "disabled"]; + let idx = OPTIONS + .iter() + .position(|value| *value == current) + .unwrap_or(0); + let next = (idx + 1) % OPTIONS.len(); + OPTIONS[next].to_string() +} + +fn parse_provider_models_from_list( + payload: &Value, + provider_name: &str, +) -> Vec { + payload + .as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + let id = row.get("id").and_then(Value::as_str)?.to_string(); + let row_provider = row + .get("provider") + .and_then(Value::as_str) + .unwrap_or_default(); + if !model_matches_provider(provider_name, row_provider, &id) { + return None; + } + + let display_name = row + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let supports_tools = row + .get("supportsTools") + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(crate::onboarding::ModelOption { + id, + display_name, + supports_tools, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn select_models_for_picker( + models: &[crate::onboarding::ModelOption], + saved_models: &[String], + current_model: &str, +) -> (BTreeSet, usize) { + let mut selected = saved_models.iter().cloned().collect::>(); + if selected.is_empty() && !current_model.trim().is_empty() { + selected.insert(current_model.trim().to_string()); + } + selected.retain(|model_id| models.iter().any(|model| model.id == *model_id)); + + let cursor = models + .iter() + .position(|model| selected.contains(&model.id)) + .unwrap_or(0); + (selected, cursor) +} + +fn model_matches_provider(provider_name: &str, row_provider: &str, model_id: &str) -> bool { + if !row_provider.is_empty() { + if row_provider == provider_name { + return true; + } + if (provider_name == "zai" && row_provider == "z.ai") + || (provider_name == "z.ai" && row_provider == "zai") + { + return true; + } + return false; + } + + model_id.starts_with(&format!("{provider_name}/")) + || model_id.starts_with(&format!("{provider_name}:")) +} + +fn http_base_url_from_ws(gateway_url: &str) -> Option { + let mut url = Url::parse(gateway_url).ok()?; + + match url.scheme() { + "ws" => { + let _ = url.set_scheme("http"); + }, + "wss" => { + let _ = url.set_scheme("https"); + }, + "http" | "https" => {}, + _ => return None, + } + + let is_loopback_ip = url.host().is_some_and(|host| match host { + Host::Ipv4(ip) => ip.is_loopback(), + Host::Ipv6(ip) => ip.is_loopback(), + Host::Domain(_) => false, + }); + + if is_loopback_ip { + let _ = url.set_host(Some("localhost")); + } + + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + + Some(url.to_string().trim_end_matches('/').to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_ws_loopback_to_http_localhost() { + let url = http_base_url_from_ws("ws://127.0.0.1:57223/ws/chat"); + assert_eq!(url.as_deref(), Some("http://localhost:57223")); + } + + #[test] + fn converts_wss_loopback_ipv6_to_https_localhost() { + let url = http_base_url_from_ws("wss://[::1]:57223/ws/chat"); + assert_eq!(url.as_deref(), Some("https://localhost:57223")); + } + + #[test] + fn dm_policy_cycles_both_directions() { + assert_eq!(previous_dm_policy("allowlist"), "disabled"); + assert_eq!(next_dm_policy("disabled"), "allowlist"); + } + + #[test] + fn parse_provider_models_filters_by_provider_and_alias() { + let payload = serde_json::json!([ + {"id":"openai/gpt-5", "provider":"openai", "displayName":"GPT-5", "supportsTools":true}, + {"id":"zai/glm-4.6", "provider":"z.ai", "displayName":"GLM-4.6", "supportsTools":true}, + {"id":"anthropic/claude-sonnet-4", "provider":"anthropic", "displayName":"Claude Sonnet 4", "supportsTools":true} + ]); + + let openai_models = parse_provider_models_from_list(&payload, "openai"); + assert_eq!(openai_models.len(), 1); + assert_eq!(openai_models[0].id, "openai/gpt-5"); + + let zai_models = parse_provider_models_from_list(&payload, "zai"); + assert_eq!(zai_models.len(), 1); + assert_eq!(zai_models[0].id, "zai/glm-4.6"); + } + + #[test] + fn select_models_for_picker_prefers_saved_and_falls_back_to_current() { + let models = vec![ + crate::onboarding::ModelOption { + id: "openai/gpt-5".to_string(), + display_name: "GPT-5".to_string(), + supports_tools: true, + }, + crate::onboarding::ModelOption { + id: "openai/gpt-4.1".to_string(), + display_name: "GPT-4.1".to_string(), + supports_tools: true, + }, + ]; + + let (selected_saved, cursor_saved) = + select_models_for_picker(&models, &["openai/gpt-4.1".to_string()], "openai/gpt-5"); + assert!(selected_saved.contains("openai/gpt-4.1")); + assert_eq!(cursor_saved, 1); + + let (selected_current, cursor_current) = + select_models_for_picker(&models, &[], "openai/gpt-5"); + assert!(selected_current.contains("openai/gpt-5")); + assert_eq!(cursor_current, 0); + } +} diff --git a/crates/tui/src/auth.rs b/crates/tui/src/auth.rs new file mode 100644 index 00000000..7851ac70 --- /dev/null +++ b/crates/tui/src/auth.rs @@ -0,0 +1,32 @@ +use moltis_protocol::ConnectAuth; + +/// Resolve authentication credentials from CLI arguments. +/// +/// Priority: explicit API key > environment variable > no auth (local instances). +pub fn resolve_auth(api_key: Option<&str>) -> ConnectAuth { + ConnectAuth { + api_key: api_key.map(String::from), + password: None, + token: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_api_key() { + let auth = resolve_auth(Some("sk-test-123")); + assert_eq!(auth.api_key.as_deref(), Some("sk-test-123")); + assert!(auth.password.is_none()); + } + + #[test] + fn without_credentials() { + let auth = resolve_auth(None); + assert!(auth.api_key.is_none()); + assert!(auth.password.is_none()); + assert!(auth.token.is_none()); + } +} diff --git a/crates/tui/src/connection.rs b/crates/tui/src/connection.rs new file mode 100644 index 00000000..8f1b1fef --- /dev/null +++ b/crates/tui/src/connection.rs @@ -0,0 +1,302 @@ +use { + crate::Error, + futures::{SinkExt, StreamExt}, + moltis_protocol::{ + ClientInfo, ConnectAuth, ConnectParams, HelloOk, PROTOCOL_VERSION, RequestFrame, + }, + std::{sync::Arc, time::Duration}, + tokio::sync::mpsc, + tokio_tungstenite::{Connector, connect_async_tls_with_config, tungstenite::Message}, + tracing::{debug, error, info}, +}; + +/// Maximum reconnect backoff delay. +const MAX_BACKOFF: Duration = Duration::from_secs(5); + +/// Events sent from the connection task to the main app loop. +#[derive(Debug)] +pub enum ConnectionEvent { + Connected(Box), + Disconnected, + Error(String), + Frame(String), +} + +/// Manages a WebSocket connection to the gateway, including handshake and +/// auto-reconnect with exponential backoff. +pub struct ConnectionManager { + /// Send JSON text frames to the WebSocket writer task. + write_tx: mpsc::UnboundedSender, +} + +impl ConnectionManager { + /// Spawn the connection manager. Connects to the gateway and begins + /// forwarding frames to `event_tx`. Returns immediately — the connection + /// runs in background tasks. + pub fn spawn( + url: String, + auth: ConnectAuth, + event_tx: mpsc::UnboundedSender, + ) -> Self { + let (write_tx, write_rx) = mpsc::unbounded_channel::(); + + tokio::spawn(connection_loop(url, auth, event_tx, write_rx)); + + Self { write_tx } + } + + /// Send a raw JSON string through the WebSocket. + pub fn send_raw(&self, json: String) { + // Ignore send error — means the connection loop has exited. + let _ = self.write_tx.send(json); + } +} + +/// Build the `ConnectParams` for the protocol v3 handshake. +fn build_connect_params(auth: &ConnectAuth) -> ConnectParams { + ConnectParams { + min_protocol: PROTOCOL_VERSION, + max_protocol: PROTOCOL_VERSION, + client: ClientInfo { + id: "moltis-tui".into(), + display_name: Some("Moltis TUI".into()), + version: env!("CARGO_PKG_VERSION").into(), + platform: std::env::consts::OS.into(), + device_family: None, + model_identifier: None, + mode: "operator".into(), + instance_id: Some(uuid::Uuid::new_v4().to_string()), + }, + caps: Some(vec!["streaming".into(), "tools".into(), "approvals".into()]), + commands: None, + permissions: None, + path_env: None, + role: Some("operator".into()), + scopes: Some(vec![ + "operator.admin".into(), + "operator.read".into(), + "operator.write".into(), + "operator.approvals".into(), + ]), + device: None, + auth: Some(auth.clone()), + locale: None, + user_agent: Some(format!("moltis-tui/{}", env!("CARGO_PKG_VERSION"))), + timezone: None, + } +} + +/// Main connection loop with auto-reconnect. +async fn connection_loop( + url: String, + auth: ConnectAuth, + event_tx: mpsc::UnboundedSender, + mut write_rx: mpsc::UnboundedReceiver, +) { + let mut backoff = Duration::from_secs(1); + + loop { + info!(url = %url, "connecting to gateway"); + + match connect_and_run(&url, &auth, &event_tx, &mut write_rx).await { + Ok(()) => { + debug!("connection closed cleanly"); + }, + Err(e) => { + error!(error = %e, "connection error"); + let _ = event_tx.send(ConnectionEvent::Error(e.to_string())); + }, + } + + let _ = event_tx.send(ConnectionEvent::Disconnected); + + // Exponential backoff before reconnect + info!(delay_ms = backoff.as_millis(), "reconnecting after delay"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + } +} + +/// Build a TLS connector that trusts the gateway's self-signed CA. +/// +/// First tries to load the Moltis CA cert from the config directory. +/// Falls back to a permissive verifier so local development always works. +fn build_tls_connector() -> Connector { + let mut root_store = rustls::RootCertStore::empty(); + + // Load system certs + for cert in rustls_native_certs::load_native_certs().certs { + let _ = root_store.add(cert); + } + + // Load the Moltis CA cert from the config directory + if let Some(config_dir) = moltis_config::config_dir() { + let ca_path = config_dir.join("certs").join("ca.pem"); + if let Ok(pem_data) = std::fs::read(&ca_path) { + let mut reader = std::io::BufReader::new(pem_data.as_slice()); + for cert in rustls_pemfile::certs(&mut reader).flatten() { + let _ = root_store.add(cert); + } + debug!(path = %ca_path.display(), "loaded Moltis CA cert"); + } + } + + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + Connector::Rustls(Arc::new(config)) +} + +/// Single connection attempt: connect, handshake, then forward frames. +async fn connect_and_run( + url: &str, + auth: &ConnectAuth, + event_tx: &mpsc::UnboundedSender, + write_rx: &mut mpsc::UnboundedReceiver, +) -> Result<(), Error> { + let connector = build_tls_connector(); + let (ws_stream, _response) = + connect_async_tls_with_config(url, None, false, Some(connector)).await?; + let (mut ws_sink, mut ws_reader) = ws_stream.split(); + + // Send connect handshake + let connect_id = uuid::Uuid::new_v4().to_string(); + let connect_frame = RequestFrame { + r#type: "req".into(), + id: connect_id.clone(), + method: "connect".into(), + params: Some(serde_json::to_value(build_connect_params(auth)).map_err(Error::Json)?), + }; + let connect_json = serde_json::to_string(&connect_frame).map_err(Error::Json)?; + ws_sink.send(Message::Text(connect_json.into())).await?; + + // Wait for hello-ok response + let hello_ok = wait_for_hello(&mut ws_reader, &connect_id).await?; + info!( + server_version = %hello_ok.server.version, + conn_id = %hello_ok.server.conn_id, + "connected to gateway" + ); + let _ = event_tx.send(ConnectionEvent::Connected(Box::new(hello_ok))); + + // Reset backoff on successful connection (handled by caller via Ok return, + // but we rely on the loop structure). + + // Forward frames bidirectionally + loop { + tokio::select! { + // Incoming frames from gateway + msg = ws_reader.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let _ = event_tx.send(ConnectionEvent::Frame(text.to_string())); + }, + Some(Ok(Message::Close(_))) | None => { + debug!("WebSocket closed by server"); + return Ok(()); + }, + Some(Ok(Message::Ping(data))) => { + ws_sink.send(Message::Pong(data)).await?; + }, + Some(Ok(_)) => {}, // Ignore binary, pong, etc. + Some(Err(e)) => { + return Err(Error::WebSocket(e)); + }, + } + }, + // Outgoing frames from app + json = write_rx.recv() => { + match json { + Some(text) => { + ws_sink.send(Message::Text(text.into())).await?; + }, + None => { + // write channel closed — app is shutting down + let _ = ws_sink.send(Message::Close(None)).await; + return Ok(()); + }, + } + }, + } + } +} + +/// Wait for the `hello-ok` response frame from the gateway. +async fn wait_for_hello( + reader: &mut (impl StreamExt> + Unpin), + connect_id: &str, +) -> Result { + let timeout = Duration::from_millis(moltis_protocol::HANDSHAKE_TIMEOUT_MS); + + let result = tokio::time::timeout(timeout, async { + while let Some(msg) = reader.next().await { + match msg { + Ok(Message::Text(text)) => { + // Parse as a response frame + if let Ok(frame) = serde_json::from_str::(&text) + && frame.id == connect_id + { + if frame.ok { + if let Some(payload) = frame.payload { + let hello: HelloOk = + serde_json::from_value(payload).map_err(Error::Json)?; + return Ok(hello); + } + return Err(Error::Protocol( + "hello-ok response missing payload".into(), + )); + } else { + let msg = frame + .error + .map(|e| e.message) + .unwrap_or_else(|| "unknown error".into()); + return Err(Error::Auth(msg)); + } + } + // Not our response — could be an event, skip it + }, + Ok(Message::Close(_)) => { + return Err(Error::Connection( + "server closed connection during handshake".into(), + )); + }, + Ok(_) => {}, + Err(e) => return Err(Error::WebSocket(e)), + } + } + Err(Error::Connection( + "connection closed before handshake".into(), + )) + }) + .await; + + match result { + Ok(inner) => inner, + Err(_) => Err(Error::Connection("handshake timed out".into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_params_built_correctly() { + let auth = ConnectAuth { + api_key: Some("test-key".into()), + password: None, + token: None, + }; + let params = build_connect_params(&auth); + assert_eq!(params.min_protocol, PROTOCOL_VERSION); + assert_eq!(params.max_protocol, PROTOCOL_VERSION); + assert_eq!(params.client.id, "moltis-tui"); + assert_eq!(params.client.mode, "operator"); + assert!(params.auth.is_some()); + assert_eq!( + params.auth.as_ref().and_then(|a| a.api_key.as_deref()), + Some("test-key") + ); + } +} diff --git a/crates/tui/src/error.rs b/crates/tui/src/error.rs new file mode 100644 index 00000000..0aa98cdf --- /dev/null +++ b/crates/tui/src/error.rs @@ -0,0 +1,21 @@ +/// Errors specific to the TUI client. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("connection failed: {0}")] + Connection(String), + + #[error("authentication failed: {0}")] + Auth(String), + + #[error("protocol error: {0}")] + Protocol(String), + + #[error("WebSocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("terminal error: {0}")] + Terminal(#[from] std::io::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/tui/src/events/chat.rs b/crates/tui/src/events/chat.rs new file mode 100644 index 00000000..f0dc3d06 --- /dev/null +++ b/crates/tui/src/events/chat.rs @@ -0,0 +1,275 @@ +use { + crate::state::{AppState, ApprovalRequest, DisplayMessage, MessageRole, ToolCallCard}, + serde_json::Value, + tracing::debug, +}; + +/// Handle a `chat` event payload. +pub fn handle_chat_event(state: &mut AppState, payload: &Value) { + let Some(event_state) = payload.get("state").and_then(|v| v.as_str()) else { + return; + }; + let session_key = payload + .get("sessionKey") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Only process events for the active session + if !session_key.is_empty() && session_key != state.active_session { + // Mark the session as replying in sidebar + if let Some(entry) = state.sessions.iter_mut().find(|s| s.key == session_key) { + entry.replying = matches!( + event_state, + "thinking" | "delta" | "tool_call_start" | "iteration" + ); + } + return; + } + + match event_state { + "thinking" => { + let run_id = payload + .get("runId") + .and_then(|v| v.as_str()) + .map(String::from); + state.active_run_id = run_id; + state.thinking_active = true; + state.thinking_text.clear(); + state.stream_buffer.clear(); + state.scroll_to_bottom(); + state.dirty = true; + }, + "thinking_text" => { + if let Some(text) = payload.get("text").and_then(|v| v.as_str()) { + state.thinking_text.push_str(text); + state.dirty = true; + } + }, + "thinking_done" => { + state.thinking_active = false; + state.dirty = true; + }, + "delta" => { + if let Some(text) = payload.get("text").and_then(|v| v.as_str()) { + state.stream_buffer.push_str(text); + state.thinking_active = false; + state.dirty = true; + } + }, + "tool_call_start" => { + let card = ToolCallCard { + id: payload + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(), + name: payload + .get("toolName") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .into(), + arguments: payload.get("arguments").cloned().unwrap_or(Value::Null), + execution_mode: payload + .get("executionMode") + .and_then(|v| v.as_str()) + .map(String::from), + success: None, + result_summary: None, + }; + + // If we have accumulated text, finalize it as a message before the tool call + if !state.stream_buffer.is_empty() { + let content = std::mem::take(&mut state.stream_buffer); + state.messages.push(DisplayMessage { + role: MessageRole::Assistant, + content, + tool_calls: Vec::new(), + thinking: None, + }); + } + + // Add tool call to a new or last assistant message + if let Some(last) = state + .messages + .last_mut() + .filter(|m| m.role == MessageRole::Assistant) + { + last.tool_calls.push(card); + } else { + state.messages.push(DisplayMessage { + role: MessageRole::Assistant, + content: String::new(), + tool_calls: vec![card], + thinking: None, + }); + } + state.dirty = true; + }, + "tool_call_end" => { + let tool_id = payload + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let success = payload.get("success").and_then(|v| v.as_bool()); + let result_summary = payload + .get("result") + .and_then(|v| v.get("stdout").and_then(|s| s.as_str())) + .or_else(|| { + payload + .get("error") + .and_then(|v| v.get("detail").and_then(|s| s.as_str())) + }) + .map(String::from); + + // Find and update the tool call card + for msg in state.messages.iter_mut().rev() { + if let Some(card) = msg.tool_calls.iter_mut().find(|c| c.id == tool_id) { + card.success = success; + card.result_summary = result_summary; + break; + } + } + state.dirty = true; + }, + "iteration" => { + debug!( + iteration = payload.get("iteration").and_then(|v| v.as_u64()), + "agent iteration" + ); + }, + "sub_agent_start" => { + debug!( + task = payload.get("task").and_then(|v| v.as_str()), + "sub-agent started" + ); + }, + "sub_agent_end" => { + debug!( + task = payload.get("task").and_then(|v| v.as_str()), + "sub-agent ended" + ); + }, + "retrying" => { + let retry_ms = payload + .get("retryAfterMs") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let error_msg = payload + .get("error") + .and_then(|v| v.get("title").and_then(|t| t.as_str())) + .unwrap_or("rate limited"); + state.messages.push(DisplayMessage { + role: MessageRole::System, + content: format!("Retrying in {:.1}s: {error_msg}", retry_ms as f64 / 1000.0), + tool_calls: Vec::new(), + thinking: None, + }); + state.dirty = true; + }, + "final" => { + let text = payload + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let model = payload + .get("model") + .and_then(|v| v.as_str()) + .map(String::from); + let provider = payload + .get("provider") + .and_then(|v| v.as_str()) + .map(String::from); + + // Update token counts + if let Some(input) = payload.get("inputTokens").and_then(|v| v.as_u64()) { + state.token_usage.session_input = + state.token_usage.session_input.saturating_add(input); + } + if let Some(output) = payload.get("outputTokens").and_then(|v| v.as_u64()) { + state.token_usage.session_output = + state.token_usage.session_output.saturating_add(output); + } + + state.finalize_stream(&text, model, provider); + + // Mark session as no longer replying + if let Some(entry) = state.sessions.iter_mut().find(|s| s.key == session_key) { + entry.replying = false; + } + }, + "error" => { + let error_msg = payload + .get("error") + .and_then(|v| v.get("detail").or(v.get("title"))) + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + state.messages.push(DisplayMessage { + role: MessageRole::System, + content: format!("Error: {error_msg}"), + tool_calls: Vec::new(), + thinking: None, + }); + state.active_run_id = None; + state.thinking_active = false; + state.stream_buffer.clear(); + + if let Some(entry) = state.sessions.iter_mut().find(|s| s.key == session_key) { + entry.replying = false; + } + state.dirty = true; + }, + "session_cleared" => { + if session_key == state.active_session { + state.messages.clear(); + state.stream_buffer.clear(); + state.active_run_id = None; + state.thinking_active = false; + state.dirty = true; + } + }, + "notice" => { + if let Some(message) = payload.get("message").and_then(|v| v.as_str()) { + let title = payload + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Notice"); + state.messages.push(DisplayMessage { + role: MessageRole::System, + content: format!("{title}: {message}"), + tool_calls: Vec::new(), + thinking: None, + }); + state.dirty = true; + } + }, + other => { + debug!(state = other, "unhandled chat event state"); + }, + } +} + +/// Handle an `exec.approval.requested` event. +pub fn handle_approval_requested(state: &mut AppState, payload: &Value) { + let request_id = payload + .get("requestId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(); + let command = payload + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(); + state.pending_approval = Some(ApprovalRequest { + request_id, + command, + }); + state.dirty = true; +} + +/// Handle an `exec.approval.resolved` event. +pub fn handle_approval_resolved(state: &mut AppState, _payload: &Value) { + state.pending_approval = None; + state.dirty = true; +} diff --git a/crates/tui/src/events/mod.rs b/crates/tui/src/events/mod.rs new file mode 100644 index 00000000..60d7cd3e --- /dev/null +++ b/crates/tui/src/events/mod.rs @@ -0,0 +1,62 @@ +pub mod chat; + +use { + crate::state::{AppState, SessionEntry}, + serde_json::Value, + tracing::debug, +}; + +/// Route a gateway event to the appropriate handler. +pub fn handle_event(state: &mut AppState, event_name: &str, payload: &Value) { + match event_name { + "chat" => chat::handle_chat_event(state, payload), + "exec.approval.requested" => chat::handle_approval_requested(state, payload), + "exec.approval.resolved" => chat::handle_approval_resolved(state, payload), + "session" => handle_session_event(state, payload), + "tick" | "presence" | "health" => { + // System events — silently handled, no UI change needed yet + }, + "shutdown" => { + state.messages.push(crate::state::DisplayMessage { + role: crate::state::MessageRole::System, + content: "Server is shutting down.".into(), + tool_calls: Vec::new(), + thinking: None, + }); + state.dirty = true; + }, + other => { + debug!(event = other, "unhandled event"); + }, + } +} + +/// Handle a `session` event. +fn handle_session_event(state: &mut AppState, payload: &Value) { + let kind = payload.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + let session_key = payload + .get("sessionKey") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + match kind { + "patched" | "created" => { + // Refresh will happen via sessions.list RPC — just mark dirty + if !state.sessions.iter().any(|s| s.key == session_key) { + state.sessions.push(SessionEntry { + key: session_key.into(), + label: None, + model: None, + message_count: 0, + replying: false, + }); + } + state.dirty = true; + }, + "deleted" => { + state.sessions.retain(|s| s.key != session_key); + state.dirty = true; + }, + _ => {}, + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs new file mode 100644 index 00000000..75ef66dd --- /dev/null +++ b/crates/tui/src/lib.rs @@ -0,0 +1,90 @@ +mod app; +mod auth; +mod connection; +pub mod error; +mod events; +mod onboarding; +mod rpc; +mod state; +mod ui; + +pub use {app::App, error::Error}; + +/// Build the default gateway WebSocket URL from `moltis.toml` config. +fn resolve_gateway_url() -> String { + let config = moltis_config::discover_and_load(); + let scheme = if config.tls.enabled { + "wss" + } else { + "ws" + }; + let bind = &config.server.bind; + let port = config.server.port; + + // Use `localhost` for loopback binds to avoid TLS/SNI warnings when an IP + // literal is used as the hostname. + let host = if matches!(bind.as_str(), "0.0.0.0" | "::" | "127.0.0.1" | "::1") { + "localhost" + } else { + bind + }; + + format!("{scheme}://{host}:{port}/ws/chat") +} + +/// Entry point for the TUI client. +/// +/// When `url` is `None`, the gateway address is derived from the local +/// `moltis.toml` config (server bind/port + TLS setting). +pub async fn run_tui(url: Option<&str>, api_key: Option<&str>) -> Result<(), Error> { + // Install the rustls ring crypto provider for TLS connections. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let url = match url { + Some(u) => u.to_owned(), + None => resolve_gateway_url(), + }; + + let connect_auth = auth::resolve_auth(api_key); + + // Enable focus-change reporting so we can redraw on tab-switch. + crossterm::execute!(std::io::stdout(), crossterm::event::EnableFocusChange) + .map_err(Error::Terminal)?; + + let terminal = ratatui::init(); + let result = App::new(url, connect_auth).run(terminal).await; + ratatui::restore(); + + let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableFocusChange); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_initial_state() { + let auth = moltis_protocol::ConnectAuth { + api_key: None, + password: None, + token: None, + }; + let app = App::new("ws://localhost:9433/ws/chat".into(), auth); + drop(app); + } + + #[test] + fn resolve_url_from_config() { + let url = resolve_gateway_url(); + assert!( + url.starts_with("ws://") || url.starts_with("wss://"), + "URL must start with ws:// or wss://, got: {url}" + ); + assert!( + url.ends_with("/ws/chat"), + "URL must end with /ws/chat, got: {url}" + ); + } +} diff --git a/crates/tui/src/onboarding.rs b/crates/tui/src/onboarding.rs new file mode 100644 index 00000000..3650f401 --- /dev/null +++ b/crates/tui/src/onboarding.rs @@ -0,0 +1,883 @@ +use std::collections::BTreeSet; + +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingStep { + Security, + Llm, + Voice, + Channel, + Identity, + Summary, +} + +impl OnboardingStep { + pub fn label(self) -> &'static str { + match self { + Self::Security => "Security", + Self::Llm => "LLM", + Self::Voice => "Voice", + Self::Channel => "Channel", + Self::Identity => "Identity", + Self::Summary => "Summary", + } + } + + pub fn title(self) -> &'static str { + match self { + Self::Security => "Secure your instance", + Self::Llm => "Add LLMs", + Self::Voice => "Voice (optional)", + Self::Channel => "Connect Channels", + Self::Identity => "Set up your identity", + Self::Summary => "Setup Summary", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelProvider { + Telegram, + Slack, + Discord, +} + +impl ChannelProvider { + pub const ALL: [Self; 3] = [Self::Telegram, Self::Slack, Self::Discord]; + + pub fn from_index(index: usize) -> Self { + Self::ALL.get(index).copied().unwrap_or(Self::Telegram) + } + + pub fn name(self) -> &'static str { + match self { + Self::Telegram => "Telegram", + Self::Slack => "Slack", + Self::Discord => "Discord", + } + } + + pub fn auth(self) -> &'static str { + match self { + Self::Telegram => "bot-token", + Self::Slack => "oauth", + Self::Discord => "bot-token", + } + } + + pub fn available(self) -> bool { + matches!(self, Self::Telegram) + } + + pub fn description(self) -> &'static str { + match self { + Self::Telegram => "Chat from your phone using a Telegram bot.", + Self::Slack => "Workspace channels and DMs (coming soon).", + Self::Discord => "Guild channels and DMs (coming soon).", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct AuthStatus { + pub setup_required: bool, + pub setup_complete: bool, + pub auth_disabled: bool, + pub setup_code_required: bool, + pub localhost_only: bool, + pub webauthn_available: bool, +} + +#[derive(Debug, Clone)] +pub struct SecurityState { + pub skippable: bool, + pub setup_required: bool, + pub setup_complete: bool, + pub setup_code_required: bool, + pub localhost_only: bool, + pub webauthn_available: bool, + pub setup_code: String, + pub password: String, + pub confirm_password: String, + pub field_index: usize, +} + +impl SecurityState { + fn new(_auth_needed: bool, auth_skippable: bool, status: Option<&AuthStatus>) -> Self { + let mut state = Self { + skippable: auth_skippable, + setup_required: false, + setup_complete: false, + setup_code_required: false, + localhost_only: false, + webauthn_available: false, + setup_code: String::new(), + password: String::new(), + confirm_password: String::new(), + field_index: 0, + }; + + if let Some(status) = status { + state.setup_required = status.setup_required; + state.setup_complete = status.setup_complete; + state.setup_code_required = status.setup_code_required; + state.localhost_only = status.localhost_only; + state.webauthn_available = status.webauthn_available; + } + + state + } + + pub fn visible_fields(&self) -> usize { + let mut fields = 2usize; + if self.setup_code_required { + fields += 1; + } + fields + } +} + +#[derive(Debug, Clone)] +pub struct ProviderEntry { + pub name: String, + pub display_name: String, + pub auth_type: String, + pub configured: bool, + pub default_base_url: Option, + pub base_url: Option, + pub models: Vec, + pub requires_model: bool, + pub key_optional: bool, +} + +#[derive(Debug, Clone)] +pub struct ModelOption { + pub id: String, + pub display_name: String, + pub supports_tools: bool, +} + +#[derive(Debug, Clone)] +pub struct LocalModelOption { + pub id: String, + pub display_name: String, + pub min_ram_gb: u64, + pub context_window: u64, + pub suggested: bool, +} + +#[derive(Debug, Clone)] +pub enum ProviderConfigurePhase { + Form, + ModelSelect { + models: Vec, + selected: BTreeSet, + cursor: usize, + }, + OAuth { + auth_url: Option, + verification_uri: Option, + user_code: Option, + }, + Local { + backend: String, + models: Vec, + cursor: usize, + note: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct ProviderConfigureState { + pub provider_name: String, + pub provider_display_name: String, + pub auth_type: String, + pub requires_model: bool, + pub key_optional: bool, + pub field_index: usize, + pub api_key: String, + pub endpoint: String, + pub model: String, + pub phase: ProviderConfigurePhase, +} + +impl ProviderConfigureState { + pub fn visible_fields(&self) -> usize { + let mut count = 1usize; + if supports_endpoint(&self.provider_name) { + count += 1; + } + if self.requires_model { + count += 1; + } + count + } +} + +#[derive(Debug, Clone, Default)] +pub struct LlmState { + pub providers: Vec, + pub selected_provider: usize, + pub configuring: Option, +} + +#[derive(Debug, Clone)] +pub struct VoiceProviderEntry { + pub id: String, + pub name: String, + pub provider_type: String, + pub category: String, + pub available: bool, + pub enabled: bool, + pub key_source: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct VoiceState { + pub available: bool, + pub providers: Vec, + pub selected_provider: usize, + pub pending_api_key: String, +} + +#[derive(Debug, Clone)] +pub struct ChannelState { + pub selected_provider: usize, + pub configuring: bool, + pub account_id: String, + pub token: String, + pub dm_policy: String, + pub allowlist: String, + pub connected: bool, + pub connected_name: String, + pub field_index: usize, +} + +impl Default for ChannelState { + fn default() -> Self { + Self { + selected_provider: 0, + configuring: false, + account_id: String::new(), + token: String::new(), + dm_policy: "allowlist".into(), + allowlist: String::new(), + connected: false, + connected_name: String::new(), + field_index: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct IdentityState { + pub user_name: String, + pub agent_name: String, + pub emoji: String, + pub creature: String, + pub vibe: String, + pub field_index: usize, +} + +impl Default for IdentityState { + fn default() -> Self { + Self { + user_name: String::new(), + agent_name: "Moltis".into(), + emoji: "🤖".into(), + creature: String::new(), + vibe: String::new(), + field_index: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct ChannelSummary { + pub name: String, + pub status: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SummaryState { + pub identity_line: Option, + pub provider_badges: Vec, + pub channels: Vec, + pub voice_enabled: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditTarget { + SecuritySetupCode, + SecurityPassword, + SecurityConfirmPassword, + ProviderApiKey, + ProviderEndpoint, + ProviderModel, + VoiceApiKey, + ChannelAccountId, + ChannelToken, + ChannelAllowlist, + IdentityUserName, + IdentityAgentName, + IdentityEmoji, + IdentityCreature, + IdentityVibe, +} + +impl EditTarget { + pub fn placeholder(self) -> &'static str { + match self { + Self::SecuritySetupCode => "6-digit setup code from process logs", + Self::SecurityPassword => "At least 8 characters", + Self::SecurityConfirmPassword => "Confirm password", + Self::ProviderApiKey => "Provider API key", + Self::ProviderEndpoint => "Optional endpoint URL", + Self::ProviderModel => "Model ID", + Self::VoiceApiKey => "Voice provider API key", + Self::ChannelAccountId => "Telegram bot username", + Self::ChannelToken => "Telegram bot token", + Self::ChannelAllowlist => "One username per line", + Self::IdentityUserName => "Your name", + Self::IdentityAgentName => "Agent name", + Self::IdentityEmoji => "Emoji", + Self::IdentityCreature => "Creature", + Self::IdentityVibe => "Vibe", + } + } +} + +#[derive(Debug, Clone)] +pub struct OnboardingState { + pub steps: Vec, + pub step_index: usize, + pub busy: bool, + pub status_message: Option, + pub error_message: Option, + pub editing: Option, + pub security: SecurityState, + pub llm: LlmState, + pub voice: VoiceState, + pub channel: ChannelState, + pub identity: IdentityState, + pub summary: SummaryState, +} + +impl OnboardingState { + pub fn new( + auth_needed: bool, + auth_skippable: bool, + voice_available: bool, + auth_status: Option<&AuthStatus>, + ) -> Self { + let mut steps = Vec::new(); + if auth_needed { + steps.push(OnboardingStep::Security); + } + steps.push(OnboardingStep::Llm); + if voice_available { + steps.push(OnboardingStep::Voice); + } + steps.push(OnboardingStep::Channel); + steps.push(OnboardingStep::Identity); + steps.push(OnboardingStep::Summary); + + Self { + steps, + step_index: 0, + busy: false, + status_message: None, + error_message: None, + editing: None, + security: SecurityState::new(auth_needed, auth_skippable, auth_status), + llm: LlmState::default(), + voice: VoiceState { + available: voice_available, + providers: Vec::new(), + selected_provider: 0, + pending_api_key: String::new(), + }, + channel: ChannelState::default(), + identity: IdentityState::default(), + summary: SummaryState::default(), + } + } + + pub fn current_step(&self) -> OnboardingStep { + self.steps + .get(self.step_index) + .copied() + .unwrap_or(OnboardingStep::Summary) + } + + pub fn go_next(&mut self) { + if self.step_index + 1 < self.steps.len() { + self.step_index += 1; + } + } + + pub fn go_back(&mut self) { + if self.step_index > 0 { + self.step_index -= 1; + } + } + + pub fn clear_messages(&mut self) { + self.error_message = None; + self.status_message = None; + } + + pub fn set_error(&mut self, message: impl Into) { + self.status_message = None; + self.error_message = Some(message.into()); + } + + pub fn set_status(&mut self, message: impl Into) { + self.error_message = None; + self.status_message = Some(message.into()); + } + + pub fn begin_edit(&mut self, target: EditTarget) -> String { + self.editing = Some(target); + self.current_value_for(target) + } + + pub fn commit_edit(&mut self, target: EditTarget, value: String) { + self.editing = None; + match target { + EditTarget::SecuritySetupCode => self.security.setup_code = value, + EditTarget::SecurityPassword => self.security.password = value, + EditTarget::SecurityConfirmPassword => self.security.confirm_password = value, + EditTarget::ProviderApiKey => { + if let Some(config) = self.llm.configuring.as_mut() { + config.api_key = value; + } + }, + EditTarget::ProviderEndpoint => { + if let Some(config) = self.llm.configuring.as_mut() { + config.endpoint = value; + } + }, + EditTarget::ProviderModel => { + if let Some(config) = self.llm.configuring.as_mut() { + config.model = value; + } + }, + EditTarget::VoiceApiKey => { + self.voice.pending_api_key = value; + }, + EditTarget::ChannelAccountId => self.channel.account_id = value, + EditTarget::ChannelToken => self.channel.token = value, + EditTarget::ChannelAllowlist => self.channel.allowlist = value, + EditTarget::IdentityUserName => self.identity.user_name = value, + EditTarget::IdentityAgentName => self.identity.agent_name = value, + EditTarget::IdentityEmoji => self.identity.emoji = value, + EditTarget::IdentityCreature => self.identity.creature = value, + EditTarget::IdentityVibe => self.identity.vibe = value, + } + } + + pub fn cancel_edit(&mut self) { + self.editing = None; + } + + fn current_value_for(&self, target: EditTarget) -> String { + match target { + EditTarget::SecuritySetupCode => self.security.setup_code.clone(), + EditTarget::SecurityPassword => self.security.password.clone(), + EditTarget::SecurityConfirmPassword => self.security.confirm_password.clone(), + EditTarget::ProviderApiKey => self + .llm + .configuring + .as_ref() + .map(|s| s.api_key.clone()) + .unwrap_or_default(), + EditTarget::ProviderEndpoint => self + .llm + .configuring + .as_ref() + .map(|s| s.endpoint.clone()) + .unwrap_or_default(), + EditTarget::ProviderModel => self + .llm + .configuring + .as_ref() + .map(|s| s.model.clone()) + .unwrap_or_default(), + EditTarget::VoiceApiKey => self.voice.pending_api_key.clone(), + EditTarget::ChannelAccountId => self.channel.account_id.clone(), + EditTarget::ChannelToken => self.channel.token.clone(), + EditTarget::ChannelAllowlist => self.channel.allowlist.clone(), + EditTarget::IdentityUserName => self.identity.user_name.clone(), + EditTarget::IdentityAgentName => self.identity.agent_name.clone(), + EditTarget::IdentityEmoji => self.identity.emoji.clone(), + EditTarget::IdentityCreature => self.identity.creature.clone(), + EditTarget::IdentityVibe => self.identity.vibe.clone(), + } + } +} + +pub fn supports_endpoint(provider: &str) -> bool { + matches!( + provider, + "openai" + | "mistral" + | "openrouter" + | "cerebras" + | "minimax" + | "moonshot" + | "venice" + | "ollama" + | "kimi-code" + | "xai" + | "deepseek" + | "groq" + | "gemini" + | "zai" + ) +} + +pub fn parse_providers(payload: &Value) -> Vec { + payload + .as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + let name = row.get("name").and_then(Value::as_str)?.to_string(); + let display_name = row + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(&name) + .to_string(); + let auth_type = row + .get("authType") + .and_then(Value::as_str) + .unwrap_or("api-key") + .to_string(); + let configured = row + .get("configured") + .and_then(Value::as_bool) + .unwrap_or(false); + let default_base_url = row + .get("defaultBaseUrl") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let base_url = row + .get("baseUrl") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let models = row + .get("models") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + let requires_model = row + .get("requiresModel") + .and_then(Value::as_bool) + .unwrap_or(false); + let key_optional = row + .get("keyOptional") + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(ProviderEntry { + name, + display_name, + auth_type, + configured, + default_base_url, + base_url, + models, + requires_model, + key_optional, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn configured_provider_badges(providers: &[ProviderEntry]) -> Vec { + providers + .iter() + .filter(|provider| provider.configured) + .map(|provider| provider.display_name.clone()) + .collect() +} + +pub fn parse_model_options(payload: &Value) -> Vec { + payload + .as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + let id = row.get("id").and_then(Value::as_str)?.to_string(); + let display_name = row + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let supports_tools = row + .get("supportsTools") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(ModelOption { + id, + display_name, + supports_tools, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn parse_voice_providers(payload: &Value) -> Vec { + let parse_side = |side: &str| { + payload + .get(side) + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + let id = row.get("id").and_then(Value::as_str)?.to_string(); + let name = row + .get("name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let provider_type = row + .get("type") + .and_then(Value::as_str) + .unwrap_or(side) + .to_string(); + let category = row + .get("category") + .and_then(Value::as_str) + .unwrap_or("cloud") + .to_string(); + let available = row + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false); + let enabled = row.get("enabled").and_then(Value::as_bool).unwrap_or(false); + let key_source = row + .get("keySource") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let description = row + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + Some(VoiceProviderEntry { + id, + name, + provider_type, + category, + available, + enabled, + key_source, + description, + }) + }) + .collect::>() + }) + .unwrap_or_default() + }; + + let mut providers = parse_side("stt"); + providers.extend(parse_side("tts")); + providers +} + +pub fn parse_channels(payload: &Value) -> Vec { + payload + .get("channels") + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .map(|row| { + let status = row + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let name = row + .get("name") + .and_then(Value::as_str) + .or_else(|| row.get("account_id").and_then(Value::as_str)) + .unwrap_or("channel") + .to_string(); + ChannelSummary { name, status } + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn parse_identity(identity: &Value) -> IdentityState { + IdentityState { + user_name: identity + .get("user_name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + agent_name: identity + .get("name") + .and_then(Value::as_str) + .unwrap_or("Moltis") + .to_string(), + emoji: identity + .get("emoji") + .and_then(Value::as_str) + .unwrap_or("🤖") + .to_string(), + creature: identity + .get("creature") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + vibe: identity + .get("vibe") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + field_index: 0, + } +} + +pub fn parse_local_models(payload: &Value, backend: &str) -> Vec { + payload + .get("recommended") + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + let model_backend = + row.get("backend").and_then(Value::as_str).unwrap_or("GGUF"); + if model_backend != backend { + return None; + } + + let id = row.get("id").and_then(Value::as_str)?.to_string(); + let display_name = row + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let min_ram_gb = row.get("minRamGb").and_then(Value::as_u64).unwrap_or(0); + let context_window = row + .get("contextWindow") + .and_then(Value::as_u64) + .unwrap_or(0); + let suggested = row + .get("suggested") + .and_then(Value::as_bool) + .unwrap_or(false); + + Some(LocalModelOption { + id, + display_name, + min_ram_gb, + context_window, + suggested, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn parse_local_recommended_backend(payload: &Value) -> String { + payload + .get("recommendedBackend") + .and_then(Value::as_str) + .unwrap_or("GGUF") + .to_string() +} + +pub fn parse_local_backend_note(payload: &Value) -> Option { + payload + .get("backendNote") + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps_follow_web_order() { + let s = OnboardingState::new(false, false, true, None); + assert_eq!(s.steps, vec![ + OnboardingStep::Llm, + OnboardingStep::Voice, + OnboardingStep::Channel, + OnboardingStep::Identity, + OnboardingStep::Summary + ]); + + let s2 = OnboardingState::new(true, true, false, None); + assert_eq!(s2.steps, vec![ + OnboardingStep::Security, + OnboardingStep::Llm, + OnboardingStep::Channel, + OnboardingStep::Identity, + OnboardingStep::Summary + ]); + } + + #[test] + fn parse_provider_rows() { + let payload = serde_json::json!([ + { + "name": "openai", + "displayName": "OpenAI", + "authType": "api-key", + "configured": true, + "defaultBaseUrl": "https://api.openai.com/v1", + "baseUrl": "https://api.openai.com/v1", + "models": ["gpt-5"], + "requiresModel": false, + "keyOptional": false + } + ]); + let providers = parse_providers(&payload); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].name, "openai"); + assert!(providers[0].configured); + assert_eq!(providers[0].models, vec!["gpt-5"]); + } + + #[test] + fn parse_voice_rows() { + let payload = serde_json::json!({ + "tts": [ + { + "id": "openai-tts", + "name": "OpenAI TTS", + "type": "tts", + "category": "cloud", + "available": true, + "enabled": false, + "keySource": "env" + } + ], + "stt": [] + }); + + let providers = parse_voice_providers(&payload); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].id, "openai-tts"); + assert_eq!(providers[0].provider_type, "tts"); + } +} diff --git a/crates/tui/src/rpc.rs b/crates/tui/src/rpc.rs new file mode 100644 index 00000000..d7712784 --- /dev/null +++ b/crates/tui/src/rpc.rs @@ -0,0 +1,152 @@ +use { + crate::{Error, connection::ConnectionManager}, + moltis_protocol::{RequestFrame, ResponseFrame}, + std::{collections::HashMap, sync::Arc, time::Duration}, + tokio::sync::{Mutex, oneshot}, +}; + +/// Timeout for individual RPC calls. +const RPC_TIMEOUT: Duration = Duration::from_secs(10); + +/// Correlates RPC request/response pairs by ID. +pub struct RpcClient { + connection: Arc, + pending: Arc>>>, +} + +impl RpcClient { + pub fn new(connection: Arc) -> Self { + Self { + connection, + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Send an RPC request and wait for the matching response. + pub async fn call( + &self, + method: &str, + params: serde_json::Value, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let frame = RequestFrame { + r#type: "req".into(), + id: id.clone(), + method: method.into(), + params: Some(params), + }; + + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.pending.lock().await; + pending.insert(id.clone(), tx); + } + + let json = serde_json::to_string(&frame).map_err(Error::Json)?; + self.connection.send_raw(json); + + // Wait for response with timeout + let result = tokio::time::timeout(RPC_TIMEOUT, rx).await; + + // Clean up pending entry on timeout or error + match result { + Ok(Ok(response)) => { + if response.ok { + Ok(response.payload.unwrap_or(serde_json::Value::Null)) + } else { + let msg = response + .error + .map(|e| e.message) + .unwrap_or_else(|| "unknown RPC error".into()); + Err(Error::Protocol(msg)) + } + }, + Ok(Err(_)) => { + // oneshot sender dropped — connection closed + let mut pending = self.pending.lock().await; + pending.remove(&id); + Err(Error::Connection( + "connection closed during RPC call".into(), + )) + }, + Err(_) => { + // Timeout + let mut pending = self.pending.lock().await; + pending.remove(&id); + Err(Error::Connection(format!( + "RPC call '{method}' timed out after {}s", + RPC_TIMEOUT.as_secs() + ))) + }, + } + } + + /// Send an RPC request without waiting for a response. + pub fn fire_and_forget(&self, method: &str, params: serde_json::Value) { + let id = uuid::Uuid::new_v4().to_string(); + let frame = RequestFrame { + r#type: "req".into(), + id, + method: method.into(), + params: Some(params), + }; + + if let Ok(json) = serde_json::to_string(&frame) { + self.connection.send_raw(json); + } + } + + /// Called by the event loop when a response frame arrives. + /// Routes it to the waiting `call()` if one exists. + pub async fn resolve_response(&self, frame: ResponseFrame) { + let mut pending = self.pending.lock().await; + if let Some(tx) = pending.remove(&frame.id) { + // Ignore send error — the caller may have timed out and dropped the receiver. + let _ = tx.send(frame); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn resolve_response_routes_to_caller() { + let (event_tx, _event_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let conn = Arc::new(ConnectionManager::spawn( + // Use a dummy URL — we won't actually connect in this test. + // The connection will fail, but we only test the pending map. + "ws://127.0.0.1:1/ws/chat".into(), + moltis_protocol::ConnectAuth { + api_key: None, + password: None, + token: None, + }, + event_tx, + )); + + let rpc = RpcClient::new(conn); + + // Manually insert a pending entry + let (tx, rx) = oneshot::channel(); + { + let mut pending = rpc.pending.lock().await; + pending.insert("test-id".into(), tx); + } + + // Resolve it + let response = ResponseFrame { + r#type: "res".into(), + id: "test-id".into(), + ok: true, + payload: Some(serde_json::json!({"result": "ok"})), + error: None, + }; + rpc.resolve_response(response).await; + + let result = rx.await; + assert!(matches!(result, Ok(frame) if frame.ok)); + } +} diff --git a/crates/tui/src/state.rs b/crates/tui/src/state.rs new file mode 100644 index 00000000..22c6f72e --- /dev/null +++ b/crates/tui/src/state.rs @@ -0,0 +1,470 @@ +use serde_json::Value; + +/// Input modes for the TUI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMode { + /// Navigation mode: scrolling, switching tabs, quitting. + Normal, + /// Default typing mode: text input is active. + Insert, + /// Command-line mode (`:quit`, `:model`, etc.). + Command, +} + +/// Which panel has focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Panel { + Chat, + Sessions, +} + +/// Top-level tab navigation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MainTab { + Chat, + Settings, + Projects, + Crons, +} + +/// Settings navigation sections. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsSection { + Identity, + Providers, + Voice, + Channels, + EnvVars, + McpServers, + Memory, +} + +impl SettingsSection { + pub const ALL: [Self; 7] = [ + Self::Identity, + Self::Providers, + Self::Voice, + Self::Channels, + Self::EnvVars, + Self::McpServers, + Self::Memory, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Identity => "Identity", + Self::Providers => "Providers", + Self::Voice => "Voice", + Self::Channels => "Channels", + Self::EnvVars => "Env Vars", + Self::McpServers => "MCP Servers", + Self::Memory => "Memory", + } + } +} + +/// State for the Settings tab. +#[derive(Debug, Clone)] +pub struct SettingsState { + pub active_section: SettingsSection, + pub sections: Vec, + #[allow(dead_code)] // Used when Settings tab loads data via RPC + pub section_data: Option, + #[allow(dead_code)] // Used when Settings form editing is implemented + pub editing_field: Option, +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + active_section: SettingsSection::Identity, + sections: SettingsSection::ALL.to_vec(), + section_data: None, + editing_field: None, + } + } +} + +/// Entry in the projects list. +#[derive(Debug, Clone)] +pub struct ProjectEntry { + pub name: String, + pub description: String, + pub path: String, + pub active: bool, +} + +/// State for the Projects tab. +#[derive(Debug, Clone, Default)] +pub struct ProjectsState { + pub projects: Vec, + pub selected: usize, +} + +/// Entry in the cron jobs list. +#[derive(Debug, Clone)] +pub struct CronJobEntry { + pub name: String, + pub schedule: String, + pub last_run: Option, + pub next_run: Option, + pub enabled: bool, +} + +/// State for the Crons tab. +#[derive(Debug, Clone, Default)] +pub struct CronsState { + pub jobs: Vec, + pub selected: usize, +} + +/// Role of a chat message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MessageRole { + User, + Assistant, + System, +} + +/// A tool call displayed as a card in the chat. +#[derive(Debug, Clone)] +pub struct ToolCallCard { + pub id: String, + pub name: String, + pub arguments: Value, + pub execution_mode: Option, + pub success: Option, + pub result_summary: Option, +} + +/// A pending approval request. +#[derive(Debug, Clone)] +pub struct ApprovalRequest { + pub request_id: String, + pub command: String, +} + +/// A single message displayed in the chat view. +#[derive(Debug, Clone)] +pub struct DisplayMessage { + pub role: MessageRole, + pub content: String, + pub tool_calls: Vec, + pub thinking: Option, +} + +/// Token usage tracking. +#[derive(Debug, Clone, Default)] +pub struct TokenUsage { + pub session_input: u64, + pub session_output: u64, + pub context_window: u64, +} + +/// Session entry for the sidebar. +#[derive(Debug, Clone)] +pub struct SessionEntry { + pub key: String, + pub label: Option, + pub model: Option, + pub message_count: u64, + pub replying: bool, +} + +impl SessionEntry { + /// Display name: label if set, otherwise key. + pub fn display_name(&self) -> &str { + self.label.as_deref().unwrap_or(&self.key) + } +} + +/// Selectable model option for the session model switcher. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelSwitchItem { + pub provider_name: String, + pub provider_display: String, + pub model_id: String, + pub model_display: String, +} + +/// Modal state for provider/model switching with search. +#[derive(Debug, Clone, Default)] +pub struct ModelSwitcherState { + pub query: String, + pub selected: usize, + pub items: Vec, + pub error_message: Option, +} + +impl ModelSwitcherState { + #[must_use] + pub fn filtered_indices(&self) -> Vec { + let q = self.query.trim().to_lowercase(); + if q.is_empty() { + return (0..self.items.len()).collect(); + } + + self.items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let provider = item.provider_display.to_lowercase(); + let model = item.model_display.to_lowercase(); + let model_id = item.model_id.to_lowercase(); + if provider.contains(&q) || model.contains(&q) || model_id.contains(&q) { + Some(index) + } else { + None + } + }) + .collect() + } + + pub fn reset_selection_to_visible(&mut self) { + let filtered = self.filtered_indices(); + if let Some(first) = filtered.first().copied() { + self.selected = first; + } else { + self.selected = 0; + } + } +} + +/// Slash command suggestion item shown above the input box. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlashMenuItem { + pub name: String, + pub description: String, +} + +/// Full application state. +pub struct AppState { + pub input_mode: InputMode, + pub active_panel: Panel, + pub active_tab: MainTab, + pub messages: Vec, + pub stream_buffer: String, + pub thinking_active: bool, + pub thinking_text: String, + pub active_run_id: Option, + pub scroll_offset: usize, + pub sidebar_visible: bool, + pub sessions: Vec, + pub active_session: String, + pub selected_session: usize, + pub session_scroll_offset: usize, + pub model: Option, + pub provider: Option, + pub shell_mode_enabled: bool, + pub token_usage: TokenUsage, + pub pending_approval: Option, + pub command_buffer: String, + pub slash_menu_items: Vec, + pub slash_menu_selected: usize, + pub dirty: bool, + pub server_version: Option, + pub settings: SettingsState, + pub projects: ProjectsState, + pub crons: CronsState, +} + +impl Default for AppState { + fn default() -> Self { + Self { + input_mode: InputMode::Insert, + active_panel: Panel::Chat, + active_tab: MainTab::Chat, + messages: Vec::new(), + stream_buffer: String::new(), + thinking_active: false, + thinking_text: String::new(), + active_run_id: None, + scroll_offset: 0, + sidebar_visible: true, + sessions: Vec::new(), + active_session: "main".into(), + selected_session: 0, + session_scroll_offset: 0, + model: None, + provider: None, + shell_mode_enabled: false, + token_usage: TokenUsage::default(), + pending_approval: None, + command_buffer: String::new(), + slash_menu_items: Vec::new(), + slash_menu_selected: 0, + dirty: true, + server_version: None, + settings: SettingsState::default(), + projects: ProjectsState::default(), + crons: CronsState::default(), + } + } +} + +impl AppState { + /// Whether the assistant is currently streaming a response. + pub fn is_streaming(&self) -> bool { + self.active_run_id.is_some() + } + + /// Finalize the current stream: move stream_buffer into a message. + pub fn finalize_stream(&mut self, text: &str, model: Option, provider: Option) { + let content = if self.stream_buffer.is_empty() { + text.to_owned() + } else { + std::mem::take(&mut self.stream_buffer) + }; + + let thinking = if self.thinking_text.is_empty() { + None + } else { + Some(std::mem::take(&mut self.thinking_text)) + }; + + self.messages.push(DisplayMessage { + role: MessageRole::Assistant, + content, + tool_calls: Vec::new(), + thinking, + }); + + self.active_run_id = None; + self.thinking_active = false; + + if let Some(m) = model { + self.model = Some(m); + } + if let Some(p) = provider { + self.provider = Some(p); + } + self.dirty = true; + } + + /// Add a user message to the history. + pub fn add_user_message(&mut self, text: String) { + self.messages.push(DisplayMessage { + role: MessageRole::User, + content: text, + tool_calls: Vec::new(), + thinking: None, + }); + self.dirty = true; + } + + /// Scroll chat messages up. + pub fn scroll_up(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_add(amount); + self.dirty = true; + } + + /// Scroll chat messages down (towards newest). + pub fn scroll_down(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + self.dirty = true; + } + + /// Scroll to the bottom (newest messages). + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = 0; + self.dirty = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_state() { + let state = AppState::default(); + assert_eq!(state.input_mode, InputMode::Insert); + assert_eq!(state.active_panel, Panel::Chat); + assert!(state.messages.is_empty()); + assert!(!state.shell_mode_enabled); + assert!(state.slash_menu_items.is_empty()); + assert!(!state.is_streaming()); + } + + #[test] + fn finalize_stream_creates_message() { + let mut state = AppState { + stream_buffer: "Hello world".into(), + active_run_id: Some("run-1".into()), + ..AppState::default() + }; + + state.finalize_stream("", Some("claude-3".into()), Some("anthropic".into())); + + assert_eq!(state.messages.len(), 1); + assert_eq!(state.messages[0].content, "Hello world"); + assert_eq!(state.model.as_deref(), Some("claude-3")); + assert!(!state.is_streaming()); + } + + #[test] + fn scroll_bounds() { + let mut state = AppState::default(); + state.scroll_down(10); // Can't go below 0 + assert_eq!(state.scroll_offset, 0); + + state.scroll_up(5); + assert_eq!(state.scroll_offset, 5); + + state.scroll_to_bottom(); + assert_eq!(state.scroll_offset, 0); + } + + #[test] + fn session_display_name() { + let s1 = SessionEntry { + key: "main".into(), + label: None, + model: None, + message_count: 0, + replying: false, + }; + assert_eq!(s1.display_name(), "main"); + + let s2 = SessionEntry { + key: "abc123".into(), + label: Some("My Chat".into()), + model: None, + message_count: 5, + replying: true, + }; + assert_eq!(s2.display_name(), "My Chat"); + } + + #[test] + fn model_switcher_filters_by_query() { + let mut switcher = ModelSwitcherState { + query: "openai".into(), + selected: 0, + items: vec![ + ModelSwitchItem { + provider_name: "openai".into(), + provider_display: "OpenAI".into(), + model_id: "openai/gpt-5".into(), + model_display: "GPT-5".into(), + }, + ModelSwitchItem { + provider_name: "anthropic".into(), + provider_display: "Anthropic".into(), + model_id: "anthropic/claude-sonnet-4".into(), + model_display: "Claude Sonnet 4".into(), + }, + ], + error_message: None, + }; + + let filtered = switcher.filtered_indices(); + assert_eq!(filtered, vec![0]); + + switcher.query = "claude".into(); + assert_eq!(switcher.filtered_indices(), vec![1]); + + switcher.query = "missing".into(); + assert!(switcher.filtered_indices().is_empty()); + } +} diff --git a/crates/tui/src/ui/chat.rs b/crates/tui/src/ui/chat.rs new file mode 100644 index 00000000..787fb3d6 --- /dev/null +++ b/crates/tui/src/ui/chat.rs @@ -0,0 +1,189 @@ +use { + super::{common, markdown, theme::Theme}, + crate::state::{AppState, DisplayMessage, MessageRole}, + ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + }, +}; + +/// Render the chat message list. +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let mut all_lines: Vec> = Vec::new(); + + // Render existing messages + for msg in &state.messages { + render_message(&mut all_lines, msg, theme); + all_lines.push(Line::from("")); // spacing between messages + } + + // Render current streaming content + if state.is_streaming() { + // Thinking indicator + if state.thinking_active { + let spinner = thinking_spinner(); + all_lines.push(Line::from(vec![ + Span::styled(format!("{spinner} "), theme.thinking), + Span::styled("Thinking...", theme.thinking), + ])); + if !state.thinking_text.is_empty() { + let thinking_lines = markdown::render_markdown(&state.thinking_text, theme); + for line in thinking_lines { + let dimmed: Vec> = line + .spans + .into_iter() + .map(|s| { + Span::styled(s.content.to_string(), s.style.add_modifier(Modifier::DIM)) + }) + .collect(); + all_lines.push(Line::from(dimmed)); + } + } + } + + // Streaming text + if !state.stream_buffer.is_empty() { + all_lines.push(Line::from(vec![Span::styled( + " assistant ", + theme.msg_card_assistant.add_modifier(Modifier::BOLD), + )])); + let stream_lines = markdown::render_markdown(&state.stream_buffer, theme); + all_lines.extend(stream_lines); + } else if !state.thinking_active { + // Show a waiting indicator + let spinner = thinking_spinner(); + all_lines.push(Line::from(Span::styled( + format!("{spinner} Waiting for response..."), + theme.thinking, + ))); + } + } + + // Approval card + if let Some(ref approval) = state.pending_approval { + all_lines.push(Line::from("")); + all_lines.push(Line::from(vec![Span::styled( + " APPROVAL REQUIRED ", + theme.approval_highlight, + )])); + all_lines.push(Line::from(vec![Span::raw(format!( + " Command: {}", + approval.command + ))])); + all_lines.push(Line::from(vec![ + Span::styled(" [y] ", theme.tool_success), + Span::raw("Approve "), + Span::styled("[n] ", theme.tool_error), + Span::raw("Deny"), + ])); + all_lines.push(Line::from("")); + } + + // Apply scroll offset (scroll from bottom) + let visible_height = area.height.saturating_sub(2) as usize; // account for borders + let total_lines = all_lines.len(); + let max_scroll = total_lines.saturating_sub(visible_height); + let effective_scroll = state.scroll_offset.min(max_scroll); + let start = total_lines.saturating_sub(visible_height + effective_scroll); + let end = total_lines.saturating_sub(effective_scroll); + let visible: Vec> = all_lines + .into_iter() + .skip(start) + .take(end - start) + .collect(); + + let title = format!(" Chat: {} ", state.active_session); + let block = common::rounded_block_focused(&title, true, theme); + let chat = Paragraph::new(visible) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(chat, area); + + // Scrollbar + if max_scroll > 0 { + let mut scrollbar_state = + ScrollbarState::new(max_scroll).position(max_scroll.saturating_sub(effective_scroll)); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + area, + &mut scrollbar_state, + ); + } +} + +/// Render a single message into lines. +fn render_message<'a>(lines: &mut Vec>, msg: &'a DisplayMessage, theme: &Theme) { + // Role header with background strip + let (role_label, role_style, _card_bg) = match msg.role { + MessageRole::User => (" you ", theme.user_msg, theme.msg_card_user), + MessageRole::Assistant => (" assistant ", theme.assistant_msg, theme.msg_card_assistant), + MessageRole::System => (" system ", theme.system_msg, theme.msg_card_system), + }; + lines.push(Line::from(vec![Span::styled( + role_label, + role_style.add_modifier(Modifier::BOLD), + )])); + + // Thinking (collapsed, dimmed) + if let Some(ref thinking) = msg.thinking { + let preview: String = thinking.chars().take(80).collect(); + lines.push(Line::from(vec![Span::styled( + format!(" (thinking: {preview}...)"), + theme.thinking, + )])); + } + + // Content with markdown rendering + let md_lines = markdown::render_markdown(&msg.content, theme); + lines.extend(md_lines); + + // Tool calls + for tool in &msg.tool_calls { + let status_icon = match tool.success { + Some(true) => "✓", + Some(false) => "✗", + None => "…", + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(status_icon, match tool.success { + Some(true) => theme.tool_success, + Some(false) => theme.tool_error, + None => theme.thinking, + }), + Span::raw(" "), + Span::styled(&tool.name, theme.tool_name), + if let Some(ref mode) = tool.execution_mode { + Span::raw(format!(" ({mode})")) + } else { + Span::raw("") + }, + ])); + + // Arguments summary (truncated) + let args_str = tool.arguments.to_string(); + let args_preview: String = args_str.chars().take(100).collect(); + lines.push(Line::from(Span::raw(format!(" {args_preview}")))); + + // Result + if let Some(ref summary) = tool.result_summary { + let preview: String = summary.chars().take(120).collect(); + lines.push(Line::from(Span::raw(format!(" {preview}")))); + } + } +} + +/// Simple spinning animation based on elapsed time. +fn thinking_spinner() -> char { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let frames = ['|', '/', '-', '\\']; + let idx = (now / 150) as usize % frames.len(); + frames[idx] +} diff --git a/crates/tui/src/ui/common.rs b/crates/tui/src/ui/common.rs new file mode 100644 index 00000000..8cfd4ff8 --- /dev/null +++ b/crates/tui/src/ui/common.rs @@ -0,0 +1,227 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders}, +}; + +use super::theme::Theme; + +/// Center a rectangle within `area` using percentage-based sizing. +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +/// Block with rounded borders and a title. +#[allow(dead_code)] // Public API used by onboarding refactor +pub fn rounded_block(title: &str) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(title) +} + +/// Block with rounded borders, title, and focus-aware border style. +pub fn rounded_block_focused<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> { + let border_style = if focused { + theme.border_focused + } else { + theme.border + }; + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(border_style) + .title(title) +} + +/// Draw a context-aware help bar (footer) with keyboard shortcut hints. +pub fn draw_help_bar<'a>(hints: &[(&'a str, &'a str)], theme: &Theme) -> Line<'a> { + let mut spans: Vec> = Vec::new(); + for (index, (key, desc)) in hints.iter().enumerate() { + if index > 0 { + spans.push(Span::styled( + " ", + Style::default().fg(theme + .footer_desc + .fg + .unwrap_or(ratatui::style::Color::DarkGray)), + )); + } + spans.push(Span::styled(*key, theme.footer_key)); + spans.push(Span::styled(format!(" {desc}"), theme.footer_desc)); + } + Line::from(spans) +} + +/// Format a count with K/M suffixes for compact display. +#[must_use] +pub fn format_count(n: u64) -> String { + if n >= 1_000_000 { + let m = n as f64 / 1_000_000.0; + if m >= 10.0 { + format!("{}M", m as u64) + } else { + format!("{m:.1}M") + } + } else if n >= 1_000 { + let k = n as f64 / 1_000.0; + if k >= 10.0 { + format!("{}K", k as u64) + } else { + format!("{k:.1}K") + } + } else { + n.to_string() + } +} + +/// Render a form field as lines suitable for onboarding/settings forms. +#[allow(dead_code)] // Public API for onboarding/settings form rendering +pub fn form_field<'a>( + label: &'a str, + value: &'a str, + active: bool, + _description: &str, + secret: bool, + _theme: &Theme, +) -> Vec> { + let marker = if active { + "▶" + } else { + " " + }; + let display = if secret { + mask_secret(value) + } else if value.trim().is_empty() { + "(empty)".to_string() + } else { + value.to_string() + }; + + vec![Line::from(format!("{marker} {label}: {display}"))] +} + +/// Highlight matching substrings in text for search results. +/// +/// All returned spans own their content, so the result is `'static`. +pub fn highlight_match( + text: &str, + query: &str, + normal_style: Style, + match_style: Style, +) -> Vec> { + if query.is_empty() { + return vec![Span::styled(text.to_string(), normal_style)]; + } + + let lower_text = text.to_lowercase(); + let lower_query = query.to_lowercase(); + let mut spans = Vec::new(); + let mut last_end = 0; + + for (start, _) in lower_text.match_indices(&lower_query) { + if start > last_end { + spans.push(Span::styled( + text[last_end..start].to_string(), + normal_style, + )); + } + let end = start + query.len(); + spans.push(Span::styled( + text[start..end].to_string(), + match_style.add_modifier(Modifier::BOLD), + )); + last_end = end; + } + + if last_end < text.len() { + spans.push(Span::styled(text[last_end..].to_string(), normal_style)); + } + + if spans.is_empty() { + spans.push(Span::styled(text.to_string(), normal_style)); + } + + spans +} + +/// Mask a secret value for display. +#[allow(dead_code)] // Used by form_field for secret values +pub fn mask_secret(value: &str) -> String { + if value.is_empty() { + return "(empty)".into(); + } + "*".repeat(value.chars().count().min(32)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_count_below_thousand() { + assert_eq!(format_count(0), "0"); + assert_eq!(format_count(999), "999"); + } + + #[test] + fn format_count_thousands() { + assert_eq!(format_count(1_000), "1.0K"); + assert_eq!(format_count(1_500), "1.5K"); + assert_eq!(format_count(10_000), "10K"); + assert_eq!(format_count(99_999), "99K"); + } + + #[test] + fn format_count_millions() { + assert_eq!(format_count(1_000_000), "1.0M"); + assert_eq!(format_count(2_500_000), "2.5M"); + assert_eq!(format_count(10_000_000), "10M"); + } + + #[test] + fn mask_secret_empty_and_filled() { + assert_eq!(mask_secret(""), "(empty)"); + assert_eq!(mask_secret("abc"), "***"); + } + + #[test] + fn highlight_match_no_query() { + let theme = Theme::default(); + let spans = highlight_match("Hello World", "", theme.sidebar_item, theme.sidebar_active); + assert_eq!(spans.len(), 1); + } + + #[test] + fn highlight_match_finds_substring() { + let theme = Theme::default(); + let spans = highlight_match( + "Hello World", + "world", + theme.sidebar_item, + theme.sidebar_active, + ); + assert_eq!(spans.len(), 2); // "Hello " + "World" + } + + #[test] + fn centered_rect_produces_smaller_rect() { + let area = Rect::new(0, 0, 100, 50); + let center = centered_rect(80, 60, area); + assert!(center.width < area.width); + assert!(center.height < area.height); + } +} diff --git a/crates/tui/src/ui/crons.rs b/crates/tui/src/ui/crons.rs new file mode 100644 index 00000000..f1ec9864 --- /dev/null +++ b/crates/tui/src/ui/crons.rs @@ -0,0 +1,118 @@ +use { + super::{common, theme::Theme}, + crate::state::AppState, + ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{List, ListItem, Paragraph, Wrap}, + }, +}; + +/// Render the Crons tab: job list + detail panel. +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let layout = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area); + + draw_job_list(frame, layout[0], state, theme); + draw_job_detail(frame, layout[1], state, theme); +} + +fn draw_job_list(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + if state.crons.jobs.is_empty() { + let block = common::rounded_block_focused(" Cron Jobs ", true, theme); + let content = Paragraph::new(vec![ + Line::from(""), + Line::from(" No cron jobs found."), + Line::from(""), + Line::from(" Jobs will load from gateway."), + Line::from(" Press n to create a new cron job."), + ]) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(content, area); + return; + } + + let items: Vec> = state + .crons + .jobs + .iter() + .enumerate() + .map(|(index, job)| { + let is_selected = index == state.crons.selected; + let style = if is_selected { + theme.sidebar_active + } else if index % 2 == 1 { + theme.zebra_odd + } else { + theme.sidebar_item + }; + let marker = if is_selected { + "▶ " + } else { + " " + }; + let dot = if job.enabled { + "●" + } else { + "○" + }; + + ListItem::new(Line::from(vec![ + Span::raw(marker), + Span::styled( + dot, + if job.enabled { + theme.status_dot_active + } else { + theme.status_dot_inactive + }, + ), + Span::raw(" "), + Span::styled(&job.name, style), + ])) + }) + .collect(); + + let list = List::new(items).block(common::rounded_block_focused(" Cron Jobs ", true, theme)); + frame.render_widget(list, area); +} + +fn draw_job_detail(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let block = common::rounded_block_focused(" Details ", false, theme); + let inner = block.inner(area); + frame.render_widget(block, area); + + let content = if let Some(job) = state.crons.jobs.get(state.crons.selected) { + vec![ + Line::from(vec![Span::styled(&job.name, theme.heading)]), + Line::from(""), + Line::from(vec![ + Span::styled("Schedule: ", theme.bold), + Span::raw(&job.schedule), + ]), + Line::from(vec![ + Span::styled("Enabled: ", theme.bold), + Span::raw(if job.enabled { + "Yes" + } else { + "No" + }), + ]), + Line::from(vec![ + Span::styled("Last run: ", theme.bold), + Span::raw(job.last_run.as_deref().unwrap_or("Never")), + ]), + Line::from(vec![ + Span::styled("Next run: ", theme.bold), + Span::raw(job.next_run.as_deref().unwrap_or("N/A")), + ]), + ] + } else { + vec![Line::from("Select a cron job to view details.")] + }; + + let paragraph = Paragraph::new(content).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, inner); +} diff --git a/crates/tui/src/ui/footer.rs b/crates/tui/src/ui/footer.rs new file mode 100644 index 00000000..78305cdd --- /dev/null +++ b/crates/tui/src/ui/footer.rs @@ -0,0 +1,82 @@ +use { + super::{common, theme::Theme}, + crate::state::{AppState, InputMode, MainTab, Panel}, + ratatui::{Frame, layout::Rect, widgets::Paragraph}, +}; + +/// Render the context-aware footer help bar. +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let hints = footer_hints(state); + let line = common::draw_help_bar(&hints, theme); + frame.render_widget(Paragraph::new(line), area); +} + +fn footer_hints(state: &AppState) -> Vec<(&'static str, &'static str)> { + if state.pending_approval.is_some() { + return vec![("y", "Approve"), ("n", "Deny"), ("Esc", "Normal")]; + } + + if matches!(state.input_mode, InputMode::Insert) && !state.slash_menu_items.is_empty() { + return vec![ + ("Up/Down", "Select"), + ("Enter/Tab", "Run"), + ("Esc", "Close"), + ]; + } + + match state.input_mode { + InputMode::Insert => { + return vec![ + ("Enter", "Send"), + ("S+Enter", "Newline"), + ("/help", "Commands"), + ("Esc", "Navigate"), + ]; + }, + InputMode::Command => { + return vec![("Enter", "Execute"), ("Esc", "Cancel")]; + }, + InputMode::Normal => {}, + } + + match state.active_tab { + MainTab::Chat => match state.active_panel { + Panel::Sessions => vec![ + ("j/k", "Nav"), + ("Enter", "Select"), + ("Tab", "Chat"), + ("Ctrl+b", "Hide"), + ("q", "Quit"), + ], + Panel::Chat => vec![ + ("i", "Type"), + (":", "Cmd"), + ("j/k", "Scroll"), + ("m", "Model"), + ("Tab", "Sidebar"), + ("q", "Quit"), + ], + }, + MainTab::Settings => vec![ + ("j/k", "Nav"), + ("Enter", "Edit"), + ("Tab", "Section"), + ("Esc", "Back"), + ("1-4", "Tabs"), + ], + MainTab::Projects => vec![ + ("j/k", "Nav"), + ("Enter", "Select"), + ("n", "New"), + ("Esc", "Back"), + ("1-4", "Tabs"), + ], + MainTab::Crons => vec![ + ("j/k", "Nav"), + ("Enter", "Edit"), + ("r", "Run"), + ("Esc", "Back"), + ("1-4", "Tabs"), + ], + } +} diff --git a/crates/tui/src/ui/header.rs b/crates/tui/src/ui/header.rs new file mode 100644 index 00000000..2ace63ec --- /dev/null +++ b/crates/tui/src/ui/header.rs @@ -0,0 +1,52 @@ +use { + super::theme::Theme, + crate::state::{AppState, MainTab}, + ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::Paragraph, + }, +}; + +/// Render the header bar with app name, tabs, and model info. +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let layout = Layout::horizontal([ + Constraint::Min(40), // tabs + Constraint::Length(30), // model info + ]) + .split(area); + + // Left: app name + tabs + let mut spans: Vec> = Vec::new(); + spans.push(Span::styled(" moltis ", theme.header_title)); + spans.push(Span::raw(" ")); + + let tabs = [ + (MainTab::Chat, "Chat", "1"), + (MainTab::Settings, "Settings", "2"), + (MainTab::Projects, "Projects", "3"), + (MainTab::Crons, "Crons", "4"), + ]; + + for (tab, label, key) in &tabs { + let style = if state.active_tab == *tab { + theme.header_tab_active + } else { + theme.header_tab_inactive + }; + spans.push(Span::styled(format!(" {label} [{key}] "), style)); + spans.push(Span::raw(" ")); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), layout[0]); + + // Right: model/provider info + let provider = state.provider.as_deref().unwrap_or("(auto)"); + let model = state.model.as_deref().unwrap_or("(auto)"); + let info = format!("{provider} · {model} "); + frame.render_widget( + Paragraph::new(Line::from(Span::raw(info))).alignment(ratatui::layout::Alignment::Right), + layout[1], + ); +} diff --git a/crates/tui/src/ui/input.rs b/crates/tui/src/ui/input.rs new file mode 100644 index 00000000..03f8834d --- /dev/null +++ b/crates/tui/src/ui/input.rs @@ -0,0 +1,128 @@ +use { + super::theme::Theme, + crate::state::{AppState, InputMode}, + ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Paragraph}, + }, + tui_textarea::TextArea, +}; + +/// Render the input area. +pub fn draw( + frame: &mut Frame, + area: Rect, + state: &AppState, + textarea: &mut TextArea<'_>, + theme: &Theme, +) { + let mut input_area = area; + let mut slash_menu_area = None; + if matches!(state.input_mode, InputMode::Insert) + && !state.slash_menu_items.is_empty() + && area.height > 3 + { + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area); + input_area = chunks[0]; + slash_menu_area = Some(chunks[1]); + } + + // Configure textarea style based on mode + match state.input_mode { + InputMode::Insert => { + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + let title = if state.shell_mode_enabled { + " Input (/sh mode, Enter to send, Shift+Enter for newline) " + } else { + " Input (Enter to send, Shift+Enter for newline, /help for commands) " + }; + textarea.set_block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border_focused) + .style(theme.input_bg) + .title(title), + ); + }, + InputMode::Normal => { + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style(Style::default().fg(Color::DarkGray)); + textarea.set_block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border) + .title(" Navigate (i to type) "), + ); + }, + InputMode::Command => { + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + textarea.set_block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border_focused) + .style(theme.input_bg) + .title(format!(" :{} ", state.command_buffer)), + ); + }, + } + + frame.render_widget(&*textarea, input_area); + + if let Some(menu_area) = slash_menu_area { + let max_items = menu_area.height.saturating_sub(2) as usize; + let lines: Vec> = state + .slash_menu_items + .iter() + .take(max_items) + .enumerate() + .map(|(index, item)| { + let selected = index == state.slash_menu_selected; + let marker_style = if selected { + theme.mode_insert + } else { + theme.footer_desc + }; + let name_style = if selected { + theme.mode_insert.add_modifier(Modifier::BOLD) + } else { + theme.footer_key + }; + let desc_style = if selected { + theme.footer_desc.add_modifier(Modifier::BOLD) + } else { + theme.footer_desc + }; + Line::from(vec![ + Span::styled( + if selected { + "▶ " + } else { + " " + }, + marker_style, + ), + Span::styled(format!("/{}", item.name), name_style), + Span::raw(" "), + Span::styled(item.description.as_str(), desc_style), + ]) + }) + .collect(); + + let menu = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border) + .title(" Slash Commands "), + ); + frame.render_widget(menu, menu_area); + } +} diff --git a/crates/tui/src/ui/markdown.rs b/crates/tui/src/ui/markdown.rs new file mode 100644 index 00000000..b445343c --- /dev/null +++ b/crates/tui/src/ui/markdown.rs @@ -0,0 +1,198 @@ +use { + super::theme::Theme, + pulldown_cmark::{Event, Parser, Tag, TagEnd}, + ratatui::{ + style::Style, + text::{Line, Span}, + }, +}; + +/// Convert a markdown string into ratatui `Line` objects for rendering. +pub fn render_markdown<'a>(text: &str, theme: &Theme) -> Vec> { + let parser = Parser::new(text); + let mut lines: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut style_stack: Vec