feat: make file mention completion tunable

This commit is contained in:
Hunter B
2026-05-31 13:29:28 -07:00
parent 6858c4e105
commit f72f609293
9 changed files with 202 additions and 18 deletions
+10
View File
@@ -578,6 +578,16 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.paste_burst.clear_after_explicit_paste();
}
}
"mention_menu_limit" | "mention_limit" => {
app.mention_menu_limit = settings.mention_menu_limit;
app.composer.mention_completion_cache = None;
app.needs_redraw = true;
}
"mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
app.mention_walk_depth = settings.mention_walk_depth;
app.composer.mention_completion_cache = None;
app.needs_redraw = true;
}
"transcript_spacing" | "spacing" => {
app.transcript_spacing =
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
+14
View File
@@ -66,6 +66,10 @@ pub struct SettingsSection {
pub composer_density: ComposerDensityValue,
pub composer_border: bool,
pub composer_vim_mode: ComposerVimModeValue,
#[schemars(range(min = 0))]
pub mention_menu_limit: usize,
#[schemars(range(min = 0))]
pub mention_walk_depth: usize,
pub transcript_spacing: TranscriptSpacingValue,
pub status_indicator: StatusIndicatorValue,
pub synchronized_output: SynchronizedOutputValue,
@@ -327,6 +331,8 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
composer_density: settings.composer_density.as_str().into(),
composer_border: settings.composer_border,
composer_vim_mode: settings.composer_vim_mode.as_str().into(),
mention_menu_limit: settings.mention_menu_limit,
mention_walk_depth: settings.mention_walk_depth,
transcript_spacing: settings.transcript_spacing.as_str().into(),
status_indicator: settings.status_indicator.as_str().into(),
synchronized_output: settings.synchronized_output.as_str().into(),
@@ -503,6 +509,14 @@ pub fn apply_document(
"composer_vim_mode",
doc.settings.composer_vim_mode.as_setting(),
),
(
"mention_menu_limit",
&doc.settings.mention_menu_limit.to_string(),
),
(
"mention_walk_depth",
&doc.settings.mention_walk_depth.to_string(),
),
(
"transcript_spacing",
doc.settings.transcript_spacing.as_setting(),
+55
View File
@@ -193,6 +193,13 @@ pub struct Settings {
/// Enable rapid-key paste-burst detection for terminals that do not emit
/// bracketed-paste events. Independent from `bracketed_paste`.
pub paste_burst_detection: bool,
/// Maximum number of file-mention popup candidates retained before the
/// composer renders its visible window. The widget paginates by terminal
/// height, so this is a data-side cap rather than a visible-row budget.
pub mention_menu_limit: usize,
/// Maximum workspace depth for `@`-mention completion walks. `0` means
/// unlimited depth; use with care in very large repositories.
pub mention_walk_depth: usize,
/// Show thinking blocks from the model
pub show_thinking: bool,
/// Show detailed tool output
@@ -297,6 +304,8 @@ impl Default for Settings {
fancy_animations: true,
bracketed_paste: true,
paste_burst_detection: true,
mention_menu_limit: 128,
mention_walk_depth: 6,
show_thinking: true,
show_tool_details: true,
locale: "auto".to_string(),
@@ -503,6 +512,12 @@ impl Settings {
"paste_burst_detection" | "paste_burst" => {
self.paste_burst_detection = parse_bool(value)?;
}
"mention_menu_limit" | "mention_limit" => {
self.mention_menu_limit = parse_usize_setting("mention_menu_limit", value)?;
}
"mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
self.mention_walk_depth = parse_usize_setting("mention_walk_depth", value)?;
}
"show_thinking" | "thinking" => {
self.show_thinking = parse_bool(value)?;
}
@@ -694,6 +709,8 @@ impl Settings {
" paste_burst_detect: {}",
self.paste_burst_detection
));
lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit));
lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth));
lines.push(format!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" locale: {}", self.locale));
@@ -768,6 +785,14 @@ impl Settings {
"paste_burst_detection",
"Fallback rapid-key paste detection: on/off",
),
(
"mention_menu_limit",
"Maximum @-mention popup candidates retained before rendering (default 128)",
),
(
"mention_walk_depth",
"Maximum @-mention workspace walk depth; 0 means unlimited (default 6)",
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
(
@@ -899,6 +924,14 @@ fn parse_bool(value: &str) -> Result<bool> {
}
}
fn parse_usize_setting(key: &str, value: &str) -> Result<usize> {
value.trim().parse::<usize>().map_err(|_| {
anyhow::anyhow!(
"Failed to update setting: invalid {key} '{value}'. Expected 0 or a positive integer."
)
})
}
fn normalize_mode(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"edit" => "agent",
@@ -1119,6 +1152,28 @@ mod tests {
assert!(!settings.paste_burst_detection);
}
#[test]
fn mention_completion_caps_are_configurable() {
let mut settings = Settings::default();
assert_eq!(settings.mention_menu_limit, 128);
assert_eq!(settings.mention_walk_depth, 6);
settings
.set("mention_menu_limit", "256")
.expect("set mention menu limit");
settings
.set("mention_walk_depth", "0")
.expect("allow unlimited walk depth");
assert_eq!(settings.mention_menu_limit, 256);
assert_eq!(settings.mention_walk_depth, 0);
let err = settings
.set("mention_walk_depth", "deep")
.expect_err("non-numeric depth should fail");
assert!(err.to_string().contains("invalid mention_walk_depth"));
}
#[test]
fn locale_normalizes_supported_values_and_rejects_unknowns() {
let mut settings = Settings::default();
+11
View File
@@ -882,6 +882,9 @@ pub struct MentionCompletionCache {
pub partial: String,
/// Candidate limit used for this completion walk.
pub limit: usize,
/// Workspace depth limit used for this completion walk. Included so live
/// config changes invalidate cached popup results.
pub walk_depth: usize,
/// Cached completion entries.
pub entries: Vec<String>,
}
@@ -1198,6 +1201,12 @@ pub struct App {
/// sequences (e.g. Windows CMD without `WT_SESSION`) get page-scrolling
/// without any explicit config (#1443).
pub composer_arrows_scroll: bool,
/// Data-side cap for the `@`-mention popup. The renderer still limits the
/// visible rows to available terminal height.
pub mention_menu_limit: usize,
/// Maximum workspace depth for `@`-mention completion walks. `0` means
/// unlimited depth.
pub mention_walk_depth: usize,
pub use_bracketed_paste: bool,
pub use_paste_burst_detection: bool,
/// Set to `true` the first time a real `Event::Paste` arrives during a
@@ -2087,6 +2096,8 @@ impl App {
.as_ref()
.and_then(|tui| tui.composer_arrows_scroll)
.unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)),
mention_menu_limit: settings.mention_menu_limit,
mention_walk_depth: settings.mention_walk_depth,
session_title: None,
receipt_text: None,
receipt_started_at: None,
+9 -2
View File
@@ -166,7 +166,11 @@ pub fn find_file_mention_completions(
/// captures the process CWD so the resolver and completion walker honor the
/// user's launch directory when it differs from `--workspace`.
fn workspace_for_app(app: &App) -> Workspace {
Workspace::with_cwd(app.workspace.clone(), std::env::current_dir().ok())
Workspace::with_cwd_and_depth(
app.workspace.clone(),
std::env::current_dir().ok(),
app.mention_walk_depth,
)
}
/// Resolve the `@`-mention completion popup contents for the current
@@ -197,16 +201,18 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String>
let workspace = app.workspace.clone();
let cwd = std::env::current_dir().ok();
let walk_depth = app.mention_walk_depth;
if let Some(ref cache) = app.composer.mention_completion_cache
&& cache.workspace == workspace
&& cache.cwd == cwd
&& cache.partial == partial
&& cache.limit == limit
&& cache.walk_depth == walk_depth
{
return cache.entries.clone();
}
let ws = Workspace::with_cwd(workspace.clone(), cwd.clone());
let ws = Workspace::with_cwd_and_depth(workspace.clone(), cwd.clone(), walk_depth);
let entries = find_file_mention_completions(&ws, &partial, limit);
app.composer.mention_completion_cache = Some(MentionCompletionCache {
@@ -214,6 +220,7 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String>
cwd,
partial,
limit,
walk_depth,
entries: entries.clone(),
});
+4 -3
View File
@@ -141,7 +141,6 @@ use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Rende
/// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond
/// the visible window because the source list itself was capped).
const SLASH_MENU_LIMIT: usize = 128;
const MENTION_MENU_LIMIT: usize = 6;
const MIN_CHAT_HEIGHT: u16 = 3;
const MIN_COMPOSER_HEIGHT: u16 = 2;
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
@@ -3023,8 +3022,9 @@ async fn run_event_loop(
if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() {
app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1);
}
let mention_menu_limit = app.mention_menu_limit;
let mention_menu_entries =
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
let mention_menu_open = !mention_menu_entries.is_empty();
if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
@@ -6073,8 +6073,9 @@ fn render(f: &mut Frame, app: &mut App) {
let header_height = 1;
let footer_height = 1;
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
let mention_menu_limit = app.mention_menu_limit;
let mention_menu_entries =
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
+14
View File
@@ -761,6 +761,20 @@ impl ConfigView {
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Composer,
key: "mention_menu_limit".to_string(),
value: settings.mention_menu_limit.to_string(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Composer,
key: "mention_walk_depth".to_string(),
value: settings.mention_walk_depth.to_string(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Sidebar,
key: "sidebar_width".to_string(),
+79 -13
View File
@@ -26,12 +26,13 @@ use std::sync::OnceLock;
/// during a session, build a fresh `Workspace`. Fuzzy lookups are backed by a
/// lazy basename → paths index built once on first miss and reused for the
/// rest of the session — without it, every mis-typed mention triggered a full
/// `WalkBuilder` traversal up to depth 6 (Gemini code-review feedback).
/// `WalkBuilder` traversal up to the configured completion depth.
#[derive(Debug)]
pub struct Workspace {
pub root: PathBuf,
cwd: Option<PathBuf>,
file_index: OnceLock<HashMap<String, Vec<PathBuf>>>,
completion_walk_depth: Option<usize>,
}
impl Workspace {
@@ -48,10 +49,17 @@ impl Workspace {
/// resolution against a known directory without depending on (and
/// mutating) the process's real working directory.
pub fn with_cwd(root: PathBuf, cwd: Option<PathBuf>) -> Self {
Self::with_cwd_and_depth(root, cwd, DEFAULT_COMPLETIONS_WALK_DEPTH)
}
/// Construct with an explicit completion walk depth. A depth of `0`
/// disables the depth limit for users with deeply nested workspaces.
pub fn with_cwd_and_depth(root: PathBuf, cwd: Option<PathBuf>, walk_depth: usize) -> Self {
Self {
root,
cwd,
file_index: OnceLock::new(),
completion_walk_depth: normalize_completion_walk_depth(walk_depth),
}
}
@@ -97,7 +105,7 @@ impl Workspace {
fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
let mut index: HashMap<String, Vec<PathBuf>> = HashMap::new();
let mut total: usize = 0;
let builder = discovery_walk_builder(&self.root, Some(6));
let builder = discovery_walk_builder(&self.root, self.completion_walk_depth);
for entry in builder.build().flatten() {
if total >= FILE_INDEX_MAX_ENTRIES {
@@ -135,8 +143,10 @@ impl Workspace {
.hidden(true)
.follow_links(false)
.git_ignore(false)
.ignore(false)
.max_depth(Some(5));
.ignore(false);
if let Some(depth) = child_completion_walk_depth(self.completion_walk_depth) {
dot_builder.max_depth(Some(depth));
}
for entry in dot_builder.build().flatten() {
if total >= FILE_INDEX_MAX_ENTRIES {
break;
@@ -163,7 +173,11 @@ impl Workspace {
// hidden/ignored path the user might `@`-mention (e.g. a project's
// own `.generated/specs/`). `local_reference_paths` walks with
// gitignore disabled but still honors `.deepseekignore`.
for path in local_reference_paths(&self.root, LOCAL_REFERENCE_SCAN_LIMIT) {
for path in local_reference_paths(
&self.root,
LOCAL_REFERENCE_SCAN_LIMIT,
self.completion_walk_depth,
) {
if total >= FILE_INDEX_MAX_ENTRIES {
break;
}
@@ -220,6 +234,7 @@ impl Workspace {
&mut prefix_hits,
&mut substring_hits,
&mut seen,
self.completion_walk_depth,
);
add_local_reference_completions(
cwd,
@@ -229,6 +244,7 @@ impl Workspace {
&mut prefix_hits,
&mut substring_hits,
&mut seen,
self.completion_walk_depth,
);
}
walk_for_completions(
@@ -239,6 +255,7 @@ impl Workspace {
&mut prefix_hits,
&mut substring_hits,
&mut seen,
self.completion_walk_depth,
);
add_local_reference_completions(
&self.root,
@@ -248,6 +265,7 @@ impl Workspace {
&mut prefix_hits,
&mut substring_hits,
&mut seen,
self.completion_walk_depth,
);
prefix_hits.sort();
@@ -258,10 +276,18 @@ impl Workspace {
}
}
/// Maximum directory depth walked when surfacing file-mention completions.
/// Default directory depth walked when surfacing file-mention completions.
/// Mirrors the existing `project_tree` cutoff and keeps Tab snappy in deep
/// monorepos.
const COMPLETIONS_WALK_DEPTH: usize = 6;
/// monorepos unless the user opts into a deeper walk.
pub const DEFAULT_COMPLETIONS_WALK_DEPTH: usize = 6;
fn normalize_completion_walk_depth(depth: usize) -> Option<usize> {
if depth == 0 { None } else { Some(depth) }
}
fn child_completion_walk_depth(depth: Option<usize>) -> Option<usize> {
depth.map(|depth| depth.saturating_sub(1))
}
/// Hard cap on the number of `(file or directory)` entries indexed by
/// [`Workspace::build_file_index`]. The fuzzy-resolve index is a
@@ -360,8 +386,9 @@ fn walk_for_completions(
prefix_hits: &mut Vec<String>,
substring_hits: &mut Vec<String>,
seen: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
) {
let builder = discovery_walk_builder(walk_root, Some(COMPLETIONS_WALK_DEPTH));
let builder = discovery_walk_builder(walk_root, max_depth);
for entry in builder.build().flatten() {
if prefix_hits.len() + substring_hits.len() >= limit {
@@ -405,7 +432,7 @@ fn walk_for_completions(
prefix_hits,
substring_hits,
seen,
Some(COMPLETIONS_WALK_DEPTH),
max_depth,
);
}
@@ -420,12 +447,13 @@ fn add_local_reference_completions(
prefix_hits: &mut Vec<String>,
substring_hits: &mut Vec<String>,
seen: &mut HashSet<PathBuf>,
max_depth: Option<usize>,
) {
if !should_try_local_reference_completion(needle) {
return;
}
for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT) {
for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT, max_depth) {
if prefix_hits.len() + substring_hits.len() >= limit {
break;
}
@@ -460,16 +488,18 @@ fn should_try_local_reference_completion(needle: &str) -> bool {
needle.starts_with('.') || needle.contains('/') || needle.contains('\\')
}
fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
fn local_reference_paths(root: &Path, limit: usize, max_depth: Option<usize>) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut builder = WalkBuilder::new(root);
builder
.hidden(false)
.follow_links(false)
.max_depth(Some(COMPLETIONS_WALK_DEPTH))
.git_ignore(false)
.git_global(false)
.git_exclude(false);
if let Some(depth) = max_depth {
builder.max_depth(Some(depth));
}
let _ = builder.add_custom_ignore_filename(".deepseekignore");
let root_for_filter = root.to_path_buf();
builder.filter_entry(move |entry| {
@@ -502,6 +532,7 @@ impl Clone for Workspace {
root: self.root.clone(),
cwd: self.cwd.clone(),
file_index: OnceLock::new(),
completion_walk_depth: self.completion_walk_depth,
}
}
}
@@ -1442,6 +1473,41 @@ mod tests {
);
}
#[test]
fn workspace_completions_honor_configured_walk_depth() {
let tmp = TempDir::new().unwrap();
let deep_dir = tmp.path().join("a/b/c/d/e/f/g/h");
std::fs::create_dir_all(&deep_dir).unwrap();
std::fs::write(deep_dir.join("target.txt"), "target").unwrap();
let default_ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
let default_entries = default_ws.completions("target", 16);
assert!(
!default_entries
.iter()
.any(|entry| entry.ends_with("target.txt")),
"default depth should keep very deep entries out of the hot completion path: {default_entries:?}",
);
let deep_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 16);
let deep_entries = deep_ws.completions("target", 16);
assert!(
deep_entries
.iter()
.any(|entry| entry.ends_with("target.txt")),
"configured deeper walk should surface the nested file: {deep_entries:?}",
);
let unlimited_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 0);
let unlimited_entries = unlimited_ws.completions("target", 16);
assert!(
unlimited_entries
.iter()
.any(|entry| entry.ends_with("target.txt")),
"depth 0 should disable the completion walk depth limit: {unlimited_entries:?}",
);
}
#[test]
fn workspace_completions_surface_explicit_hidden_and_ignored_paths() {
let tmp = TempDir::new().unwrap();
+6
View File
@@ -462,6 +462,12 @@ Common settings keys:
- `paste_burst_detection` (on/off, default on): fallback rapid-key paste
detection for terminals that do not emit bracketed-paste events. This is
independent of terminal bracketed-paste mode.
- `mention_menu_limit` (integer, default `128`): maximum number of
`@`-mention popup candidates retained before the composer renders the
visible window. The visible rows still depend on terminal height.
- `mention_walk_depth` (integer, default `6`): maximum workspace depth for
`@`-mention completion walks. Set to `0` for unlimited depth in deeply
nested workspaces; keep the default in very large repos unless needed.
- `show_thinking` (on/off)
- `show_tool_details` (on/off)
- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome