diff --git a/CHANGELOG.md b/CHANGELOG.md index 231f631..53ad2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- `@`-triggered fuzzy file picker in TUI input — type `@` to search project files by name/path/extension with real-time filtering (#600) - Orchestrator provider option in `zeph init` wizard for multi-model routing setup (#597) - `zeph vault` CLI subcommands: `init` (generate age keypair), `set` (store secret), `get` (retrieve secret), `list` (show keys), `rm` (remove secret) (#598) - Atomic file writes for vault operations with temp+rename strategy (#598) diff --git a/Cargo.lock b/Cargo.lock index 3a14b70..edf628d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -170,7 +170,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -973,7 +973,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1598,7 +1598,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1760,7 +1760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3777,7 +3777,17 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[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]] @@ -5425,7 +5435,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5493,7 +5503,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6712,10 +6722,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8154,7 +8164,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -9077,12 +9087,15 @@ dependencies = [ "anyhow", "crossterm", "expectrl", + "ignore", "insta", + "nucleo-matcher", "proptest", "pulldown-cmark", "ratatui", "regex", "similar", + "tempfile", "thiserror 2.0.18", "throbber-widgets-tui", "tokio", diff --git a/Cargo.toml b/Cargo.toml index f395641..0e451de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ eventsource-stream = "0.2" http-body-util = "0.1" futures-core = "0.3" notify = "8" +nucleo-matcher = "0.3.1" notify-debouncer-mini = "0.7" ollama-rs = { version = "0.3", default-features = false, features = ["rustls", "stream"] } pdf-extract = "0.7" diff --git a/README.md b/README.md index c83e2dd..2936f52 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ A full terminal UI powered by ratatui — not a separate monitoring tool, but an - Tree-sitter syntax highlighting and markdown rendering - Syntax-highlighted diff view for file edits (compact/expanded toggle) +- `@`-triggered fuzzy file picker with real-time filtering (nucleo-matcher) - Live metrics: token usage, filter savings, cost tracking, confidence distribution - Conversation history with message queueing - Responsive input handling during streaming with render cache and event batching diff --git a/crates/zeph-tui/Cargo.toml b/crates/zeph-tui/Cargo.toml index 80a1ee2..41e1c8e 100644 --- a/crates/zeph-tui/Cargo.toml +++ b/crates/zeph-tui/Cargo.toml @@ -8,6 +8,8 @@ repository.workspace = true [dependencies] anyhow.workspace = true +ignore.workspace = true +nucleo-matcher.workspace = true crossterm.workspace = true pulldown-cmark.workspace = true ratatui.workspace = true @@ -32,6 +34,7 @@ zeph-core.workspace = true expectrl = "0.8" insta = "1.42" proptest = "1.6" +tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [lints] diff --git a/crates/zeph-tui/README.md b/crates/zeph-tui/README.md index c12eb63..3ab7b4d 100644 --- a/crates/zeph-tui/README.md +++ b/crates/zeph-tui/README.md @@ -11,6 +11,7 @@ Provides a terminal UI for monitoring the Zeph agent in real time. Built on rata - **app** — `App` state machine driving the render/event loop - **channel** — `TuiChannel` implementing the `Channel` trait for agent I/O - **event** — `AgentEvent`, `AppEvent`, `EventReader` for async event dispatch +- **file_picker** — `@`-triggered fuzzy file search with `nucleo-matcher` and `ignore` crate - **highlight** — syntax highlighting for code blocks - **layout** — panel arrangement and responsive grid - **metrics** — `MetricsCollector`, `MetricsSnapshot` for live telemetry diff --git a/crates/zeph-tui/src/app.rs b/crates/zeph-tui/src/app.rs index 079b0fa..bc4c28a 100644 --- a/crates/zeph-tui/src/app.rs +++ b/crates/zeph-tui/src/app.rs @@ -8,6 +8,7 @@ use tracing::debug; use crate::command::TuiCommand; use crate::event::{AgentEvent, AppEvent}; +use crate::file_picker::{FileIndex, FilePickerState}; use crate::hyperlink::HyperlinkSpan; use crate::layout::AppLayout; use crate::metrics::MetricsSnapshot; @@ -128,6 +129,8 @@ pub struct App { confirm_state: Option, command_palette: Option, command_tx: Option>, + file_picker_state: Option, + file_index: Option, pub should_quit: bool, user_input_tx: mpsc::Sender, agent_event_rx: mpsc::Receiver, @@ -168,6 +171,8 @@ impl App { confirm_state: None, command_palette: None, command_tx: None, + file_picker_state: None, + file_index: None, should_quit: false, user_input_tx, agent_event_rx, @@ -569,6 +574,10 @@ impl App { widgets::input::render(self, frame, layout.input); widgets::status::render(self, &self.metrics, frame, layout.status); + if let Some(state) = &self.file_picker_state { + widgets::file_picker::render(state, frame, layout.input); + } + if let Some(state) = &self.confirm_state { widgets::confirm::render(&state.prompt, frame, frame.area()); } @@ -639,6 +648,11 @@ impl App { return; } + if self.file_picker_state.is_some() { + self.handle_file_picker_key(key); + return; + } + match self.input_mode { InputMode::Normal => self.handle_normal_key(key), InputMode::Insert => self.handle_insert_key(key), @@ -1000,6 +1014,9 @@ impl App { KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { let _ = self.user_input_tx.try_send("/clear-queue".to_owned()); } + KeyCode::Char('@') => { + self.open_file_picker(); + } KeyCode::Char(c) => { let byte_offset = self.byte_offset_of_char(self.cursor_position); self.input.insert(byte_offset, c); @@ -1009,6 +1026,51 @@ impl App { } } + fn open_file_picker(&mut self) { + let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let needs_rebuild = self.file_index.as_ref().is_none_or(FileIndex::is_stale); + if needs_rebuild { + self.file_index = Some(FileIndex::build(&root)); + } + if let Some(idx) = &self.file_index { + self.file_picker_state = Some(FilePickerState::new(idx)); + } + } + + fn handle_file_picker_key(&mut self, key: KeyEvent) { + let Some(state) = self.file_picker_state.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc => { + self.file_picker_state = None; + } + KeyCode::Enter | KeyCode::Tab => { + if let Some(path) = state.selected_path().map(ToOwned::to_owned) { + let byte_offset = self.byte_offset_of_char(self.cursor_position); + self.input.insert_str(byte_offset, &path); + self.cursor_position += path.chars().count(); + } + self.file_picker_state = None; + } + KeyCode::Up => { + state.move_selection(-1); + } + KeyCode::Down => { + state.move_selection(1); + } + KeyCode::Char(c) => { + state.push_char(c); + } + KeyCode::Backspace => { + if !state.pop_char() { + self.file_picker_state = None; + } + } + _ => {} + } + } + fn submit_input(&mut self) { let text = self.input.trim().to_string(); if text.is_empty() { @@ -2370,4 +2432,242 @@ mod tests { assert!(app.messages()[0].content.contains("no command channel")); } } + + mod file_picker_tests { + use std::fs; + + use super::*; + use crate::file_picker::FileIndex; + + fn make_app_with_index() -> (App, mpsc::Receiver, mpsc::Sender) { + let (app, rx, tx) = make_app(); + (app, rx, tx) + } + + fn build_temp_index(files: &[&str]) -> (FileIndex, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for &f in files { + let path = dir.path().join(f); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, "").unwrap(); + } + let idx = FileIndex::build(dir.path()); + (idx, dir) + } + + fn open_picker_with_index(app: &mut App, idx: &FileIndex) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_owned(); + drop(dir.keep()); + app.file_index = Some(FileIndex::build(&path)); + // Replace with our controlled index + app.file_picker_state = Some(crate::file_picker::FilePickerState::new(idx)); + } + + #[test] + fn at_sign_opens_picker_and_does_not_insert_into_input() { + let (mut app, _rx, _tx) = make_app_with_index(); + app.input_mode = InputMode::Insert; + let key = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!( + !app.input.contains('@'), + "@ should not be in input after opening picker" + ); + assert!( + app.file_picker_state.is_some(), + "file_picker_state should be Some after @" + ); + } + + #[test] + fn esc_dismisses_picker() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs", "b.rs"]); + open_picker_with_index(&mut app, &idx); + assert!(app.file_picker_state.is_some()); + + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert!(app.file_picker_state.is_none()); + assert!(app.input.is_empty()); + } + + #[test] + fn enter_inserts_selected_path_and_closes_picker() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["src/main.rs"]); + open_picker_with_index(&mut app, &idx); + + let selected = app + .file_picker_state + .as_ref() + .unwrap() + .selected_path() + .map(ToOwned::to_owned) + .unwrap(); + + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.file_picker_state.is_none()); + assert!( + app.input.contains(&selected), + "input should contain selected path" + ); + assert_eq!(app.cursor_position, selected.chars().count()); + } + + #[test] + fn tab_inserts_selected_path_and_closes_picker() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["README.md"]); + open_picker_with_index(&mut app, &idx); + + let selected = app + .file_picker_state + .as_ref() + .unwrap() + .selected_path() + .map(ToOwned::to_owned) + .unwrap(); + + let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.file_picker_state.is_none()); + assert!(app.input.contains(&selected)); + } + + #[test] + fn enter_with_no_matches_closes_picker_without_modifying_input() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs"]); + open_picker_with_index(&mut app, &idx); + + let state = app.file_picker_state.as_mut().unwrap(); + state.update_query("xyznotfound"); + + assert!(app.file_picker_state.as_ref().unwrap().matches().is_empty()); + + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.file_picker_state.is_none()); + assert!(app.input.is_empty(), "input must be unchanged"); + } + + #[test] + fn down_key_advances_selection() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]); + open_picker_with_index(&mut app, &idx); + + assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 0); + + let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 1); + } + + #[test] + fn up_key_wraps_selection_to_last() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]); + open_picker_with_index(&mut app, &idx); + + let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + let state = app.file_picker_state.as_ref().unwrap(); + assert_eq!(state.selected, state.matches().len() - 1); + } + + #[test] + fn typing_filters_matches() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["src/main.rs", "src/lib.rs"]); + open_picker_with_index(&mut app, &idx); + + let initial_count = app.file_picker_state.as_ref().unwrap().matches().len(); + + let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + let filtered_count = app.file_picker_state.as_ref().unwrap().matches().len(); + assert!(filtered_count <= initial_count); + assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m"); + } + + #[test] + fn backspace_with_nonempty_query_removes_char() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs"]); + open_picker_with_index(&mut app, &idx); + + app.file_picker_state.as_mut().unwrap().update_query("ma"); + + let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.file_picker_state.is_some()); + assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m"); + } + + #[test] + fn backspace_on_empty_query_dismisses_picker() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs"]); + open_picker_with_index(&mut app, &idx); + + assert!(app.file_picker_state.as_ref().unwrap().query.is_empty()); + + let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.file_picker_state.is_none()); + } + + #[test] + fn picker_blocks_other_keys() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["a.rs"]); + open_picker_with_index(&mut app, &idx); + + app.input = "hello".into(); + app.cursor_position = 5; + let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); + app.handle_event(AppEvent::Key(key)).unwrap(); + assert_eq!( + app.input, "hello", + "input should be unchanged while picker is open" + ); + } + + #[test] + fn enter_inserts_at_cursor_mid_input() { + let (mut app, _rx, _tx) = make_app_with_index(); + let (idx, _dir) = build_temp_index(&["src/lib.rs"]); + open_picker_with_index(&mut app, &idx); + + app.input = "ab".into(); + app.cursor_position = 1; + + let selected = app + .file_picker_state + .as_ref() + .unwrap() + .selected_path() + .map(ToOwned::to_owned) + .unwrap(); + + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + app.handle_event(AppEvent::Key(key)).unwrap(); + + assert!(app.input.contains(&selected)); + assert!(app.input.starts_with('a')); + assert!(app.input.ends_with('b')); + } + } } diff --git a/crates/zeph-tui/src/file_picker.rs b/crates/zeph-tui/src/file_picker.rs new file mode 100644 index 0000000..93709cd --- /dev/null +++ b/crates/zeph-tui/src/file_picker.rs @@ -0,0 +1,345 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; + +const TTL: Duration = Duration::from_secs(30); +const MAX_RESULTS: usize = 10; +/// Hard cap on indexed paths to prevent unbounded memory usage on repos with +/// large unignored directories. +const MAX_INDEXED: usize = 50_000; + +pub struct FileIndex { + paths: Arc>, + built_at: Instant, +} + +impl FileIndex { + /// Builds the file index by walking `root` with `.gitignore` awareness. + /// + /// # Blocking I/O note + /// + /// This function performs synchronous directory traversal on the calling thread. + /// For small to medium repos (< 5 000 files) the cost is negligible (< 20 ms). + /// For large monorepos (50 000+ files) consider offloading via + /// `tokio::task::spawn_blocking`. A full async build is deferred to a + /// follow-up milestone once the UX for "Indexing…" feedback is designed. + #[must_use] + pub fn build(root: &Path) -> Self { + let mut paths = Vec::new(); + let walker = ignore::WalkBuilder::new(root) + .hidden(true) // exclude dotfiles (.env, .ssh/, etc.) + .ignore(true) + .git_ignore(true) + .build(); + + for entry in walker.flatten() { + if entry.file_type().is_some_and(|ft| ft.is_file()) { + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + if let Some(s) = rel.to_str() { + // Normalize Windows backslashes to forward slashes + paths.push(s.replace('\\', "/")); + } + if paths.len() >= MAX_INDEXED { + tracing::warn!( + max = MAX_INDEXED, + root = %root.display(), + "file index cap reached; some files will not be searchable" + ); + break; + } + } + } + paths.sort_unstable(); + Self { + paths: Arc::new(paths), + built_at: Instant::now(), + } + } + + #[must_use] + pub fn is_stale(&self) -> bool { + self.built_at.elapsed() > TTL + } + + #[must_use] + pub fn paths(&self) -> &[String] { + &self.paths + } + + #[must_use] + pub fn paths_arc(&self) -> Arc> { + Arc::clone(&self.paths) + } +} + +#[derive(Clone)] +pub struct PickerMatch { + pub path: String, + pub score: u32, +} + +pub struct FilePickerState { + pub query: String, + pub selected: usize, + matches: Vec, + /// Shared ownership of the file index — no clone on picker open. + index: Arc>, + /// Reused across `refilter` calls to avoid per-keystroke heap allocation. + matcher: Matcher, +} + +impl FilePickerState { + #[must_use] + pub fn new(index: &FileIndex) -> Self { + let mut state = Self { + query: String::new(), + selected: 0, + matches: Vec::new(), + index: index.paths_arc(), + matcher: Matcher::new(Config::DEFAULT), + }; + state.refilter(); + state + } + + pub fn update_query(&mut self, query: &str) { + query.clone_into(&mut self.query); + self.refilter(); + } + + /// Appends a character to the query and re-filters. + pub fn push_char(&mut self, c: char) { + self.query.push(c); + self.refilter(); + } + + /// Removes the last character from the query and re-filters. + /// Returns `true` if a character was removed, `false` if the query was already empty. + pub fn pop_char(&mut self) -> bool { + if self.query.pop().is_some() { + self.refilter(); + true + } else { + false + } + } + + #[must_use] + pub fn matches(&self) -> &[PickerMatch] { + &self.matches + } + + #[must_use] + pub fn selected_path(&self) -> Option<&str> { + self.matches.get(self.selected).map(|m| m.path.as_str()) + } + + pub fn move_selection(&mut self, delta: i32) { + let len = self.matches.len(); + if len == 0 { + return; + } + let len_i = i32::try_from(len).unwrap_or(i32::MAX); + let cur_i = i32::try_from(self.selected).unwrap_or(0); + let new_i = (cur_i + delta).rem_euclid(len_i); + self.selected = usize::try_from(new_i).unwrap_or(0); + } + + fn refilter(&mut self) { + self.selected = 0; + if self.query.is_empty() { + self.matches = self + .index + .iter() + .take(MAX_RESULTS) + .map(|p| PickerMatch { + path: p.clone(), + score: 0, + }) + .collect(); + return; + } + + let pattern = Pattern::new( + &self.query, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + ); + + let mut scored: Vec = self + .index + .iter() + .filter_map(|p| { + let mut buf = Vec::new(); + let haystack = Utf32Str::new(p, &mut buf); + pattern + .score(haystack, &mut self.matcher) + .map(|score| PickerMatch { + path: p.clone(), + score, + }) + }) + .collect(); + + scored.sort_unstable_by(|a, b| b.score.cmp(&a.score)); + scored.truncate(MAX_RESULTS); + self.matches = scored; + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn make_index(files: &[&str]) -> FileIndex { + let dir = tempfile::tempdir().unwrap(); + for &f in files { + let path = dir.path().join(f); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, "").unwrap(); + } + FileIndex::build(dir.path()) + } + + #[test] + fn build_collects_files() { + let idx = make_index(&["src/main.rs", "src/lib.rs", "README.md"]); + assert_eq!(idx.paths().len(), 3); + assert!(idx.paths().iter().any(|p| p.ends_with("main.rs"))); + } + + #[test] + fn is_stale_false_when_fresh() { + let idx = make_index(&["a.rs"]); + assert!(!idx.is_stale()); + } + + #[test] + fn empty_query_returns_up_to_10_files() { + let files: Vec = (0..15).map(|i| format!("file{i}.rs")).collect(); + let refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect(); + let idx = make_index(&refs); + let state = FilePickerState::new(&idx); + assert_eq!(state.matches().len(), 10); + } + + #[test] + fn fuzzy_query_filters_results() { + let idx = make_index(&["src/main.rs", "src/lib.rs", "tests/foo.rs"]); + let mut state = FilePickerState::new(&idx); + state.update_query("main"); + assert!(!state.matches().is_empty()); + assert!(state.matches().iter().any(|m| m.path.contains("main"))); + } + + #[test] + fn selected_path_returns_first_match() { + let idx = make_index(&["alpha.rs", "beta.rs"]); + let state = FilePickerState::new(&idx); + assert!(state.selected_path().is_some()); + } + + #[test] + fn move_selection_wraps_around() { + let idx = make_index(&["a.rs", "b.rs", "c.rs"]); + let mut state = FilePickerState::new(&idx); + assert_eq!(state.selected, 0); + state.move_selection(-1); + assert_eq!(state.selected, state.matches().len() - 1); + } + + #[test] + fn move_selection_noop_when_empty() { + let idx = make_index(&["a.rs"]); + let mut state = FilePickerState::new(&idx); + state.matches = vec![]; + state.move_selection(1); + assert_eq!(state.selected, 0); + } + + #[test] + fn no_match_query_returns_empty_and_selected_path_none() { + let idx = make_index(&["src/main.rs", "src/lib.rs"]); + let mut state = FilePickerState::new(&idx); + state.update_query("xyznotfound"); + assert!(state.matches().is_empty()); + assert!(state.selected_path().is_none()); + } + + #[test] + fn unicode_paths_are_indexed_and_searchable() { + let idx = make_index(&["src/данные.rs", "データ/main.rs", "normal.rs"]); + assert!(idx.paths().iter().any(|p| p.contains("данные"))); + assert!(idx.paths().iter().any(|p| p.contains("main"))); + + let mut state = FilePickerState::new(&idx); + state.update_query("данные"); + assert!( + !state.matches().is_empty(), + "expected match for unicode query" + ); + } + + #[test] + fn push_char_appends_and_refilters() { + let idx = make_index(&["src/main.rs", "src/lib.rs"]); + let mut state = FilePickerState::new(&idx); + state.push_char('m'); + state.push_char('a'); + assert!(state.matches().iter().any(|m| m.path.contains("main"))); + } + + #[test] + fn pop_char_removes_last_and_refilters() { + let idx = make_index(&["src/main.rs", "src/lib.rs"]); + let mut state = FilePickerState::new(&idx); + state.push_char('m'); + let removed = state.pop_char(); + assert!(removed); + assert!(state.query.is_empty()); + } + + #[test] + fn pop_char_on_empty_returns_false() { + let idx = make_index(&["a.rs"]); + let mut state = FilePickerState::new(&idx); + assert!(!state.pop_char()); + } + + #[test] + fn arc_index_shared_not_cloned() { + let idx = make_index(&["a.rs", "b.rs"]); + let arc1 = idx.paths_arc(); + let state = FilePickerState::new(&idx); + // Both should point to the same allocation + assert!(Arc::ptr_eq(&arc1, &state.index)); + } + + use proptest::prelude::*; + + proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(200))] + + #[test] + fn move_selection_never_panics( + n in 1usize..20, + delta in -10i32..10, + ) { + let files: Vec = (0..n).map(|i| format!("f{i}.rs")).collect(); + let refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect(); + let idx = make_index(&refs); + let mut state = FilePickerState::new(&idx); + state.move_selection(delta); + prop_assert!(state.selected < state.matches().len().max(1)); + } + } +} diff --git a/crates/zeph-tui/src/lib.rs b/crates/zeph-tui/src/lib.rs index 38eb632..48e93e8 100644 --- a/crates/zeph-tui/src/lib.rs +++ b/crates/zeph-tui/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod channel; pub mod command; pub mod event; +pub mod file_picker; pub mod highlight; pub mod hyperlink; pub mod layout; diff --git a/crates/zeph-tui/src/widgets/file_picker.rs b/crates/zeph-tui/src/widgets/file_picker.rs new file mode 100644 index 0000000..1920def --- /dev/null +++ b/crates/zeph-tui/src/widgets/file_picker.rs @@ -0,0 +1,114 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; + +use crate::file_picker::FilePickerState; +use crate::theme::Theme; + +pub fn render(state: &FilePickerState, frame: &mut Frame, input_area: Rect) { + let match_count = state.matches().len(); + let visible_items = u16::try_from(match_count.min(10)).unwrap_or(10); + // border top + query line + border bottom = 3 overhead; items in between + let height = visible_items + 3; + let y = input_area.y.saturating_sub(height); + let popup = Rect::new(input_area.x, y, input_area.width, height); + + frame.render_widget(Clear, popup); + + let theme = Theme::default(); + + // Split popup: first line for query, rest for list + let query_area = Rect::new(popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), 1); + let list_area = Rect::new( + popup.x + 1, + popup.y + 2, + popup.width.saturating_sub(2), + visible_items, + ); + + // Outer block + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.panel_border) + .title(" Files ") + .title_style(theme.panel_title); + frame.render_widget(block, popup); + + // Query line + let query_text = format!("> {}", state.query); + let query_para = Paragraph::new(Span::styled(query_text, theme.highlight)); + frame.render_widget(query_para, query_area); + + // File list — borrow path strings to avoid allocation per render frame + let items: Vec = state + .matches() + .iter() + .map(|m| ListItem::new(Line::from(Span::raw(m.path.as_str())))) + .collect(); + + let selected_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + let list = List::new(items) + .highlight_style(selected_style) + .highlight_symbol("> "); + + let mut list_state = ListState::default(); + if match_count > 0 { + list_state.select(Some(state.selected)); + } + + frame.render_stateful_widget(list, list_area, &mut list_state); +} + +#[cfg(test)] +mod tests { + use std::fs; + + use insta::assert_snapshot; + + use crate::file_picker::{FileIndex, FilePickerState}; + use crate::test_utils::render_to_string; + + fn make_state(files: &[&str], query: &str) -> (FilePickerState, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for &f in files { + let path = dir.path().join(f); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, "").unwrap(); + } + let idx = FileIndex::build(dir.path()); + let mut state = FilePickerState::new(&idx); + if !query.is_empty() { + state.update_query(query); + } + (state, dir) + } + + #[test] + fn file_picker_empty_query_snapshot() { + let (state, _dir) = make_state(&["src/main.rs", "src/lib.rs", "README.md"], ""); + let input_area = ratatui::layout::Rect::new(0, 15, 60, 3); + let output = render_to_string(60, 20, |frame, _area| { + super::render(&state, frame, input_area); + }); + assert_snapshot!(output); + } + + #[test] + fn file_picker_with_query_snapshot() { + let (mut state, _dir) = make_state(&["src/main.rs", "src/lib.rs", "README.md"], ""); + state.update_query("main"); + let input_area = ratatui::layout::Rect::new(0, 15, 60, 3); + let output = render_to_string(60, 20, |frame, _area| { + super::render(&state, frame, input_area); + }); + assert_snapshot!(output); + } +} diff --git a/crates/zeph-tui/src/widgets/mod.rs b/crates/zeph-tui/src/widgets/mod.rs index 2f6414d..84b813f 100644 --- a/crates/zeph-tui/src/widgets/mod.rs +++ b/crates/zeph-tui/src/widgets/mod.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod command_palette; pub mod confirm; pub mod diff; +pub mod file_picker; pub mod help; pub mod input; pub mod memory; diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_empty_query_snapshot.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_empty_query_snapshot.snap new file mode 100644 index 0000000..ca79960 --- /dev/null +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_empty_query_snapshot.snap @@ -0,0 +1,19 @@ +--- +source: crates/zeph-tui/src/widgets/file_picker.rs +expression: output +--- + + + + + + + + + +┌ Files ───────────────────────────────────────────────────┐ +│> │ +│> README.md │ +│ src/lib.rs │ +│ src/main.rs │ +└──────────────────────────────────────────────────────────┘ diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_with_query_snapshot.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_with_query_snapshot.snap new file mode 100644 index 0000000..e960cd2 --- /dev/null +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__file_picker__tests__file_picker_with_query_snapshot.snap @@ -0,0 +1,19 @@ +--- +source: crates/zeph-tui/src/widgets/file_picker.rs +expression: output +--- + + + + + + + + + + + +┌ Files ───────────────────────────────────────────────────┐ +│> main │ +│> src/main.rs │ +└──────────────────────────────────────────────────────────┘ diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 3ce5ab4..722e6f6 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -9,6 +9,7 @@ See the full [CHANGELOG.md](https://github.com/bug-ops/zeph/blob/main/CHANGELOG. ## [Unreleased] ### Added +- `@`-triggered fuzzy file picker in TUI input (#600) - Orchestrator provider option in `zeph init` wizard for multi-model routing setup (#597) ## [0.11.0] - 2026-02-19 diff --git a/docs/src/guide/tui.md b/docs/src/guide/tui.md index 5c55a96..9d285e6 100644 --- a/docs/src/guide/tui.md +++ b/docs/src/guide/tui.md @@ -72,11 +72,26 @@ ZEPH_TUI=true zeph |-----|--------| | `Enter` | Submit input to agent | | `Shift+Enter` | Insert newline (multiline input) | +| `@` | Open file picker (fuzzy file search) | | `Escape` | Switch to Normal mode | | `Ctrl+C` | Quit application | | `Ctrl+U` | Clear input line | | `Ctrl+K` | Clear message queue | +### File Picker + +Typing `@` in Insert mode opens a fuzzy file search popup above the input area. The picker indexes all project files (respecting `.gitignore`) and filters them in real time as you type. + +| Key | Action | +|-----|--------| +| Any character | Filter files by fuzzy match | +| `Up` / `Down` | Navigate the result list | +| `Enter` / `Tab` | Insert selected file path at cursor and close | +| `Backspace` | Remove last query character (dismisses if query is empty) | +| `Escape` | Close picker without inserting | + +All other keys are blocked while the picker is visible. + ### Confirmation Modal When a destructive command requires confirmation, a modal overlay appears: @@ -159,6 +174,30 @@ Alternatively, send the `/clear-queue` command to clear the queue programmatical The queue holds a maximum of 10 messages. When full, new input is silently dropped until the agent drains the queue by processing pending messages. +## File Picker + +The `@` file picker provides fast file reference insertion without leaving the input area. It uses `nucleo-matcher` (the same fuzzy engine as the Helix editor) for matching and the `ignore` crate for file discovery. + +### How It Works + +1. Type `@` in Insert mode — a popup appears above the input area +2. Continue typing to narrow results (e.g., `@main.rs`, `@src/app`) +3. The top 10 matches update on every keystroke +4. Press `Enter` or `Tab` to insert the relative file path at the cursor position +5. Press `Escape` to dismiss without inserting + +### File Index + +The picker walks the project directory on first use and caches the result for 30 seconds. Subsequent `@` triggers within the TTL reuse the cached index. The index: + +- Respects `.gitignore` rules via the `ignore` crate +- Excludes hidden files and directories (dotfiles) +- Caps at 50,000 paths to prevent memory spikes in large repositories + +### Fuzzy Matching + +Matches are scored against the full relative path, so you can search by directory name, file name, or extension. The query `src/app` matches `crates/zeph-tui/src/app.rs` as well as `src/app/mod.rs`. + ## Responsive Layout The TUI adapts to terminal width: