diff --git a/Cargo.lock b/Cargo.lock index c88d744e0d..1aa06b79a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,7 +163,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec08254d61379df136135d3d1ac04301be7699fd7d9e57655c63ac7d650a6922" dependencies = [ - "derive_builder 0.20.2", + "derive_builder", "getrandom 0.3.4", "serde", "serde_json", @@ -948,7 +948,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] @@ -1343,6 +1343,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "filedescriptor", "mio", "parking_lot", "rustix 1.1.4", @@ -1395,16 +1396,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "darling" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" -dependencies = [ - "darling_core 0.14.4", - "darling_macro 0.14.4", -] - [[package]] name = "darling" version = "0.20.11" @@ -1435,20 +1426,6 @@ dependencies = [ "darling_macro 0.23.0", ] -[[package]] -name = "darling_core" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 1.0.109", -] - [[package]] name = "darling_core" version = "0.20.11" @@ -1459,7 +1436,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -1473,7 +1450,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -1486,21 +1463,10 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" -dependencies = [ - "darling_core 0.14.4", - "quote", - "syn 1.0.109", -] - [[package]] name = "darling_macro" version = "0.20.11" @@ -1589,34 +1555,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_builder" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" -dependencies = [ - "derive_builder_macro 0.12.0", -] - [[package]] name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "derive_builder_macro 0.20.2", -] - -[[package]] -name = "derive_builder_core" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" -dependencies = [ - "darling 0.14.4", - "proc-macro2", - "quote", - "syn 1.0.109", + "derive_builder_macro", ] [[package]] @@ -1631,23 +1576,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_builder_macro" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" -dependencies = [ - "derive_builder_core 0.12.0", - "syn 1.0.109", -] - [[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 0.20.2", + "derive_builder_core", "syn 2.0.117", ] @@ -2140,6 +2075,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -2541,12 +2487,16 @@ dependencies = [ "indexmap 2.14.0", "insta", "lazy_static", + "libc", "merge", "nu-ansi-term", + "nucleo", + "nucleo-picker", "num-format", "open", "pretty_assertions", "reedline", + "regex", "rustls 0.23.40", "serde", "serde_json", @@ -2654,9 +2604,13 @@ name = "forge_select" version = "0.1.0" dependencies = [ "anyhow", + "bstr", "colored", "console", - "fzf-wrapped", + "crossterm 0.29.0", + "derive_setters", + "nucleo", + "nucleo-picker", "pretty_assertions", "rustyline", "tracing", @@ -2959,15 +2913,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fzf-wrapped" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c61a44d13f57f2bb4c181a380dbb2e0367d1af53ca6721b5c9fc6b9c7e345d" -dependencies = [ - "derive_builder 0.12.0", -] - [[package]] name = "generator" version = "0.7.5" @@ -4204,7 +4149,7 @@ version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" dependencies = [ - "derive_builder 0.20.2", + "derive_builder", "log", "num-order", "pest", @@ -5575,6 +5520,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ncp-engine" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4b904e494a9e626d4056d26451ea0ff7c61d0527bdd7fa382d8dc0fbc95228b" +dependencies = [ + "ncp-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "ncp-matcher" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169f19d4393d100a624fd04f4267965329afe3b0841835d84a35b25b7a9ea160" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -5647,6 +5613,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "nucleo-picker" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c280559561e7d56bb9d4df36a80abf8d87a10a7a8d68310f8d8bb542ba5c0b1f" +dependencies = [ + "crossterm 0.29.0", + "memchr", + "ncp-engine", + "parking_lot", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -6187,7 +6188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "502b134f921ef9e879f59ac88ebd34fa37ba95483438e0f53e1008560eeeb3c8" dependencies = [ "chrono", - "derive_builder 0.20.2", + "derive_builder", "regex", "reqwest 0.13.2", "semver", @@ -7916,12 +7917,6 @@ dependencies = [ "vte", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 51e3801695..89a61657cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ derive_setters = "0.1.9" dirs = "6.0.0" dissimilar = "1.0.9" dotenvy = "0.15.7" -fzf-wrapped = "0.1.4" futures = "0.3.32" gh-workflow = "0.8.1" glob = "0.3.3" @@ -131,6 +130,7 @@ rmcp = { version = "0.10.0", features = [ ] } open = "5.3.2" nucleo = "0.5.0" +nucleo-picker = "0.11.1" gray_matter = "0.3.2" num-format = "0.4" humantime = "2.1.0" diff --git a/README.md b/README.md index db588d3a78..2c39c6ea25 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Install the ZSH plugin once with `forge setup`, then use `:` commands directly a : refactor the auth module # Send a prompt to the active agent :commit # AI-powered git commit :suggest "find large log files" # Translate description → shell command in your buffer -:conversation # Browse saved conversations with fzf preview +:conversation # Browse saved conversations with interactive picker ``` See the full [ZSH Plugin reference below](#zsh-plugin-the--prefix-system) for all commands and aliases. @@ -235,7 +235,7 @@ When you install the ZSH plugin (`forge setup`), you get a `:` prefix command sy ```zsh : # Send a prompt to the active agent :sage # Send a prompt to a specific agent by name (sage, muse, forge, or any custom agent) -:agent # Switch the active agent; opens fzf picker if no name given +:agent # Switch the active agent; opens interactive picker if no name given ``` ### Agents @@ -275,13 +275,13 @@ Forge saves every conversation. You can switch between them like switching direc ```zsh :new # Start a fresh conversation (saves current for :conversation -) :new # Start a new conversation and immediately send a prompt -:conversation # Open fzf picker: browse and switch conversations with preview +:conversation # Open interactive picker: browse and switch conversations with preview :conversation # Switch directly to a conversation by ID :conversation - # Toggle between current and previous conversation (like cd -) :clone # Branch the current conversation (try a different direction) :clone # Clone a specific conversation by ID :rename # Rename the current conversation -:conversation-rename # Rename a conversation via fzf picker +:conversation-rename # Rename a conversation via interactive picker :retry # Retry the last prompt (useful if the AI misunderstood) :copy # Copy the last AI response to clipboard as markdown :dump # Export conversation as JSON @@ -377,11 +377,11 @@ After running `:sync`, the AI can search your codebase by meaning rather than ex |---|---|---| | `: ` | | Send prompt to active agent | | `:new` | `:n` | Start new conversation | -| `:conversation` | `:c` | Browse/switch conversations (fzf) | +| `:conversation` | `:c` | Browse/switch conversations (interactive picker) | | `:conversation -` | | Toggle to previous conversation | | `:clone` | | Branch current conversation | | `:rename ` | `:rn` | Rename current conversation | -| `:conversation-rename` | | Rename conversation (fzf picker) | +| `:conversation-rename` | | Rename conversation (interactive picker) | | `:retry` | `:r` | Retry last prompt | | `:copy` | | Copy last response to clipboard | | `:dump` | `:d` | Export conversation as JSON | @@ -392,7 +392,7 @@ After running `:sync`, the AI can search your codebase by meaning rather than ex | `:edit` | `:ed` | Compose prompt in $EDITOR | | `:sage ` | `:ask` | Q&A / code understanding agent | | `:muse ` | `:plan` | Planning agent | -| `:agent ` | `:a` | Switch active agent (fzf picker if no name given) | +| `:agent ` | `:a` | Switch active agent (interactive picker if no name given) | | `:model ` | `:m` | Set model for this session only | | `:config-model ` | `:cm` | Set default model (persistent) | | `:reasoning-effort ` | `:re` | Set reasoning effort for session | diff --git a/crates/forge_app/src/operation.rs b/crates/forge_app/src/operation.rs index 47cda35742..1a88fce4f0 100644 --- a/crates/forge_app/src/operation.rs +++ b/crates/forge_app/src/operation.rs @@ -2335,7 +2335,7 @@ mod tests { let long_content = format!( "{}{}", "A".repeat(config.max_fetch_chars), - &truncated_content + truncated_content ); let fixture = ToolOperation::NetFetch { input: forge_domain::NetFetch { diff --git a/crates/forge_infra/src/mcp_client.rs b/crates/forge_infra/src/mcp_client.rs index 511771833f..23e246a1f3 100644 --- a/crates/forge_infra/src/mcp_client.rs +++ b/crates/forge_infra/src/mcp_client.rs @@ -617,7 +617,7 @@ fn resolve_http_templates( let template_data = serde_json::json!({"env": env_vars}); // Resolve templates in headers - for (_, value) in http.headers.iter_mut() { + for value in http.headers.values_mut() { // Try to render the template, but keep original value if it fails if let Ok(resolved) = handlebars.render_template(value, &template_data) { *value = resolved; diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index e136bc306d..57d0124d03 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -27,6 +27,9 @@ forge_spinner.workspace = true forge_select.workspace = true merge.workspace = true +nucleo.workspace = true +nucleo-picker.workspace = true +libc = "0.2" forge_fs.workspace = true tokio.workspace = true tokio-stream.workspace = true @@ -41,9 +44,10 @@ crossterm = "0.29.0" nu-ansi-term.workspace = true tracing.workspace = true chrono.workspace = true -serde_json.workspace = true serde.workspace = true +serde_json.workspace = true toml_edit.workspace = true +regex.workspace = true strum.workspace = true strum_macros.workspace = true diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index df83a23c25..a4c859bcd7 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -152,6 +152,103 @@ pub enum TopLevelCommand { /// Stream forge log output (defaults to the most recent log file). Logs(LogsArgs), + + /// Interactive fuzzy item picker. + Select(SelectCommandGroup), +} + +/// Command group for the `forge select` interactive picker. +/// +/// Subcommands provide purpose-built pickers for specific domain types (models, +/// agents, providers, etc.) that fetch data internally and output the selected +/// value. +#[derive(Parser, Debug, Clone)] +pub struct SelectCommandGroup { + #[command(subcommand)] + pub command: SelectCommand, +} + +/// Purpose-built interactive pickers for specific domain types. +/// +/// Each variant fetches data internally through the Forge API, presents an +/// interactive fuzzy picker, and prints the selected value to stdout for the +/// shell plugin to consume. +#[derive(Subcommand, Debug, Clone)] +pub enum SelectCommand { + /// Select a model interactively from all configured providers. + /// + /// Prints the selected model_id on the first line and provider_id on the + /// second line. Prints nothing if the user cancels. + Model { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, + + /// Select an agent interactively. + /// + /// Prints the selected agent_id on stdout. Prints nothing if the user + /// cancels. + Agent { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, + + /// Select a provider interactively. + /// + /// Prints the selected provider_id on stdout. Prints nothing if the user + /// cancels. + Provider { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + + /// Only show providers that are configured (logged in). + #[arg(long)] + configured: bool, + }, + + /// Select a reasoning effort level interactively. + /// + /// Prints the selected effort level (none, minimal, low, medium, high, + /// xhigh, max) on stdout. Prints nothing if the user cancels. + ReasoningEffort { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, + + /// Select a command interactively. + /// + /// Prints the selected command name on stdout. Prints nothing if the user + /// cancels. + Command { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, + + /// Select a conversation interactively with a preview pane. + /// + /// Prints the selected conversation_id on stdout. Prints nothing if the + /// user cancels. + Conversation { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, + + /// Select a file interactively with a preview pane. + /// + /// Walks the workspace and presents a fuzzy picker with syntax-highlighted + /// file previews. Prints the selected file path on stdout. Prints nothing + /// if the user cancels. + File { + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + query: Option, + }, } /// Command group for custom command management. diff --git a/crates/forge_main/src/completer/command.rs b/crates/forge_main/src/completer/command.rs index 6401b7df16..7e6287ff0d 100644 --- a/crates/forge_main/src/completer/command.rs +++ b/crates/forge_main/src/completer/command.rs @@ -6,7 +6,7 @@ use reedline::{Completer, Span, Suggestion}; use crate::model::{ForgeCommand, ForgeCommandManager}; /// A display wrapper for `ForgeCommand` that renders the name and description -/// side-by-side for fzf. +/// side-by-side for the interactive picker. #[derive(Clone)] struct CommandRow(ForgeCommand); diff --git a/crates/forge_main/src/completer/input_completer.rs b/crates/forge_main/src/completer/input_completer.rs index 9f81a661dc..d5548f5868 100644 --- a/crates/forge_main/src/completer/input_completer.rs +++ b/crates/forge_main/src/completer/input_completer.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use forge_select::ForgeWidget; +use forge_select::{ForgeWidget, PreviewLayout, PreviewPlacement, SelectRow}; use forge_walker::Walker; use reedline::{Completer, Span, Suggestion}; @@ -9,6 +9,52 @@ use crate::completer::CommandCompleter; use crate::completer::search_term::SearchTerm; use crate::model::ForgeCommandManager; +pub fn select_workspace_file(cwd: &Path, query: Option) -> anyhow::Result> { + let files: Vec = Walker::max_all() + .cwd(cwd.to_path_buf()) + .get_blocking() + .unwrap_or_default() + .into_iter() + .map(|file| file.path) + .collect(); + + if files.is_empty() { + return Ok(None); + } + + let has_bat = std::process::Command::new("bat") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok(); + let cat_cmd = if has_bat { + "bat --color=always --style=numbers,changes --line-range=:500" + } else { + "cat" + }; + + let preview_cmd = format!( + "if [ -d {{}} ]; then ls -la --color=always {{}} 2>/dev/null || ls -la {{}}; else {cat_cmd} {{}}; fi" + ); + let rows: Vec = files + .into_iter() + .map(|path| SelectRow { + raw: path.clone(), + display: path.clone(), + search: path.clone(), + fields: vec![path], + }) + .collect(); + + Ok(ForgeWidget::select_rows("File ❯ ", rows) + .query(Some(query.unwrap_or_default())) + .preview(Some(preview_cmd)) + .preview_layout(PreviewLayout { placement: PreviewPlacement::Bottom, percent: 75 }) + .prompt()? + .map(|row| row.raw)) +} + pub struct InputCompleter { cwd: PathBuf, command: CommandCompleter, @@ -32,35 +78,13 @@ impl Completer for InputCompleter { } if let Some(query) = SearchTerm::new(line, pos).process() { - let walker = Walker::max_all().cwd(self.cwd.clone()).skip_binary(true); - let files: Vec = walker - .get_blocking() - .unwrap_or_default() - .into_iter() - .map(|file| file.path) - .collect(); - - // Preview command: show directory listing for dirs, file contents for files. - // {2} references the path column (items are formatted as "{idx}\t{path}"). - // Use bat for syntax-highlighted file previews when available, falling back - // to cat. Mirrors the shell plugin's _FORGE_CAT_CMD and completion.zsh preview. - let cat_cmd = if which_bat() { - "bat --color=always --style=numbers,changes --line-range=:500" + let initial_text = if !query.term.is_empty() { + Some(query.term.to_string()) } else { - "cat" + None }; - let preview_cmd = format!( - "if [ -d {{2}} ]; then ls -la --color=always {{2}} 2>/dev/null || ls -la {{2}}; else {cat_cmd} {{2}}; fi" - ); - - let mut builder = ForgeWidget::select("File", files) - .with_preview(preview_cmd) - .with_preview_window("bottom:75%:wrap:border-sharp"); - if !query.term.is_empty() { - builder = builder.with_initial_text(query.term); - } - if let Ok(Some(selected)) = builder.prompt() { + if let Ok(Some(selected)) = select_workspace_file(&self.cwd, initial_text) { let value = format!("[{}]", selected); return vec![Suggestion { description: None, @@ -78,12 +102,3 @@ impl Completer for InputCompleter { vec![] } } - -/// Returns `true` if the `bat` binary is available on `PATH`. -fn which_bat() -> bool { - std::process::Command::new("which") - .arg("bat") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} diff --git a/crates/forge_main/src/completer/mod.rs b/crates/forge_main/src/completer/mod.rs index bccc4ce6cb..a650a27349 100644 --- a/crates/forge_main/src/completer/mod.rs +++ b/crates/forge_main/src/completer/mod.rs @@ -3,4 +3,4 @@ mod input_completer; mod search_term; pub use command::CommandCompleter; -pub use input_completer::InputCompleter; +pub use input_completer::{InputCompleter, select_workspace_file}; diff --git a/crates/forge_main/src/conversation_selector.rs b/crates/forge_main/src/conversation_selector.rs index 84dc3b09b2..0424d27f7a 100644 --- a/crates/forge_main/src/conversation_selector.rs +++ b/crates/forge_main/src/conversation_selector.rs @@ -1,10 +1,8 @@ -use std::fmt::Display; - use anyhow::Result; use chrono::Utc; use forge_api::Conversation; use forge_domain::ConversationId; -use forge_select::ForgeWidget; +use forge_select::{ForgeWidget, PreviewLayout, PreviewPlacement, SelectRow}; use crate::display_constants::markers; use crate::info::Info; @@ -14,16 +12,18 @@ use crate::porcelain::Porcelain; pub struct ConversationSelector; impl ConversationSelector { - /// Select a conversation from the provided list using porcelain-style - /// tabular display matching the shell plugin's `:conversation` action. + /// Select a conversation from the provided list using a custom TUI with + /// a preview pane showing conversation details. /// - /// Displays columns: TITLE, UPDATED (hiding the UUID column). - /// The header row is non-selectable via `header_lines=1`. + /// The preview command uses `forge conversation info` and + /// `forge conversation show` to display the selected conversation's + /// metadata and last message side-by-side with the picker list. /// - /// Returns the selected conversation, or None if no selection was made. + /// Returns the selected conversation, or None if the user cancelled. pub async fn select_conversation( conversations: &[Conversation], - current_conversation_id: Option, + _current_conversation_id: Option, + query: Option, ) -> Result> { if conversations.is_empty() { return Ok(None); @@ -39,7 +39,7 @@ impl ConversationSelector { return Ok(None); } - // Build Info structure (same as on_show_conversations) + // Build Info structure for display let now = Utc::now(); let mut info = Info::new(); @@ -67,7 +67,8 @@ impl ConversationSelector { .add_key_value("Updated", time_ago); } - // Convert to porcelain, drop UUID column (col 0), truncate title + // Convert to porcelain, drop the UUID title column (col 0), truncate the + // Title column for display, uppercase headers let porcelain_output = Porcelain::from(&info) .drop_col(0) .truncate(0, 60) @@ -79,47 +80,52 @@ impl ConversationSelector { return Ok(None); } - #[derive(Clone)] - struct ConversationRow { - conversation: Option, - display: String, - } - impl Display for ConversationRow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display) - } - } + // Build SelectRow items for the shared Rust selector UI. + // Each row stores the UUID in `fields[0]` so that `{1}` in the preview + // command resolves to the conversation ID. The `raw` field is what gets + // returned on selection (the UUID). + let mut rows: Vec = Vec::with_capacity(all_lines.len()); - let mut rows: Vec = Vec::with_capacity(all_lines.len()); // Header row (non-selectable via header_lines=1) if let Some(header) = all_lines.first() { - rows.push(ConversationRow { conversation: None, display: header.to_string() }); + rows.push(SelectRow::header(header.to_string())); } - // Data rows + + // Data rows: each maps to a conversation for (i, line) in all_lines.iter().skip(1).enumerate() { - rows.push(ConversationRow { - conversation: valid_conversations.get(i).cloned().cloned(), - display: line.to_string(), - }); + if let Some(conv) = valid_conversations.get(i) { + let uuid = conv.id.to_string(); + rows.push(SelectRow { + raw: uuid.clone(), + display: line.to_string(), + search: line.to_string(), + fields: vec![uuid], + }); + } } - // Find starting cursor for the current conversation - let starting_cursor = current_conversation_id - .and_then(|current| valid_conversations.iter().position(|c| c.id == current)) - .unwrap_or(0); + // Build a lookup map from UUID to Conversation for the result + let conv_map: std::collections::HashMap = valid_conversations + .into_iter() + .map(|c| (c.id.to_string(), c.clone())) + .collect(); - if let Some(selected) = tokio::task::spawn_blocking(move || { - ForgeWidget::select("Conversation", rows) - .with_starting_cursor(starting_cursor) - .with_header_lines(1) - .prompt() + let preview_command = + "CLICOLOR_FORCE=1 forge conversation info {1}; echo; CLICOLOR_FORCE=1 forge conversation show {1}" + .to_string(); + + let selected_uuid = tokio::task::spawn_blocking(move || -> Result> { + Ok(ForgeWidget::select_rows("Conversation", rows) + .query(query) + .header_lines(1_usize) + .preview(Some(preview_command)) + .preview_layout(PreviewLayout { placement: PreviewPlacement::Bottom, percent: 60 }) + .prompt()? + .map(|row| row.raw)) }) - .await?? - { - Ok(selected.conversation) - } else { - Ok(None) - } + .await??; + + Ok(selected_uuid.and_then(|uuid| conv_map.get(&uuid).cloned())) } } @@ -146,7 +152,7 @@ mod tests { #[tokio::test] async fn test_select_conversation_empty_list() { let conversations = vec![]; - let result = ConversationSelector::select_conversation(&conversations, None) + let result = ConversationSelector::select_conversation(&conversations, None, None) .await .unwrap(); assert!(result.is_none()); @@ -165,8 +171,6 @@ mod tests { ), ]; - // We can't test the actual selection without mocking the UI, - // but we can test that the function structure is correct assert_eq!(conversations.len(), 2); } diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index 2f618acf01..7ad2b39be1 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -7,7 +7,7 @@ use clap::Parser; use forge_api::ForgeAPI; use forge_config::ForgeConfig; use forge_domain::TitleFormat; -use forge_main::{Cli, Sandbox, TitleDisplayExt, UI, tracker}; +use forge_main::{Cli, Sandbox, TitleDisplayExt, TopLevelCommand, UI, tracker}; /// Enables ENABLE_VIRTUAL_TERMINAL_PROCESSING on the stdout console handle. /// @@ -90,8 +90,10 @@ async fn run() -> Result<()> { // Initialize and run the UI let mut cli = Cli::parse(); - // Check if there's piped input - if !std::io::stdin().is_terminal() { + // Check if there's piped input, but skip for `forge select` since that + // command uses stdin for its item list. + let is_select = matches!(cli.subcommands, Some(TopLevelCommand::Select(_))); + if !is_select && !std::io::stdin().is_terminal() { let mut stdin_content = String::new(); std::io::stdin().read_to_string(&mut stdin_content)?; let trimmed_content = stdin_content.trim(); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 7089fcd3c8..00a75d7e67 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::fmt::Display; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -22,7 +21,7 @@ use forge_domain::{ AuthMethod, ChatResponseContent, ConsoleWriter, ContextMessage, Role, TitleFormat, UserCommand, }; use forge_fs::ForgeFS; -use forge_select::ForgeWidget; +use forge_select::{ForgeWidget, SelectRow}; use forge_spinner::SpinnerManager; use forge_tracker::ToolCallPayload; use forge_walker::Walker; @@ -32,7 +31,8 @@ use tokio_stream::StreamExt; use url::Url; use crate::cli::{ - Cli, CommitCommandGroup, ConversationCommand, ListCommand, McpCommand, TopLevelCommand, + Cli, CommitCommandGroup, ConversationCommand, ListCommand, McpCommand, SelectCommand, + TopLevelCommand, }; use crate::conversation_selector::ConversationSelector; use crate::display_constants::{CommandType, headers, markers, status}; @@ -150,6 +150,51 @@ impl A + Send + Sync> UI } } + fn select_raw_row( + &self, + prompt: &str, + query: Option, + rows: Vec, + header_lines: usize, + initial_raw: Option, + ) -> Result> { + ForgeWidget::select_rows(prompt, rows) + .query(query) + .header_lines(header_lines) + .initial_raw(initial_raw) + .prompt() + } + + fn select_row_output( + &mut self, + prompt: &str, + query: Option, + rows: Vec, + ) -> Result<()> { + if let Some(row) = self.select_raw_row(prompt, query, rows, 1, None)? { + self.writeln(row.raw)?; + } + + Ok(()) + } + + fn porcelain_rows(porcelain: impl ToString) -> Result> { + let porcelain = porcelain.to_string(); + let mut lines = porcelain.lines(); + let Some(header) = lines.next() else { + return Ok(Vec::new()); + }; + + let mut rows = vec![SelectRow::header(header.to_string())]; + rows.extend(lines.filter_map(|line| { + line.split_whitespace() + .next() + .map(|raw| SelectRow::new(raw.to_string(), line.to_string())) + })); + + Ok(rows) + } + /// Displays banner only if user is in interactive mode. fn display_banner(&self) -> Result<()> { if self.cli.is_interactive() { @@ -233,7 +278,7 @@ impl A + Send + Sync> UI let command = Arc::new(ForgeCommandManager::default()); let spinner = SharedSpinner::new(SpinnerManager::new(api.clone())); Ok(Self { - state: Default::default(), + state: UIState::new(env.clone()), api, new_api: Arc::new(f), console: Console::new( @@ -742,6 +787,73 @@ impl A + Send + Sync> UI crate::logs::run(args, log_dir).await?; return Ok(()); } + TopLevelCommand::Select(cmd) => { + if !matches!(&cmd.command, SelectCommand::File { .. }) { + self.init_state(false).await?; + } + + match &cmd.command { + SelectCommand::File { query } => { + if let Some(file) = + crate::completer::select_workspace_file(&self.state.cwd, query.clone())? + { + self.writeln(file)?; + } + } + SelectCommand::Model { query } => { + if let Some((model_id, provider_id)) = + self.select_model(None, query.clone()).await? + { + self.writeln(model_id.as_str())?; + self.writeln(provider_id.as_ref())?; + } + } + SelectCommand::Agent { query } => { + if let Some(agent_id) = self.select_agent(query.clone()).await? { + self.writeln(agent_id.as_str())?; + } + } + SelectCommand::Provider { query, configured } => { + if let Some(provider) = + self.select_provider(query.clone(), *configured).await? + { + self.writeln(provider.id().as_ref())?; + } + } + SelectCommand::ReasoningEffort { query } => { + if let Some(effort) = self + .select_reasoning_effort("Reasoning Effort", query.clone()) + .await? + { + self.writeln(effort)?; + } + } + SelectCommand::Command { query } => { + let rows = Self::porcelain_rows(self.commands_porcelain().await?)?; + + if !rows.is_empty() { + self.select_row_output("Command", query.clone(), rows)?; + } + } + SelectCommand::Conversation { query } => { + let max_conversations = self.config.max_conversations; + let conversations = + self.api.get_conversations(Some(max_conversations)).await?; + + if !conversations.is_empty() + && let Some(conversation) = ConversationSelector::select_conversation( + &conversations, + self.state.conversation_id, + query.clone(), + ) + .await? + { + self.writeln(conversation.id)?; + } + } + } + return Ok(()); + } } Ok(()) } @@ -1020,7 +1132,7 @@ impl A + Send + Sync> UI // Fetch all providers for selection (no type filter, like shell :login) let providers = self.api.get_providers().await?; - match self.select_provider_from_list(providers, "Provider", None)? { + match self.select_provider_from_list(providers, "Provider", None, None)? { Some(provider) => provider, None => { self.writeln_title(TitleFormat::info("Cancelled"))?; @@ -1076,7 +1188,7 @@ impl A + Send + Sync> UI return Ok(false); } - match self.select_provider_from_list(configured_providers, "Provider", None)? { + match self.select_provider_from_list(configured_providers, "Provider", None, None)? { Some(provider) => { let provider_id = provider.id(); self.api.remove_provider(&provider_id).await?; @@ -1339,79 +1451,68 @@ impl A + Send + Sync> UI Ok(()) } - /// Lists all the commands - async fn on_show_commands(&mut self, porcelain: bool) -> anyhow::Result<()> { - // Fetch custom commands once — used by both the porcelain and plain paths. + async fn commands_porcelain(&self) -> Result { let custom_commands = self.api.get_commands().await?; + let mut info = Info::new(); - if porcelain { - // Build the full info with type/description columns for porcelain - // (used by the shell plugin for tab completion). - let mut info = Info::new(); + for cmd in AppCommand::iter().filter(|c| !c.is_internal() && !c.is_agent_switch()) { + info = info + .add_title(cmd.name()) + .add_key_value("type", CommandType::Command) + .add_key_value("description", cmd.usage()); + } - // Generate built-in commands directly from the SlashCommand enum so - // the list always stays in sync with what the REPL actually supports. - // Internal/meta variants (Message, Custom, Shell, AgentSwitch, Rename) - // are excluded via is_internal(). - // Agent-switch shorthands (forge, muse, sage) are excluded via - // is_agent_switch() because they are already emitted as AGENT rows - // by the agent-info loop below, and must not appear twice. - for cmd in AppCommand::iter().filter(|c| !c.is_internal() && !c.is_agent_switch()) { - info = info - .add_title(cmd.name()) - .add_key_value("type", CommandType::Command) - .add_key_value("description", cmd.usage()); - } + info = info + .add_title("ask") + .add_key_value("type", CommandType::Agent) + .add_key_value( + "description", + "Research and investigation agent [alias for: sage]", + ) + .add_title("plan") + .add_key_value("type", CommandType::Agent) + .add_key_value( + "description", + "Planning and strategy agent [alias for: muse]", + ); - // Add agent aliases + let agent_infos = self.api.get_agent_infos().await?; + for agent_info in agent_infos { + let title = agent_info + .title + .map(|title| title.lines().collect::>().join(" ")); info = info - .add_title("ask") - .add_key_value("type", CommandType::Agent) - .add_key_value( - "description", - "Research and investigation agent [alias for: sage]", - ) - .add_title("plan") + .add_title(agent_info.id.to_string()) .add_key_value("type", CommandType::Agent) - .add_key_value( - "description", - "Planning and strategy agent [alias for: muse]", - ); + .add_key_value("description", title); + } - // Fetch agent infos and add them to the commands list. - // Uses get_agent_infos() so no provider/model is required for listing. - let agent_infos = self.api.get_agent_infos().await?; - for agent_info in agent_infos { - let title = agent_info - .title - .map(|title| title.lines().collect::>().join(" ")); - info = info - .add_title(agent_info.id.to_string()) - .add_key_value("type", CommandType::Agent) - .add_key_value("description", title); - } + for command in custom_commands { + info = info + .add_title(command.name.clone()) + .add_key_value("type", CommandType::Custom) + .add_key_value("description", command.description.clone()); + } - for command in custom_commands { - info = info - .add_title(command.name.clone()) - .add_key_value("type", CommandType::Custom) - .add_key_value("description", command.description.clone()); - } + Ok(Porcelain::from(&info) + .uppercase_headers() + .sort_by(&[1, 0]) + .to_case(&[1], Case::UpperSnake) + .map_col(0, |col| { + if col.as_deref() == Some(headers::ID) { + Some("COMMAND".to_string()) + } else { + col + } + })) + } - // Original order from Info: [$ID, type, description] - // So the original order is fine! But $ID should become COMMAND - let porcelain = Porcelain::from(&info) - .uppercase_headers() - .sort_by(&[1, 0]) - .to_case(&[1], Case::UpperSnake) - .map_col(0, |col| { - if col.as_deref() == Some(headers::ID) { - Some("COMMAND".to_string()) - } else { - col - } - }); - self.writeln(porcelain)?; + /// Lists all the commands + async fn on_show_commands(&mut self, porcelain: bool) -> anyhow::Result<()> { + let custom_commands = self.api.get_commands().await?; + + if porcelain { + self.writeln(self.commands_porcelain().await?)?; } else { // Non-porcelain: render in the same flat format as :help in the REPL. let command_manager = ForgeCommandManager::default(); @@ -1618,7 +1719,7 @@ impl A + Send + Sync> UI // Show failed MCP servers if !porcelain && !all_tools.mcp.get_failures().is_empty() { self.writeln("MCP FAILURES\n".dimmed().bold())?; - for (_, error) in all_tools.mcp.get_failures().iter() { + for error in all_tools.mcp.get_failures().values() { let error = style(error).red(); self.writeln(error)?; } @@ -1906,9 +2007,12 @@ impl A + Send + Sync> UI return Ok(()); } - if let Some(conversation) = - ConversationSelector::select_conversation(&conversations, self.state.conversation_id) - .await? + if let Some(conversation) = ConversationSelector::select_conversation( + &conversations, + self.state.conversation_id, + None, + ) + .await? { let conversation_id = conversation.id; self.state.conversation_id = Some(conversation_id); @@ -2076,81 +2180,8 @@ impl A + Send + Sync> UI } } AppCommand::Agent => { - #[derive(Clone)] - struct Agent { - id: AgentId, - label: String, - } - - impl Display for Agent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.label) - } - } - - let agents = self.api.get_agent_infos().await?; - - if agents.is_empty() { - return Ok(false); - } - - // Reuse the same Info building logic as list agents - let info = self.build_agents_info(false).await?; - - // Convert to porcelain format matching shell plugin's :agent - // Shell uses --with-nth="1,2,4,5,6" hiding Location (col 3). - // Original cols: 0=Title, 1=Id, 2=Title, 3=Location, 4=Provider, 5=Model, - // 6=Reasoning Drop cols 0 (title) and 3 (location) - let porcelain_output = Porcelain::from(&info) - .drop_cols(&[0, 3]) - .truncate(3, 30) - .uppercase_headers(); - let porcelain_str = porcelain_output.to_string(); - - let all_lines: Vec<&str> = porcelain_str.lines().collect(); - if all_lines.is_empty() { - return Ok(false); - } - - let mut display_agents = Vec::new(); - // Header row (non-selectable via header_lines=1) - let Some(header) = all_lines.first() else { - return Err(UIError::MissingHeaderLine.into()); - }; - display_agents.push(Agent { - id: AgentId::new("__header__".to_string()), - label: header.to_string(), - }); - // Data rows - for line in all_lines.iter().skip(1) { - if let Some(id_str) = line.split_whitespace().next() { - display_agents.push(Agent { - label: line.to_string(), - id: AgentId::new(id_str.to_string()), - }); - } - } - - // Find starting cursor for the current agent - let current_agent = self.api.get_active_agent().await; - let starting_cursor = current_agent - .and_then(|current| { - // Skip header row (index 0) when searching - all_lines.iter().skip(1).position(|line| { - line.split_whitespace() - .next() - .map(|id| id == current.as_str()) - .unwrap_or(false) - }) - }) - .unwrap_or(0); - - if let Some(selected_agent) = ForgeWidget::select("Agent", display_agents) - .with_starting_cursor(starting_cursor) - .with_header_lines(1) - .prompt()? - { - self.on_agent_change(selected_agent.id).await?; + if let Some(selected_agent) = self.select_agent(None).await? { + self.on_agent_change(selected_agent).await?; } } AppCommand::Login => { @@ -2310,33 +2341,13 @@ impl A + Send + Sync> UI async fn on_reasoning_effort_selection(&mut self, global: bool) -> anyhow::Result<()> { use std::str::FromStr; - let effort_levels: Vec = vec![ - "none".to_string(), - "minimal".to_string(), - "low".to_string(), - "medium".to_string(), - "high".to_string(), - "xhigh".to_string(), - "max".to_string(), - ]; - - let current_effort = self.api.get_reasoning_effort().await.ok().flatten(); - let current_str = current_effort.as_ref().map(|e| e.to_string()); - - let starting_cursor = current_str - .as_ref() - .and_then(|c| effort_levels.iter().position(|e| e == c)) - .unwrap_or(0); - let prompt = if global { "Config Reasoning Effort" } else { "Reasoning Effort" }; - let selected = ForgeWidget::select(prompt, effort_levels) - .with_starting_cursor(starting_cursor) - .prompt()?; + let selected = self.select_reasoning_effort(prompt, None).await?; if let Some(effort_str) = selected { let effort = forge_domain::Effort::from_str(&effort_str) @@ -2352,9 +2363,27 @@ impl A + Send + Sync> UI Ok(()) } + async fn select_reasoning_effort( + &self, + prompt: &str, + query: Option, + ) -> anyhow::Result> { + let effort_levels = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + let current_effort = self.api.get_reasoning_effort().await.ok().flatten(); + let current_str = current_effort.as_ref().map(|e| e.to_string()); + let rows = effort_levels + .iter() + .map(|level| SelectRow::new(*level, *level)) + .collect(); + + Ok(self + .select_raw_row(prompt, query, rows, 0, current_str)? + .map(|row| row.raw)) + } + /// Selects and sets the commit model via interactive model picker. async fn on_config_commit_model(&mut self) -> anyhow::Result<()> { - let selection = self.select_model(None).await?; + let selection = self.select_model(None, None).await?; if let Some((model, provider_id)) = selection { let commit_config = forge_domain::ModelConfig::new(provider_id.clone(), model.clone()); self.api @@ -2369,7 +2398,7 @@ impl A + Send + Sync> UI /// Selects and sets the suggest model via interactive model picker. async fn on_config_suggest_model(&mut self) -> anyhow::Result<()> { - let selection = self.select_model(None).await?; + let selection = self.select_model(None, None).await?; if let Some((model, provider_id)) = selection { let suggest_config = forge_domain::ModelConfig::new(provider_id.clone(), model.clone()); self.api @@ -2558,6 +2587,7 @@ impl A + Send + Sync> UI let selected = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, + None, ) .await?; @@ -2634,6 +2664,7 @@ impl A + Send + Sync> UI let selected = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, + None, ) .await?; @@ -2726,6 +2757,29 @@ impl A + Send + Sync> UI Ok(()) } + async fn select_agent(&self, query: Option) -> Result> { + let rows = self.agent_select_rows().await?; + let initial_raw = self + .api + .get_active_agent() + .await + .map(|current| current.as_str().to_string()); + + Ok(self + .select_raw_row("Agent", query, rows, 1, initial_raw)? + .map(|row| AgentId::new(row.raw))) + } + + async fn agent_select_rows(&self) -> Result> { + let info = self.build_agents_info(false).await?; + let porcelain = Porcelain::from(&info) + .drop_cols(&[0, 3]) + .truncate(3, 30) + .uppercase_headers(); + + Self::porcelain_rows(porcelain) + } + /// Select a model from all configured providers using porcelain-style /// tabular display matching the shell plugin's `:model` UI. /// @@ -2744,6 +2798,7 @@ impl A + Send + Sync> UI async fn select_model( &mut self, provider_filter: Option, + query: Option, ) -> Result> { // Check if provider is set otherwise first ask to select a provider if provider_filter.is_none() && self.api.get_session_config().await.is_none() { @@ -2854,59 +2909,64 @@ impl A + Send + Sync> UI } } - // Create display items: header line first, then data lines paired with - // model and provider IDs. - #[derive(Clone)] - struct ModelRow { - model_id: Option, - provider_id: Option, - display: String, - } - impl std::fmt::Display for ModelRow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display) - } - } - - let mut rows: Vec = Vec::with_capacity(all_lines.len()); + let mut rows = Vec::with_capacity(all_lines.len()); // Header row (non-selectable via header_lines=1) let Some(header) = all_lines.first() else { return Err(UIError::MissingHeaderLine.into()); }; - rows.push(ModelRow { - model_id: None, - provider_id: None, - display: header.to_string(), - }); + rows.push(SelectRow::header(header.to_string())); // Data rows for (i, line) in all_lines.iter().skip(1).enumerate() { - let entry = model_entries.get(i); - rows.push(ModelRow { - model_id: entry.map(|(m, _)| m.clone()), - provider_id: entry.map(|(_, p)| p.clone()), + let Some((model_id, provider_id)) = model_entries.get(i) else { + continue; + }; + rows.push(SelectRow { + raw: format!("{}\t{}", model_id.as_str(), provider_id.as_ref()), display: line.to_string(), + search: format!("{} {}", model_id.as_str(), provider_id.as_ref()), + fields: vec![model_id.to_string(), provider_id.as_ref().to_string()], }); } // Find starting cursor position for the current model. - // The cursor position is relative to the data rows (header is excluded - // by fzf's --header-lines), so index 0 = first data row. let current_model = self .get_agent_model(self.api.get_active_agent().await) .await; - let starting_cursor = current_model - .as_ref() - .and_then(|current| model_entries.iter().position(|(id, _)| id == current)) - .unwrap_or(0); + let current_provider = self + .get_provider(self.api.get_active_agent().await) + .await + .ok() + .map(|provider| provider.id); + let initial_raw = current_model.as_ref().and_then(|current| { + model_entries + .iter() + .find(|(model_id, provider_id)| { + model_id == current + && current_provider + .as_ref() + .map(|provider| provider_id == provider) + .unwrap_or(true) + }) + .map(|(model_id, provider_id)| { + format!("{}\t{}", model_id.as_str(), provider_id.as_ref()) + }) + }); - match ForgeWidget::select("Model", rows) - .with_starting_cursor(starting_cursor) - .with_header_lines(1) - .prompt()? - { - Some(row) => Ok(row.model_id.zip(row.provider_id)), - None => Ok(None), - } + let selected = self.select_raw_row("Model ❯ ", query, rows, 1, initial_raw)?; + + let Some(selected) = selected else { + return Ok(None); + }; + + let mut parts = selected.raw.splitn(2, '\t'); + let selection = match (parts.next(), parts.next()) { + (Some(model_id), Some(provider_id)) => Some(( + ModelId::new(model_id.to_string()), + ProviderId::from(provider_id.to_string()), + )), + _ => None, + }; + Ok(selection) } async fn handle_api_key_input( @@ -3342,7 +3402,8 @@ impl A + Send + Sync> UI } /// Builds a porcelain-style provider selection list from a set of - /// providers, displays it in fzf, and returns the selected provider. + /// providers, displays it in the interactive picker, and returns the + /// selected provider. /// /// The display matches the shell plugin's `_forge_select_provider`: /// columns NAME, HOST, TYPE, LOGGED IN (hiding the raw ID column). @@ -3351,6 +3412,7 @@ impl A + Send + Sync> UI providers: Vec, prompt: &str, current_provider_id: Option, + query: Option, ) -> Result> { if providers.is_empty() { return Ok(None); @@ -3394,47 +3456,41 @@ impl A + Send + Sync> UI return Ok(None); } - // Build display rows: header + data - #[derive(Clone)] - struct ProviderRow { - provider: Option, - display: String, - } - impl std::fmt::Display for ProviderRow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display) - } - } - - let mut rows: Vec = Vec::with_capacity(all_lines.len()); - // Header row (non-selectable via header_lines=1) let Some(header) = all_lines.first() else { return Err(UIError::MissingHeaderLine.into()); }; - rows.push(ProviderRow { provider: None, display: header.to_string() }); - // Data rows - for (i, line) in all_lines.iter().skip(1).enumerate() { - rows.push(ProviderRow { provider: sorted.get(i).cloned(), display: line.to_string() }); - } - - // Find starting cursor for the current provider - let starting_cursor = current_provider_id - .and_then(|current| sorted.iter().position(|p| p.id() == current)) - .unwrap_or(0); + let mut rows = vec![SelectRow::header(header.to_string())]; + for (index, line) in all_lines.iter().skip(1).enumerate() { + if let Some(provider) = sorted.get(index) { + rows.push(SelectRow::new( + provider.id().as_ref().to_string(), + line.to_string(), + )); + } + } + + let selected = self.select_raw_row( + prompt, + query, + rows, + 1, + current_provider_id.map(|current| current.as_ref().to_string()), + )?; - match ForgeWidget::select(prompt, rows) - .with_starting_cursor(starting_cursor) - .with_header_lines(1) - .prompt()? - { - Some(row) => Ok(row.provider), - None => Ok(None), - } + Ok(selected.and_then(|row| { + sorted + .into_iter() + .find(|provider| provider.id().as_ref().as_ref() == row.raw) + })) } /// Selects a provider, optionally configuring it if not already configured. - async fn select_provider(&mut self) -> Result> { - let providers: Vec = self + async fn select_provider( + &mut self, + query: Option, + configured_only: bool, + ) -> Result> { + let mut providers: Vec = self .api .get_providers() .await? @@ -3448,6 +3504,10 @@ impl A + Send + Sync> UI }) .collect(); + if configured_only { + providers.retain(|provider| provider.is_configured()); + } + if providers.is_empty() { return Err(anyhow::anyhow!("No AI provider API keys configured")); } @@ -3458,7 +3518,7 @@ impl A + Send + Sync> UI .ok() .map(|p| p.id); - self.select_provider_from_list(providers, "Provider", current_provider_id) + self.select_provider_from_list(providers, "Provider", current_provider_id, query) } // Helper method to handle model selection and update the conversation. @@ -3471,7 +3531,7 @@ impl A + Send + Sync> UI provider_filter: Option, ) -> Result> { // Select a model; the selector returns both the model and its provider - let selection = self.select_model(provider_filter).await?; + let selection = self.select_model(provider_filter, None).await?; // If no model was selected (user canceled), return early let (model, provider_id) = match selection { @@ -3497,7 +3557,7 @@ impl A + Send + Sync> UI async fn on_provider_selection(&mut self) -> Result { // Select a provider // If no provider was selected (user canceled), return early - let any_provider = match self.select_provider().await? { + let any_provider = match self.select_provider(None, false).await? { Some(provider) => provider, None => return Ok(false), }; diff --git a/crates/forge_repo/src/context_engine.rs b/crates/forge_repo/src/context_engine.rs index 97f5bf68ef..f1a1e4b56d 100644 --- a/crates/forge_repo/src/context_engine.rs +++ b/crates/forge_repo/src/context_engine.rs @@ -105,10 +105,9 @@ impl ForgeContextEngineRepository { mut request: tonic::Request, auth_token: &ApiKey, ) -> Result> { - request.metadata_mut().insert( - "authorization", - format!("Bearer {}", &**auth_token).parse()?, - ); + request + .metadata_mut() + .insert("authorization", format!("Bearer {}", **auth_token).parse()?); Ok(request) } } diff --git a/crates/forge_select/Cargo.toml b/crates/forge_select/Cargo.toml index d12b3a0e71..1494f9b142 100644 --- a/crates/forge_select/Cargo.toml +++ b/crates/forge_select/Cargo.toml @@ -6,9 +6,13 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true +bstr.workspace = true colored.workspace = true console.workspace = true -fzf-wrapped.workspace = true +crossterm = "0.29.0" +derive_setters.workspace = true +nucleo.workspace = true +nucleo-picker.workspace = true rustyline.workspace = true tracing.workspace = true diff --git a/crates/forge_select/src/lib.rs b/crates/forge_select/src/lib.rs index 30024cdf02..392c8c3e62 100644 --- a/crates/forge_select/src/lib.rs +++ b/crates/forge_select/src/lib.rs @@ -1,10 +1,12 @@ mod confirm; mod input; mod multi; +mod preview; mod select; mod widget; pub use input::InputBuilder; pub use multi::MultiSelectBuilder; +pub use preview::{PreviewLayout, PreviewPlacement, SelectMode, SelectRow, SelectUiOptions}; pub use select::SelectBuilder; pub use widget::ForgeWidget; diff --git a/crates/forge_select/src/multi.rs b/crates/forge_select/src/multi.rs index 9d02e42930..65fc136668 100644 --- a/crates/forge_select/src/multi.rs +++ b/crates/forge_select/src/multi.rs @@ -2,9 +2,8 @@ use std::io::IsTerminal; use anyhow::Result; use console::strip_ansi_codes; -use fzf_wrapped::{Fzf, Layout}; -use crate::select::{indexed_items, parse_fzf_index}; +use crate::preview::{SelectMode, SelectRow, SelectUiOptions}; /// Builder for multi-select prompts. pub struct MultiSelectBuilder { @@ -17,19 +16,17 @@ impl MultiSelectBuilder { /// /// # Returns /// - /// - `Ok(Some(Vec))` - User selected one or more options - /// - `Ok(None)` - No options available or user cancelled (ESC / Ctrl+C) + /// - `Ok(Some(Vec))` when the user selects one or more options. + /// - `Ok(None)` when no options are available or the user cancels. /// /// # Errors /// - /// Returns an error if the fzf process fails to start or interact + /// Returns an error if terminal setup, event handling, or rendering fails. pub fn prompt(self) -> Result>> where T: std::fmt::Display + Clone, { - // Bail immediately when stdin is not a terminal to prevent the process - // from blocking indefinitely on a detached or non-interactive session. - if !std::io::stdin().is_terminal() { + if !std::io::stderr().is_terminal() { return Ok(None); } @@ -37,64 +34,40 @@ impl MultiSelectBuilder { return Ok(None); } - let display_options: Vec = self + let rows = self .options .iter() - .map(|item| strip_ansi_codes(&item.to_string()).trim().to_string()) - .collect(); + .enumerate() + .map(|(index, item)| { + let display = strip_ansi_codes(&item.to_string()).trim().to_string(); + SelectRow::new(index.to_string(), display.clone()).search(display) + }) + .collect::>(); - let fzf = build_multi_fzf(&self.message); + let selected = SelectUiOptions::new(format!("{} ❯ ", self.message), rows) + .mode(SelectMode::Multi) + .prompt_multi()?; - let mut fzf = fzf; - fzf.run() - .map_err(|e| anyhow::anyhow!("Failed to start fzf: {e}"))?; - fzf.add_items(indexed_items(&display_options)) - .map_err(|e| anyhow::anyhow!("Failed to add items to fzf: {e}"))?; + Ok(selected.and_then(|rows| { + let selected_items = rows + .into_iter() + .filter_map(|row| { + row.raw + .parse::() + .ok() + .and_then(|index| self.options.get(index).cloned()) + }) + .collect::>(); - let raw_output = fzf.output(); - - match raw_output { - None => Ok(None), - Some(output) => { - let selected_items: Vec = output - .lines() - .filter(|line| !line.trim().is_empty()) - .filter_map(|line| { - parse_fzf_index(line).and_then(|index| self.options.get(index).cloned()) - }) - .collect(); - - if selected_items.is_empty() { - Ok(None) - } else { - Ok(Some(selected_items)) - } + if selected_items.is_empty() { + None + } else { + Some(selected_items) } - } + })) } } -/// Builds an `Fzf` instance for multi-select prompts. -fn build_multi_fzf(message: &str) -> Fzf { - let mut builder = Fzf::builder(); - builder.layout(Layout::Reverse); - builder.no_scrollbar(true); - builder.prompt(format!("{} ❯ ", message)); - builder.custom_args(vec![ - "--height=80%".to_string(), - "--exact".to_string(), - "--cycle".to_string(), - "--color=dark,header:bold".to_string(), - "--pointer=▌".to_string(), - "--delimiter=\t".to_string(), - "--with-nth=2..".to_string(), - "--multi".to_string(), - ]); - builder - .build() - .expect("fzf builder should always succeed with default options") -} - #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/crates/forge_select/src/preview.rs b/crates/forge_select/src/preview.rs new file mode 100644 index 0000000000..81d1836378 --- /dev/null +++ b/crates/forge_select/src/preview.rs @@ -0,0 +1,1337 @@ +use std::collections::BTreeSet; +use std::io::{self, Write}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; +use std::{cmp, fmt}; + +use bstr::ByteSlice; +use crossterm::cursor::{Hide, MoveToColumn, MoveUp, RestorePosition, SavePosition, Show}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, + KeyboardEnhancementFlags, MouseEventKind, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, +}; +use crossterm::style::{ + Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor, +}; +use crossterm::terminal::{self, Clear, ClearType, disable_raw_mode, enable_raw_mode}; +use crossterm::{Command as CrosstermCommand, execute, queue}; +use derive_setters::Setters; +use nucleo::pattern::{CaseMatching, Normalization}; +use nucleo::{Config as NucleoConfig, Nucleo, Utf32String}; + +/// Row rendered by the shared selector UI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SelectRow { + /// Machine-readable value returned when the row is selected. + pub raw: String, + /// User-facing text rendered in the selector list. + pub display: String, + /// Text indexed by the fuzzy matcher. + pub search: String, + /// Additional machine-readable fields used for preview placeholder + /// expansion. + pub fields: Vec, +} + +impl SelectRow { + /// Creates a selectable row with a raw value and a display value. + pub fn new(raw: impl Into, display: impl Into) -> Self { + let raw = raw.into(); + Self { + fields: vec![raw.clone()], + search: raw.clone(), + raw, + display: display.into(), + } + } + + /// Sets the text indexed by the fuzzy matcher. + pub fn search(mut self, search: impl Into) -> Self { + self.search = search.into(); + self + } + + /// Creates a non-selectable header row. + pub fn header(display: impl Into) -> Self { + Self { + raw: String::new(), + display: display.into(), + search: String::new(), + fields: Vec::new(), + } + } +} + +impl fmt::Display for SelectRow { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.display) + } +} + +/// Placement of the selector preview pane. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreviewPlacement { + /// Render preview to the right of the list. + Right, + /// Render preview below the list. + Bottom, +} + +/// Preview pane layout configuration. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreviewLayout { + /// Preview pane placement. + pub placement: PreviewPlacement, + /// Percentage of available space allocated to preview. + pub percent: u16, +} + +impl Default for PreviewLayout { + fn default() -> Self { + Self { placement: PreviewPlacement::Right, percent: 50 } + } +} + +const SELECT_VIEWPORT_PERCENT: u16 = 80; + +fn max_select_viewport_height(full_height: u16) -> u16 { + let full_height = full_height.max(1); + ((full_height as u32 * SELECT_VIEWPORT_PERCENT as u32) / 100) + .max(1) + .min(full_height as u32) as u16 +} + +fn select_viewport_height(full_height: u16, desired_height: u16) -> u16 { + let full_height = full_height.max(1); + let desired_height = desired_height.max(1); + if desired_height <= full_height { + desired_height + } else { + max_select_viewport_height(full_height) + } +} + +fn reserve_inline_viewport_space(stderr: &mut io::Stderr, desired_height: u16) -> io::Result { + let (_, full_height) = terminal::size()?; + let reserved_height = select_viewport_height(full_height, desired_height); + + for _ in 0..reserved_height { + queue!(stderr, Print("\r\n"))?; + } + queue!( + stderr, + MoveUp(reserved_height), + MoveToColumn(0), + SavePosition + )?; + stderr.flush()?; + + Ok(reserved_height) +} + +fn desired_select_viewport_height( + header_rows: usize, + matched_rows: usize, + preview_lines: usize, + layout: PreviewLayout, +) -> u16 { + let header_height = 2u16.saturating_add(header_rows as u16); + let list_height = (matched_rows as u16).max(1); + let preview_lines = preview_lines as u16; + + match layout.placement { + PreviewPlacement::Right => header_height.saturating_add(list_height), + PreviewPlacement::Bottom if preview_lines > 0 => header_height + .saturating_add(list_height) + .saturating_add(preview_lines.saturating_add(2)), + PreviewPlacement::Bottom => header_height.saturating_add(list_height), + } + .max(1) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DeleteLines(u16); + +impl CrosstermCommand for DeleteLines { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + if self.0 > 0 { + write!(f, "\u{1b}[{}M", self.0)?; + } + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ViewportMoveTo { + x: u16, + y: u16, +} + +impl CrosstermCommand for ViewportMoveTo { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + RestorePosition.write_ansi(f)?; + for _ in 0..self.y { + write!(f, "\r\n")?; + } + MoveToColumn(self.x).write_ansi(f)?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } +} + +fn viewport_move_to(x: u16, y: u16, _top_offset: u16) -> ViewportMoveTo { + ViewportMoveTo { x, y } +} + +fn clear_rendered_viewport(stderr: &mut io::Stderr, reserved_height: u16) -> io::Result<()> { + for row_index in 0..reserved_height { + queue!( + stderr, + viewport_move_to(0, row_index, 0), + Clear(ClearType::CurrentLine) + )?; + } + queue!( + stderr, + RestorePosition, + MoveToColumn(0), + DeleteLines(reserved_height) + )?; + stderr.flush() +} + +struct TerminalGuard { + raw_mode_was_enabled: bool, +} + +impl TerminalGuard { + fn enter() -> anyhow::Result { + let raw_mode_was_enabled = terminal::is_raw_mode_enabled()?; + enable_raw_mode()?; + execute!( + io::stderr(), + EnableMouseCapture, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES), + Hide + )?; + Ok(Self { raw_mode_was_enabled }) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let mut stderr = io::stderr(); + let _ = execute!( + stderr, + Show, + PopKeyboardEnhancementFlags, + DisableMouseCapture + ); + if !self.raw_mode_was_enabled { + let _ = disable_raw_mode(); + } + } +} + +/// Options for running the shared selector UI. +#[derive(Debug, Setters)] +#[setters(into)] +pub struct SelectUiOptions { + /// Optional prompt text displayed before the query. + #[setters(skip)] + pub prompt: Option, + /// Optional initial search query. + pub query: Option, + /// Rows rendered by the selector. + pub rows: Vec, + /// Number of leading rows treated as non-selectable headers. + pub header_lines: usize, + /// Selection mode. + pub mode: SelectMode, + /// Optional shell command used to render the selected row preview. + pub preview: Option, + /// Preview pane layout. + pub preview_layout: PreviewLayout, + /// Optional raw value to focus initially. + pub initial_raw: Option, +} + +impl SelectUiOptions { + /// Creates selector options for the provided prompt and rows. + pub fn new(prompt: impl Into, rows: Vec) -> Self { + Self { + prompt: Some(prompt.into()), + query: None, + rows, + header_lines: 0, + mode: SelectMode::Single, + preview: None, + preview_layout: PreviewLayout::default(), + initial_raw: None, + } + } + + /// Runs the selector and returns the selected row. + /// + /// # Errors + /// + /// Returns an error if terminal setup, event handling, rendering, or + /// preview command execution setup fails. + pub fn prompt(self) -> anyhow::Result> { + let rows = self.rows.clone(); + let selected_raw = run_select_ui(self)?; + Ok(selected_raw.and_then(|raw| rows.into_iter().find(|row| row.raw == raw))) + } + + /// Runs the selector and returns all selected rows. + /// + /// # Errors + /// + /// Returns an error if terminal setup, event handling, rendering, or + /// preview command execution setup fails. + pub fn prompt_multi(self) -> anyhow::Result>> { + let rows = self.rows.clone(); + let selected_raws = run_select_ui_values(self)?; + Ok(selected_raws.map(|raws| { + raws.into_iter() + .filter_map(|raw| rows.iter().find(|row| row.raw == raw).cloned()) + .collect() + })) + } +} + +/// Runs the shared nucleo-backed selector UI and returns the selected raw +/// value. +/// +/// # Errors +/// +/// Returns an error if terminal setup, event handling, rendering, or preview +/// command execution setup fails. +pub fn run_select_ui(options: SelectUiOptions) -> anyhow::Result> { + Ok(run_select_ui_values(options)?.and_then(|values| values.into_iter().next())) +} + +fn run_select_ui_values(options: SelectUiOptions) -> anyhow::Result>> { + let SelectUiOptions { + prompt, + query, + rows, + header_lines, + mode, + preview, + preview_layout, + initial_raw, + } = options; + let header_count = header_lines.min(rows.len()); + let header_rows = rows.iter().take(header_count).collect::>(); + let data_rows = rows.iter().skip(header_count).cloned().collect::>(); + if data_rows.is_empty() { + return Ok(None); + } + + let mut matcher = Nucleo::new(NucleoConfig::DEFAULT, Arc::new(|| {}), None, 1); + let injector = matcher.injector(); + for row in data_rows.iter().cloned() { + injector.push(row, |item, columns| { + if let Some(column) = columns.get_mut(0) { + *column = Utf32String::from(item.search.as_str()); + } + }); + } + drop(injector); + + let mut query = query.unwrap_or_default(); + matcher + .pattern + .reparse(0, &query, CaseMatching::Smart, Normalization::Smart, false); + let _ = matcher.tick(50); + + let guard = TerminalGuard::enter()?; + let mut stderr = io::stderr(); + let prompt = prompt.unwrap_or_else(|| "❯ ".to_string()); + let preview_command = preview.unwrap_or_default(); + let initial_matched_rows = matched_rows(&matcher); + let initial_desired_height = desired_select_viewport_height( + header_rows.len(), + initial_matched_rows.len(), + 0, + preview_layout, + ); + let reserved_height = reserve_inline_viewport_space(&mut stderr, initial_desired_height)?; + let mut selected_index = 0usize; + let mut initial_raw = initial_raw; + let mut initial_selection_applied = false; + let mut scroll_offset = 0usize; + let mut preview_scroll_offset = 0usize; + let mut queued_indices = BTreeSet::new(); + let mut preview_cache = String::new(); + let mut last_preview_key = String::new(); + let mut last_query = query.clone(); + + let mut needs_render = true; + loop { + if query != last_query { + matcher.pattern.reparse( + 0, + &query, + CaseMatching::Smart, + Normalization::Smart, + query.starts_with(&last_query), + ); + let previous_query = last_query.clone(); + last_query = query.clone(); + let _ = matcher.tick(50); + selected_index = if query.starts_with(&previous_query) { + selected_index + } else { + 0 + }; + scroll_offset = 0; + preview_scroll_offset = 0; + needs_render = true; + } + + let matched_rows = matched_rows(&matcher); + if !initial_selection_applied { + if let Some(initial_raw) = initial_raw.take() + && let Some(index) = matched_rows.iter().position(|row| row.raw == initial_raw) + { + selected_index = index; + needs_render = true; + } + initial_selection_applied = true; + } + + if matched_rows.is_empty() { + if selected_index != 0 || scroll_offset != 0 { + needs_render = true; + } + selected_index = 0; + scroll_offset = 0; + } else if selected_index >= matched_rows.len() { + selected_index = matched_rows.len().saturating_sub(1); + needs_render = true; + } + + let selected_row = matched_rows.get(selected_index).copied(); + let preview_key = selected_row + .map(|row| format!("{}\0{}", row.raw, query)) + .unwrap_or_default(); + if preview_key != last_preview_key { + preview_cache = selected_row + .map(|row| render_preview(&preview_command, row)) + .unwrap_or_else(|| "No matches".to_string()); + preview_scroll_offset = 0; + last_preview_key = preview_key; + needs_render = true; + } + + let rendered_preview = if preview_command.is_empty() { + "" + } else { + &preview_cache + }; + + if needs_render { + draw_preview_ui( + &mut stderr, + PreviewUi { + prompt: &prompt, + query: &query, + total_rows: data_rows.len(), + matched_rows: &matched_rows, + header_rows: &header_rows, + selected_index, + scroll_offset: &mut scroll_offset, + preview: rendered_preview, + preview_scroll_offset, + layout: preview_layout, + reserved_height, + }, + )?; + needs_render = false; + } + + if event::poll(Duration::from_millis(250))? { + match event::read()? { + Event::Key(key) => { + match handle_key_event( + key, + &mut query, + matched_rows.len(), + &mut selected_index, + !preview_command.is_empty(), + ) { + PickerAction::Continue => { + needs_render = true; + } + PickerAction::PreviewScrollUp => { + preview_scroll_offset = preview_scroll_offset.saturating_sub(1); + needs_render = true; + } + PickerAction::PreviewScrollDown => { + preview_scroll_offset = preview_scroll_offset.saturating_add(1); + needs_render = true; + } + PickerAction::PreviewPageUp => { + let page_size = preview_content_height( + header_rows.len(), + matched_rows.len(), + &preview_cache, + preview_layout, + reserved_height, + ) + .saturating_sub(1) + .max(1); + preview_scroll_offset = preview_scroll_offset.saturating_sub(page_size); + needs_render = true; + } + PickerAction::PreviewPageDown => { + let page_size = preview_content_height( + header_rows.len(), + matched_rows.len(), + &preview_cache, + preview_layout, + reserved_height, + ) + .saturating_sub(1) + .max(1); + preview_scroll_offset = preview_scroll_offset.saturating_add(page_size); + needs_render = true; + } + PickerAction::Toggle => { + if mode == SelectMode::Multi && selected_row.is_some() { + if !queued_indices.remove(&selected_index) { + queued_indices.insert(selected_index); + } + selected_index = cmp::min( + selected_index + 1, + matched_rows.len().saturating_sub(1), + ); + needs_render = true; + } + } + PickerAction::Accept => { + if mode == SelectMode::Multi && !queued_indices.is_empty() { + clear_rendered_viewport(&mut stderr, reserved_height)?; + drop(guard); + let selected = queued_indices + .iter() + .filter_map(|index| matched_rows.get(*index)) + .map(|row| row.raw.clone()) + .collect::>(); + return Ok(Some(selected)); + } + + if let Some(row) = selected_row { + clear_rendered_viewport(&mut stderr, reserved_height)?; + drop(guard); + return Ok(Some(vec![row.raw.clone()])); + } + } + PickerAction::Exit => { + clear_rendered_viewport(&mut stderr, reserved_height)?; + drop(guard); + return Ok(None); + } + } + } + Event::Mouse(mouse) => { + if !preview_command.is_empty() + && mouse_over_preview( + mouse.column, + mouse.row, + header_rows.len(), + matched_rows.len(), + &preview_cache, + preview_layout, + reserved_height, + ) + { + match mouse.kind { + MouseEventKind::ScrollUp => { + preview_scroll_offset = preview_scroll_offset.saturating_sub(3); + needs_render = true; + } + MouseEventKind::ScrollDown => { + preview_scroll_offset = preview_scroll_offset.saturating_add(3); + needs_render = true; + } + _ => {} + } + } else { + match mouse.kind { + MouseEventKind::ScrollUp => { + selected_index = selected_index.saturating_sub(1); + needs_render = true; + } + MouseEventKind::ScrollDown => { + selected_index = cmp::min( + selected_index.saturating_add(1), + matched_rows.len().saturating_sub(1), + ); + needs_render = true; + } + _ => {} + } + } + } + Event::Resize(_, _) => { + needs_render = true; + } + _ => {} + } + } + + if !preview_command.is_empty() { + let clamped_offset = preview_scroll_offset.min(max_preview_scroll_offset( + &preview_cache, + header_rows.len(), + matched_rows.len(), + preview_layout, + reserved_height, + )); + if clamped_offset != preview_scroll_offset { + preview_scroll_offset = clamped_offset; + needs_render = true; + } + } + } +} + +/// Selector behavior for accepting one or more rows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectMode { + /// Accept a single row. + Single, + /// Accept multiple rows queued with tab. + Multi, +} + +#[derive(Debug, PartialEq, Eq)] +enum PickerAction { + Continue, + Accept, + Toggle, + Exit, + PreviewScrollUp, + PreviewScrollDown, + PreviewPageUp, + PreviewPageDown, +} + +fn handle_key_event( + key: KeyEvent, + query: &mut String, + matched_len: usize, + selected_index: &mut usize, + has_preview: bool, +) -> PickerAction { + match key { + KeyEvent { + code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. + } + | KeyEvent { code: KeyCode::Esc, .. } => PickerAction::Exit, + KeyEvent { code: KeyCode::Char('U'), .. } if has_preview => PickerAction::PreviewPageUp, + KeyEvent { code: KeyCode::Char('u'), modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewPageUp + } + KeyEvent { code: KeyCode::PageUp, modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewPageUp + } + KeyEvent { code: KeyCode::Char('D'), .. } if has_preview => PickerAction::PreviewPageDown, + KeyEvent { code: KeyCode::Char('d'), modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewPageDown + } + KeyEvent { code: KeyCode::PageDown, modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewPageDown + } + KeyEvent { code: KeyCode::Char('K'), .. } if has_preview => PickerAction::PreviewScrollUp, + KeyEvent { code: KeyCode::Char('k'), modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewScrollUp + } + KeyEvent { code: KeyCode::Up, modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewScrollUp + } + KeyEvent { code: KeyCode::Char('J'), .. } if has_preview => PickerAction::PreviewScrollDown, + KeyEvent { code: KeyCode::Char('j'), modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewScrollDown + } + KeyEvent { code: KeyCode::Down, modifiers, .. } + if has_preview && modifiers.contains(KeyModifiers::SHIFT) => + { + PickerAction::PreviewScrollDown + } + KeyEvent { code: KeyCode::Enter, .. } => PickerAction::Accept, + KeyEvent { code: KeyCode::BackTab, .. } | KeyEvent { code: KeyCode::Tab, .. } => { + PickerAction::Toggle + } + KeyEvent { code: KeyCode::Up, .. } => { + if matched_len > 0 { + *selected_index = selected_index.saturating_sub(1); + } + PickerAction::Continue + } + KeyEvent { code: KeyCode::Down, .. } => { + if matched_len > 0 { + *selected_index = cmp::min(*selected_index + 1, matched_len.saturating_sub(1)); + } + PickerAction::Continue + } + KeyEvent { code: KeyCode::PageUp, .. } => { + if matched_len > 0 { + *selected_index = selected_index.saturating_sub(10); + } + PickerAction::Continue + } + KeyEvent { code: KeyCode::PageDown, .. } => { + if matched_len > 0 { + *selected_index = cmp::min(*selected_index + 10, matched_len.saturating_sub(1)); + } + PickerAction::Continue + } + KeyEvent { code: KeyCode::Backspace, .. } => { + query.pop(); + PickerAction::Continue + } + KeyEvent { code: KeyCode::Char(ch), modifiers, .. } + if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => + { + query.push(ch); + PickerAction::Continue + } + _ => PickerAction::Continue, + } +} + +fn max_preview_scroll_offset( + preview: &str, + header_rows: usize, + matched_rows: usize, + layout: PreviewLayout, + reserved_height: u16, +) -> usize { + preview.lines().count().saturating_sub( + preview_content_height(header_rows, matched_rows, preview, layout, reserved_height).max(1), + ) +} + +fn preview_content_height( + header_rows: usize, + matched_rows: usize, + preview: &str, + layout: PreviewLayout, + reserved_height: u16, +) -> usize { + let Ok((_, height)) = terminal::size() else { + return 1; + }; + let desired_height = + desired_select_viewport_height(header_rows, matched_rows, preview.lines().count(), layout); + let height = select_viewport_height(height, desired_height).min(reserved_height); + let header_height = 2u16.saturating_add(header_rows as u16); + let body_height = height.saturating_sub(header_height).max(1); + + (match layout.placement { + PreviewPlacement::Right => body_height, + PreviewPlacement::Bottom => { + let preview_height = ((height as u32 * layout.percent as u32) / 100) as u16; + preview_height + .clamp(3, body_height.saturating_sub(1).max(3)) + .saturating_sub(2) + } + }) as usize +} + +fn mouse_over_preview( + column: u16, + row: u16, + header_rows: usize, + matched_rows: usize, + preview: &str, + layout: PreviewLayout, + reserved_height: u16, +) -> bool { + let Ok((width, height)) = terminal::size() else { + return false; + }; + let width = width.max(20); + let desired_height = + desired_select_viewport_height(header_rows, matched_rows, preview.lines().count(), layout); + let height = select_viewport_height(height, desired_height).min(reserved_height); + let header_height = 2u16.saturating_add(header_rows as u16); + let body_height = height.saturating_sub(header_height).max(1); + + match layout.placement { + PreviewPlacement::Right => { + let preview_width = ((width as u32 * layout.percent as u32) / 100) as u16; + let preview_width = preview_width.clamp(10, width.saturating_sub(10)); + let list_width = width.saturating_sub(preview_width + 3).max(10); + let preview_x = list_width + 3; + column >= preview_x && column < width && row >= header_height && row < height + } + PreviewPlacement::Bottom => { + let preview_height = ((height as u32 * layout.percent as u32) / 100) as u16; + let preview_height = preview_height.clamp(3, body_height.saturating_sub(1).max(3)); + let list_height = body_height.saturating_sub(preview_height).max(1); + let preview_y = header_height + list_height; + column < width && row >= preview_y && row < preview_y.saturating_add(preview_height) + } + } +} + +fn matched_rows(matcher: &Nucleo) -> Vec<&SelectRow> { + matcher + .snapshot() + .matched_items(..) + .map(|item| item.data) + .collect() +} + +fn render_preview(command: &str, row: &SelectRow) -> String { + if command.trim().is_empty() { + return String::new(); + } + + let substituted = substitute_preview_command(command, row); + let output = Command::new("/bin/sh") + .arg("-c") + .arg(&substituted) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output(); + + match output { + Ok(output) => { + let mut rendered = output.stdout.to_str_lossy().into_owned(); + let stderr = output.stderr.to_str_lossy(); + if !stderr.is_empty() { + if !rendered.is_empty() && !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(&stderr); + } + rendered + } + Err(error) => format!("Preview command failed: {error}"), + } +} + +fn substitute_preview_command(command: &str, row: &SelectRow) -> String { + let mut rendered = command.replace("{}", &shell_escape(&row.raw)); + for (index, field) in row.fields.iter().enumerate() { + let token = format!("{{{}}}", index + 1); + rendered = rendered.replace(&token, &shell_escape(field)); + } + rendered +} + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +struct PreviewUi<'a> { + prompt: &'a str, + query: &'a str, + total_rows: usize, + matched_rows: &'a [&'a SelectRow], + header_rows: &'a [&'a SelectRow], + selected_index: usize, + scroll_offset: &'a mut usize, + preview: &'a str, + preview_scroll_offset: usize, + layout: PreviewLayout, + reserved_height: u16, +} + +fn draw_preview_ui(stderr: &mut io::Stderr, ui: PreviewUi<'_>) -> anyhow::Result<()> { + let PreviewUi { + prompt, + query, + total_rows, + matched_rows, + header_rows, + selected_index, + scroll_offset, + preview, + preview_scroll_offset, + layout, + reserved_height, + } = ui; + let (width, height) = terminal::size()?; + let width = width.max(20); + + let has_preview = !preview.is_empty(); + let desired_height = desired_select_viewport_height( + header_rows.len(), + matched_rows.len(), + preview.lines().count(), + layout, + ); + let height = select_viewport_height(height, desired_height).min(reserved_height); + let top_offset = 0; + let header_height = 3u16.saturating_add(header_rows.len() as u16); + let body_height = height.saturating_sub(header_height).max(1); + + let ( + list_x, + list_y, + list_width, + list_height, + preview_x, + preview_y, + preview_width, + preview_height, + ) = if has_preview { + match layout.placement { + PreviewPlacement::Right => { + let preview_width = ((width as u32 * layout.percent as u32) / 100) as u16; + let preview_width = preview_width.clamp(10, width.saturating_sub(10)); + let list_width = width.saturating_sub(preview_width + 3).max(10); + ( + 0, + header_height, + list_width, + body_height, + list_width + 3, + header_height, + preview_width, + body_height, + ) + } + PreviewPlacement::Bottom => { + let preview_height = ((height as u32 * layout.percent as u32) / 100) as u16; + let preview_height = preview_height.clamp(3, body_height.saturating_sub(1).max(3)); + let list_height = body_height.saturating_sub(preview_height).max(1); + ( + 0, + header_height, + width, + list_height, + 0, + header_height + list_height, + width, + preview_height, + ) + } + } + } else { + (0, header_height, width, body_height, 0, height, 0, 0) + }; + + let visible_rows = list_height as usize; + if visible_rows > 0 { + if selected_index < *scroll_offset { + *scroll_offset = selected_index; + } else if selected_index >= scroll_offset.saturating_add(visible_rows) { + *scroll_offset = selected_index.saturating_sub(visible_rows.saturating_sub(1)); + } + } + + for row_index in 0..reserved_height { + queue!( + stderr, + viewport_move_to(0, row_index, 0), + Clear(ClearType::CurrentLine) + )?; + } + queue!( + stderr, + viewport_move_to(0, 0, top_offset), + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::AnsiValue(110)), + Print(truncate_line( + &format_prompt_query(prompt, query), + width as usize + )), + ResetColor, + SetAttribute(Attribute::Reset) + )?; + queue!( + stderr, + viewport_move_to(2, 1, top_offset), + SetForegroundColor(Color::AnsiValue(144)), + Print(format!("{}/{}", matched_rows.len(), total_rows)), + SetForegroundColor(Color::AnsiValue(59)), + Print(" "), + Print(truncate_line( + &"─".repeat(width as usize), + width.saturating_sub(3 + match_count_width(matched_rows.len(), total_rows)) as usize, + )), + ResetColor + )?; + for (index, row) in header_rows.iter().enumerate() { + let row_y = 2u16.saturating_add(index as u16); + if row_y < header_height { + queue!( + stderr, + viewport_move_to(2, row_y, top_offset), + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::AnsiValue(109)) + )?; + queue!( + stderr, + Print(truncate_line( + &row.display, + width.saturating_sub(2) as usize + )) + )?; + queue!(stderr, ResetColor, SetAttribute(Attribute::Reset))?; + } + } + + for row_index in 0..list_height { + queue!( + stderr, + viewport_move_to(list_x, list_y + row_index, top_offset), + Clear(ClearType::CurrentLine) + )?; + let item_index = *scroll_offset + row_index as usize; + if let Some(row) = matched_rows.get(item_index) { + let is_selected = item_index == selected_index; + let marker = "▌"; + let content_width = list_width.saturating_sub(2) as usize; + if is_selected { + queue!( + stderr, + viewport_move_to(list_x, list_y + row_index, top_offset), + SetAttribute(Attribute::Bold), + SetForegroundColor(Color::AnsiValue(161)), + SetBackgroundColor(Color::AnsiValue(236)), + Print(marker), + SetForegroundColor(Color::AnsiValue(254)), + Print(" "), + Print(truncate_line_with_ellipsis(&row.display, content_width)), + ResetColor, + SetAttribute(Attribute::Reset) + )?; + } else { + queue!( + stderr, + viewport_move_to(list_x, list_y + row_index, top_offset), + SetForegroundColor(Color::AnsiValue(236)), + Print(marker), + ResetColor, + Print(" "), + Print(truncate_line_with_ellipsis(&row.display, content_width)) + )?; + } + } + } + + if has_preview { + match layout.placement { + PreviewPlacement::Right => { + let divider_x = list_width + 1; + for row_index in 0..body_height { + queue!( + stderr, + viewport_move_to(divider_x, header_height + row_index, top_offset), + Print("│") + )?; + } + } + PreviewPlacement::Bottom => { + queue!( + stderr, + viewport_move_to(0, preview_y, top_offset), + SetForegroundColor(Color::AnsiValue(59)), + Print("┌"), + Print("─".repeat(width.saturating_sub(2) as usize)), + Print("┐"), + ResetColor + )?; + } + } + + let preview_content_height = match layout.placement { + PreviewPlacement::Bottom => preview_height.saturating_sub(2), + PreviewPlacement::Right => preview_height, + } as usize; + let preview_width_for_content = match layout.placement { + PreviewPlacement::Bottom => preview_width.saturating_sub(4), + PreviewPlacement::Right => preview_width, + } as usize; + let preview_lines = wrap_preview_lines(preview, preview_width_for_content.max(1)); + let preview_scroll_offset = preview_scroll_offset.min( + preview_lines + .len() + .saturating_sub(preview_content_height.max(1)), + ); + for row_index in 0..preview_height { + let y = preview_y + row_index; + if layout.placement == PreviewPlacement::Bottom && row_index == 0 { + continue; + } + if layout.placement == PreviewPlacement::Bottom + && row_index == preview_height.saturating_sub(1) + { + queue!( + stderr, + viewport_move_to(preview_x, y, top_offset), + SetForegroundColor(Color::AnsiValue(59)), + Print("└"), + Print("─".repeat(preview_width.saturating_sub(2) as usize)), + Print("┘"), + ResetColor + )?; + continue; + } + + let (content_x, content_width) = if layout.placement == PreviewPlacement::Bottom { + queue!( + stderr, + viewport_move_to(preview_x, y, top_offset), + SetForegroundColor(Color::AnsiValue(59)), + Print("│"), + viewport_move_to(preview_x + preview_width.saturating_sub(1), y, top_offset), + Print("│"), + ResetColor + )?; + (preview_x + 2, preview_width.saturating_sub(4)) + } else { + (preview_x, preview_width) + }; + + queue!( + stderr, + viewport_move_to(content_x, y, top_offset), + Print(" ".repeat(content_width as usize)) + )?; + let line_index = if layout.placement == PreviewPlacement::Bottom { + preview_scroll_offset + row_index.saturating_sub(1) as usize + } else { + preview_scroll_offset + row_index as usize + }; + if let Some(line) = preview_lines.get(line_index) { + queue!( + stderr, + viewport_move_to(content_x, y, top_offset), + Print(truncate_line(line, content_width as usize)) + )?; + } + + if layout.placement == PreviewPlacement::Bottom + && row_index == 1 + && !preview_lines.is_empty() + { + let indicator = + preview_scroll_indicator(preview_scroll_offset, preview_lines.len()); + let indicator_width = indicator.chars().count() as u16; + if indicator_width.saturating_add(1) < preview_width { + queue!( + stderr, + viewport_move_to( + preview_x + preview_width.saturating_sub(indicator_width + 2), + y, + top_offset, + ), + SetAttribute(Attribute::Reverse), + SetForegroundColor(Color::AnsiValue(144)), + Print(indicator), + ResetColor, + SetAttribute(Attribute::Reset), + SetForegroundColor(Color::AnsiValue(59)), + Print(" "), + Print("│"), + ResetColor + )?; + } + } + } + } + + stderr.flush()?; + Ok(()) +} + +fn preview_scroll_indicator(scroll_offset: usize, line_count: usize) -> String { + format!("{}/{line_count}", scroll_offset.saturating_add(1)) +} + +fn wrap_preview_lines(preview: &str, max_width: usize) -> Vec { + if max_width == 0 { + return Vec::new(); + } + + preview + .lines() + .flat_map(|line| wrap_ansi_line(line, max_width)) + .collect() +} + +fn wrap_ansi_line(line: &str, max_width: usize) -> Vec { + const WRAP_ICON: &str = "↪ "; + const WRAP_ICON_WIDTH: usize = 2; + + if line.is_empty() { + return vec![String::new()]; + } + + let mut wrapped_lines = Vec::new(); + let mut current_line = String::new(); + let mut visible_width = 0usize; + let mut chars = line.chars().peekable(); + let mut is_continuation = false; + + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + current_line.push(ch); + for ansi_ch in chars.by_ref() { + current_line.push(ansi_ch); + if ansi_ch.is_ascii_alphabetic() || ansi_ch == '~' { + break; + } + } + continue; + } + + let current_limit = if is_continuation { + max_width.saturating_sub(WRAP_ICON_WIDTH).max(1) + } else { + max_width + }; + + if visible_width >= current_limit { + let pushed = if is_continuation { + format!("{WRAP_ICON}{current_line}") + } else { + current_line.clone() + }; + wrapped_lines.push(pushed); + current_line = String::new(); + visible_width = 0; + is_continuation = true; + } + + current_line.push(ch); + visible_width = visible_width.saturating_add(1); + } + + if !current_line.is_empty() { + let pushed = if is_continuation { + format!("{WRAP_ICON}{current_line}") + } else { + current_line + }; + wrapped_lines.push(pushed); + } + + if wrapped_lines.is_empty() { + vec![String::new()] + } else { + wrapped_lines + } +} + +fn format_prompt_query(prompt: &str, query: &str) -> String { + if query.is_empty() || prompt.ends_with(char::is_whitespace) { + format!("{prompt}{query}") + } else { + format!("{prompt} {query}") + } +} + +fn match_count_width(matched: usize, total: usize) -> u16 { + format!("{matched}/{total}").chars().count() as u16 +} + +fn truncate_line_with_ellipsis(value: &str, max_width: usize) -> String { + const ELLIPSIS: &str = "…"; + let full_width = value.chars().count(); + if full_width <= max_width { + return value.to_string(); + } + + if max_width <= ELLIPSIS.len() { + return ELLIPSIS.chars().take(max_width).collect(); + } + + let keep_width = max_width.saturating_sub(ELLIPSIS.len()); + let prefix: String = value.chars().take(keep_width).collect(); + format!("{prefix}{ELLIPSIS}") +} + +fn truncate_line(value: &str, max_width: usize) -> String { + let mut rendered = String::new(); + let mut visible_width = 0usize; + let mut chars = value.chars().peekable(); + let mut truncated = false; + let mut has_ansi = false; + + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + has_ansi = true; + rendered.push(ch); + for ansi_ch in chars.by_ref() { + rendered.push(ansi_ch); + if ansi_ch.is_ascii_alphabetic() || ansi_ch == '~' { + break; + } + } + continue; + } + + if visible_width >= max_width { + truncated = true; + break; + } + + rendered.push(ch); + visible_width = visible_width.saturating_add(1); + } + + if truncated && has_ansi { + rendered.push_str("\u{1b}[0m"); + } + + rendered +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_desired_select_viewport_height_right_ignores_preview_line_count() { + let fixture = PreviewLayout { placement: PreviewPlacement::Right, percent: 50 }; + let actual = desired_select_viewport_height(1, 2, 285, fixture); + let expected = 5; + assert_eq!(actual, expected); + } + + #[test] + fn test_desired_select_viewport_height_bottom_includes_preview_line_count() { + let fixture = PreviewLayout { placement: PreviewPlacement::Bottom, percent: 50 }; + let actual = desired_select_viewport_height(1, 2, 4, fixture); + let expected = 11; + assert_eq!(actual, expected); + } +} diff --git a/crates/forge_select/src/select.rs b/crates/forge_select/src/select.rs index 2eb0c5b70f..ed59b9d4ea 100644 --- a/crates/forge_select/src/select.rs +++ b/crates/forge_select/src/select.rs @@ -2,7 +2,8 @@ use std::io::IsTerminal; use anyhow::Result; use console::strip_ansi_codes; -use fzf_wrapped::{Fzf, Layout, run_with_output}; + +use crate::preview::{PreviewLayout, PreviewPlacement, SelectMode, SelectRow, SelectUiOptions}; /// Builder for select prompts with fuzzy search. pub struct SelectBuilder { @@ -17,102 +18,6 @@ pub struct SelectBuilder { pub(crate) preview_window: Option, } -/// Builds an `Fzf` instance with standard layout and an optional header. -/// -/// `--height=80%` is always added so fzf runs inline (below the current cursor) -/// rather than switching to the alternate screen buffer. Without this flag fzf -/// uses full-screen mode which enters the alternate screen (`\033[?1049h`), -/// making it appear as though the terminal is cleared. 80% matches the shell -/// plugin's `_forge_fzf` wrapper for a consistent UI. -/// -/// Items are always passed as `"{idx}\t{display}"` and fzf is configured with -/// `--delimiter=\t --with-nth=2..` so only the display portion is shown. The -/// index prefix survives in fzf's output and is parsed back to look up the -/// original item by position — this avoids the `position()` ambiguity when -/// multiple items have identical display strings after ANSI stripping. -/// -/// When `starting_cursor` is provided, `--bind="load:pos(N)"` is added so fzf -/// pre-positions the cursor on the Nth item (1-based in fzf's `pos()` action). -/// The `load` event is used instead of `start` because items are written to -/// fzf's stdin after the process starts. -/// -/// The flags `--exact`, `--cycle`, `--select-1`, `--no-scrollbar`, and -/// `--color=dark,header:bold` mirror the shell plugin's `_forge_fzf` wrapper -/// for a consistent user experience across both entry points. -/// -/// The `message` is used as the fzf `--prompt` so the prompt line reads -/// `"Select a model: "` instead of the default `"> "`, placing the question -/// inline with the search cursor (e.g. `Select a model: ❯`). If a -/// `help_message` is provided it is shown as a `--header` above the list. -fn build_fzf( - message: &str, - help_message: Option<&str>, - initial_text: Option<&str>, - starting_cursor: Option, - header_lines: usize, - preview: Option<&str>, - preview_window: Option<&str>, -) -> Fzf { - let mut builder = Fzf::builder(); - builder.layout(Layout::Reverse); - builder.no_scrollbar(true); - builder.prompt(format!("{} ❯ ", message)); - - if let Some(help) = help_message { - builder.header(help); - } - - let mut args = vec![ - "--height=80%".to_string(), - "--exact".to_string(), - "--cycle".to_string(), - "--select-1".to_string(), - "--color=dark,header:bold".to_string(), - "--pointer=▌".to_string(), - "--delimiter=\t".to_string(), - "--with-nth=2..".to_string(), - ]; - if let Some(query) = initial_text { - args.push(format!("--query={}", query)); - } - if let Some(cursor) = starting_cursor { - args.push(format!("--bind=load:pos({})", cursor + 1)); - } - if header_lines > 0 { - args.push(format!("--header-lines={}", header_lines)); - } - if let Some(cmd) = preview { - args.push(format!("--preview={}", cmd)); - } - if let Some(window) = preview_window { - args.push(format!("--preview-window={}", window)); - } - builder.custom_args(args); - - builder - .build() - .expect("fzf builder should always succeed with default options") -} - -/// Formats items as `"{idx}\t{display}"` for passing to fzf. -/// -/// The index prefix lets us recover the original position from fzf's output -/// without relying on string matching, which breaks when multiple items have -/// the same display string. -pub(crate) fn indexed_items(display_options: &[String]) -> Vec { - display_options - .iter() - .enumerate() - .map(|(i, d)| format!("{}\t{}", i, d)) - .collect() -} - -/// Parses the index from a line returned by fzf when items were formatted with -/// `indexed_items`. Returns `None` if the line is malformed. -pub(crate) fn parse_fzf_index(line: &str) -> Option { - line.split('\t').next()?.trim().parse().ok() -} - impl SelectBuilder { /// Set starting cursor position. pub fn with_starting_cursor(mut self, cursor: usize) -> Self { @@ -121,25 +26,18 @@ impl SelectBuilder { } /// Set a preview command shown in a side panel as the user navigates items. - /// - /// The command is passed directly to fzf's `--preview` flag. Use `{2}` to - /// reference the display field of the currently highlighted item (field 2 - /// after the internal index tab-prefix). pub fn with_preview(mut self, command: impl Into) -> Self { self.preview = Some(command.into()); self } /// Set the layout of the preview panel. - /// - /// Passed directly to fzf's `--preview-window` flag (e.g. - /// `"bottom:75%:wrap:border-sharp"`). pub fn with_preview_window(mut self, layout: impl Into) -> Self { self.preview_window = Some(layout.into()); self } - /// Set default for confirm (only works with bool options). + /// Set default for confirm prompts using bool options. pub fn with_default(mut self, default: bool) -> Self { self.default = Some(default); self @@ -157,12 +55,7 @@ impl SelectBuilder { self } - /// Set the number of header lines (non-selectable) at the top of the list. - /// - /// When set to `n`, the first `n` items are displayed as a fixed header - /// that is always visible but cannot be selected. Mirrors fzf's - /// `--header-lines` flag, matching the shell plugin's porcelain output - /// where the first line contains column headings. + /// Set the number of header lines treated as non-selectable options. pub fn with_header_lines(mut self, n: usize) -> Self { self.header_lines = n; self @@ -172,19 +65,18 @@ impl SelectBuilder { /// /// # Returns /// - /// - `Ok(Some(T))` - User selected an option - /// - `Ok(None)` - No options available or user cancelled (ESC / Ctrl+C) + /// - `Ok(Some(T))` when the user selects an option. + /// - `Ok(None)` when no options are available or the user cancels. /// /// # Errors /// - /// Returns an error if the fzf process fails to start or interact. + /// Returns an error if the picker cannot set up terminal interaction, + /// render, process events, or run a preview command. pub fn prompt(self) -> Result> where T: std::fmt::Display + Clone, { - // Bail immediately when stdin is not a terminal to prevent the process - // from blocking indefinitely on a detached or non-interactive session. - if !std::io::stdin().is_terminal() { + if !std::io::stderr().is_terminal() { return Ok(None); } @@ -196,64 +88,100 @@ impl SelectBuilder { return Ok(None); } - let display_options: Vec = self + let rows = self .options .iter() - .map(|item| strip_ansi_codes(&item.to_string()).trim().to_string()) - .collect(); + .enumerate() + .map(|(index, item)| { + let display = strip_ansi_codes(&item.to_string()).trim().to_string(); + if index < self.header_lines { + SelectRow::header(display) + } else { + SelectRow::new(index.to_string(), display.clone()).search(display) + } + }) + .collect::>(); + + let header_count = self.header_lines.min(rows.len()); + if rows.len() == header_count { + return Ok(None); + } + + let mut selector = SelectUiOptions::new(format!("{} ❯ ", self.message), rows) + .header_lines(header_count) + .mode(SelectMode::Single) + .preview_layout(parse_preview_layout(self.preview_window.as_deref())); + + if let Some(query) = self.initial_text { + selector = selector.query(Some(query)); + } + + if let Some(preview) = self.preview { + selector = selector.preview(Some(preview)); + } - let fzf = build_fzf( - &self.message, - self.help_message, - self.initial_text.as_deref(), - self.starting_cursor, - self.header_lines, - self.preview.as_deref(), - self.preview_window.as_deref(), - ); - - let selected = run_with_output(fzf, indexed_items(&display_options)); - - match selected { - None => Ok(None), - Some(selection) if selection.trim().is_empty() => Ok(None), - Some(selection) => { - Ok(parse_fzf_index(&selection).and_then(|index| self.options.get(index).cloned())) - } + if let Some(cursor) = self.starting_cursor { + selector = selector.initial_raw(Some(cursor.to_string())); } + + if let Some(help) = self.help_message { + selector.rows.insert(0, SelectRow::header(help)); + selector.header_lines = selector.header_lines.saturating_add(1); + } + + let selected = selector.prompt()?; + Ok(selected.and_then(|row| { + row.raw + .parse::() + .ok() + .and_then(|index| self.options.get(index).cloned()) + })) } } -/// Runs a yes/no confirmation prompt via fzf. +fn parse_preview_layout(layout: Option<&str>) -> PreviewLayout { + let Some(layout) = layout else { + return PreviewLayout::default(); + }; + + let placement = if layout.contains("down") || layout.contains("bottom") { + PreviewPlacement::Bottom + } else { + PreviewPlacement::Right + }; + + let percent = layout + .split(|ch: char| !ch.is_ascii_digit()) + .find_map(|part| part.parse::().ok()) + .unwrap_or_else(|| PreviewLayout::default().percent) + .clamp(1, 99); + + PreviewLayout { placement, percent } +} + +/// Runs a yes/no confirmation prompt. /// /// Returns `Ok(Some(true))` for Yes, `Ok(Some(false))` for No, and `Ok(None)` /// if cancelled. fn prompt_confirm(message: &str, default: Option) -> Result> { - let items = ["Yes", "No"]; - let starting_cursor = if default == Some(false) { - Some(1) + let rows = if default == Some(false) { + vec![SelectRow::new("no", "No"), SelectRow::new("yes", "Yes")] } else { - Some(0) + vec![SelectRow::new("yes", "Yes"), SelectRow::new("no", "No")] }; - let fzf = build_fzf(message, None, None, starting_cursor, 0, None, None); - let selected = run_with_output(fzf, items.iter().copied()); - - let result: Option = match selected.as_deref().map(str::trim) { - Some("Yes") => Some(true), - Some("No") => Some(false), + let selected = SelectUiOptions::new(format!("{} ❯ ", message), rows).prompt()?; + Ok(selected.and_then(|row| match row.raw.as_str() { + "yes" => Some(true), + "no" => Some(false), _ => None, - }; - - Ok(result) + })) } /// Wrapper around [`prompt_confirm`] that safely converts the `bool` result /// into the generic type `T`. /// -/// This must only be called when `T` is known to be `bool` (verified via -/// `TypeId` at the call site). The conversion uses `Any` downcasting instead -/// of `transmute_copy` to remain fully safe. +/// This must only be called when `T` is known to be `bool`. fn prompt_confirm_as( message: &str, default: Option, @@ -301,36 +229,15 @@ mod tests { #[test] fn test_ansi_stripping() { - let options = ["\x1b[1mBold\x1b[0m", "\x1b[31mRed\x1b[0m"]; - let display: Vec = options + let fixture = ["\x1b[1mBold\x1b[0m", "\x1b[31mRed\x1b[0m"]; + let actual: Vec = fixture .iter() .map(|value| strip_ansi_codes(value).to_string()) .collect(); - - assert_eq!(display, vec!["Bold", "Red"]); - } - - #[test] - fn test_indexed_items() { - let fixture = vec![ - "Apple".to_string(), - "Apple".to_string(), - "Banana".to_string(), - ]; - let actual = indexed_items(&fixture); - let expected = vec!["0\tApple", "1\tApple", "2\tBanana"]; + let expected = vec!["Bold", "Red"]; assert_eq!(actual, expected); } - #[test] - fn test_parse_fzf_index() { - assert_eq!(parse_fzf_index("0\tApple"), Some(0)); - assert_eq!(parse_fzf_index("2\tBanana"), Some(2)); - assert_eq!(parse_fzf_index("1\tApple"), Some(1)); - assert_eq!(parse_fzf_index("notanindex\tApple"), None); - assert_eq!(parse_fzf_index(""), None); - } - #[test] fn test_display_options_are_trimmed() { let fixture = [ @@ -353,4 +260,20 @@ mod tests { let builder = ForgeWidget::select("Test", vec!["a", "b", "c"]).with_starting_cursor(2); assert_eq!(builder.starting_cursor, Some(2)); } + + #[test] + fn test_parse_preview_layout_defaults_to_right() { + let fixture = None; + let actual = parse_preview_layout(fixture); + let expected = PreviewLayout { placement: PreviewPlacement::Right, percent: 50 }; + assert_eq!(actual, expected); + } + + #[test] + fn test_parse_preview_layout_supports_bottom_percent() { + let fixture = Some("down,60%"); + let actual = parse_preview_layout(fixture); + let expected = PreviewLayout { placement: PreviewPlacement::Bottom, percent: 60 }; + assert_eq!(actual, expected); + } } diff --git a/crates/forge_select/src/widget.rs b/crates/forge_select/src/widget.rs index ae64ceadd6..ac73b5cd57 100644 --- a/crates/forge_select/src/widget.rs +++ b/crates/forge_select/src/widget.rs @@ -1,12 +1,13 @@ use crate::confirm::ConfirmBuilder; use crate::input::InputBuilder; use crate::multi::MultiSelectBuilder; +use crate::preview::{SelectRow, SelectUiOptions}; use crate::select::SelectBuilder; -/// Centralized fzf-based select functionality with consistent error handling. +/// Centralized fuzzy select functionality with consistent error handling. /// -/// All interactive selection is delegated to the external `fzf` binary. -/// Requires `fzf` to be installed on the system. +/// All interactive selection is handled by the shared nucleo-backed selector +/// UI. pub struct ForgeWidget; impl ForgeWidget { @@ -44,4 +45,9 @@ impl ForgeWidget { pub fn multi_select(message: impl Into, options: Vec) -> MultiSelectBuilder { MultiSelectBuilder { message: message.into(), options } } + + /// Entry point for row-based select operations. + pub fn select_rows(message: impl Into, rows: Vec) -> SelectUiOptions { + SelectUiOptions::new(message, rows) + } } diff --git a/shell-plugin/README.md b/shell-plugin/README.md index fb1b485dd0..a70c0bbb15 100644 --- a/shell-plugin/README.md +++ b/shell-plugin/README.md @@ -9,13 +9,12 @@ A powerful ZSH plugin that provides intelligent command transformation, file tag - **File Tagging**: Interactive file selection with `@[filename]` syntax - **Syntax Highlighting**: Visual feedback for commands and tagged files - **Conversation Continuity**: Automatic session management across commands -- **Interactive Completion**: Fuzzy finding for files and agents +- **Interactive Completion**: Fuzzy finding for files and agents via built-in picker ## Prerequisites Before using this plugin, ensure you have the following tools installed: -- **fzf** - Command-line fuzzy finder - **fd** - Fast file finder (alternative to find) - **forge** - The Forge CLI tool @@ -23,13 +22,13 @@ Before using this plugin, ensure you have the following tools installed: ```bash # macOS (using Homebrew) -brew install fzf fd +brew install fd # Ubuntu/Debian -sudo apt install fzf fd-find +sudo apt install fd-find # Arch Linux -sudo pacman -S fzf fd +sudo pacman -S fd ``` ## Usage @@ -250,7 +249,7 @@ This will check: - Forge installation and version - Plugin and theme loading status - Completions availability -- Dependencies (fzf, fd, bat) +- Dependencies (fd, bat) - ZSH plugins (autosuggestions, syntax-highlighting) - Editor configuration and PATH setup - Nerd Font support for icons diff --git a/shell-plugin/doctor.zsh b/shell-plugin/doctor.zsh index 25913697f3..ebb0330938 100755 --- a/shell-plugin/doctor.zsh +++ b/shell-plugin/doctor.zsh @@ -241,21 +241,9 @@ function version_gte() { # 5. Check dependencies print_section "Dependencies" -# Check for fzf - required for interactive selection -if command -v fzf &> /dev/null; then - local fzf_version=$(fzf --version 2>&1 | head -n1 | awk '{print $1}') - if [[ -n "$fzf_version" ]]; then - if version_gte "$fzf_version" "0.36.0"; then - print_result pass "fzf: ${fzf_version}" - else - print_result fail "fzf: ${fzf_version}" "Version 0.36.0 or higher required. Update: https://github.com/junegunn/fzf#installation" - fi - else - print_result pass "fzf: installed" - fi -else - print_result fail "fzf not found" "Required for interactive features. See installation: https://github.com/junegunn/fzf#installation" -fi +# Forge uses its built-in nucleo-picker for interactive selection +# No external fuzzy finder (like fzf) is required +print_result pass "Interactive picker: built-in (nucleo-picker)" # Check for fd/fdfind - used for file discovery if command -v fd &> /dev/null; then diff --git a/shell-plugin/lib/actions/auth.zsh b/shell-plugin/lib/actions/auth.zsh index e7fa1c8b62..d8f1e6f87d 100644 --- a/shell-plugin/lib/actions/auth.zsh +++ b/shell-plugin/lib/actions/auth.zsh @@ -6,13 +6,11 @@ function _forge_action_login() { local input_text="$1" echo - local selected - # Pass input_text as query parameter for fuzzy search - selected=$(_forge_select_provider "" "" "" "$input_text") - if [[ -n "$selected" ]]; then - # Extract the second field (provider ID) - # Use multi-space delimiter to handle display names containing spaces - local provider=$(echo "$selected" | awk -F ' +' '{print $2}') + + local provider + provider=$(_forge_select_with_query "$input_text" provider) + + if [[ -n "$provider" ]]; then _forge_exec_interactive provider login "$provider" fi } @@ -21,13 +19,11 @@ function _forge_action_login() { function _forge_action_logout() { local input_text="$1" echo - local selected - # Pass input_text as query parameter for fuzzy search - selected=$(_forge_select_provider "\[yes\]" "" "" "$input_text") - if [[ -n "$selected" ]]; then - # Extract the second field (provider ID) - # Use multi-space delimiter to handle display names containing spaces - local provider=$(echo "$selected" | awk -F ' +' '{print $2}') + + local provider + provider=$(_forge_select_with_query "$input_text" provider --configured) + + if [[ -n "$provider" ]]; then _forge_exec provider logout "$provider" fi } diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 05bee2de09..c51ba2a05d 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -28,201 +28,56 @@ function _forge_action_agent() { return 0 fi - # Get agents list - local agents_output - agents_output=$($_FORGE_BIN list agents --porcelain 2>/dev/null) + # Use forge select agent for interactive picking + local agent_id + agent_id=$(_forge_select_with_query "$input_text" agent) - if [[ -n "$agents_output" ]]; then - # Get current agent ID - local current_agent="$_FORGE_ACTIVE_AGENT" - - local sorted_agents="$agents_output" - - # Create prompt with current agent - show agent ID, title, provider, model and reasoning - local prompt_text="Agent ❯ " - local fzf_args=( - --prompt="$prompt_text" - --delimiter="$_FORGE_DELIMITER" - --with-nth="1,2,4,5,6" - ) - - # If there's a current agent, position cursor on it - if [[ -n "$current_agent" ]]; then - local index=$(_forge_find_index "$sorted_agents" "$current_agent") - fzf_args+=(--bind="start:pos($index)") - fi - - local selected_agent - # Use fzf without preview for simple selection like provider/model - selected_agent=$(echo "$sorted_agents" | _forge_fzf --header-lines=1 "${fzf_args[@]}") - - if [[ -n "$selected_agent" ]]; then - # Extract the first field (agent ID) - local agent_id=$(echo "$selected_agent" | awk '{print $1}') - - # Set the selected agent as active - _FORGE_ACTIVE_AGENT="$agent_id" - - # Print log about agent switching - _forge_log success "Switched to agent \033[1m${agent_id}\033[0m" - - fi - else - _forge_log error "No agents found" - fi -} - -# Helper: Open an fzf model picker and print the raw selected line. -# -# Model list columns (from `forge list models --porcelain`): -# 1:model_id 2:model_name 3:provider(display) 4:provider_id(raw) 5:context 6:tools 7:image -# The picker hides model_id (field 1) and provider_id (field 4) via --with-nth. -# -# Arguments: -# $1 prompt_text - fzf prompt label (e.g. "Model ❯ ") -# $2 current_model - model_id to pre-position the cursor on (may be empty) -# $3 input_text - optional pre-fill query for fzf -# $4 current_provider - provider value to disambiguate when model names collide (may be empty) -# $5 provider_field - which porcelain field to match the provider against -# (3 for display name, 4 for raw id) -# -# Outputs the raw selected line to stdout, or nothing if cancelled. -function _forge_pick_model() { - local prompt_text="$1" - local current_model="$2" - local input_text="$3" - local current_provider="${4:-}" - local provider_field="${5:-}" - - local output - output=$($_FORGE_BIN list models --porcelain 2>/dev/null) - - if [[ -z "$output" ]]; then - return 1 - fi - - local fzf_args=( - --delimiter="$_FORGE_DELIMITER" - --prompt="$prompt_text" - --with-nth="2,3,5.." - ) - - if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") - fi - - if [[ -n "$current_model" ]]; then - # Match on both model_id (field 1) and provider to disambiguate - # when the same model name exists across multiple providers - local index - if [[ -n "$current_provider" && -n "$provider_field" ]]; then - index=$(_forge_find_index "$output" "$current_model" 1 "$provider_field" "$current_provider") - else - index=$(_forge_find_index "$output" "$current_model" 1) - fi - fzf_args+=(--bind="start:pos($index)") + if [[ -n "$agent_id" ]]; then + _FORGE_ACTIVE_AGENT="$agent_id" + _forge_log success "Switched to agent \033[1m${agent_id}\033[0m" fi - - echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}" } -# Action handler: Select model (across all configured providers) +# Action handler: Select model for the current session only. # When the selected model belongs to a different provider, switches it first. function _forge_action_model() { local input_text="$1" - ( - echo - local current_model current_provider - current_model=$($_FORGE_BIN config get model 2>/dev/null) - # config get provider returns the display name (e.g. "OpenAI"), - # which corresponds to porcelain field 3 (provider display) - current_provider=$($_FORGE_BIN config get provider 2>/dev/null) - local selected - selected=$(_forge_pick_model "Model ❯ " "$current_model" "$input_text" "$current_provider" 3) - - if [[ -n "$selected" ]]; then - # Field 1 = model_id (raw), field 3 = provider display name, - # field 4 = provider_id (raw, for config set) - local model_id provider_display provider_id - # Extract fields separately to handle display names with spaces - model_id=$(echo "$selected" | awk -F ' +' '{print $1}') - provider_display=$(echo "$selected" | awk -F ' +' '{print $3}') - provider_id=$(echo "$selected" | awk -F ' +' '{print $4}') - model_id=${model_id//[[:space:]]/} - provider_id=${provider_id//[[:space:]]/} - provider_display=${provider_display//[[:space:]]/} - - # Switch provider first if it differs from the current one - # current_provider (fetched above) is the display name, compare against that - if [[ -n "$provider_display" && "$provider_display" != "$current_provider" ]]; then - _forge_exec_interactive config set model "$provider_id" "$model_id" - return - fi - _forge_exec config set model "$provider_id" "$model_id" - fi - ) + echo + + local model_id provider_id + if _forge_select_model_pair "$input_text"; then + model_id="${reply[1]}" + provider_id="${reply[2]}" + _forge_exec config set model "$provider_id" "$model_id" + fi } # Action handler: Select model for commit message generation # Calls `forge config set commit ` on selection. function _forge_action_commit_model() { local input_text="$1" - ( - echo - # config get commit outputs two lines: provider_id (raw) then model_id - local commit_output current_commit_model current_commit_provider - commit_output=$(_forge_exec config get commit 2>/dev/null) - current_commit_provider=$(echo "$commit_output" | head -n 1) - current_commit_model=$(echo "$commit_output" | tail -n 1) - - local selected - # provider_id from config get commit is the raw id, matching porcelain field 4 - selected=$(_forge_pick_model "Commit Model ❯ " "$current_commit_model" "$input_text" "$current_commit_provider" 4) - - if [[ -n "$selected" ]]; then - # Field 1 = model_id (raw), field 4 = provider_id (raw) - local model_id provider_id - # Extract fields separately to handle display names with spaces - model_id=$(echo "$selected" | awk -F ' +' '{print $1}') - provider_id=$(echo "$selected" | awk -F ' +' '{print $4}') - - model_id=${model_id//[[:space:]]/} - provider_id=${provider_id//[[:space:]]/} - - _forge_exec config set commit "$provider_id" "$model_id" - fi - ) + echo + + local model_id provider_id + if _forge_select_model_pair "$input_text"; then + model_id="${reply[1]}" + provider_id="${reply[2]}" + _forge_exec config set commit "$provider_id" "$model_id" + fi } # Action handler: Select model for command suggestion generation # Calls `forge config set suggest ` on selection. function _forge_action_suggest_model() { local input_text="$1" - ( - echo - # config get suggest outputs two lines: provider_id (raw) then model_id - local suggest_output current_suggest_model current_suggest_provider - suggest_output=$(_forge_exec config get suggest 2>/dev/null) - current_suggest_provider=$(echo "$suggest_output" | head -n 1) - current_suggest_model=$(echo "$suggest_output" | tail -n 1) - - local selected - # provider_id from config get suggest is the raw id, matching porcelain field 4 - selected=$(_forge_pick_model "Suggest Model ❯ " "$current_suggest_model" "$input_text" "$current_suggest_provider" 4) - - if [[ -n "$selected" ]]; then - # Field 1 = model_id (raw), field 4 = provider_id (raw) - local model_id provider_id - # Extract fields separately to handle display names with spaces - model_id=$(echo "$selected" | awk -F ' +' '{print $1}') - provider_id=$(echo "$selected" | awk -F ' +' '{print $4}') - - model_id=${model_id//[[:space:]]/} - provider_id=${provider_id//[[:space:]]/} - - _forge_exec config set suggest "$provider_id" "$model_id" - fi - ) + echo + + local model_id provider_id + if _forge_select_model_pair "$input_text"; then + model_id="${reply[1]}" + provider_id="${reply[2]}" + _forge_exec config set suggest "$provider_id" "$model_id" + fi } # Action handler: Sync workspace for codebase search @@ -254,56 +109,6 @@ function _forge_action_sync_info() { _forge_exec workspace info "." } -# Helper function to select and set config values with fzf -function _forge_select_and_set_config() { - local show_command="$1" - local config_flag="$2" - local prompt_text="$3" - local default_value="$4" - local with_nth="${5:-}" # Optional column selection parameter - local query="${6:-}" # Optional query parameter for fuzzy search - ( - echo - local output - # Handle multi-word commands properly - if [[ "$show_command" == *" "* ]]; then - # Split the command into words and execute with --porcelain - local cmd_parts=(${=show_command}) - output=$($_FORGE_BIN "${cmd_parts[@]}" --porcelain 2>/dev/null) - else - output=$($_FORGE_BIN "$show_command" --porcelain 2>/dev/null) - fi - - if [[ -n "$output" ]]; then - local selected - local fzf_args=(--delimiter="$_FORGE_DELIMITER" --prompt="$prompt_text ❯ ") - - if [[ -n "$with_nth" ]]; then - fzf_args+=(--with-nth="$with_nth") - fi - - # Add query parameter if provided - if [[ -n "$query" ]]; then - fzf_args+=(--query="$query") - fi - - if [[ -n "$default_value" ]]; then - # For models, compare against the first field (model_id) - local index=$(_forge_find_index "$output" "$default_value" 1) - - fzf_args+=(--bind="start:pos($index)") - - fi - selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") - - if [[ -n "$selected" ]]; then - local name="${selected%% *}" - _forge_exec config set "$config_flag" "$name" - fi - fi - ) -} - # Action handler: Select model for the current session only. # Sets _FORGE_SESSION_MODEL and _FORGE_SESSION_PROVIDER in the shell environment # so that every subsequent forge invocation uses those values via --model / @@ -312,40 +117,10 @@ function _forge_action_session_model() { local input_text="$1" echo - local current_model current_provider provider_index - # Use session overrides as the starting selection if already set, - # otherwise fall back to the globally configured values. - if [[ -n "$_FORGE_SESSION_MODEL" ]]; then - current_model="$_FORGE_SESSION_MODEL" - provider_index=4 - else - current_model=$($_FORGE_BIN config get model 2>/dev/null) - provider_index=3 - fi - if [[ -n "$_FORGE_SESSION_PROVIDER" ]]; then - current_provider="$_FORGE_SESSION_PROVIDER" - provider_index=4 - else - current_provider=$($_FORGE_BIN config get provider 2>/dev/null) - provider_index=3 - fi - - local selected - selected=$(_forge_pick_model "Session Model ❯ " "$current_model" "$input_text" "$current_provider" "$provider_index") - - if [[ -n "$selected" ]]; then - local model_id provider_display provider_id - # Extract fields separately to handle display names with spaces - model_id=$(echo "$selected" | awk -F ' +' '{print $1}') - provider_display=$(echo "$selected" | awk -F ' +' '{print $3}') - provider_id=$(echo "$selected" | awk -F ' +' '{print $4}') - model_id=${model_id//[[:space:]]/} - provider_id=${provider_id//[[:space:]]/} - - _FORGE_SESSION_MODEL="$model_id" - _FORGE_SESSION_PROVIDER="$provider_id" - - _forge_log success "Session model set to \033[1m${model_id}\033[0m (provider: \033[1m${provider_id}\033[0m)" + if _forge_select_model_pair "$input_text"; then + _FORGE_SESSION_MODEL="${reply[1]}" + _FORGE_SESSION_PROVIDER="${reply[2]}" + _forge_log success "Session model set to \033[1m${_FORGE_SESSION_MODEL}\033[0m (provider: \033[1m${_FORGE_SESSION_PROVIDER}\033[0m)" fi } @@ -376,31 +151,8 @@ function _forge_action_reasoning_effort() { local input_text="$1" echo - local efforts - efforts=$'EFFORT\nnone\nminimal\nlow\nmedium\nhigh\nxhigh\nmax' - - local current_effort - if [[ -n "$_FORGE_SESSION_REASONING_EFFORT" ]]; then - current_effort="$_FORGE_SESSION_REASONING_EFFORT" - else - current_effort=$($_FORGE_BIN config get reasoning-effort 2>/dev/null) - fi - - local fzf_args=( - --prompt="Reasoning Effort ❯ " - ) - - if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") - fi - - if [[ -n "$current_effort" ]]; then - local index=$(_forge_find_index "$efforts" "$current_effort" 1) - fzf_args+=(--bind="start:pos($index)") - fi - local selected - selected=$(echo "$efforts" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(_forge_select_with_query "$input_text" reasoning-effort) if [[ -n "$selected" ]]; then _FORGE_SESSION_REASONING_EFFORT="$selected" @@ -413,35 +165,14 @@ function _forge_action_reasoning_effort() { # writing the chosen effort level permanently to ~/forge/.forge.toml. function _forge_action_config_reasoning_effort() { local input_text="$1" - ( - echo - - local efforts - efforts=$'EFFORT\nnone\nminimal\nlow\nmedium\nhigh\nxhigh\nmax' - - local current_effort - current_effort=$($_FORGE_BIN config get reasoning-effort 2>/dev/null) - - local fzf_args=( - --prompt="Config Reasoning Effort ❯ " - ) - - if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") - fi - - if [[ -n "$current_effort" ]]; then - local index=$(_forge_find_index "$efforts" "$current_effort" 1) - fzf_args+=(--bind="start:pos($index)") - fi + echo - local selected - selected=$(echo "$efforts" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + local selected + selected=$(_forge_select_with_query "$input_text" reasoning-effort) - if [[ -n "$selected" ]]; then - _forge_exec config set reasoning-effort "$selected" - fi - ) + if [[ -n "$selected" ]]; then + _forge_exec config set reasoning-effort "$selected" + fi } # Action handler: Show config list diff --git a/shell-plugin/lib/actions/conversation.zsh b/shell-plugin/lib/actions/conversation.zsh index 5920e7f4a8..4a31c8bbf1 100644 --- a/shell-plugin/lib/actions/conversation.zsh +++ b/shell-plugin/lib/actions/conversation.zsh @@ -3,7 +3,7 @@ # Conversation management action handlers # # Features: -# - :conversation - List and switch conversations (with fzf) +# - :conversation - List and switch conversations (with interactive picker) # - :conversation - Switch to specific conversation by ID # - :conversation - - Toggle between current and previous conversation (like cd -) # - :clone - Clone current or selected conversation @@ -95,55 +95,23 @@ function _forge_action_conversation() { return 0 fi - # Get conversations list - local conversations_output - conversations_output=$($_FORGE_BIN conversation list --porcelain 2>/dev/null) + # Use Rust's built-in conversation picker with preview + local conversation_id + conversation_id=$(_forge_select conversation) - if [[ -n "$conversations_output" ]]; then - # Get current conversation ID if set - local current_id="$_FORGE_CONVERSATION_ID" + if [[ -n "$conversation_id" ]]; then + # Switch to conversation and track in history + _forge_switch_conversation "$conversation_id" - # Create prompt with current conversation - local prompt_text="Conversation ❯ " - local fzf_args=( - --prompt="$prompt_text" - --delimiter="$_FORGE_DELIMITER" - --with-nth="2,3" - --preview="CLICOLOR_FORCE=1 $_FORGE_BIN conversation info {1}; echo; CLICOLOR_FORCE=1 $_FORGE_BIN conversation show {1}" - $_FORGE_PREVIEW_WINDOW - ) - - # If there's a current conversation, position cursor on it - if [[ -n "$current_id" ]]; then - # For conversations, compare against the first field (conversation_id) - local index=$(_forge_find_index "$conversations_output" "$current_id" 1) - fzf_args+=(--bind="start:pos($index)") - fi - - local selected_conversation - # Use fzf with preview showing the last message from the conversation - selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + # Show conversation content + echo + _forge_exec conversation show "$conversation_id" - if [[ -n "$selected_conversation" ]]; then - # Extract the first field (UUID) - everything before the first multi-space delimiter - local conversation_id=$(echo "$selected_conversation" | sed -E 's/ .*//' | tr -d '\n') - - # Switch to conversation and track in history - _forge_switch_conversation "$conversation_id" - - # Show conversation content - echo - _forge_exec conversation show "$conversation_id" - - # Show conversation info - _forge_exec conversation info "$conversation_id" - - # Print log about conversation switching - _forge_log success "Switched to conversation \033[1m${conversation_id}\033[0m" - - fi - else - _forge_log error "No conversations found" + # Show conversation info + _forge_exec conversation info "$conversation_id" + + # Print log about conversation switching + _forge_log success "Switched to conversation \033[1m${conversation_id}\033[0m" fi } @@ -160,40 +128,11 @@ function _forge_action_clone() { return 0 fi - # Get conversations list for fzf selection - local conversations_output - conversations_output=$($_FORGE_BIN conversation list --porcelain 2>/dev/null) - - if [[ -z "$conversations_output" ]]; then - _forge_log error "No conversations found" - return 0 - fi - - # Get current conversation ID if set - local current_id="$_FORGE_CONVERSATION_ID" - - # Create fzf interface similar to :conversation - local prompt_text="Clone Conversation ❯ " - local fzf_args=( - --prompt="$prompt_text" - --delimiter="$_FORGE_DELIMITER" - --with-nth="2,3" - --preview="CLICOLOR_FORCE=1 $_FORGE_BIN conversation info {1}; echo; CLICOLOR_FORCE=1 $_FORGE_BIN conversation show {1}" - $_FORGE_PREVIEW_WINDOW - ) - - # Position cursor on current conversation if available - if [[ -n "$current_id" ]]; then - local index=$(_forge_find_index "$conversations_output" "$current_id") - fzf_args+=(--bind="start:pos($index)") - fi - - local selected_conversation - selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + # Use Rust's built-in conversation picker + local conversation_id + conversation_id=$(_forge_select conversation) - if [[ -n "$selected_conversation" ]]; then - # Extract conversation ID - local conversation_id=$(echo "$selected_conversation" | sed -E 's/ .*//' | tr -d '\n') + if [[ -n "$conversation_id" ]]; then _forge_clone_and_switch "$conversation_id" fi } @@ -279,37 +218,11 @@ function _forge_action_conversation_rename() { return 0 fi - # No args — show interactive picker - local conversations_output - conversations_output=$($_FORGE_BIN conversation list --porcelain 2>/dev/null) - - if [[ -z "$conversations_output" ]]; then - _forge_log error "No conversations found" - return 0 - fi - - local current_id="$_FORGE_CONVERSATION_ID" - - local prompt_text="Rename Conversation ❯ " - local fzf_args=( - --prompt="$prompt_text" - --delimiter="$_FORGE_DELIMITER" - --with-nth="2,3" - --preview="CLICOLOR_FORCE=1 $_FORGE_BIN conversation info {1}; echo; CLICOLOR_FORCE=1 $_FORGE_BIN conversation show {1}" - $_FORGE_PREVIEW_WINDOW - ) - - if [[ -n "$current_id" ]]; then - local index=$(_forge_find_index "$conversations_output" "$current_id" 1) - fzf_args+=(--bind="start:pos($index)") - fi - - local selected_conversation - selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") - - if [[ -n "$selected_conversation" ]]; then - local conversation_id=$(echo "$selected_conversation" | sed -E 's/ .*//' | tr -d '\n') + # No args — use Rust's built-in conversation picker + local conversation_id + conversation_id=$(_forge_select conversation) + if [[ -n "$conversation_id" ]]; then # Prompt for new name echo -n "Enter new name: " read -r new_name /dev/null) - - if [[ -z "$output" ]]; then - _forge_log error "No providers available" - return 1 - fi - - # Filter by status if specified (e.g., "available" for configured providers) - if [[ -n "$filter_status" ]]; then - # Preserve the header line and filter the rest - local header=$(echo "$output" | head -n 1) - local filtered=$(echo "$output" | tail -n +2 | grep -i "$filter_status") - if [[ -z "$filtered" ]]; then - _forge_log error "No ${filter_status} providers found" - return 1 - fi - output=$(printf "%s\n%s" "$header" "$filtered") - fi - - # Get current provider if not provided - if [[ -z "$current_provider" ]]; then - current_provider=$($_FORGE_BIN config get provider --porcelain 2>/dev/null) - fi - - local fzf_args=( - --delimiter="$_FORGE_DELIMITER" - --prompt="Provider ❯ " - --with-nth=1,3.. - ) - - # Add query parameter if provided - if [[ -n "$query" ]]; then - fzf_args+=(--query="$query") - fi - - # Position cursor on current provider if available - if [[ -n "$current_provider" ]]; then - # For providers, compare against the first field (display name) - local index=$(_forge_find_index "$output" "$current_provider" 1) - fzf_args+=(--bind="start:pos($index)") - fi - local selected - selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") - + selected=$(_forge_select_with_query "$input_text" provider) + if [[ -n "$selected" ]]; then - echo "$selected" - return 0 + _FORGE_SESSION_PROVIDER="$selected" + _forge_log success "Session provider set to \033[1m${selected}\033[0m" fi - - return 1 } diff --git a/shell-plugin/lib/completion.zsh b/shell-plugin/lib/completion.zsh index ceb196cbab..0d32f0b0d6 100644 --- a/shell-plugin/lib/completion.zsh +++ b/shell-plugin/lib/completion.zsh @@ -9,17 +9,9 @@ function forge-completion() { if [[ "$current_word" =~ ^@.*$ ]]; then local filter_text="${current_word#@}" local selected - local fzf_args=( - --preview="if [ -d {} ]; then ls -la --color=always {} 2>/dev/null || ls -la {}; else $_FORGE_CAT_CMD {}; fi" - $_FORGE_PREVIEW_WINDOW - ) - local file_list=$(${FORGE_BIN:-forge} list files --porcelain) - if [[ -n "$filter_text" ]]; then - selected=$(echo "$file_list" | _forge_fzf --query "$filter_text" "${fzf_args[@]}") - else - selected=$(echo "$file_list" | _forge_fzf "${fzf_args[@]}") - fi + # Use Rust's built-in file picker + selected=$(_forge_select_with_query "$filter_text" file) if [[ -n "$selected" ]]; then selected="@[${selected}]" @@ -37,24 +29,14 @@ function forge-completion() { # Extract the text after the colon for filtering local filter_text="${LBUFFER#:}" - # Lazily load the commands list - local commands_list=$(_forge_get_commands) - if [[ -n "$commands_list" ]]; then - # Use fzf for interactive selection with prefilled filter - local selected - if [[ -n "$filter_text" ]]; then - selected=$(echo "$commands_list" | _forge_fzf --header-lines=1 --delimiter="$_FORGE_DELIMITER" --nth=1 --query "$filter_text" --prompt="Command ❯ ") - else - selected=$(echo "$commands_list" | _forge_fzf --header-lines=1 --delimiter="$_FORGE_DELIMITER" --nth=1 --prompt="Command ❯ ") - fi - - if [[ -n "$selected" ]]; then - # Extract just the command name (first word before any description) - local command_name="${selected%% *}" - # Replace the current buffer with the selected command - BUFFER=":$command_name " - CURSOR=${#BUFFER} - fi + # Use Rust's built-in command picker + local selected + selected=$(_forge_select_with_query "$filter_text" command) + + if [[ -n "$selected" ]]; then + # Replace the current buffer with the selected command + BUFFER=":$selected " + CURSOR=${#BUFFER} fi zle reset-prompt diff --git a/shell-plugin/lib/config.zsh b/shell-plugin/lib/config.zsh index b28c1914a8..adbbe487e0 100644 --- a/shell-plugin/lib/config.zsh +++ b/shell-plugin/lib/config.zsh @@ -6,17 +6,7 @@ typeset -h _FORGE_BIN="${FORGE_BIN:-forge}" typeset -h _FORGE_CONVERSATION_PATTERN=":" typeset -h _FORGE_MAX_COMMIT_DIFF="${FORGE_MAX_COMMIT_DIFF:-100000}" -typeset -h _FORGE_DELIMITER='\s\s+' -typeset -h _FORGE_PREVIEW_WINDOW="--preview-window=bottom:75%:wrap:border-sharp" -# Detect bat command - use bat if available, otherwise fall back to cat -if command -v bat &>/dev/null; then - typeset -h _FORGE_CAT_CMD="bat --color=always --style=numbers,changes --line-range=:500" -else - typeset -h _FORGE_CAT_CMD="cat" -fi - -# Commands cache - loaded lazily on first use typeset -h _FORGE_COMMANDS="" # Hidden variables to be used only via the ForgeCLI diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index e18ced3567..63d094a687 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -11,11 +11,6 @@ function _forge_get_commands() { echo "$_FORGE_COMMANDS" } -# Private fzf function with common options for consistent UX -function _forge_fzf() { - fzf --reverse --exact --cycle --select-1 --height 80% --no-scrollbar --ansi --color="header:bold" "$@" -} - # Helper function to execute forge commands consistently # This ensures proper handling of special characters and consistent output function _forge_exec() { @@ -49,7 +44,7 @@ function _forge_exec() { } # Like _forge_exec but connects stdin/stdout to /dev/tty so that interactive -# prompts (rustyline, fzf, etc.) work correctly when forge is launched as a +# prompts (rustyline, nucleo-picker, etc.) work correctly when forge is launched as a # child of a ZLE widget. ZLE owns the terminal and replaces the process's # stdin/stdout with its own pipes, so without this redirect any readline # library would see a non-tty stdin and return EOF immediately. @@ -82,6 +77,34 @@ function _forge_exec_interactive() { "${cmd[@]}" /dev/tty } +function _forge_select() { + CLICOLOR_FORCE=0 $_FORGE_BIN select "$@" /dev/tty +} + +function _forge_select_with_query() { + local query="$1" + shift + + if [[ -n "$query" ]]; then + _forge_select "$@" --query "$query" + else + _forge_select "$@" + fi +} + +function _forge_select_model_pair() { + local result + result=$(_forge_select_with_query "$1" model) + + if [[ -z "$result" ]]; then + reply=() + return 1 + fi + + reply=("${(@f)result}") + [[ ${#reply[@]} -ge 2 ]] +} + function _forge_reset() { # Clear buffer and reset cursor position BUFFER="" @@ -91,48 +114,6 @@ function _forge_reset() { zle reset-prompt } -# Helper function to find the index of a value in a list (1-based) -# Returns the index if found, 1 otherwise -# Usage: _forge_find_index [field_number] [field_number2] [value_to_find2] -# field_number: which porcelain column to compare (1-based, using multi-space delimiter) -# field_number2/value_to_find2: optional second column+value for compound matching -# Note: This function expects porcelain output WITH headers and skips the header line -function _forge_find_index() { - local output="$1" - local value_to_find="$2" - local field_number="${3:-1}" - local field_number2="${4:-}" - local value_to_find2="${5:-}" - - local index=1 - local line_num=0 - while IFS= read -r line; do - ((line_num++)) - # Skip the header line (first line) - if [[ $line_num -eq 1 ]]; then - continue - fi - - local field_value=$(echo "$line" | awk -F ' +' "{print \$$field_number}") - if [[ "$field_value" == "$value_to_find" ]]; then - if [[ -n "$field_number2" && -n "$value_to_find2" ]]; then - local field_value2=$(echo "$line" | awk -F ' +' "{print \$$field_number2}") - if [[ "$field_value2" == "$value_to_find2" ]]; then - echo "$index" - return 0 - fi - else - echo "$index" - return 0 - fi - fi - ((index++)) - done <<< "$output" - - echo "1" - return 0 -} - # Helper function to print messages with consistent formatting based on log level # Usage: _forge_log # Levels: error, info, success, warning, debug