From 096aa91823cd7186820a90213b5e1409aedabf5c Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 18:26:07 -0500 Subject: [PATCH] Rank file picker by working set relevance (#155) --- crates/tui/src/tui/file_picker.rs | 157 +++++++++++++++++++-- crates/tui/src/tui/ui.rs | 223 +++++++++++++++++++++++++++++- crates/tui/src/tui/ui/tests.rs | 50 +++++++ 3 files changed, 414 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index 8e0d7214..51da63ad 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -9,6 +9,7 @@ //! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns //! into an `@` insertion at the composer cursor. +use std::collections::HashSet; use std::path::Path; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -36,9 +37,79 @@ const WALK_DEPTH: usize = 6; /// Visible candidate rows in the overlay. const VISIBLE_ROWS: usize = 14; +const MODIFIED_BOOST: i32 = 360; +const MENTIONED_BOOST: i32 = 240; +const TOOL_BOOST: i32 = 160; + +/// Working-set hints captured when the picker opens. +/// +/// The picker keeps this as plain path strings so filtering stays in-memory and +/// per-keystroke work remains the same shape as the original fuzzy search. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilePickerRelevance { + modified: HashSet, + mentioned: HashSet, + tool: HashSet, +} + +impl FilePickerRelevance { + pub fn mark_modified(&mut self, path: impl Into) { + let path = path.into(); + if !path.is_empty() { + self.modified.insert(path); + } + } + + pub fn mark_mentioned(&mut self, path: impl Into) { + let path = path.into(); + if !path.is_empty() { + self.mentioned.insert(path); + } + } + + pub fn mark_tool(&mut self, path: impl Into) { + let path = path.into(); + if !path.is_empty() { + self.tool.insert(path); + } + } + + fn boost_for(&self, path: &str) -> i32 { + let mut boost = 0; + if self.modified.contains(path) { + boost += MODIFIED_BOOST; + } + if self.mentioned.contains(path) { + boost += MENTIONED_BOOST; + } + if self.tool.contains(path) { + boost += TOOL_BOOST; + } + boost + } + + fn markers_for(&self, path: &str) -> String { + let mut markers = String::with_capacity(3); + markers.push(if self.modified.contains(path) { + 'M' + } else { + ' ' + }); + markers.push(if self.mentioned.contains(path) { + '@' + } else { + ' ' + }); + markers.push(if self.tool.contains(path) { 'T' } else { ' ' }); + markers + } +} + pub struct FilePickerView { /// All workspace-relative candidate paths, captured once at construction. candidates: Vec, + /// Working-set relevance hints, captured once at construction. + relevance: FilePickerRelevance, /// Filtered indices into `candidates`, sorted by descending score. filtered: Vec, /// User's typed query (lowercased on each refilter). @@ -50,12 +121,12 @@ pub struct FilePickerView { } 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 { + /// Build a picker with working-set relevance hints. + pub fn new_with_relevance(workspace_root: &Path, relevance: FilePickerRelevance) -> Self { let candidates = collect_candidates(workspace_root); let mut view = Self { candidates, + relevance, filtered: Vec::new(), query: String::new(), selected: 0, @@ -67,17 +138,25 @@ impl FilePickerView { fn refilter(&mut self) { let query = self.query.trim().to_lowercase(); - let mut scored: Vec<(usize, i32)> = if query.is_empty() { + let mut scored: Vec<(usize, i32, i32, i32)> = if query.is_empty() { self.candidates .iter() .enumerate() - .map(|(idx, _)| (idx, 0)) + .map(|(idx, path)| { + let boost = self.relevance.boost_for(path); + (idx, boost, 0, boost) + }) .collect() } else { self.candidates .iter() .enumerate() - .filter_map(|(idx, path)| score(&query, path).map(|s| (idx, s))) + .filter_map(|(idx, path)| { + score(&query, path).map(|fuzzy| { + let boost = self.relevance.boost_for(path); + (idx, fuzzy + boost, fuzzy, boost) + }) + }) .collect() }; @@ -85,11 +164,13 @@ impl FilePickerView { // so shorter / more central matches surface above deep nested ones. scored.sort_by(|a, b| { b.1.cmp(&a.1) + .then_with(|| b.2.cmp(&a.2)) + .then_with(|| b.3.cmp(&a.3)) .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(); + self.filtered = scored.into_iter().map(|(idx, _, _, _)| idx).collect(); if self.filtered.is_empty() { self.selected = 0; self.scroll = 0; @@ -145,6 +226,11 @@ impl FilePickerView { pub fn selected_for_test(&self) -> Option<&str> { self.selected_path() } + + #[cfg(test)] + pub fn markers_for_test(&self, path: &str) -> String { + self.relevance.markers_for(path) + } } impl ModalView for FilePickerView { @@ -282,8 +368,14 @@ impl ModalView for FilePickerView { 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}")); + let marker_field = if inner.width >= 18 { + format!("{} ", self.relevance.markers_for(path)) + } else { + String::new() + }; + let reserved = prefix.chars().count() + marker_field.chars().count(); + let display = truncate_path(path, (inner.width as usize).saturating_sub(reserved)); + let mut line = Line::from(format!("{prefix}{marker_field}{display}")); line.style = style; lines.push(line); } @@ -490,7 +582,7 @@ mod tests { fs::write(root.join("README.md"), "").unwrap(); fs::write(root.join("Cargo.toml"), "").unwrap(); - let mut view = FilePickerView::new(root); + let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default()); // Empty query -> all 4 files visible. assert_eq!(view.visible_count(), 4, "expected all 4 candidates"); @@ -505,6 +597,43 @@ mod tests { assert!(selected.ends_with("main.rs"), "selected = {selected}"); } + #[test] + fn picker_empty_query_prioritizes_working_set_files() { + 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(); + + let mut relevance = FilePickerRelevance::default(); + relevance.mark_modified("src/lib.rs"); + let view = FilePickerView::new_with_relevance(root, relevance); + + assert_eq!(view.selected_for_test(), Some("src/lib.rs")); + assert_eq!(view.markers_for_test("src/lib.rs"), "M "); + } + + #[test] + fn picker_fuzzy_query_keeps_working_set_boosts() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/alpha.rs"), "").unwrap(); + fs::write(root.join("src/zeta.rs"), "").unwrap(); + + let mut relevance = FilePickerRelevance::default(); + relevance.mark_mentioned("src/zeta.rs"); + relevance.mark_tool("src/zeta.rs"); + let mut view = FilePickerView::new_with_relevance(root, relevance); + for ch in "rs".chars() { + view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert_eq!(view.selected_for_test(), Some("src/zeta.rs")); + assert_eq!(view.markers_for_test("src/zeta.rs"), " @T"); + } + #[test] fn picker_backspace_widens_candidates() { let dir = TempDir::new().expect("tempdir"); @@ -512,7 +641,7 @@ mod tests { fs::write(root.join("a.txt"), "").unwrap(); fs::write(root.join("b.txt"), "").unwrap(); - let mut view = FilePickerView::new(root); + let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default()); 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)); @@ -525,7 +654,7 @@ mod tests { let root = dir.path(); fs::write(root.join("only.txt"), "").unwrap(); - let mut view = FilePickerView::new(root); + let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default()); let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match action { ViewAction::EmitAndClose(ViewEvent::FilePickerSelected { path }) => { @@ -541,7 +670,7 @@ mod tests { let root = dir.path(); fs::write(root.join("only.txt"), "").unwrap(); - let mut view = FilePickerView::new(root); + let mut view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default()); let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(matches!(action, ViewAction::Close)); } @@ -557,7 +686,7 @@ mod tests { fs::write(root.join("keepme.txt"), "").unwrap(); fs::write(root.join("skipme.txt"), "").unwrap(); - let view = FilePickerView::new(root); + let view = FilePickerView::new_with_relevance(root, FilePickerRelevance::default()); let visible: Vec<_> = view .filtered .iter() diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d703d1ea..e308d7ab 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1,5 +1,6 @@ //! TUI event loop and rendering logic for `DeepSeek` CLI. +use std::collections::HashSet; use std::io::{self, Stdout}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -1348,8 +1349,7 @@ async fn run_event_loop( && app.view_stack.is_empty() && !app.is_loading { - app.view_stack - .push(crate::tui::file_picker::FilePickerView::new(&app.workspace)); + open_file_picker(app); continue; } @@ -2616,6 +2616,225 @@ fn open_context_inspector(app: &mut App) { )); } +fn open_file_picker(app: &mut App) { + let relevance = build_file_picker_relevance(app); + app.view_stack + .push(crate::tui::file_picker::FilePickerView::new_with_relevance( + &app.workspace, + relevance, + )); +} + +fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance { + let mut relevance = crate::tui::file_picker::FilePickerRelevance::default(); + + for path in modified_workspace_paths(&app.workspace) { + relevance.mark_modified(path); + } + + for record in app.session_context_references.iter().rev().take(64) { + let reference = &record.reference; + if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention { + continue; + } + if !matches!( + reference.kind, + crate::tui::file_mention::ContextReferenceKind::File + ) { + continue; + } + for raw in [&reference.target, &reference.label] { + if let Some(path) = workspace_file_candidate(raw, &app.workspace) { + relevance.mark_mentioned(path); + } + } + } + + let mut seen_tool_paths = HashSet::new(); + for detail in app.active_tool_details.values() { + mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); + } + let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect(); + rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx)); + for (_, detail) in rows.into_iter().take(48) { + mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); + } + + relevance +} + +fn modified_workspace_paths(workspace: &Path) -> Vec { + let Ok(output) = Command::new("git") + .arg("-C") + .arg(workspace) + .args(["status", "--short", "--untracked-files=normal"]) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_git_status_path) + .filter_map(|path| workspace_file_candidate(&path, workspace)) + .collect() +} + +fn parse_git_status_path(line: &str) -> Option { + if line.len() < 4 { + return None; + } + let raw = line.get(3..)?.trim(); + let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim(); + let raw = raw.trim_matches('"'); + if raw.is_empty() { + None + } else { + Some(raw.to_string()) + } +} + +fn mark_tool_detail_paths( + detail: &ToolDetailRecord, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, +) { + let mut budget = 256usize; + mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget); + if let Some(output) = detail + .output + .as_deref() + .filter(|output| output.len() <= 8_192) + { + mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget); + } +} + +fn mark_tool_paths_from_value( + value: &serde_json::Value, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, + budget: &mut usize, +) { + if *budget == 0 { + return; + } + match value { + serde_json::Value::String(text) => { + mark_tool_paths_from_text(text, workspace, seen, relevance, budget); + } + serde_json::Value::Array(items) => { + for item in items { + mark_tool_paths_from_value(item, workspace, seen, relevance, budget); + if *budget == 0 { + break; + } + } + } + serde_json::Value::Object(map) => { + for item in map.values() { + mark_tool_paths_from_value(item, workspace, seen, relevance, budget); + if *budget == 0 { + break; + } + } + } + _ => {} + } +} + +fn mark_tool_paths_from_text( + text: &str, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, + budget: &mut usize, +) { + if *budget == 0 || text.len() > 8_192 { + return; + } + if let Some(path) = workspace_file_candidate(text, workspace) + && seen.insert(path.clone()) + { + relevance.mark_tool(path); + *budget = (*budget).saturating_sub(1); + } + for token in text.split_whitespace().take(128) { + if *budget == 0 { + break; + } + if let Some(path) = workspace_file_candidate(token, workspace) + && seen.insert(path.clone()) + { + relevance.mark_tool(path); + *budget = (*budget).saturating_sub(1); + } + } +} + +fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option { + let cleaned = clean_path_token(raw)?; + let path = Path::new(&cleaned); + let absolute = if path.is_absolute() { + PathBuf::from(path) + } else { + workspace.join(path) + }; + if !absolute.is_file() { + return None; + } + let rel = absolute.strip_prefix(workspace).ok()?; + workspace_path_to_picker_string(rel) +} + +fn clean_path_token(raw: &str) -> Option { + let mut trimmed = raw.trim().trim_matches(|ch: char| { + ch.is_ascii_whitespace() + || matches!( + ch, + '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' + ) + }); + if let Some(stripped) = trimmed.strip_prefix("./") { + trimmed = stripped; + } + if let Some((before, after)) = trimmed.rsplit_once(':') + && !before.is_empty() + && after.chars().all(|ch| ch.is_ascii_digit()) + { + trimmed = before; + } + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn workspace_path_to_picker_string(path: &Path) -> Option { + let mut out = String::new(); + for (idx, component) in path.components().enumerate() { + if matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) { + return None; + } + if idx > 0 { + out.push('/'); + } + out.push_str(&component.as_os_str().to_string_lossy()); + } + if out.is_empty() { None } else { Some(out) } +} + async fn apply_command_result( app: &mut App, engine_handle: &mut EngineHandle, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 84c2082f..0c662fcf 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -114,6 +114,56 @@ fn transcript_scroll_percent_is_clamped_and_relative() { assert_eq!(transcript_scroll_percent(0, 20, 20), None); } +#[test] +fn parse_git_status_path_handles_simple_and_renamed_entries() { + assert_eq!( + parse_git_status_path(" M crates/tui/src/tui/ui.rs"), + Some("crates/tui/src/tui/ui.rs".to_string()) + ); + assert_eq!( + parse_git_status_path("R old name.rs -> crates/tui/src/tui/file_picker.rs"), + Some("crates/tui/src/tui/file_picker.rs".to_string()) + ); +} + +#[test] +fn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let path = root.join("src/lib.rs"); + std::fs::write(&path, "").unwrap(); + + let raw = format!("\"{}:42\",", path.display()); + assert_eq!( + workspace_file_candidate(&raw, root), + Some("src/lib.rs".to_string()) + ); +} + +#[test] +fn tool_path_relevance_extracts_paths_from_command_text() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/alpha.rs"), "").unwrap(); + std::fs::write(root.join("src/zeta.rs"), "").unwrap(); + + let mut relevance = crate::tui::file_picker::FilePickerRelevance::default(); + let mut seen = HashSet::new(); + let mut budget = 16; + mark_tool_paths_from_text( + "sed -n '1,20p' src/zeta.rs", + root, + &mut seen, + &mut relevance, + &mut budget, + ); + + let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance); + assert_eq!(view.selected_for_test(), Some("src/zeta.rs")); +} + fn create_test_app() -> App { let options = TuiOptions { model: "deepseek-v4-pro".to_string(),