diff --git a/Cargo.lock b/Cargo.lock index 4c446e99..e4cc2fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.1" @@ -540,6 +546,47 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "mio 1.0.4", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -991,6 +1038,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1183,7 +1248,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439d62e241dae2dffd55bfeeabe551275cf9d9f084c5ebc6b48bad49d03285b7" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bstr", "gix-path", "libc", @@ -1310,7 +1375,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90181472925b587f6079698f79065ff64786e6d6c14089517a1972bca99fb6e9" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bstr", "gix-features", "gix-path", @@ -1358,7 +1423,7 @@ version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b38e919efd59cb8275d23ad2394b2ab9d002007b27620e145d866d546403b665" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bstr", "filetime", "fnv", @@ -1375,7 +1440,7 @@ dependencies = [ "itoa", "libc", "memmap2", - "rustix", + "rustix 1.0.7", "smallvec", "thiserror 2.0.12", ] @@ -1397,7 +1462,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e1ea901acc4d5b44553132a29e8697210cb0e739b2d9752d713072e9391e3c9" dependencies = [ - "bitflags", + "bitflags 2.9.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1514,7 +1579,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce061c50e5f8f7c830cacb3da3e999ae935e283ce8522249f0ce2256d110979d" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bstr", "gix-attributes", "gix-config-value", @@ -1532,7 +1597,7 @@ dependencies = [ "gix-command", "gix-config-value", "parking_lot", - "rustix", + "rustix 1.0.7", "thiserror 2.0.12", ] @@ -1614,7 +1679,7 @@ version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78d0b8e5cbd1c329e25383e088cb8f17439414021a643b30afa5146b71e3c65d" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bstr", "gix-commitgraph", "gix-date", @@ -1647,7 +1712,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0dabbc78c759ecc006b970339394951b2c8e1e38a37b072c105b80b84c308fd" dependencies = [ - "bitflags", + "bitflags 2.9.1", "gix-path", "libc", "windows-sys 0.59.0", @@ -1724,7 +1789,7 @@ version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8648172f85aca3d6e919c06504b7ac26baef54e04c55eb0100fa588c102cc33" dependencies = [ - "bitflags", + "bitflags 2.9.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1833,7 +1898,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.9.1", "ignore", "walkdir", ] @@ -2232,6 +2297,23 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.9.1", + "crossterm 0.25.0", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "io-close" version = "0.3.7" @@ -2365,7 +2447,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.9.1", "libc", "redox_syscall", ] @@ -2379,6 +2461,12 @@ dependencies = [ "zlib-rs", ] +[[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.9.4" @@ -2470,6 +2558,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -2477,6 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -2498,6 +2599,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nom" version = "7.1.3" @@ -2557,7 +2667,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -2898,7 +3008,7 @@ checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.9.1", "lazy_static", "num-traits", "rand 0.9.1", @@ -3080,7 +3190,7 @@ version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] @@ -3275,16 +3385,29 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "781442f29170c5c93b7185ad559492601acdc71d5bb0706f5868094f45cfcd08" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags", + "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -3435,7 +3558,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3448,7 +3571,7 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3614,6 +3737,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "mio 1.0.4", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3750,12 +3904,14 @@ dependencies = [ "clap", "colored", "crossbeam", + "crossterm 0.28.1", "dashmap", "dirs", "env_logger", "futures-util", "glob", "indicatif", + "inquire", "log", "memmap2", "num_cpus", @@ -3804,7 +3960,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3854,7 +4010,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -3967,6 +4123,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.41" @@ -4031,7 +4196,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.0.4", "pin-project-lite", "socket2 0.6.0", "tokio-macros", @@ -4203,7 +4368,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.9.1", "bytes", "futures-util", "http", @@ -4729,6 +4894,15 @@ dependencies = [ "windows-link 0.1.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4765,6 +4939,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4797,6 +4986,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4809,6 +5004,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4821,6 +5022,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4845,6 +5052,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4857,6 +5070,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4869,6 +5088,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4881,6 +5106,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4917,7 +5148,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 847804e0..04cc2879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ rayon = "1.7" termcolor = "1" chrono = { version = "0.4", features = ["serde"] } colored = "3" +crossterm = "0.28" # Terminal raw mode for interactive input +inquire = "0.7" # Interactive terminal prompts with autocomplete prettytable = "0.10" term_size = "0.3" diff --git a/Screenshot 2025-12-16 at 21.12.14.png b/Screenshot 2025-12-16 at 21.12.14.png new file mode 100644 index 00000000..342262bf Binary files /dev/null and b/Screenshot 2025-12-16 at 21.12.14.png differ diff --git a/src/agent/commands.rs b/src/agent/commands.rs new file mode 100644 index 00000000..6ffe85a8 --- /dev/null +++ b/src/agent/commands.rs @@ -0,0 +1,416 @@ +//! Slash command definitions and interactive command picker +//! +//! Provides Gemini CLI-style "/" command system with: +//! - Interactive command picker when typing "/" +//! - Arrow key navigation +//! - Auto-complete on Enter +//! - Token usage tracking via /cost + +use crate::agent::ui::colors::ansi; +use crossterm::{ + cursor::{self, MoveTo, MoveUp, MoveToColumn}, + event::{self, Event, KeyCode, KeyEvent as CrosstermKeyEvent, KeyModifiers}, + execute, + terminal::{self, Clear, ClearType}, +}; +use std::io::{self, Write}; + +/// A slash command definition +#[derive(Clone)] +pub struct SlashCommand { + /// Command name (without the /) + pub name: &'static str, + /// Short alias (e.g., "m" for "model") + pub alias: Option<&'static str>, + /// Description shown in picker + pub description: &'static str, + /// Whether this command auto-executes on selection (vs. inserting text) + pub auto_execute: bool, +} + +/// All available slash commands +pub const SLASH_COMMANDS: &[SlashCommand] = &[ + SlashCommand { + name: "model", + alias: Some("m"), + description: "Select a different AI model", + auto_execute: true, + }, + SlashCommand { + name: "provider", + alias: Some("p"), + description: "Switch provider (OpenAI/Anthropic)", + auto_execute: true, + }, + SlashCommand { + name: "cost", + alias: None, + description: "Show token usage and estimated cost", + auto_execute: true, + }, + SlashCommand { + name: "clear", + alias: Some("c"), + description: "Clear conversation history", + auto_execute: true, + }, + SlashCommand { + name: "help", + alias: Some("h"), + description: "Show available commands", + auto_execute: true, + }, + SlashCommand { + name: "exit", + alias: Some("q"), + description: "Exit the chat", + auto_execute: true, + }, +]; + +/// Token usage statistics for /cost command +#[derive(Debug, Default, Clone)] +pub struct TokenUsage { + /// Total prompt/input tokens + pub prompt_tokens: u64, + /// Total completion/output tokens + pub completion_tokens: u64, + /// Number of requests made + pub request_count: u64, + /// Session start time + pub session_start: Option, +} + +impl TokenUsage { + pub fn new() -> Self { + Self { + session_start: Some(std::time::Instant::now()), + ..Default::default() + } + } + + /// Add tokens from a request + pub fn add_request(&mut self, prompt: u64, completion: u64) { + self.prompt_tokens += prompt; + self.completion_tokens += completion; + self.request_count += 1; + } + + /// Estimate token count from text (rough approximation: ~4 chars per token) + pub fn estimate_tokens(text: &str) -> u64 { + (text.len() as f64 / 4.0).ceil() as u64 + } + + /// Get total tokens + pub fn total_tokens(&self) -> u64 { + self.prompt_tokens + self.completion_tokens + } + + /// Get session duration + pub fn session_duration(&self) -> std::time::Duration { + self.session_start + .map(|start| start.elapsed()) + .unwrap_or_default() + } + + /// Estimate cost based on model (rough estimates in USD) + /// Returns (input_cost, output_cost, total_cost) + pub fn estimate_cost(&self, model: &str) -> (f64, f64, f64) { + // Pricing per 1M tokens (as of Dec 2025, approximate) + let (input_per_m, output_per_m) = match model { + m if m.starts_with("gpt-5.2-mini") => (0.15, 0.60), + m if m.starts_with("gpt-5") => (2.50, 10.00), + m if m.starts_with("gpt-4o") => (2.50, 10.00), + m if m.starts_with("o1") => (15.00, 60.00), + m if m.contains("sonnet") => (3.00, 15.00), + m if m.contains("opus") => (15.00, 75.00), + m if m.contains("haiku") => (0.25, 1.25), + _ => (2.50, 10.00), // Default to GPT-4o pricing + }; + + let input_cost = (self.prompt_tokens as f64 / 1_000_000.0) * input_per_m; + let output_cost = (self.completion_tokens as f64 / 1_000_000.0) * output_per_m; + + (input_cost, output_cost, input_cost + output_cost) + } + + /// Print cost report + pub fn print_report(&self, model: &str) { + let duration = self.session_duration(); + let (input_cost, output_cost, total_cost) = self.estimate_cost(model); + + println!(); + println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET); + println!(" {}💰 Session Cost & Usage{}", ansi::PURPLE, ansi::RESET); + println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET); + println!(); + println!(" {}Model:{} {}", ansi::DIM, ansi::RESET, model); + println!(" {}Duration:{} {:02}:{:02}:{:02}", + ansi::DIM, ansi::RESET, + duration.as_secs() / 3600, + (duration.as_secs() % 3600) / 60, + duration.as_secs() % 60 + ); + println!(" {}Requests:{} {}", ansi::DIM, ansi::RESET, self.request_count); + println!(); + println!(" {}Tokens:{}", ansi::CYAN, ansi::RESET); + println!(" Input: {:>10} tokens", self.prompt_tokens); + println!(" Output: {:>10} tokens", self.completion_tokens); + println!(" {}Total: {:>10} tokens{}", ansi::BOLD, self.total_tokens(), ansi::RESET); + println!(); + println!(" {}Estimated Cost:{}", ansi::SUCCESS, ansi::RESET); + println!(" Input: ${:.4}", input_cost); + println!(" Output: ${:.4}", output_cost); + println!(" {}Total: ${:.4}{}", ansi::BOLD, total_cost, ansi::RESET); + println!(); + println!(" {}(Estimates based on public API pricing){}", ansi::DIM, ansi::RESET); + println!(); + } +} + +/// Interactive command picker state +pub struct CommandPicker { + /// Current filter text (after the /) + pub filter: String, + /// Currently selected index + pub selected_index: usize, + /// Filtered commands + pub filtered_commands: Vec<&'static SlashCommand>, +} + +impl CommandPicker { + pub fn new() -> Self { + Self { + filter: String::new(), + selected_index: 0, + filtered_commands: SLASH_COMMANDS.iter().collect(), + } + } + + /// Update filter and refresh filtered commands + pub fn set_filter(&mut self, filter: &str) { + self.filter = filter.to_lowercase(); + self.filtered_commands = SLASH_COMMANDS + .iter() + .filter(|cmd| { + cmd.name.starts_with(&self.filter) || + cmd.alias.map(|a| a.starts_with(&self.filter)).unwrap_or(false) + }) + .collect(); + + // Reset selection if out of bounds + if self.selected_index >= self.filtered_commands.len() { + self.selected_index = 0; + } + } + + /// Move selection up + pub fn move_up(&mut self) { + if !self.filtered_commands.is_empty() && self.selected_index > 0 { + self.selected_index -= 1; + } + } + + /// Move selection down + pub fn move_down(&mut self) { + if !self.filtered_commands.is_empty() && self.selected_index < self.filtered_commands.len() - 1 { + self.selected_index += 1; + } + } + + /// Get currently selected command + pub fn selected_command(&self) -> Option<&'static SlashCommand> { + self.filtered_commands.get(self.selected_index).copied() + } + + /// Render the picker suggestions below current line + pub fn render_suggestions(&self) -> usize { + let mut stdout = io::stdout(); + + if self.filtered_commands.is_empty() { + println!("\n {}No matching commands{}", ansi::DIM, ansi::RESET); + let _ = stdout.flush(); + return 1; + } + + for (i, cmd) in self.filtered_commands.iter().enumerate() { + let is_selected = i == self.selected_index; + + if is_selected { + // Selected item - highlighted with arrow + println!(" {}▸ /{:<15}{} {}{}{}", + ansi::PURPLE, cmd.name, ansi::RESET, + ansi::PURPLE, cmd.description, ansi::RESET); + } else { + // Normal item - dimmed + println!(" {} /{:<15} {}{}", + ansi::DIM, cmd.name, cmd.description, ansi::RESET); + } + } + + let _ = stdout.flush(); + self.filtered_commands.len() + } + + /// Clear n lines above cursor + pub fn clear_lines(&self, num_lines: usize) { + let mut stdout = io::stdout(); + for _ in 0..num_lines { + let _ = execute!(stdout, MoveUp(1), Clear(ClearType::CurrentLine)); + } + let _ = stdout.flush(); + } +} + +/// Show interactive command picker and return selected command +/// This is called when user types "/" - shows suggestions immediately +/// Returns None if cancelled, Some(command_name) if selected +pub fn show_command_picker(initial_filter: &str) -> Option { + let mut picker = CommandPicker::new(); + picker.set_filter(initial_filter); + + // Enable raw mode for real-time key handling + if terminal::enable_raw_mode().is_err() { + // Fallback to simple mode if raw mode fails + return show_simple_picker(&picker); + } + + let mut stdout = io::stdout(); + let mut input_buffer = format!("/{}", initial_filter); + let mut last_rendered_lines = 0; + + // Initial render + println!(); // Move to new line for suggestions + last_rendered_lines = picker.render_suggestions(); + + // Move back up to input line and position cursor + let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1), MoveToColumn(0)); + print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer); + let _ = stdout.flush(); + + // Move down to after suggestions + let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1)); + + let result = loop { + // Wait for key event + if let Ok(Event::Key(key_event)) = event::read() { + match key_event.code { + KeyCode::Esc => { + // Cancel + break None; + } + KeyCode::Enter => { + // Select current + if let Some(cmd) = picker.selected_command() { + break Some(cmd.name.to_string()); + } + break None; + } + KeyCode::Up => { + picker.move_up(); + } + KeyCode::Down => { + picker.move_down(); + } + KeyCode::Backspace => { + if input_buffer.len() > 1 { + input_buffer.pop(); + let filter = input_buffer.trim_start_matches('/'); + picker.set_filter(filter); + } else { + // Backspace on just "/" - cancel + break None; + } + } + KeyCode::Char(c) => { + // Add character to filter + input_buffer.push(c); + let filter = input_buffer.trim_start_matches('/'); + picker.set_filter(filter); + + // If there's an exact match and user typed enough, auto-select + if picker.filtered_commands.len() == 1 { + // Perfect match - could auto-complete + } + } + KeyCode::Tab => { + // Tab to auto-complete current selection + if let Some(cmd) = picker.selected_command() { + break Some(cmd.name.to_string()); + } + } + _ => {} + } + + // Clear old suggestions and re-render + picker.clear_lines(last_rendered_lines); + + // Re-render input line + let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0)); + print!("{}You: {}{}", ansi::SUCCESS, ansi::RESET, input_buffer); + let _ = stdout.flush(); + + // Render suggestions below + println!(); + last_rendered_lines = picker.render_suggestions(); + + // Move back to input line position + let _ = execute!(stdout, MoveUp(last_rendered_lines as u16 + 1)); + let _ = execute!(stdout, MoveToColumn((5 + input_buffer.len()) as u16)); + let _ = stdout.flush(); + + // Move down to after suggestions for next iteration + let _ = execute!(stdout, cursor::MoveDown(last_rendered_lines as u16 + 1)); + } + }; + + // Disable raw mode + let _ = terminal::disable_raw_mode(); + + // Clean up display + picker.clear_lines(last_rendered_lines); + let _ = execute!(stdout, Clear(ClearType::CurrentLine), MoveToColumn(0)); + let _ = stdout.flush(); + + result +} + +/// Fallback simple picker when raw mode is not available +fn show_simple_picker(picker: &CommandPicker) -> Option { + println!(); + println!(" {}📋 Available Commands:{}", ansi::CYAN, ansi::RESET); + println!(); + + for (i, cmd) in picker.filtered_commands.iter().enumerate() { + print!(" {} {}/{:<12}", format!("[{}]", i + 1), ansi::PURPLE, cmd.name); + if let Some(alias) = cmd.alias { + print!(" ({})", alias); + } + println!("{} - {}{}{}", ansi::RESET, ansi::DIM, cmd.description, ansi::RESET); + } + + println!(); + print!(" Select (1-{}) or press Enter to cancel: ", picker.filtered_commands.len()); + let _ = io::stdout().flush(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() { + let input = input.trim(); + if let Ok(num) = input.parse::() { + if num >= 1 && num <= picker.filtered_commands.len() { + return Some(picker.filtered_commands[num - 1].name.to_string()); + } + } + } + + None +} + +/// Check if a command matches a query (name or alias) +pub fn match_command(query: &str) -> Option<&'static SlashCommand> { + let query = query.trim_start_matches('/').to_lowercase(); + + SLASH_COMMANDS.iter().find(|cmd| { + cmd.name == query || cmd.alias.map(|a| a == query).unwrap_or(false) + }) +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 47942d74..be9468ff 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -24,6 +24,7 @@ //! - `/clear` - Clear conversation history //! - `/exit` - Exit the chat +pub mod commands; pub mod session; pub mod tools; pub mod ui; @@ -35,6 +36,7 @@ use rig::{ providers::{anthropic, openai}, }; use session::ChatSession; +use commands::TokenUsage; use std::path::Path; use std::sync::Arc; use ui::{ResponseFormatter, Spinner, ToolDisplayHook, spawn_tool_display_handler}; @@ -228,6 +230,12 @@ pub async fn run_interactive( // Stop spinner and show beautifully formatted response spinner.stop().await; ResponseFormatter::print_response(&text); + + // Track token usage (estimate since Rig doesn't expose exact counts) + let prompt_tokens = TokenUsage::estimate_tokens(&input); + let completion_tokens = TokenUsage::estimate_tokens(&text); + session.token_usage.add_request(prompt_tokens, completion_tokens); + session.history.push(("user".to_string(), input)); session.history.push(("assistant".to_string(), text)); } diff --git a/src/agent/session.rs b/src/agent/session.rs index d2a7d0d0..e9a56a06 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -3,13 +3,17 @@ //! Provides a rich REPL experience similar to Claude Code with: //! - `/model` - Select from available models based on configured API keys //! - `/provider` - Switch provider (prompts for API key if not set) +//! - `/cost` - Show token usage and estimated cost //! - `/help` - Show available commands //! - `/clear` - Clear conversation history //! - `/exit` or `/quit` - Exit the session +use crate::agent::commands::{TokenUsage, SLASH_COMMANDS}; use crate::agent::{AgentError, AgentResult, ProviderType}; +use crate::agent::ui::{SlashCommandAutocomplete, ansi}; use crate::config::{load_agent_config, save_agent_config}; use colored::Colorize; +use inquire::Text; use std::io::{self, Write}; use std::path::Path; @@ -39,6 +43,7 @@ pub struct ChatSession { pub model: String, pub project_path: std::path::PathBuf, pub history: Vec<(String, String)>, // (role, content) + pub token_usage: TokenUsage, } impl ChatSession { @@ -53,6 +58,7 @@ impl ChatSession { model: model.unwrap_or(default_model), project_path: project_path.to_path_buf(), history: Vec::new(), + token_usage: TokenUsage::new(), } } @@ -258,15 +264,21 @@ impl ChatSession { /// Handle /help command pub fn print_help() { println!(); - println!("{}", "📖 Available Commands:".cyan().bold()); + println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET); + println!(" {}📖 Available Commands{}", ansi::PURPLE, ansi::RESET); + println!(" {}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}", ansi::PURPLE, ansi::RESET); println!(); - println!(" {} - Select a different AI model", "/model".white().bold()); - println!(" {} - Switch provider (OpenAI/Anthropic)", "/provider".white().bold()); - println!(" {} - Clear conversation history", "/clear".white().bold()); - println!(" {} - Show this help message", "/help".white().bold()); - println!(" {} - Exit the chat", "/exit".white().bold()); + + for cmd in SLASH_COMMANDS.iter() { + let alias = cmd.alias.map(|a| format!(" ({})", a)).unwrap_or_default(); + println!(" {}/{:<12}{}{} - {}{}{}", + ansi::CYAN, cmd.name, alias, ansi::RESET, + ansi::DIM, cmd.description, ansi::RESET + ); + } + println!(); - println!("{}", "Just type your message and press Enter to chat!".dimmed()); + println!(" {}Tip: Type / to see interactive command picker!{}", ansi::DIM, ansi::RESET); println!(); } @@ -343,6 +355,13 @@ impl ChatSession { pub fn process_command(&mut self, input: &str) -> AgentResult { let cmd = input.trim().to_lowercase(); + // Handle bare "/" - now handled interactively in read_input + // Just show help if they somehow got here + if cmd == "/" { + Self::print_help(); + return Ok(true); + } + match cmd.as_str() { "/exit" | "/quit" | "/q" => { println!("\n{}", "👋 Goodbye!".green()); @@ -357,12 +376,16 @@ impl ChatSession { "/provider" | "/p" => { self.handle_provider_command()?; } + "/cost" => { + self.token_usage.print_report(&self.model); + } "/clear" | "/c" => { self.history.clear(); println!("{}", "✓ Conversation history cleared".green()); } _ => { if cmd.starts_with('/') { + // Unknown command - interactive picker already handled in read_input println!("{}", format!("Unknown command: {}. Type /help for available commands.", cmd).yellow()); } } @@ -376,13 +399,31 @@ impl ChatSession { input.trim().starts_with('/') } - /// Read user input with prompt + /// Read user input with prompt - with interactive slash command support + /// Uses `inquire` library for proper terminal handling and autocomplete pub fn read_input(&self) -> io::Result { - print!("{}", "You: ".green().bold()); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(input.trim().to_string()) + // Use inquire::Text with custom autocomplete for slash commands + let input = Text::new("You:") + .with_autocomplete(SlashCommandAutocomplete::new()) + .with_help_message("Type / for commands, or ask a question") + .prompt(); + + match input { + Ok(text) => { + let trimmed = text.trim(); + // Handle case where full suggestion was submitted (e.g., "/model Description") + // Extract just the command if it looks like a suggestion format + if trimmed.starts_with('/') && trimmed.contains(" ") { + // This looks like a suggestion format, extract just the command + if let Some(cmd) = trimmed.split_whitespace().next() { + return Ok(cmd.to_string()); + } + } + Ok(trimmed.to_string()) + } + Err(inquire::InquireError::OperationCanceled) => Ok("exit".to_string()), + Err(inquire::InquireError::OperationInterrupted) => Ok("exit".to_string()), + Err(e) => Err(io::Error::new(io::ErrorKind::Other, e.to_string())), + } } } diff --git a/src/agent/ui/autocomplete.rs b/src/agent/ui/autocomplete.rs new file mode 100644 index 00000000..f7e1edde --- /dev/null +++ b/src/agent/ui/autocomplete.rs @@ -0,0 +1,72 @@ +//! Autocomplete support for slash commands using inquire +//! +//! Provides a custom Autocomplete implementation that shows +//! slash command suggestions as the user types. + +use inquire::autocompletion::{Autocomplete, Replacement}; +use crate::agent::commands::SLASH_COMMANDS; + +/// Autocomplete provider for slash commands +/// Shows suggestions when user types "/" followed by characters +#[derive(Clone, Default)] +pub struct SlashCommandAutocomplete { + /// Cache of filtered commands for current input + filtered_commands: Vec<&'static str>, +} + +impl SlashCommandAutocomplete { + pub fn new() -> Self { + Self { + filtered_commands: Vec::new(), + } + } +} + +impl Autocomplete for SlashCommandAutocomplete { + fn get_suggestions(&mut self, input: &str) -> Result, inquire::CustomUserError> { + // Only show suggestions when input starts with / + if !input.starts_with('/') { + self.filtered_commands.clear(); + return Ok(vec![]); + } + + let filter = input.trim_start_matches('/').to_lowercase(); + + // Store the command names for use in get_completion + self.filtered_commands = SLASH_COMMANDS.iter() + .filter(|cmd| { + cmd.name.to_lowercase().starts_with(&filter) || + cmd.alias.map(|a| a.to_lowercase().starts_with(&filter)).unwrap_or(false) + }) + .take(6) + .map(|cmd| cmd.name) + .collect(); + + // Return formatted suggestions for display + let suggestions: Vec = SLASH_COMMANDS.iter() + .filter(|cmd| { + cmd.name.to_lowercase().starts_with(&filter) || + cmd.alias.map(|a| a.to_lowercase().starts_with(&filter)).unwrap_or(false) + }) + .take(6) + .map(|cmd| format!("/{:<12} {}", cmd.name, cmd.description)) + .collect(); + + Ok(suggestions) + } + + fn get_completion( + &mut self, + _input: &str, + highlighted_suggestion: Option, + ) -> Result { + if let Some(suggestion) = highlighted_suggestion { + // Extract just the command name - first word after the / + // Format is: "/model Select a different AI model" + if let Some(cmd_with_slash) = suggestion.split_whitespace().next() { + return Ok(Replacement::Some(cmd_with_slash.to_string())); + } + } + Ok(Replacement::None) + } +} diff --git a/src/agent/ui/mod.rs b/src/agent/ui/mod.rs index 079efe67..f8e88a02 100644 --- a/src/agent/ui/mod.rs +++ b/src/agent/ui/mod.rs @@ -8,6 +8,7 @@ //! - Thinking/reasoning indicators //! - Elapsed time tracking +pub mod autocomplete; pub mod colors; pub mod hooks; pub mod response; @@ -15,6 +16,7 @@ pub mod spinner; pub mod streaming; pub mod tool_display; +pub use autocomplete::*; pub use colors::*; pub use hooks::*; pub use response::*; diff --git a/src/agent/ui/spinner.rs b/src/agent/ui/spinner.rs index 8381b5a2..bc8eebe3 100644 --- a/src/agent/ui/spinner.rs +++ b/src/agent/ui/spinner.rs @@ -154,6 +154,7 @@ async fn run_spinner( let mut phrase_index = 0; let mut current_tool: Option = None; let mut tools_completed: usize = 0; + let mut has_printed_tool_line = false; let mut interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS)); let mut rng = StdRng::from_entropy(); @@ -172,7 +173,7 @@ async fn run_spinner( let frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.len()]; frame_index += 1; - // Cycle phrases every PHRASE_CHANGE_INTERVAL_SECS if not showing tool activity + // Cycle phrases if idle if current_tool.is_none() && last_phrase_change.elapsed().as_secs() >= PHRASE_CHANGE_INTERVAL_SECS { if rng.gen_bool(0.25) && !TIPS.is_empty() { let tip_idx = rng.gen_range(0..TIPS.len()); @@ -184,43 +185,44 @@ async fn run_spinner( last_phrase_change = Instant::now(); } - // Build compact single-line display - let display = if let Some(ref tool) = current_tool { - // Currently executing a tool - if tools_completed > 0 { - format!("{}{}{} {}✓{}{} {}🔧 {}{} {}", - ansi::CYAN, frame, ansi::RESET, - ansi::SUCCESS, tools_completed, ansi::RESET, - ansi::PURPLE, tool, ansi::RESET, - current_text) - } else { - format!("{}{}{} {}🔧 {}{} {}", - ansi::CYAN, frame, ansi::RESET, - ansi::PURPLE, tool, ansi::RESET, - current_text) + if has_printed_tool_line { + // Move up to tool line, update it, move back down to spinner line + if let Some(ref tool) = current_tool { + print!("{}{} {}🔧 {}{}{}", + ansi::CURSOR_UP, + ansi::CLEAR_LINE, + ansi::PURPLE, + tool, + ansi::RESET, + "\n" // Move back down + ); } - } else if tools_completed > 0 { - // Between tools, show completed count - format!("{}{}{} {}✓{}{} {}", - ansi::CYAN, frame, ansi::RESET, - ansi::SUCCESS, tools_completed, ansi::RESET, - current_text) + // Now update spinner line + print!("\r{} {}{}{} {} {}{}({}){}", + ansi::CLEAR_LINE, + ansi::CYAN, + frame, + ansi::RESET, + current_text, + ansi::GRAY, + ansi::DIM, + format_elapsed(elapsed), + ansi::RESET + ); } else { - // Initial state, just thinking - format!("{}{}{} {}", - ansi::CYAN, frame, ansi::RESET, - current_text) - }; - - // Update the SAME line (no newlines!) - print!("\r{}{} {}{}({}){}", - ansi::CLEAR_LINE, - display, - ansi::GRAY, - ansi::DIM, - format_elapsed(elapsed), - ansi::RESET - ); + // Single line mode (no tool yet) + print!("\r{} {}{}{} {} {}{}({}){}", + ansi::CLEAR_LINE, + ansi::CYAN, + frame, + ansi::RESET, + current_text, + ansi::GRAY, + ansi::DIM, + format_elapsed(elapsed), + ansi::RESET + ); + } let _ = io::stdout().flush(); } Some(msg) = receiver.recv() => { @@ -229,6 +231,18 @@ async fn run_spinner( current_text = text; } SpinnerMessage::ToolExecuting { name, description } => { + if !has_printed_tool_line { + // First tool - print tool line then newline for spinner + print!("\r{} {}🔧 {}{}{}\n", + ansi::CLEAR_LINE, + ansi::PURPLE, + name, + ansi::RESET, + "" // Spinner will be on next line + ); + has_printed_tool_line = true; + } + // Tool line will be updated on next tick current_tool = Some(name); current_text = description; last_phrase_change = Instant::now(); @@ -241,7 +255,6 @@ async fn run_spinner( } SpinnerMessage::Thinking(subject) => { current_text = format!("💭 {}", subject); - current_tool = None; } SpinnerMessage::Stop => { is_running.store(false, Ordering::SeqCst); @@ -252,9 +265,17 @@ async fn run_spinner( } } - // Clear the spinner line and show cursor - // Optionally print a summary if tools were used - print!("\r{}", ansi::CLEAR_LINE); + // Clear both lines and show summary + if has_printed_tool_line { + // Clear spinner line + print!("\r{}", ansi::CLEAR_LINE); + // Move up and clear tool line + print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE); + } else { + print!("\r{}", ansi::CLEAR_LINE); + } + + // Print summary if tools_completed > 0 { println!(" {}✓{} {} tool{} used", ansi::SUCCESS, ansi::RESET,