From 07dc8f20379cc10b946ff7a88d15bce157bedfe8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 26 Apr 2026 17:45:14 -0500 Subject: [PATCH] feat(tui): Ctrl+P fuzzy file picker (#97) Adds a modal overlay (`FilePickerView`) bound to Ctrl+P from the composer when no other modal is open and the engine is not streaming. * Single-pass `WalkBuilder` walk at construction (depth 6, hidden=true, follow_links=false, .gitignore honored) caches workspace-relative paths so per-keystroke filtering is fully in-memory. * Custom subsequence scorer with start/boundary bonuses, consecutive-run reward, and gap penalty. ~70 lines, no new crate dependency. * Up/Down + PgUp/PgDn navigate; Backspace and Ctrl+U edit the query; Enter emits `ViewEvent::FilePickerSelected` which the UI handler inserts at the composer cursor as `@` (with surrounding spaces so the existing `@`-mention parser picks it up); Esc closes without modifying the composer. * Ten unit tests cover the scorer (subsequence / boundary / case / empty-query edge cases) and the view (typing narrows, backspace widens, Enter emits, Esc closes, `.ignore` is honored). Fixes #97 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/file_picker.rs | 568 ++++++++++++++++++++++++++++++ crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/ui.rs | 33 ++ crates/tui/src/tui/views/mod.rs | 7 + 4 files changed, 609 insertions(+) create mode 100644 crates/tui/src/tui/file_picker.rs diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs new file mode 100644 index 00000000..60fd1ee4 --- /dev/null +++ b/crates/tui/src/tui/file_picker.rs @@ -0,0 +1,568 @@ +//! Fuzzy file-picker modal (Ctrl+P). +//! +//! Opens an overlay populated with workspace-relative paths discovered by a +//! single-pass `WalkBuilder` walk (depth 6, hidden=true, follow_links=false, +//! `.gitignore` honored). Subsequent keystrokes filter the cached candidate +//! list in memory using a small subsequence + first-letter-bonus scorer — no +//! per-keystroke disk traversal. +//! +//! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns +//! into an `@` insertion at the composer cursor. + +use std::path::Path; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ignore::WalkBuilder; +use ratatui::{ + buffer::Buffer, + layout::Rect, + prelude::Stylize, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, +}; + +use crate::palette; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +/// Maximum number of candidates collected from the initial walk. Keeps memory +/// bounded for very large monorepos; matches the limits codex-rs uses for the +/// equivalent overlay. +const MAX_CANDIDATES: usize = 20_000; + +/// Walk depth for the initial scan. Mirrors the `Workspace` fuzzy index. +const WALK_DEPTH: usize = 6; + +/// Visible candidate rows in the overlay. +const VISIBLE_ROWS: usize = 14; + +pub struct FilePickerView { + /// All workspace-relative candidate paths, captured once at construction. + candidates: Vec, + /// Filtered indices into `candidates`, sorted by descending score. + filtered: Vec, + /// User's typed query (lowercased on each refilter). + query: String, + /// Selected row within `filtered`. + selected: usize, + /// Top of the visible window within `filtered`. + scroll: usize, +} + +impl FilePickerView { + /// Build a picker rooted at `workspace_root`. Performs the directory walk + /// eagerly so per-keystroke filtering stays in memory. + pub fn new(workspace_root: &Path) -> Self { + let candidates = collect_candidates(workspace_root); + let mut view = Self { + candidates, + filtered: Vec::new(), + query: String::new(), + selected: 0, + scroll: 0, + }; + view.refilter(); + view + } + + fn refilter(&mut self) { + let query = self.query.trim().to_lowercase(); + let mut scored: Vec<(usize, i32)> = if query.is_empty() { + self.candidates + .iter() + .enumerate() + .map(|(idx, _)| (idx, 0)) + .collect() + } else { + self.candidates + .iter() + .enumerate() + .filter_map(|(idx, path)| score(&query, path).map(|s| (idx, s))) + .collect() + }; + + // Higher scores first; tie-break by ascending path length, then lex order + // so shorter / more central matches surface above deep nested ones. + scored.sort_by(|a, b| { + b.1.cmp(&a.1) + .then_with(|| self.candidates[a.0].len().cmp(&self.candidates[b.0].len())) + .then_with(|| self.candidates[a.0].cmp(&self.candidates[b.0])) + }); + + self.filtered = scored.into_iter().map(|(idx, _)| idx).collect(); + if self.filtered.is_empty() { + self.selected = 0; + self.scroll = 0; + } else if self.selected >= self.filtered.len() { + self.selected = self.filtered.len() - 1; + } + self.adjust_scroll(); + } + + fn adjust_scroll(&mut self) { + if self.filtered.is_empty() { + self.scroll = 0; + return; + } + if self.selected < self.scroll { + self.scroll = self.selected; + } else if self.selected >= self.scroll + VISIBLE_ROWS { + self.scroll = self.selected + 1 - VISIBLE_ROWS; + } + } + + fn move_selection(&mut self, delta: isize) { + if self.filtered.is_empty() { + return; + } + let max = self.filtered.len() - 1; + let next = if delta.is_negative() { + self.selected.saturating_sub(delta.unsigned_abs()) + } else { + (self.selected + delta as usize).min(max) + }; + self.selected = next; + self.adjust_scroll(); + } + + fn selected_path(&self) -> Option<&str> { + let idx = *self.filtered.get(self.selected)?; + self.candidates.get(idx).map(String::as_str) + } + + /// Visible candidate count for tests / diagnostics. + #[cfg(test)] + pub fn visible_count(&self) -> usize { + self.filtered.len() + } + + #[cfg(test)] + pub fn query(&self) -> &str { + &self.query + } + + #[cfg(test)] + pub fn selected_for_test(&self) -> Option<&str> { + self.selected_path() + } +} + +impl ModalView for FilePickerView { + fn kind(&self) -> ModalKind { + ModalKind::FilePicker + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => ViewAction::Close, + KeyCode::Enter => { + if let Some(path) = self.selected_path() { + let path = path.to_string(); + return ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }); + } + ViewAction::Close + } + KeyCode::Up => { + self.move_selection(-1); + ViewAction::None + } + KeyCode::Down => { + self.move_selection(1); + ViewAction::None + } + KeyCode::PageUp => { + self.move_selection(-(VISIBLE_ROWS as isize)); + ViewAction::None + } + KeyCode::PageDown => { + self.move_selection(VISIBLE_ROWS as isize); + ViewAction::None + } + KeyCode::Backspace => { + self.query.pop(); + self.selected = 0; + self.scroll = 0; + self.refilter(); + ViewAction::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.query.clear(); + self.selected = 0; + self.scroll = 0; + self.refilter(); + ViewAction::None + } + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) + && !ch.is_control() => + { + self.query.push(ch); + self.selected = 0; + self.scroll = 0; + self.refilter(); + ViewAction::None + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 80.min(area.width.saturating_sub(4)); + let popup_height = ((VISIBLE_ROWS as u16) + 6).min(area.height.saturating_sub(4)); + + let popup_area = Rect { + x: area.x + (area.width.saturating_sub(popup_width)) / 2, + y: area.y + (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + let title = Line::from(vec![Span::styled( + " File Picker ", + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD), + )]); + let footer_text = format!( + " {} match{} ↑/↓ select Enter insert @path Esc close ", + self.filtered.len(), + if self.filtered.len() == 1 { "" } else { "es" }, + ); + let block = Block::default() + .title(title) + .title_bottom(Line::from(Span::styled( + footer_text, + Style::default().fg(palette::TEXT_MUTED), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); + + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let mut lines: Vec> = Vec::new(); + // Query line. + lines.push(Line::from(vec![ + Span::styled("> ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::raw(self.query.clone()), + Span::styled( + " ", + Style::default() + .fg(palette::DEEPSEEK_INK) + .bg(palette::DEEPSEEK_SKY), + ), + ])); + lines.push(Line::from("")); + + let visible = VISIBLE_ROWS.min(inner.height.saturating_sub(2) as usize); + let end = (self.scroll + visible).min(self.filtered.len()); + if self.filtered.is_empty() { + lines.push(Line::from(Span::styled( + " No matches", + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + for idx in self.scroll..end { + let path = &self.candidates[self.filtered[idx]]; + let selected = idx == self.selected; + let style = if selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let prefix = if selected { "▶ " } else { " " }; + let display = truncate_path(path, inner.width.saturating_sub(2) as usize); + let mut line = Line::from(format!("{prefix}{display}")); + line.style = style; + lines.push(line); + } + } + + Paragraph::new(lines) + .style(Style::default().fg(palette::TEXT_PRIMARY)) + .render(inner, buf); + } +} + +fn truncate_path(path: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + if path.chars().count() <= max { + return path.to_string(); + } + let take = max.saturating_sub(1); + let truncated: String = path + .chars() + .rev() + .take(take) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("…{truncated}") +} + +/// Single-pass walk that collects workspace-relative paths. +fn collect_candidates(root: &Path) -> Vec { + let mut builder = WalkBuilder::new(root); + builder + .hidden(true) + .follow_links(false) + .max_depth(Some(WALK_DEPTH)) + .git_ignore(true) + .git_exclude(true) + .git_global(true); + + let mut out: Vec = Vec::new(); + for entry in builder.build().flatten() { + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + continue; + } + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(path); + if rel.as_os_str().is_empty() { + continue; + } + let display = path_to_workspace_string(rel); + if !display.is_empty() { + out.push(display); + } + if out.len() >= MAX_CANDIDATES { + break; + } + } + out.sort(); + out +} + +fn path_to_workspace_string(path: &Path) -> String { + // Use forward-slash separators for cross-platform display, matching how + // @-mentions are spelled in the composer. + let mut out = String::new(); + for (idx, comp) in path.components().enumerate() { + if idx > 0 { + out.push('/'); + } + out.push_str(&comp.as_os_str().to_string_lossy()); + } + out +} + +/// Subsequence scorer with first-letter and boundary bonuses. +/// +/// Returns `None` if `query` is not a subsequence of `path` (case-insensitive), +/// otherwise a positive score where higher is better. +/// +/// Heuristics (kept deliberately small and predictable): +/// * +25 for each match that lands at the start of the path or right after a +/// boundary character (`/`, `_`, `-`, `.`, ` `). +/// * +10 if the very first character of the query matches the first character +/// of the path. +/// * +5 per consecutive match (rewards contiguous runs like typing "main" and +/// matching `main.rs`). +/// * Penalty proportional to the gap between consecutive matches keeps tightly +/// matched candidates above scattered ones. +pub fn score(query: &str, path: &str) -> Option { + if query.is_empty() { + return Some(0); + } + let q: Vec = query.chars().flat_map(char::to_lowercase).collect(); + let p: Vec = path.chars().flat_map(char::to_lowercase).collect(); + if q.len() > p.len() { + return None; + } + + let mut qi = 0usize; + let mut score: i32 = 0; + let mut last_match: Option = None; + let mut consecutive = 0i32; + + for (i, ch) in p.iter().enumerate() { + if qi >= q.len() { + break; + } + if *ch == q[qi] { + // Boundary / start bonus. + if i == 0 { + score += 25; + if qi == 0 { + score += 10; + } + } else if matches!(p[i - 1], '/' | '_' | '-' | '.' | ' ') { + score += 25; + } else { + score += 1; + } + + // Consecutive bonus. + if last_match == Some(i.saturating_sub(1)) { + consecutive += 1; + score += 5 * consecutive; + } else { + consecutive = 0; + } + + // Gap penalty. + if let Some(prev) = last_match { + let gap = i - prev - 1; + score -= gap as i32; + } + + last_match = Some(i); + qi += 1; + } + } + + if qi == q.len() { Some(score) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn score_subsequence_match() { + // Identical query matches start with high bonus. + let a = score("main", "main.rs").unwrap(); + let b = score("main", "src/very/deep/main.rs").unwrap(); + assert!(a > b, "a={} b={}", a, b); + } + + #[test] + fn score_rejects_non_subsequence() { + assert!(score("zzz", "main.rs").is_none()); + assert!(score("xyz", "src/lib.rs").is_none()); + } + + #[test] + fn score_boundary_bonus_beats_substring() { + // "fp" matches the boundary letters in "file_picker.rs" but only the + // first letter in "filepicker.rs" — so the boundary candidate should + // win. + let boundary = score("fp", "src/file_picker.rs").unwrap(); + let inline = score("fp", "src/filepicker.rs"); + // inline doesn't even contain 'p' immediately following 'f'? It does: + // f-i-l-e-p-i-c-k-e-r — 'p' is preceded by 'e' (no boundary), so it + // gets only the +1 path score, while boundary gets +25 for the 'p' + // following the underscore. + if let Some(inline_score) = inline { + assert!( + boundary > inline_score, + "boundary={} inline={}", + boundary, + inline_score + ); + } + } + + #[test] + fn score_case_insensitive() { + assert!(score("MAIN", "main.rs").is_some()); + assert!(score("main", "MAIN.RS").is_some()); + } + + #[test] + fn score_empty_query_returns_zero() { + assert_eq!(score("", "anything").unwrap(), 0); + } + + #[test] + fn picker_typing_narrows_candidates() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/main.rs"), "").unwrap(); + fs::write(root.join("src/lib.rs"), "").unwrap(); + fs::write(root.join("README.md"), "").unwrap(); + fs::write(root.join("Cargo.toml"), "").unwrap(); + + let mut view = FilePickerView::new(root); + // Empty query -> all 4 files visible. + assert_eq!(view.visible_count(), 4, "expected all 4 candidates"); + + // Typing "main" should narrow to just src/main.rs. + for ch in "main".chars() { + view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(view.query(), "main"); + let visible = view.visible_count(); + assert_eq!(visible, 1, "expected exactly 1 match for 'main'"); + let selected = view.selected_for_test().expect("selected path"); + assert!(selected.ends_with("main.rs"), "selected = {selected}"); + } + + #[test] + fn picker_backspace_widens_candidates() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("a.txt"), "").unwrap(); + fs::write(root.join("b.txt"), "").unwrap(); + + let mut view = FilePickerView::new(root); + view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!(view.visible_count(), 1); + view.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(view.visible_count(), 2); + } + + #[test] + fn picker_enter_emits_event() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("only.txt"), "").unwrap(); + + let mut view = FilePickerView::new(root); + let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match action { + ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => { + assert!(path.ends_with("only.txt")); + } + other => panic!("expected EmitAndClose(FilePickerSelected), got {other:?}"), + } + } + + #[test] + fn picker_esc_closes_without_emit() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("only.txt"), "").unwrap(); + + let mut view = FilePickerView::new(root); + let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::Close)); + } + + #[test] + fn picker_honors_gitignore() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + // .gitignore filtering only kicks in inside a git repo or with an + // explicit `.ignore` file. Use `.ignore` which `WalkBuilder` honors + // even outside of git. + fs::write(root.join(".ignore"), "skipme.txt\n").unwrap(); + fs::write(root.join("keepme.txt"), "").unwrap(); + fs::write(root.join("skipme.txt"), "").unwrap(); + + let view = FilePickerView::new(root); + let visible: Vec<_> = view + .filtered + .iter() + .map(|i| view.candidates[*i].as_str()) + .collect(); + assert!(visible.iter().any(|p| p.ends_with("keepme.txt"))); + assert!( + !visible.iter().any(|p| p.ends_with("skipme.txt")), + "skipme.txt should be filtered by .ignore: {visible:?}" + ); + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 0d617674..9b6290fb 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -11,6 +11,7 @@ pub mod diff_render; pub mod event_broker; pub mod external_editor; pub mod file_mention; +pub mod file_picker; pub mod history; pub mod markdown_render; pub mod model_picker; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3497f981..7c17008e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1191,6 +1191,19 @@ async fn run_event_loop( continue; } + // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the + // composer is focused (no other modal on top of the stack) and the + // engine is not actively streaming a turn. + if key.code == KeyCode::Char('p') + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.view_stack.is_empty() + && !app.is_loading + { + app.view_stack + .push(crate::tui::file_picker::FilePickerView::new(&app.workspace)); + continue; + } + if !app.view_stack.is_empty() { let events = app.view_stack.handle_key(key); if handle_view_events(app, config, &task_manager, &mut engine_handle, events) @@ -2993,6 +3006,26 @@ async fn handle_view_events( app.status_message = Some("Refreshing sub-agents...".to_string()); let _ = engine_handle.send(Op::ListSubAgents).await; } + ViewEvent::FilePickerSelected { path } => { + // Insert `@` at the composer's cursor with surrounding + // whitespace so the existing `@`-mention parser picks it up. + let cursor = app.cursor_position; + let needs_leading_space = cursor > 0 + && !app + .input + .chars() + .nth(cursor.saturating_sub(1)) + .is_some_and(|c| c.is_whitespace()); + let mut insertion = String::new(); + if needs_leading_space { + insertion.push(' '); + } + insertion.push('@'); + insertion.push_str(&path); + insertion.push(' '); + app.insert_str(&insertion); + app.status_message = Some(format!("Attached @{path}")); + } ViewEvent::ModelPickerApplied { model, effort, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index af300f3e..036c94fe 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -27,6 +27,7 @@ pub enum ModalKind { SessionPicker, Config, ModelPicker, + FilePicker, } #[derive(Debug, Clone)] @@ -73,6 +74,12 @@ pub enum ViewEvent { }, PlanPromptDismissed, SubAgentsRefresh, + /// Emitted by the file picker (`Ctrl+P`) when the user presses Enter on a + /// candidate. The handler should insert `@` at the composer's cursor + /// position. + FilePickerSelected { + path: String, + }, SessionSelected { session_id: String, },