Rank file picker by working set relevance (#155)

This commit is contained in:
Hunter Bown
2026-04-28 18:26:07 -05:00
committed by GitHub
parent 0e96928f35
commit 096aa91823
3 changed files with 414 additions and 16 deletions
+143 -14
View File
@@ -9,6 +9,7 @@
//! Enter emits a [`ViewEvent::FilePickerSelected`] which the UI handler turns
//! into an `@<path>` 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<String>,
mentioned: HashSet<String>,
tool: HashSet<String>,
}
impl FilePickerRelevance {
pub fn mark_modified(&mut self, path: impl Into<String>) {
let path = path.into();
if !path.is_empty() {
self.modified.insert(path);
}
}
pub fn mark_mentioned(&mut self, path: impl Into<String>) {
let path = path.into();
if !path.is_empty() {
self.mentioned.insert(path);
}
}
pub fn mark_tool(&mut self, path: impl Into<String>) {
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<String>,
/// Working-set relevance hints, captured once at construction.
relevance: FilePickerRelevance,
/// Filtered indices into `candidates`, sorted by descending score.
filtered: Vec<usize>,
/// 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()
+221 -2
View File
@@ -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<String> {
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<String> {
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<String>,
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<String>,
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<String>,
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<String> {
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<String> {
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<String> {
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,
+50
View File
@@ -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(),