From 6d07fcd17a87b0b390b980571ca28c1d0f2edcb4 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 21 Apr 2026 15:42:32 +0530 Subject: [PATCH] feat(select): migrate from fzf to nucleo-picker for built-in fuzzy selection Replace external fzf dependency with nucleo-picker crate for all interactive selection UIs. Adds new `forge select` command with preview support, field delimiters, and multi-select. Updates shell plugin to use built-in picker instead of requiring external fzf binary. --- Cargo.lock | 193 +++--- Cargo.toml | 2 +- README.md | 14 +- crates/forge_main/Cargo.toml | 6 +- crates/forge_main/src/cli.rs | 35 + crates/forge_main/src/completer/command.rs | 2 +- crates/forge_main/src/lib.rs | 3 +- crates/forge_main/src/main.rs | 8 +- crates/forge_main/src/select_cmd.rs | 744 +++++++++++++++++++++ crates/forge_main/src/ui.rs | 8 +- crates/forge_select/Cargo.toml | 2 +- crates/forge_select/src/multi.rs | 64 +- crates/forge_select/src/select.rs | 253 +++---- crates/forge_select/src/widget.rs | 5 +- shell-plugin/README.md | 11 +- shell-plugin/doctor.zsh | 18 +- shell-plugin/lib/actions/config.zsh | 50 +- shell-plugin/lib/actions/conversation.zsh | 26 +- shell-plugin/lib/actions/provider.zsh | 8 +- shell-plugin/lib/completion.zsh | 12 +- shell-plugin/lib/helpers.zsh | 129 +++- 21 files changed, 1191 insertions(+), 402 deletions(-) create mode 100644 crates/forge_main/src/select_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index b231d54a45..ad1d7ebdce 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", @@ -799,7 +799,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", ] [[package]] @@ -874,7 +874,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1184,6 +1184,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "filedescriptor", "mio", "parking_lot", "rustix 1.1.4", @@ -1236,16 +1237,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" @@ -1276,20 +1267,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" @@ -1300,7 +1277,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -1314,7 +1291,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -1327,21 +1304,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" @@ -1430,34 +1396,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]] @@ -1472,23 +1417,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", ] @@ -1649,7 +1584,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1822,7 +1757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1945,6 +1880,17 @@ dependencies = [ "simd-adler32", ] +[[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" @@ -2293,12 +2239,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.38", "serde", "serde_json", @@ -2405,7 +2355,7 @@ dependencies = [ "anyhow", "colored", "console", - "fzf-wrapped", + "nucleo-picker", "pretty_assertions", "rustyline", "tracing", @@ -2692,15 +2642,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 = "generic-array" version = "0.14.7" @@ -3921,7 +3862,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", @@ -4691,7 +4632,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5230,6 +5171,27 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[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" @@ -5309,7 +5271,42 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "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]] @@ -5781,7 +5778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3862eb754386a1273612a885e961b19be0bb08e6d99e483557af41236d0dac12" dependencies = [ "chrono", - "derive_builder 0.20.2", + "derive_builder", "regex", "reqwest 0.13.2", "semver", @@ -6549,7 +6546,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6620,7 +6617,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7351,12 +7348,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" @@ -7522,7 +7513,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7585,7 +7576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8639,7 +8630,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0fe6600b35..a3d8f54656 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,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" @@ -130,6 +129,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_main/Cargo.toml b/crates/forge_main/Cargo.toml index 2660fac793..c47ea5907c 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -26,6 +26,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 @@ -40,9 +43,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 1af889ab80..4bfea7d94d 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -148,6 +148,41 @@ pub enum TopLevelCommand { /// Run diagnostics on shell environment (alias for `zsh doctor`). Doctor, + + /// Interactive fuzzy item picker. + Select(SelectArgs), +} + +/// Arguments for the `forge select` command. +#[derive(Parser, Debug, Clone)] +pub struct SelectArgs { + /// Prompt text displayed before the picker. + #[arg(long, short = 'p')] + pub prompt: Option, + + /// Initial query text pre-filled in the search box. + #[arg(long, short = 'q')] + pub query: Option, + + /// Allow selecting multiple items. + #[arg(long, short = 'm')] + pub multi: bool, + + /// Shell command used to render a preview for the selected item. + #[arg(long)] + pub preview: Option, + + /// Regex delimiter used to split fields in each input line. + #[arg(long)] + pub delimiter: Option, + + /// Comma-separated field list used for display text. + #[arg(long = "with-nth")] + pub with_nth: Option, + + /// Preview window layout hint. + #[arg(long = "preview-window")] + pub preview_window: 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/lib.rs b/crates/forge_main/src/lib.rs index f0490bbf63..ef37baccde 100644 --- a/crates/forge_main/src/lib.rs +++ b/crates/forge_main/src/lib.rs @@ -24,11 +24,12 @@ mod utils; mod vscode; mod zsh; +mod select_cmd; mod update; use std::sync::LazyLock; -pub use cli::{Cli, ListCommand, ListCommandGroup, TopLevelCommand}; +pub use cli::{Cli, ListCommand, ListCommandGroup, SelectArgs, TopLevelCommand}; pub use sandbox::Sandbox; pub use title_display::*; pub use ui::UI; 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/select_cmd.rs b/crates/forge_main/src/select_cmd.rs new file mode 100644 index 0000000000..61ddfa2690 --- /dev/null +++ b/crates/forge_main/src/select_cmd.rs @@ -0,0 +1,744 @@ +use std::cmp; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Context; +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::style::{Attribute, Print, SetAttribute}; +use crossterm::terminal::{ + self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, +}; +use crossterm::{execute, queue}; +use nucleo::pattern::{CaseMatching, Normalization}; +use nucleo::{Config as NucleoConfig, Nucleo, Utf32String}; +use nucleo_picker::error::PickError; +use nucleo_picker::{PickerOptions, render::StrRenderer}; +use regex::Regex; + +use crate::cli::SelectArgs; + +/// Run the interactive fuzzy picker. +/// +/// Reads items from stdin, presents them in a nucleo-based TUI, and prints the +/// selected item(s) to stdout. When stdin is not a terminal, `/dev/tty` is +/// opened for keyboard input. +pub fn run_select(args: SelectArgs) -> anyhow::Result<()> { + let stdin = io::stdin(); + let mut items = Vec::new(); + + for line in stdin.lock().lines() { + items.push(line?); + } + + if items.is_empty() { + std::process::exit(1); + } + + #[cfg(unix)] + if !stdin.is_terminal() { + redirect_stdin_to_tty()?; + } + + if args.preview.is_some() { + return run_select_with_preview(args, items); + } + + let mut picker_opts = PickerOptions::default() + .reversed(true) + .case_matching(nucleo_picker::CaseMatching::Smart); + + if let Some(query) = args.query { + picker_opts = picker_opts.query(query); + } + + let mut picker: nucleo_picker::Picker = picker_opts.picker(StrRenderer); + picker.extend_exact(items.into_iter()); + + if args.multi { + match picker.pick_multi() { + Ok(selection) if selection.is_empty() => std::process::exit(1), + Ok(selection) => { + for item in selection.iter() { + println!("{}", item); + } + } + Err(PickError::NotInteractive) | Err(PickError::UserInterrupted) => { + std::process::exit(1); + } + Err(error) => anyhow::bail!("Picker error: {error}"), + } + } else { + match picker.pick() { + Ok(Some(item)) => println!("{}", item), + Ok(None) => std::process::exit(1), + Err(PickError::NotInteractive) | Err(PickError::UserInterrupted) => { + std::process::exit(1); + } + Err(error) => anyhow::bail!("Picker error: {error}"), + } + } + + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SelectRow { + raw: String, + display: String, + fields: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum FieldSelector { + Index(usize), + RangeFrom(usize), + RangeInclusive(usize, usize), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct FieldPart { + value: String, + start: usize, + end: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PreviewPlacement { + Right, + Bottom, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PreviewLayout { + placement: PreviewPlacement, + percent: u16, +} + +impl Default for PreviewLayout { + fn default() -> Self { + Self { + placement: PreviewPlacement::Right, + percent: 50, + } + } +} + +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> anyhow::Result { + enable_raw_mode()?; + execute!(io::stderr(), EnterAlternateScreen, Hide)?; + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(io::stderr(), Show, LeaveAlternateScreen); + let _ = disable_raw_mode(); + } +} + +fn run_select_with_preview(args: SelectArgs, items: Vec) -> anyhow::Result<()> { + if args.multi { + anyhow::bail!("Preview mode does not support --multi yet"); + } + + let rows = build_rows(&items, args.delimiter.as_deref(), args.with_nth.as_deref())?; + if rows.is_empty() { + std::process::exit(1); + } + + let mut matcher = Nucleo::new(NucleoConfig::DEFAULT, Arc::new(|| {}), None, 1); + let injector = matcher.injector(); + for row in rows.iter().cloned() { + injector.push(row, |item, columns| { + columns[0] = Utf32String::from(item.display.as_str()); + }); + } + drop(injector); + + let mut query = args.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 = args.prompt.unwrap_or_else(|| "❯ ".to_string()); + let preview_command = args.preview.unwrap_or_default(); + let preview_layout = parse_preview_window(args.preview_window.as_deref()); + let mut selected_index = 0usize; + let mut scroll_offset = 0usize; + let mut preview_cache = String::new(); + let mut last_preview_key = String::new(); + let mut last_query = query.clone(); + let mut last_tick = Instant::now(); + + if rows.len() > 1 { + selected_index = 1; + } + + 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; + } else if last_tick.elapsed() >= Duration::from_millis(25) { + let _ = matcher.tick(10); + last_tick = Instant::now(); + } + + let matched_rows = matched_rows(&matcher); + if matched_rows.is_empty() { + selected_index = 0; + scroll_offset = 0; + } else if selected_index >= matched_rows.len() { + selected_index = matched_rows.len().saturating_sub(1); + } + + 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()); + last_preview_key = preview_key; + } + + draw_preview_ui( + &mut stderr, + &prompt, + &query, + &matched_rows, + selected_index, + &mut scroll_offset, + &preview_cache, + preview_layout, + )?; + + if event::poll(Duration::from_millis(50))? { + match event::read()? { + Event::Key(key) => match handle_key_event(key, &mut query, matched_rows.len(), &mut selected_index) { + PickerAction::Continue => {} + PickerAction::Accept => { + if let Some(row) = selected_row { + drop(guard); + println!("{}", row.raw); + return Ok(()); + } + } + PickerAction::Exit => { + drop(guard); + std::process::exit(1); + } + }, + Event::Resize(_, _) => {} + _ => {} + } + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum PickerAction { + Continue, + Accept, + Exit, +} + +fn handle_key_event( + key: KeyEvent, + query: &mut String, + matched_len: usize, + selected_index: &mut usize, +) -> PickerAction { + match key { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Esc, .. + } => PickerAction::Exit, + KeyEvent { + code: KeyCode::Enter, + .. + } => PickerAction::Accept, + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::BackTab, + .. + } => { + if matched_len > 0 { + *selected_index = selected_index.saturating_sub(1); + } + PickerAction::Continue + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Tab, .. + } => { + 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 build_rows( + items: &[String], + delimiter: Option<&str>, + with_nth: Option<&str>, +) -> anyhow::Result> { + let delimiter_regex = match delimiter { + Some(value) => Some(Regex::new(value).with_context(|| format!("Invalid delimiter regex: {value}"))?), + None => None, + }; + let display_fields = parse_with_nth(with_nth)?; + + let row_parts = items + .iter() + .map(|item| split_field_parts(item, delimiter_regex.as_ref())) + .collect::>(); + + let column_widths = compute_display_widths(&row_parts, display_fields.as_deref()); + + let rows = items + .iter() + .zip(row_parts) + .map(|(item, parts)| SelectRow { + raw: item.clone(), + display: build_display(item, &parts, display_fields.as_deref(), &column_widths), + fields: parts.into_iter().map(|part| part.value).collect(), + }) + .collect(); + + Ok(rows) +} + +fn parse_with_nth(with_nth: Option<&str>) -> anyhow::Result>> { + let Some(value) = with_nth else { + return Ok(None); + }; + + let mut selectors = Vec::new(); + for part in value.split(',').map(str::trim).filter(|part| !part.is_empty()) { + if let Some((start, end)) = part.split_once("..") { + let start = start + .trim() + .parse::() + .with_context(|| format!("Invalid --with-nth field: {part}"))?; + + if end.trim().is_empty() { + selectors.push(FieldSelector::RangeFrom(start)); + } else { + let end = end + .trim() + .parse::() + .with_context(|| format!("Invalid --with-nth field: {part}"))?; + selectors.push(FieldSelector::RangeInclusive(start, end)); + } + } else { + selectors.push(FieldSelector::Index( + part.parse::() + .with_context(|| format!("Invalid --with-nth field: {part}"))?, + )); + } + } + + if selectors.is_empty() { + return Ok(None); + } + + Ok(Some(selectors)) +} + +fn split_field_parts(item: &str, delimiter: Option<&Regex>) -> Vec { + match delimiter { + Some(regex) => { + let mut parts = Vec::new(); + let mut last_end = 0usize; + + for delimiter_match in regex.find_iter(item) { + let field = item[last_end..delimiter_match.start()].trim(); + if !field.is_empty() { + parts.push(FieldPart { + value: field.to_string(), + start: last_end, + end: delimiter_match.start(), + }); + } + last_end = delimiter_match.end(); + } + + let field = item[last_end..].trim(); + if !field.is_empty() { + parts.push(FieldPart { + value: field.to_string(), + start: last_end, + end: item.len(), + }); + } + + if parts.is_empty() { + vec![FieldPart { + value: item.to_string(), + start: 0, + end: item.len(), + }] + } else { + parts + } + } + None => vec![FieldPart { + value: item.to_string(), + start: 0, + end: item.len(), + }], + } +} + +fn build_display( + item: &str, + parts: &[FieldPart], + display_fields: Option<&[FieldSelector]>, + column_widths: &[usize], +) -> String { + let Some(display_fields) = display_fields else { + return item.to_string(); + }; + + let selected_parts = select_display_parts(parts, display_fields); + + if selected_parts.is_empty() { + return item.to_string(); + } + + selected_parts + .iter() + .enumerate() + .map(|(index, part)| { + if index == selected_parts.len() - 1 { + part.value.clone() + } else { + format!( + "{:>() + .join(" ") +} + +fn compute_display_widths( + rows: &[Vec], + display_fields: Option<&[FieldSelector]>, +) -> Vec { + let Some(display_fields) = display_fields else { + return Vec::new(); + }; + + let mut widths = Vec::new(); + for row in rows { + for (index, part) in select_display_parts(row, display_fields).iter().enumerate() { + let width = part.end.saturating_sub(part.start); + if widths.len() <= index { + widths.push(width); + } else if widths[index] < width { + widths[index] = width; + } + } + } + + widths +} + +fn select_display_parts(parts: &[FieldPart], selectors: &[FieldSelector]) -> Vec { + let mut selected = Vec::new(); + + for selector in selectors { + match *selector { + FieldSelector::Index(index) => { + if let Some(value) = parts.get(index.saturating_sub(1)) { + selected.push(value.clone()); + } + } + FieldSelector::RangeFrom(start) => { + for value in parts.iter().skip(start.saturating_sub(1)) { + selected.push(value.clone()); + } + } + FieldSelector::RangeInclusive(start, end) => { + for value in parts + .iter() + .skip(start.saturating_sub(1)) + .take(end.saturating_sub(start).saturating_add(1)) + { + selected.push(value.clone()); + } + } + } + } + + selected +} + +fn parse_preview_window(value: Option<&str>) -> PreviewLayout { + let Some(value) = value else { + return PreviewLayout::default(); + }; + + let placement = if value.contains("down") || value.contains("bottom") || value.contains("up") { + PreviewPlacement::Bottom + } else { + PreviewPlacement::Right + }; + + let percent = value + .split(':') + .flat_map(|segment| segment.split(',')) + .find_map(|segment| segment.trim().strip_suffix('%')) + .and_then(|segment| segment.parse::().ok()) + .map(|percent| percent.clamp(10, 90)) + .unwrap_or(50); + + PreviewLayout { placement, percent } +} + +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 = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr); + 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('\'', "'\\''")) +} + +fn draw_preview_ui( + stderr: &mut io::Stderr, + prompt: &str, + query: &str, + matched_rows: &[&SelectRow], + selected_index: usize, + scroll_offset: &mut usize, + preview: &str, + layout: PreviewLayout, +) -> anyhow::Result<()> { + let (width, height) = terminal::size()?; + let width = width.max(20); + let height = height.max(6); + + let header_height = 1u16; + let status_height = 1u16; + let body_height = height.saturating_sub(header_height + status_height).max(1); + + let (list_x, list_y, list_width, list_height, preview_x, preview_y, preview_width, preview_height) = + 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 = ((body_height as u32 * layout.percent as u32) / 100) as u16; + let preview_height = preview_height.clamp(3, body_height.saturating_sub(2).max(3)); + let list_height = body_height.saturating_sub(preview_height + 1).max(1); + (0, header_height, width, list_height, 0, header_height + list_height + 1, width, preview_height) + } + }; + + 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)); + } + } + + queue!(stderr, MoveTo(0, 0), Clear(ClearType::All))?; + queue!(stderr, MoveTo(0, 0), Print(truncate_line(&format!("{}{}", prompt, query), width as usize)))?; + + for row_index in 0..list_height { + queue!(stderr, MoveTo(list_x, list_y + row_index), Clear(ClearType::CurrentLine))?; + let item_index = *scroll_offset + row_index as usize; + if let Some(row) = matched_rows.get(item_index) { + let prefix = if item_index == selected_index { "> " } else { " " }; + let content_width = list_width.saturating_sub(prefix.chars().count() as u16) as usize; + let line = format!("{prefix}{}", truncate_line(&row.display, content_width)); + if item_index == selected_index { + queue!(stderr, SetAttribute(Attribute::Reverse))?; + } + queue!(stderr, MoveTo(list_x, list_y + row_index), Print(line))?; + if item_index == selected_index { + queue!(stderr, SetAttribute(Attribute::Reset))?; + } + } + } + + match layout.placement { + PreviewPlacement::Right => { + let divider_x = list_width + 1; + for row_index in 0..body_height { + queue!(stderr, MoveTo(divider_x, header_height + row_index), Print("│"))?; + } + } + PreviewPlacement::Bottom => { + queue!(stderr, MoveTo(0, preview_y.saturating_sub(1)), Clear(ClearType::CurrentLine))?; + queue!(stderr, MoveTo(0, preview_y.saturating_sub(1)), Print("─".repeat(width as usize)))?; + } + } + + let preview_lines = preview.lines().collect::>(); + for row_index in 0..preview_height { + queue!(stderr, MoveTo(preview_x, preview_y + row_index), Print(" ".repeat(preview_width as usize)))?; + if let Some(line) = preview_lines.get(row_index as usize) { + queue!(stderr, MoveTo(preview_x, preview_y + row_index), Print(truncate_line(line, preview_width as usize)))?; + } + } + + queue!(stderr, MoveTo(0, height.saturating_sub(1)), Clear(ClearType::CurrentLine))?; + queue!( + stderr, + MoveTo(0, height.saturating_sub(1)), + Print(truncate_line( + &format!("{} matches", matched_rows.len()), + width as usize, + )) + )?; + + stderr.flush()?; + Ok(()) +} + +fn truncate_line(value: &str, max_width: usize) -> String { + value.chars().take(max_width).collect() +} + +#[cfg(unix)] +fn redirect_stdin_to_tty() -> io::Result<()> { + use std::os::unix::io::AsRawFd; + + let tty = std::fs::File::open("/dev/tty")?; + let tty_fd = tty.as_raw_fd(); + + unsafe { + if libc::dup2(tty_fd, 0) == -1 { + return Err(io::Error::last_os_error()); + } + } + + Ok(()) +} diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9967c7e317..968202475b 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -722,6 +722,10 @@ impl A + Send + Sync> UI self.on_zsh_doctor().await?; return Ok(()); } + TopLevelCommand::Select(args) => { + crate::select_cmd::run_select(args)?; + return Ok(()); + } } Ok(()) } @@ -2870,7 +2874,7 @@ impl A + Send + Sync> UI // 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. + // by the picker's header-lines), so index 0 = first data row. let current_model = self .get_agent_model(self.api.get_active_agent().await) .await; @@ -3290,7 +3294,7 @@ 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). diff --git a/crates/forge_select/Cargo.toml b/crates/forge_select/Cargo.toml index d12b3a0e71..1037b0e11d 100644 --- a/crates/forge_select/Cargo.toml +++ b/crates/forge_select/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true anyhow.workspace = true colored.workspace = true console.workspace = true -fzf-wrapped.workspace = true +nucleo-picker.workspace = true rustyline.workspace = true tracing.workspace = true diff --git a/crates/forge_select/src/multi.rs b/crates/forge_select/src/multi.rs index 9d02e42930..928c3e8a82 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 nucleo_picker::{PickerOptions, render::StrRenderer}; +use nucleo_picker::error::PickError; /// Builder for multi-select prompts. pub struct MultiSelectBuilder { @@ -22,14 +21,12 @@ impl MultiSelectBuilder { /// /// # Errors /// - /// Returns an error if the fzf process fails to start or interact + /// Returns an error if the picker fails to start or interact 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); } @@ -43,24 +40,23 @@ impl MultiSelectBuilder { .map(|item| strip_ansi_codes(&item.to_string()).trim().to_string()) .collect(); - let fzf = build_multi_fzf(&self.message); + let mut picker: nucleo_picker::Picker = PickerOptions::default() + .reversed(true) + .picker(StrRenderer); - 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}"))?; + picker.extend_exact(display_options.into_iter()); - let raw_output = fzf.output(); + println!("{}", self.message); - 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()) + match picker.pick_multi() { + Ok(selection) if selection.is_empty() => Ok(None), + Ok(selection) => { + let selected_items: Vec = selection + .iter() + .filter_map(|selected_str| { + self.options.iter().find(|opt| { + strip_ansi_codes(&opt.to_string()).trim() == *selected_str + }).cloned() }) .collect(); @@ -70,31 +66,13 @@ impl MultiSelectBuilder { Ok(Some(selected_items)) } } + Err(PickError::NotInteractive) => Ok(None), + Err(PickError::UserInterrupted) => Ok(None), + Err(e) => Err(anyhow::anyhow!("Picker error: {e}")), } } } -/// 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/select.rs b/crates/forge_select/src/select.rs index 2eb0c5b70f..66bae6c28b 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 nucleo_picker::{PickerOptions, render::StrRenderer}; +use nucleo_picker::error::PickError; /// 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 { @@ -122,20 +27,17 @@ 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()); + /// This is a no-op with nucleo-picker and is retained for API compatibility. + 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()); + /// This is a no-op with nucleo-picker and is retained for API compatibility. + pub fn with_preview_window(mut self, _layout: impl Into) -> Self { + self.preview_window = Some(_layout.into()); self } @@ -159,10 +61,8 @@ impl SelectBuilder { /// 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. + /// Header lines are printed before the picker but are not injected as + /// selectable items. pub fn with_header_lines(mut self, n: usize) -> Self { self.header_lines = n; self @@ -177,14 +77,12 @@ impl SelectBuilder { /// /// # Errors /// - /// Returns an error if the fzf process fails to start or interact. + /// Returns an error if the picker fails to start or interact. 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); } @@ -202,50 +100,96 @@ impl SelectBuilder { .map(|item| strip_ansi_codes(&item.to_string()).trim().to_string()) .collect(); - 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())) + let header_count = self.header_lines.min(display_options.len()); + let data_items = display_options.into_iter().skip(header_count).collect::>(); + + if data_items.is_empty() { + return Ok(None); + } + + let mut picker_opts = PickerOptions::default() + .reversed(true) + .case_matching(nucleo_picker::CaseMatching::Smart); + + if let Some(query) = &self.initial_text { + picker_opts = picker_opts.query(query.clone()); + } + + let mut picker: nucleo_picker::Picker = picker_opts.picker(StrRenderer); + + if let Some(cursor) = self.starting_cursor { + let effective_cursor = cursor.saturating_sub(header_count); + if effective_cursor > 0 && effective_cursor < data_items.len() { + let mut reordered = data_items; + reordered.swap(0, effective_cursor); + picker.extend_exact(reordered.into_iter()); + } else { + picker.extend_exact(data_items.into_iter()); + } + } else { + picker.extend_exact(data_items.into_iter()); + } + + if let Some(help) = self.help_message { + println!("{}", help); + } + for i in 0..header_count { + println!("{}", self.options[i]); + } + + match picker.pick() { + Ok(Some(selected)) => { + let selected_str: &str = selected.as_ref(); + Ok(self + .options + .iter() + .skip(header_count) + .find(|opt| { + strip_ansi_codes(&opt.to_string()).trim() == selected_str + }) + .cloned()) } + Ok(None) => Ok(None), + Err(PickError::NotInteractive) => Ok(None), + Err(PickError::UserInterrupted) => Ok(None), + Err(e) => Err(anyhow::anyhow!("Picker error: {e}")), } } } -/// Runs a yes/no confirmation prompt via fzf. +/// Runs a yes/no confirmation prompt via nucleo-picker. /// /// 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 items = if default == Some(false) { + vec!["No".to_string(), "Yes".to_string()] } else { - Some(0) + vec!["Yes".to_string(), "No".to_string()] }; - let fzf = build_fzf(message, None, None, starting_cursor, 0, None, None); - let selected = run_with_output(fzf, items.iter().copied()); + let mut picker: nucleo_picker::Picker = PickerOptions::default() + .reversed(true) + .picker(StrRenderer); - let result: Option = match selected.as_deref().map(str::trim) { - Some("Yes") => Some(true), - Some("No") => Some(false), - _ => None, - }; + picker.extend_exact(items.into_iter()); - Ok(result) + println!("{}", message); + + match picker.pick() { + Ok(Some(selected)) => { + let result = match selected.as_str() { + "Yes" => Some(true), + "No" => Some(false), + _ => None, + }; + Ok(result) + } + Ok(None) => Ok(None), + Err(PickError::NotInteractive) => Ok(None), + Err(PickError::UserInterrupted) => Ok(None), + Err(e) => Err(anyhow::anyhow!("Picker error: {e}")), + } } /// Wrapper around [`prompt_confirm`] that safely converts the `bool` result @@ -310,27 +254,6 @@ mod tests { 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"]; - 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 = [ diff --git a/crates/forge_select/src/widget.rs b/crates/forge_select/src/widget.rs index ae64ceadd6..61304d56cc 100644 --- a/crates/forge_select/src/widget.rs +++ b/crates/forge_select/src/widget.rs @@ -3,10 +3,9 @@ use crate::input::InputBuilder; use crate::multi::MultiSelectBuilder; 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 built-in `nucleo-picker` crate. pub struct ForgeWidget; impl ForgeWidget { 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/config.zsh b/shell-plugin/lib/actions/config.zsh index 5bf6d8f376..79c029d6ee 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -40,7 +40,7 @@ function _forge_action_agent() { # Create prompt with current agent - show agent ID, title, provider, model and reasoning local prompt_text="Agent ❯ " - local fzf_args=( + local select_args=( --prompt="$prompt_text" --delimiter="$_FORGE_DELIMITER" --with-nth="1,2,4,5,6" @@ -49,12 +49,12 @@ function _forge_action_agent() { # 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)") + select_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[@]}") + # Use interactive picker without preview for simple selection like provider/model + selected_agent=$(echo "$sorted_agents" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected_agent" ]]; then # Extract the first field (agent ID) @@ -72,16 +72,16 @@ function _forge_action_agent() { fi } -# Helper: Open an fzf model picker and print the raw selected line. +# Helper: Open an interactive 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 ❯ ") +# $1 prompt_text - 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 +# $3 input_text - optional pre-fill query # $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) @@ -101,14 +101,14 @@ function _forge_pick_model() { return 1 fi - local fzf_args=( + local select_args=( --delimiter="$_FORGE_DELIMITER" --prompt="$prompt_text" --with-nth="2,3,5.." ) if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") + select_args+=(--query="$input_text") fi if [[ -n "$current_model" ]]; then @@ -120,10 +120,10 @@ function _forge_pick_model() { else index=$(_forge_find_index "$output" "$current_model" 1) fi - fzf_args+=(--bind="start:pos($index)") + select_args+=(--bind="start:pos($index)") fi - echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}" + echo "$output" | _forge_select --header-lines=1 "${select_args[@]}" } # Action handler: Select model (across all configured providers) @@ -254,7 +254,7 @@ function _forge_action_sync_info() { _forge_exec workspace info "." } -# Helper function to select and set config values with fzf +# Helper function to select and set config values with interactive picker function _forge_select_and_set_config() { local show_command="$1" local config_flag="$2" @@ -276,25 +276,25 @@ function _forge_select_and_set_config() { if [[ -n "$output" ]]; then local selected - local fzf_args=(--delimiter="$_FORGE_DELIMITER" --prompt="$prompt_text ❯ ") + local select_args=(--delimiter="$_FORGE_DELIMITER" --prompt="$prompt_text ❯ ") if [[ -n "$with_nth" ]]; then - fzf_args+=(--with-nth="$with_nth") + select_args+=(--with-nth="$with_nth") fi # Add query parameter if provided if [[ -n "$query" ]]; then - fzf_args+=(--query="$query") + select_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)") + select_args+=(--bind="start:pos($index)") fi - selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(echo "$output" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected" ]]; then local name="${selected%% *}" @@ -386,21 +386,21 @@ function _forge_action_reasoning_effort() { current_effort=$($_FORGE_BIN config get reasoning-effort 2>/dev/null) fi - local fzf_args=( + local select_args=( --prompt="Reasoning Effort ❯ " ) if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") + select_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)") + select_args+=(--bind="start:pos($index)") fi local selected - selected=$(echo "$efforts" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(echo "$efforts" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected" ]]; then _FORGE_SESSION_REASONING_EFFORT="$selected" @@ -422,21 +422,21 @@ function _forge_action_config_reasoning_effort() { local current_effort current_effort=$($_FORGE_BIN config get reasoning-effort 2>/dev/null) - local fzf_args=( + local select_args=( --prompt="Config Reasoning Effort ❯ " ) if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") + select_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)") + select_args+=(--bind="start:pos($index)") fi local selected - selected=$(echo "$efforts" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(echo "$efforts" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected" ]]; then _forge_exec config set reasoning-effort "$selected" diff --git a/shell-plugin/lib/actions/conversation.zsh b/shell-plugin/lib/actions/conversation.zsh index 5920e7f4a8..7ab1bb7a33 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 @@ -105,7 +105,7 @@ function _forge_action_conversation() { # Create prompt with current conversation local prompt_text="Conversation ❯ " - local fzf_args=( + local select_args=( --prompt="$prompt_text" --delimiter="$_FORGE_DELIMITER" --with-nth="2,3" @@ -117,12 +117,12 @@ function _forge_action_conversation() { 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)") + select_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[@]}") + # Use interactive picker with preview showing the last message from the conversation + selected_conversation=$(echo "$conversations_output" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected_conversation" ]]; then # Extract the first field (UUID) - everything before the first multi-space delimiter @@ -160,7 +160,7 @@ function _forge_action_clone() { return 0 fi - # Get conversations list for fzf selection + # Get conversations list for interactive picker selection local conversations_output conversations_output=$($_FORGE_BIN conversation list --porcelain 2>/dev/null) @@ -172,9 +172,9 @@ function _forge_action_clone() { # Get current conversation ID if set local current_id="$_FORGE_CONVERSATION_ID" - # Create fzf interface similar to :conversation + # Create interactive picker interface similar to :conversation local prompt_text="Clone Conversation ❯ " - local fzf_args=( + local select_args=( --prompt="$prompt_text" --delimiter="$_FORGE_DELIMITER" --with-nth="2,3" @@ -185,11 +185,11 @@ function _forge_action_clone() { # 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)") + select_args+=(--bind="start:pos($index)") fi local selected_conversation - selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected_conversation=$(echo "$conversations_output" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected_conversation" ]]; then # Extract conversation ID @@ -291,7 +291,7 @@ function _forge_action_conversation_rename() { local current_id="$_FORGE_CONVERSATION_ID" local prompt_text="Rename Conversation ❯ " - local fzf_args=( + local select_args=( --prompt="$prompt_text" --delimiter="$_FORGE_DELIMITER" --with-nth="2,3" @@ -301,11 +301,11 @@ function _forge_action_conversation_rename() { if [[ -n "$current_id" ]]; then local index=$(_forge_find_index "$conversations_output" "$current_id" 1) - fzf_args+=(--bind="start:pos($index)") + select_args+=(--bind="start:pos($index)") fi local selected_conversation - selected_conversation=$(echo "$conversations_output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected_conversation=$(echo "$conversations_output" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected_conversation" ]]; then local conversation_id=$(echo "$selected_conversation" | sed -E 's/ .*//' | tr -d '\n') diff --git a/shell-plugin/lib/actions/provider.zsh b/shell-plugin/lib/actions/provider.zsh index 844ce772f0..5d6474f583 100644 --- a/shell-plugin/lib/actions/provider.zsh +++ b/shell-plugin/lib/actions/provider.zsh @@ -42,7 +42,7 @@ function _forge_select_provider() { current_provider=$($_FORGE_BIN config get provider --porcelain 2>/dev/null) fi - local fzf_args=( + local select_args=( --delimiter="$_FORGE_DELIMITER" --prompt="Provider ❯ " --with-nth=1,3.. @@ -50,18 +50,18 @@ function _forge_select_provider() { # Add query parameter if provided if [[ -n "$query" ]]; then - fzf_args+=(--query="$query") + select_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)") + select_args+=(--bind="start:pos($index)") fi local selected - selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(echo "$output" | _forge_select --header-lines=1 "${select_args[@]}") if [[ -n "$selected" ]]; then echo "$selected" diff --git a/shell-plugin/lib/completion.zsh b/shell-plugin/lib/completion.zsh index ceb196cbab..c41730fd6a 100644 --- a/shell-plugin/lib/completion.zsh +++ b/shell-plugin/lib/completion.zsh @@ -9,16 +9,16 @@ function forge-completion() { if [[ "$current_word" =~ ^@.*$ ]]; then local filter_text="${current_word#@}" local selected - local fzf_args=( + local select_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[@]}") + selected=$(echo "$file_list" | _forge_select --query "$filter_text" "${select_args[@]}") else - selected=$(echo "$file_list" | _forge_fzf "${fzf_args[@]}") + selected=$(echo "$file_list" | _forge_select "${select_args[@]}") fi if [[ -n "$selected" ]]; then @@ -40,12 +40,12 @@ function forge-completion() { # Lazily load the commands list local commands_list=$(_forge_get_commands) if [[ -n "$commands_list" ]]; then - # Use fzf for interactive selection with prefilled filter + # Use interactive picker for 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 ❯ ") + selected=$(echo "$commands_list" | _forge_select --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 ❯ ") + selected=$(echo "$commands_list" | _forge_select --header-lines=1 --delimiter="$_FORGE_DELIMITER" --nth=1 --prompt="Command ❯ ") fi if [[ -n "$selected" ]]; then diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index e18ced3567..9b1fec2910 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -11,9 +11,130 @@ 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" "$@" +# Private select function using forge's built-in nucleo-picker +# Translates common picker options to forge select arguments. +function _forge_select() { + local -a forge_args=() + local query="" + local prompt="" + local multi=false + local header_lines=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --query=*) + query="${1#--query=}" + ;; + --query) + shift + query="$1" + ;; + --prompt=*) + prompt="${1#--prompt=}" + ;; + --prompt) + shift + prompt="$1" + ;; + --delimiter=*) + forge_args+=(--delimiter "${1#--delimiter=}") + ;; + --delimiter) + shift + forge_args+=(--delimiter "$1") + ;; + --with-nth=*) + forge_args+=(--with-nth "${1#--with-nth=}") + ;; + --with-nth) + shift + forge_args+=(--with-nth "$1") + ;; + --preview=*) + forge_args+=(--preview "${1#--preview=}") + ;; + --preview) + shift + forge_args+=(--preview "$1") + ;; + --preview-window=*) + forge_args+=(--preview-window "${1#--preview-window=}") + ;; + --preview-window) + shift + forge_args+=(--preview-window "$1") + ;; + --multi) + multi=true + ;; + --header-lines=*) + header_lines="${1#--header-lines=}" + ;; + --header-lines) + shift + header_lines="$1" + ;; + --nth=*|--bind=*|--ansi|--no-scrollbar|--height=*|--cycle|--select-1|--reverse|--exact|--color=*|--color) + # Unsupported picker options - silently ignore + if [[ "$1" == --prompt ]]; then + shift + fi + ;; + *) + # Unknown option - ignore + ;; + esac + shift + done + + if [[ -n "$query" ]]; then + forge_args+=(--query "$query") + fi + if [[ -n "$prompt" ]]; then + forge_args+=(--prompt "$prompt") + fi + if [[ "$multi" == true ]]; then + forge_args+=(--multi) + fi + + local input_file output_file selectable_file exit_status + input_file=$(mktemp -t forge-select-input.XXXXXX) || return 1 + output_file=$(mktemp -t forge-select-output.XXXXXX) || { + rm -f "$input_file" + return 1 + } + selectable_file=$(mktemp -t forge-select-choices.XXXXXX) || { + rm -f "$input_file" "$output_file" + return 1 + } + + cat > "$input_file" + + if [[ ! -s "$input_file" ]]; then + rm -f "$input_file" "$output_file" "$selectable_file" + return 1 + fi + + if (( header_lines > 0 )); then + tail -n +$((header_lines + 1)) "$input_file" > "$selectable_file" + else + cp "$input_file" "$selectable_file" + fi + + if [[ ! -s "$selectable_file" ]]; then + rm -f "$input_file" "$output_file" "$selectable_file" + return 1 + fi + + if $_FORGE_BIN select "${forge_args[@]}" < "$selectable_file" > "$output_file" 2>/dev/tty; then + cat "$output_file" + exit_status=0 + else + exit_status=$? + fi + + rm -f "$input_file" "$output_file" "$selectable_file" + return $exit_status } # Helper function to execute forge commands consistently @@ -49,7 +170,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.