From f72f6092936cbc83f4e37a8268963c7fa155785c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sun, 31 May 2026 13:29:28 -0700 Subject: [PATCH] feat: make file mention completion tunable --- crates/tui/src/commands/config.rs | 10 ++++ crates/tui/src/config_ui.rs | 14 +++++ crates/tui/src/settings.rs | 55 ++++++++++++++++++ crates/tui/src/tui/app.rs | 11 ++++ crates/tui/src/tui/file_mention.rs | 11 +++- crates/tui/src/tui/ui.rs | 7 ++- crates/tui/src/tui/views/mod.rs | 14 +++++ crates/tui/src/working_set.rs | 92 +++++++++++++++++++++++++----- docs/CONFIGURATION.md | 6 ++ 9 files changed, 202 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c582c7d4..20a592b8 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 8526f10a..f938fb08 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -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 { 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(), diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 0b8d9b25..6dd40791 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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 { } } +fn parse_usize_setting(key: &str, value: &str) -> Result { + value.trim().parse::().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(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9f405a40..c3f6ed33 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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, } @@ -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, diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 98b104ad..86d237c2 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -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 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 cwd, partial, limit, + walk_depth, entries: entries.clone(), }); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f9a87483..69113a24 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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); } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 27b24192..2f79796f 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -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(), diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 655d1f15..be556796 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -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, file_index: OnceLock>>, + completion_walk_depth: Option, } 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) -> 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, 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> { let mut index: HashMap> = 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 { + if depth == 0 { None } else { Some(depth) } +} + +fn child_completion_walk_depth(depth: Option) -> Option { + 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, substring_hits: &mut Vec, seen: &mut HashSet, + max_depth: Option, ) { - 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, substring_hits: &mut Vec, seen: &mut HashSet, + max_depth: Option, ) { 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 { +fn local_reference_paths(root: &Path, limit: usize, max_depth: Option) -> Vec { 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(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cc1f4c62..a6846667 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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