diff --git a/crates/tui/src/tui/file_picker_relevance.rs b/crates/tui/src/tui/file_picker_relevance.rs new file mode 100644 index 00000000..f263ae8c --- /dev/null +++ b/crates/tui/src/tui/file_picker_relevance.rs @@ -0,0 +1,240 @@ +//! Helpers that decide which workspace files to surface in the +//! `/files` picker. +//! +//! The picker ranks files by three signals harvested from the running +//! session: +//! +//! * `modified` — files git reports as staged/unstaged or untracked +//! * `mentioned` — files the user @-referenced in the composer +//! * `tool` — files that recent tool calls touched (input or output) +//! +//! [`build_relevance`] composes those signals into a +//! `FilePickerRelevance` that the picker view uses to order results. +//! The remaining helpers are deterministic string/path utilities that +//! make path discovery resilient to quoting, leading `./`, and +//! trailing `:line` markers. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::tui::app::App; +use crate::tui::file_picker::FilePickerRelevance; +use crate::tui::file_picker::FilePickerView; +use crate::tui::file_mention::{ContextReferenceKind, ContextReferenceSource}; +use crate::tui::app::ToolDetailRecord; + +/// Push the `/files` picker onto the view stack, pre-populated with +/// per-session relevance ranks (modified, @-mentioned, tool-touched). +pub(super) fn open_file_picker(app: &mut App) { + let relevance = build_relevance(app); + app.view_stack + .push(FilePickerView::new_with_relevance(&app.workspace, relevance)); +} + +pub(super) fn build_relevance(app: &App) -> FilePickerRelevance { + let mut relevance = 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 != ContextReferenceSource::AtMention { + continue; + } + if !matches!(reference.kind, 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() +} + +pub(super) 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 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 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; + } + } + } + _ => {} + } +} + +pub(super) fn mark_tool_paths_from_text( + text: &str, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut 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); + } + } +} + +pub(super) 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) } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 1cd6a33a..df64682b 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -30,6 +30,7 @@ pub mod feedback_picker; pub mod file_frecency; pub mod file_mention; pub mod file_picker; +pub mod file_picker_relevance; pub mod file_tree; pub mod frame_rate_limiter; pub mod history; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3058a79a..0df067ab 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1,8 +1,7 @@ //! TUI event loop and rendering logic for `DeepSeek` CLI. -use std::collections::HashSet; use std::io::{self, Stdout, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -74,6 +73,7 @@ use crate::tui::vim_mode; use crate::tui::streaming_thinking; use crate::tui::workspace_context; use crate::tui::notifications; +use crate::tui::file_picker_relevance; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::persistence_actor::{self, PersistRequest}; @@ -100,7 +100,7 @@ use crate::tui::views::subagent_view_agents; use super::app::{ App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus, - StatusToastLevel, SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions, + StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -2376,7 +2376,7 @@ async fn run_event_loop( && app.view_stack.is_empty() && !app.is_loading { - open_file_picker(app); + file_picker_relevance::open_file_picker(app); continue; } @@ -4396,224 +4396,7 @@ 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) } -} +// File-picker relevance scoring moved to `tui/file_picker_relevance.rs`. async fn apply_command_result( terminal: &mut AppTerminal, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ebb1a318..7bfc9ceb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::config::{ApiProvider, Config}; use crate::tui::active_cell::ActiveCell; +use crate::tui::app::ToolDetailRecord; +use std::collections::HashSet; use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::mock_engine_handle; use crate::tui::file_mention::{ @@ -1055,11 +1057,11 @@ fn transcript_scroll_percent_is_clamped_and_relative() { #[test] fn parse_git_status_path_handles_simple_and_renamed_entries() { assert_eq!( - parse_git_status_path(" M crates/tui/src/tui/ui.rs"), + crate::tui::file_picker_relevance::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"), + crate::tui::file_picker_relevance::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()) ); } @@ -1074,7 +1076,7 @@ fn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() { let raw = format!("\"{}:42\",", path.display()); assert_eq!( - workspace_file_candidate(&raw, root), + crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root), Some("src/lib.rs".to_string()) ); } @@ -1090,7 +1092,7 @@ fn tool_path_relevance_extracts_paths_from_command_text() { let mut relevance = crate::tui::file_picker::FilePickerRelevance::default(); let mut seen = HashSet::new(); let mut budget = 16; - mark_tool_paths_from_text( + crate::tui::file_picker_relevance::mark_tool_paths_from_text( "sed -n '1,20p' src/zeta.rs", root, &mut seen,