refactor(tui/ui): extract file-picker relevance scoring

The `/files` picker ranks workspace files by three signals harvested
from the session: which files git reports as modified, which the user
@-mentioned in the composer, and which recent tool calls touched. The
scoring code — `open_file_picker` plus 9 helpers (build_relevance,
modified_workspace_paths, parse_git_status_path,
mark_tool_detail_paths / from_value / from_text, workspace_file_candidate,
clean_path_token, workspace_path_to_picker_string) — was ~218 lines
of self-contained logic mid-ui.rs.

Moved to `tui/file_picker_relevance.rs`. Same behavior; the picker
view file (`tui/file_picker.rs`) keeps the rendering layer, and the
new module owns the per-session ranking that fed it.

ui.rs is now 9073 lines (down ~950 from the pre-refactor 10025).
This commit is contained in:
Hunter Bown
2026-05-13 01:57:18 -05:00
parent b434b92137
commit 8a0c8ca3ce
4 changed files with 252 additions and 226 deletions
+240
View File
@@ -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<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()
}
pub(super) 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 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 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<String>,
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<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) }
}
+1
View File
@@ -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;
+5 -222
View File
@@ -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<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) }
}
// File-picker relevance scoring moved to `tui/file_picker_relevance.rs`.
async fn apply_command_result(
terminal: &mut AppTerminal,
+6 -4
View File
@@ -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,