diff --git a/Cargo.lock b/Cargo.lock index 63a011b329..4b05768a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,7 +986,7 @@ dependencies = [ "httparse", "hyper 0.14.32", "hyper-rustls 0.24.2", - "indexmap 2.7.0", + "indexmap 2.9.0", "once_cell", "pin-project-lite", "pin-utils", @@ -1114,6 +1114,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bincode" version = "1.3.3" @@ -1449,16 +1455,18 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "pure-rust-locales", "serde", - "windows-targets 0.52.6", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -1558,8 +1566,10 @@ version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ + "anstyle", "heck 0.5.0", "proc-macro2", + "pulldown-cmark", "quote", "syn 2.0.100", ] @@ -1888,6 +1898,19 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1916,6 +1939,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -2001,6 +2033,41 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -2039,6 +2106,16 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "defer-drop" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f613ec9fa66a6b28cdb1842b27f9adf24f39f9afc4dcdd9fdecee4aca7945c57" +dependencies = [ + "crossbeam-channel", + "once_cell", +] + [[package]] name = "deranged" version = "0.3.11" @@ -2060,6 +2137,37 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.100", +] + [[package]] name = "derive_more" version = "0.99.18" @@ -2119,6 +2227,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -2131,6 +2249,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2294,6 +2423,35 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -2420,7 +2578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.2", + "rustix 1.0.5", "windows-sys 0.59.0", ] @@ -3742,7 +3900,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.7.0", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -3761,7 +3919,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.2.0", - "indexmap 2.7.0", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -4198,6 +4356,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -4276,9 +4440,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -4466,6 +4630,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f33145a5cbea837164362c7bd596106eb7c5198f97d1ba6f6ebb3223952e488" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ce13c40ec6956157a3635d97a1ee2df323b263f09ea14165131289cb0f5c19" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "jni" version = "0.21.1" @@ -5097,6 +5285,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.25.1" @@ -5323,7 +5522,7 @@ dependencies = [ "dirs-sys", "fancy-regex", "heck 0.5.0", - "indexmap 2.7.0", + "indexmap 2.9.0", "log", "lru", "memchr", @@ -5986,7 +6185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.7.0", + "indexmap 2.9.0", ] [[package]] @@ -6137,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" dependencies = [ "base64 0.22.1", - "indexmap 2.7.0", + "indexmap 2.9.0", "quick-xml 0.32.0", "serde", "time", @@ -6211,6 +6410,15 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -6385,9 +6593,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] @@ -6540,6 +6748,17 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.9.0", + "memchr", + "unicase", +] + [[package]] name = "pure-rust-locales" version = "0.8.1" @@ -6627,6 +6846,7 @@ dependencies = [ "shell-color", "shlex", "similar", + "skim", "spinners", "syntect", "sysinfo 0.32.1", @@ -7267,9 +7487,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.2" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ "bitflags 2.9.0", "errno", @@ -7581,7 +7801,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.9.0", "itoa 1.0.14", "memchr", "ryu", @@ -7635,7 +7855,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.9.0", "itoa 1.0.14", "ryu", "serde", @@ -7745,6 +7965,15 @@ dependencies = [ "nu-color-config", ] +[[package]] +name = "shell-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb502615975ae2365825521fa1529ca7648fd03ce0b0746604e0683856ecd7e4" +dependencies = [ + "bstr", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -7823,6 +8052,37 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "skim" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0735e2a3c31d1b0742df1f624da11492d5ffe34aec5d027030e54eb1b70704bb" +dependencies = [ + "beef", + "bitflags 1.3.2", + "chrono", + "clap", + "crossbeam", + "defer-drop", + "derive_builder", + "env_logger", + "fuzzy-matcher", + "indexmap 2.9.0", + "log", + "nix 0.29.0", + "rand 0.9.0", + "rayon", + "regex", + "shell-quote", + "shlex", + "time", + "timer", + "tuikit", + "unicode-width 0.2.0", + "vte 0.15.0", + "which 7.0.3", +] + [[package]] name = "slab" version = "0.4.9" @@ -8255,7 +8515,7 @@ dependencies = [ "fastrand", "getrandom 0.3.1", "once_cell", - "rustix 1.0.2", + "rustix 1.0.5", "windows-sys 0.59.0", ] @@ -8270,6 +8530,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "terminal_size" version = "0.4.1" @@ -8426,6 +8697,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "timer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31d42176308937165701f50638db1c31586f183f1aab416268216577aec7306b" +dependencies = [ + "chrono", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -8598,7 +8878,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.9.0", "toml_datetime", "winnow 0.5.40", ] @@ -8609,7 +8889,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.9.0", "toml_datetime", "winnow 0.5.40", ] @@ -8620,7 +8900,7 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.7.0", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -8813,6 +9093,20 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tuikit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e19c6ab038babee3d50c8c12ff8b910bdb2196f62278776422f50390d8e53d8" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "log", + "nix 0.24.3", + "term", + "unicode-width 0.1.14", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -9397,6 +9691,18 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.0.5", + "winsafe", +] + [[package]] name = "whoami" version = "1.5.2" diff --git a/crates/q_cli/Cargo.toml b/crates/q_cli/Cargo.toml index eb3244c041..43ea84785f 100644 --- a/crates/q_cli/Cargo.toml +++ b/crates/q_cli/Cargo.toml @@ -35,7 +35,7 @@ clap_complete_fig = "4.4.0" color-eyre = "0.6.2" color-print = "0.3.5" convert_case.workspace = true -crossterm = { version = "0.28.1", features = ["event-stream"] } +crossterm = { version = "0.28.1", features = ["event-stream", "events"] } ctrlc = "3.4.6" dialoguer = { version = "0.11.0", features = ["fuzzy-select"] } eyre = "0.6.8" @@ -64,7 +64,8 @@ owo-colors = "4.2.0" parking_lot.workspace = true rand.workspace = true regex.workspace = true -rustyline = { version = "15.0.0", features = ["derive"] } +rustyline = { version = "15.0.0", features = ["derive", "custom-bindings"] } +skim = "0.16.1" semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/q_cli/src/cli/chat/input_source.rs b/crates/q_cli/src/cli/chat/input_source.rs index 59ba13196a..dbf9c5b1e0 100644 --- a/crates/q_cli/src/cli/chat/input_source.rs +++ b/crates/q_cli/src/cli/chat/input_source.rs @@ -1,6 +1,14 @@ +use std::sync::Arc; + use eyre::Result; use rustyline::error::ReadlineError; +use rustyline::{ + EventHandler, + KeyEvent, +}; +use super::skim_integration::SkimCommandSelector; +use crate::cli::chat::context::ContextManager; use crate::cli::chat::prompt::rl; #[derive(Debug)] @@ -28,6 +36,15 @@ impl InputSource { Ok(Self(inner::Inner::Readline(rl()?))) } + pub fn put_skim_command_selector(&mut self, context_manager: Arc) { + if let inner::Inner::Readline(rl) = &mut self.0 { + rl.bind_sequence( + KeyEvent::ctrl('k'), + EventHandler::Conditional(Box::new(SkimCommandSelector::new(context_manager))), + ); + } + } + #[allow(dead_code)] pub fn new_mock(lines: Vec) -> Self { Self(inner::Inner::Mock { index: 0, lines }) diff --git a/crates/q_cli/src/cli/chat/mod.rs b/crates/q_cli/src/cli/chat/mod.rs index 25347c09d8..7034e59a74 100644 --- a/crates/q_cli/src/cli/chat/mod.rs +++ b/crates/q_cli/src/cli/chat/mod.rs @@ -6,6 +6,7 @@ mod input_source; mod parse; mod parser; mod prompt; +mod skim_integration; mod summarization_state; mod tools; @@ -161,6 +162,7 @@ const WELCOME_TEXT: &str = color_print::cstr! {" /quit Quit the application Use Ctrl(^) + j to provide multi-line prompts. +Use Ctrl(^) + k to fuzzily search commands and context (use tab to select multiple files). "}; @@ -733,6 +735,14 @@ where )?; } + // Do this here so that the skim integration sees an updated view of the context *during the current + // q session*. (e.g., if I add files to context, that won't show up for skim for the current + // q session unless we do this in prompt_user... unless you can find a better way) + if let Some(ref context_manager) = self.conversation_state.context_manager { + self.input_source + .put_skim_command_selector(Arc::new(context_manager.clone())); + } + let user_input = match self.read_user_input(&self.generate_tool_trust_prompt(), false) { Some(input) => input, None => return Ok(ChatState::Exit), @@ -2359,7 +2369,7 @@ fn create_stream(model_responses: serde_json::Value) -> StreamingClient { } /// Returns all tools supported by Q chat. -fn load_tools() -> Result> { +pub fn load_tools() -> Result> { Ok(serde_json::from_str(include_str!("tools/tool_index.json"))?) } diff --git a/crates/q_cli/src/cli/chat/prompt.rs b/crates/q_cli/src/cli/chat/prompt.rs index 2be0e1828b..710c1e5c60 100644 --- a/crates/q_cli/src/cli/chat/prompt.rs +++ b/crates/q_cli/src/cli/chat/prompt.rs @@ -35,7 +35,7 @@ use rustyline::{ }; use winnow::stream::AsChar; -const COMMANDS: &[&str] = &[ +pub const COMMANDS: &[&str] = &[ "/clear", "/help", "/editor", diff --git a/crates/q_cli/src/cli/chat/skim_integration.rs b/crates/q_cli/src/cli/chat/skim_integration.rs new file mode 100644 index 0000000000..bb0ae613bb --- /dev/null +++ b/crates/q_cli/src/cli/chat/skim_integration.rs @@ -0,0 +1,365 @@ +use std::io::{ + BufReader, + Cursor, + Write, + stdout, +}; + +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, + LeaveAlternateScreen, +}; +use eyre::{ + Result, + eyre, +}; +use rustyline::{ + Cmd, + ConditionalEventHandler, + EventContext, + RepeatCount, +}; +use skim::prelude::*; +use tempfile::NamedTempFile; + +use super::context::ContextManager; + +pub struct SkimCommandSelector { + context_manager: Arc, +} + +impl SkimCommandSelector { + /// This allows the ConditionalEventHandler handle function to be bound to a KeyEvent. + pub fn new(context_manager: Arc) -> Self { + Self { context_manager } + } +} + +impl ConditionalEventHandler for SkimCommandSelector { + fn handle( + &self, + _evt: &rustyline::Event, + _n: RepeatCount, + _positive: bool, + _ctx: &EventContext<'_>, + ) -> Option { + // Launch skim command selector with the context manager if available + match select_command(self.context_manager.as_ref()) { + Ok(Some(command)) => Some(Cmd::Insert(1, command)), + _ => { + // If cancelled or error, do nothing + Some(Cmd::Noop) + }, + } + } +} + +/// Load tool names from the tool_index.json file +fn load_tool_names() -> Result> { + let tool_specs = super::load_tools()?; + let tool_names: Vec = tool_specs.values().map(|spec| spec.name.clone()).collect(); + Ok(tool_names) +} + +pub fn get_available_commands() -> Vec { + // Import the COMMANDS array directly from prompt.rs + // This is the single source of truth for available commands + let commands_array = super::prompt::COMMANDS; + + let mut commands = Vec::new(); + for &cmd in commands_array { + commands.push(cmd.to_string()); + } + + commands +} + +/// Format commands for skim display +/// Create a standard set of skim options with consistent styling +fn create_skim_options(prompt: &str, multi: bool) -> Result { + SkimOptionsBuilder::default() + .height("100%".to_string()) + .prompt(prompt.to_string()) + .reverse(true) + .multi(multi) + .build() + .map_err(|e| eyre!("Failed to build skim options: {}", e)) +} + +/// Run skim with the given options and items in an alternate screen +/// This helper function handles entering/exiting the alternate screen and running skim +fn run_skim_with_options(options: &SkimOptions, items: SkimItemReceiver) -> Result>>> { + // Enter alternate screen to prevent skim output from persisting in terminal history + execute!(stdout(), EnterAlternateScreen).map_err(|e| eyre!("Failed to enter alternate screen: {}", e))?; + + let selected_items = + Skim::run_with(options, Some(items)).and_then(|out| if out.is_abort { None } else { Some(out.selected_items) }); + + execute!(stdout(), LeaveAlternateScreen).map_err(|e| eyre!("Failed to leave alternate screen: {}", e))?; + + Ok(selected_items) +} + +/// Extract string selections from skim items +fn extract_selections(items: Vec>) -> Vec { + items.iter().map(|item| item.output().to_string()).collect() +} + +/// Launch skim with the given items and return the selected item +pub fn launch_skim_selector(items: &[String], prompt: &str, multi: bool) -> Result>> { + let mut temp_file_for_skim_input = NamedTempFile::new()?; + temp_file_for_skim_input.write_all(items.join("\n").as_bytes())?; + + let options = create_skim_options(prompt, multi)?; + let item_reader = SkimItemReader::default(); + let items = item_reader.of_bufread(BufReader::new(std::fs::File::open(temp_file_for_skim_input.path())?)); + + // Run skim and get selected items + match run_skim_with_options(&options, items)? { + Some(items) if !items.is_empty() => { + let selections = extract_selections(items); + Ok(Some(selections)) + }, + _ => Ok(None), // User cancelled or no selection + } +} + +/// Select files using skim +pub fn select_files_with_skim() -> Result>> { + // Create skim options with appropriate settings + let options = create_skim_options("Select files: ", true)?; + + // Create a command that will be executed by skim + // This avoids loading all files into memory at once + let find_cmd = "find . -type f -not -path '*/\\.*'"; + + // Create a command collector that will execute the find command + let item_reader = SkimItemReader::default(); + let items = item_reader.of_bufread(BufReader::new( + std::process::Command::new("sh") + .args(["-c", find_cmd]) + .stdout(std::process::Stdio::piped()) + .spawn()? + .stdout + .ok_or_else(|| eyre!("Failed to get stdout from command"))?, + )); + + // Run skim with the command output as a stream + match run_skim_with_options(&options, items)? { + Some(items) if !items.is_empty() => { + let selections = extract_selections(items); + Ok(Some(selections)) + }, + _ => Ok(None), // User cancelled or no selection + } +} + +/// Select context paths using skim +pub fn select_context_paths_with_skim(context_manager: &ContextManager) -> Result, bool)>> { + let mut global_paths = Vec::new(); + let mut profile_paths = Vec::new(); + + // Get global paths + for path in &context_manager.global_config.paths { + global_paths.push(format!("(global) {}", path)); + } + + // Get profile-specific paths + for path in &context_manager.profile_config.paths { + profile_paths.push(format!("(profile: {}) {}", context_manager.current_profile, path)); + } + + // Combine paths, but keep track of which are global + let mut all_paths = Vec::new(); + all_paths.extend(global_paths); + all_paths.extend(profile_paths); + + if all_paths.is_empty() { + return Ok(None); // No paths to select + } + + // Create skim options + let options = create_skim_options("Select paths to remove: ", true)?; + + // Create item reader + let item_reader = SkimItemReader::default(); + let items = item_reader.of_bufread(Cursor::new(all_paths.join("\n"))); + + // Run skim and get selected paths + match run_skim_with_options(&options, items)? { + Some(items) if !items.is_empty() => { + let selected_paths = extract_selections(items); + + // Check if any global paths were selected + let has_global = selected_paths.iter().any(|p| p.starts_with("(global)")); + + // Extract the actual paths from the formatted strings + let paths: Vec = selected_paths + .iter() + .map(|p| { + // Extract the path part after the prefix + let parts: Vec<&str> = p.splitn(2, ") ").collect(); + if parts.len() > 1 { + parts[1].to_string() + } else { + p.clone() + } + }) + .collect(); + + Ok(Some((paths, has_global))) + }, + _ => Ok(None), // User cancelled selection + } +} + +/// Launch the command selector and handle the selected command +pub fn select_command(context_manager: &ContextManager) -> Result> { + let commands = get_available_commands(); + + match launch_skim_selector(&commands, "Select command: ", false)? { + Some(selections) if !selections.is_empty() => { + let selected_command = &selections[0]; + + match CommandType::from_str(selected_command) { + Some(CommandType::ContextAdd(cmd)) => { + // For context add commands, we need to select files + match select_files_with_skim()? { + Some(files) if !files.is_empty() => { + // Construct the full command with selected files + let mut cmd = cmd.clone(); + for file in files { + cmd.push_str(&format!(" {}", file)); + } + Ok(Some(cmd)) + }, + _ => Ok(Some(selected_command.clone())), /* User cancelled file selection, return just the + * command */ + } + }, + Some(CommandType::ContextRemove(cmd)) => { + // For context rm commands, we need to select from existing context paths + match select_context_paths_with_skim(context_manager)? { + Some((paths, has_global)) if !paths.is_empty() => { + // Construct the full command with selected paths + let mut full_cmd = cmd.clone(); + if has_global { + full_cmd.push_str(" --global"); + } + for path in paths { + full_cmd.push_str(&format!(" {}", path)); + } + Ok(Some(full_cmd)) + }, + Some((_, _)) => Ok(Some(format!("{} (No paths selected)", cmd))), + None => Ok(Some(selected_command.clone())), // User cancelled path selection + } + }, + Some(CommandType::Tools(_)) => { + // For tools trust/untrust, we need to select a tool + // Load tool names from the tool_index.json file + let tools = load_tool_names()?; + + let options = create_skim_options("Select tool: ", false)?; + let item_reader = SkimItemReader::default(); + let items = item_reader.of_bufread(Cursor::new(tools.join("\n"))); + let selected_tool = match run_skim_with_options(&options, items)? { + Some(items) if !items.is_empty() => Some(items[0].output().to_string()), + _ => None, + }; + + match selected_tool { + Some(tool) => Ok(Some(format!("{} {}", selected_command, tool))), + None => Ok(Some(selected_command.clone())), /* User cancelled tool selection, return just the + * command */ + } + }, + Some(CommandType::Profile(_)) => { + // For profile operations, we'd need to prompt for the name + // For now, just return the command and let the user type the name + Ok(Some(selected_command.clone())) + }, + None => { + // Command doesn't need additional parameters + Ok(Some(selected_command.clone())) + }, + } + }, + _ => Ok(None), // User cancelled command selection + } +} + +#[derive(PartialEq)] +enum CommandType { + ContextAdd(String), + ContextRemove(String), + Tools(&'static str), + Profile(&'static str), +} + +impl CommandType { + fn from_str(cmd: &str) -> Option { + if cmd.starts_with("/context add") { + Some(CommandType::ContextAdd(cmd.to_string())) + } else if cmd.starts_with("/context rm") { + Some(CommandType::ContextRemove(cmd.to_string())) + } else { + match cmd { + "/tools trust" => Some(CommandType::Tools("trust")), + "/tools untrust" => Some(CommandType::Tools("untrust")), + "/profile set" => Some(CommandType::Profile("set")), + "/profile delete" => Some(CommandType::Profile("delete")), + "/profile rename" => Some(CommandType::Profile("rename")), + "/profile create" => Some(CommandType::Profile("create")), + _ => None, + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + /// Test to verify that all hardcoded command strings in select_command + /// are present in the COMMANDS array from prompt.rs + #[test] + fn test_hardcoded_commands_in_commands_array() { + // Get the set of available commands from prompt.rs + let available_commands: HashSet = get_available_commands().iter().map(|cmd| cmd.clone()).collect(); + + // List of hardcoded commands used in select_command + let hardcoded_commands = vec![ + "/context add", + "/context add --global", + "/context rm", + "/context rm --global", + "/tools trust", + "/tools untrust", + "/profile set", + "/profile delete", + "/profile rename", + "/profile create", + ]; + + // Check that each hardcoded command is in the COMMANDS array + for cmd in hardcoded_commands { + assert!( + available_commands.contains(cmd), + "Command '{}' is used in select_command but not defined in COMMANDS array", + cmd + ); + + // This should assert that all the commands we assert are present in the match statement of + // select_command() + assert!( + CommandType::from_str(cmd).is_some(), + "Command '{}' cannot be parsed into a CommandType", + cmd + ); + } + } +}