diff --git a/config.example.toml b/config.example.toml index b4d21c158..2f4725ddb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -423,6 +423,7 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer # # Override: `locale = "zh-Hans"` for Simplified Chinese regardless of OS locale. # # Also settable at runtime: /config locale zh-Hans # # Note: this only affects TUI labels/chrome — it does NOT change model output language. +# mention_menu_behavior = "fuzzy" # fuzzy | browser; browser lists immediate directory children for @-mentions. # ───────────────────────────────────────────────────────────────────────────────── # Feature Flags diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index fad755e1a..6b88ba62c 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -595,6 +595,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.composer.mention_completion_cache = None; app.needs_redraw = true; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + app.mention_menu_behavior = settings.mention_menu_behavior.clone(); + 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; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a7cf27f6d..d5632befe 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -68,6 +68,7 @@ pub struct SettingsSection { pub composer_vim_mode: ComposerVimModeValue, #[schemars(range(min = 0))] pub mention_menu_limit: usize, + pub mention_menu_behavior: MentionMenuBehaviorValue, #[schemars(range(min = 0))] pub mention_walk_depth: usize, pub transcript_spacing: TranscriptSpacingValue, @@ -204,6 +205,13 @@ pub enum ComposerVimModeValue { Vim, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MentionMenuBehaviorValue { + Fuzzy, + Browser, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptSpacingValue { @@ -332,6 +340,7 @@ pub fn build_document(app: &App, config: &Config) -> Result { composer_border: settings.composer_border, composer_vim_mode: settings.composer_vim_mode.as_str().into(), mention_menu_limit: settings.mention_menu_limit, + mention_menu_behavior: settings.mention_menu_behavior.as_str().into(), mention_walk_depth: settings.mention_walk_depth, transcript_spacing: settings.transcript_spacing.as_str().into(), status_indicator: settings.status_indicator.as_str().into(), @@ -513,6 +522,10 @@ pub fn apply_document( "mention_menu_limit", &doc.settings.mention_menu_limit.to_string(), ), + ( + "mention_menu_behavior", + doc.settings.mention_menu_behavior.as_setting(), + ), ( "mention_walk_depth", &doc.settings.mention_walk_depth.to_string(), @@ -782,6 +795,24 @@ impl From<&str> for ComposerVimModeValue { } } +impl MentionMenuBehaviorValue { + fn as_setting(self) -> &'static str { + match self { + Self::Fuzzy => "fuzzy", + Self::Browser => "browser", + } + } +} + +impl From<&str> for MentionMenuBehaviorValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "browser" => Self::Browser, + _ => Self::Fuzzy, + } + } +} + impl TranscriptSpacingValue { fn as_setting(self) -> &'static str { match self { diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6dd40791c..c0226475f 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -200,6 +200,9 @@ pub struct Settings { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth; use with care in very large repositories. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output @@ -306,6 +309,7 @@ impl Default for Settings { paste_burst_detection: true, mention_menu_limit: 128, mention_walk_depth: 6, + mention_menu_behavior: "fuzzy".to_string(), show_thinking: true, show_tool_details: true, locale: "auto".to_string(), @@ -518,6 +522,9 @@ impl Settings { "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { self.mention_walk_depth = parse_usize_setting("mention_walk_depth", value)?; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + self.mention_menu_behavior = normalize_mention_menu_behavior(value)?; + } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } @@ -711,6 +718,10 @@ impl Settings { )); lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit)); lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth)); + lines.push(format!( + " mention_behavior: {}", + self.mention_menu_behavior + )); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" locale: {}", self.locale)); @@ -793,6 +804,10 @@ impl Settings { "mention_walk_depth", "Maximum @-mention workspace walk depth; 0 means unlimited (default 6)", ), + ( + "mention_menu_behavior", + "@-mention completion behavior: fuzzy/browser (default fuzzy)", + ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), ( @@ -932,6 +947,18 @@ fn parse_usize_setting(key: &str, value: &str) -> Result { }) } +fn normalize_mention_menu_behavior(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "fuzzy" | "default" => Ok("fuzzy".to_string()), + "browser" | "browse" | "file-browser" | "file_browser" => Ok("browser".to_string()), + _ => { + anyhow::bail!( + "Failed to update setting: invalid mention_menu_behavior '{value}'. Expected: fuzzy, browser." + ) + } + } +} + fn normalize_mode(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "edit" => "agent", @@ -1157,6 +1184,7 @@ mod tests { let mut settings = Settings::default(); assert_eq!(settings.mention_menu_limit, 128); assert_eq!(settings.mention_walk_depth, 6); + assert_eq!(settings.mention_menu_behavior, "fuzzy"); settings .set("mention_menu_limit", "256") @@ -1164,14 +1192,23 @@ mod tests { settings .set("mention_walk_depth", "0") .expect("allow unlimited walk depth"); + settings + .set("mention_menu_behavior", "browser") + .expect("set mention menu behavior"); assert_eq!(settings.mention_menu_limit, 256); assert_eq!(settings.mention_walk_depth, 0); + assert_eq!(settings.mention_menu_behavior, "browser"); let err = settings .set("mention_walk_depth", "deep") .expect_err("non-numeric depth should fail"); assert!(err.to_string().contains("invalid mention_walk_depth")); + + let err = settings + .set("mention_menu_behavior", "random") + .expect_err("unknown mention behavior should fail"); + assert!(err.to_string().contains("invalid mention_menu_behavior")); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7a8e46bbe..6a7b60791 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -885,6 +885,9 @@ pub struct MentionCompletionCache { /// Workspace depth limit used for this completion walk. Included so live /// config changes invalidate cached popup results. pub walk_depth: usize, + /// Completion behavior used for this walk. Included so live config changes + /// invalidate cached popup results. + pub behavior: String, /// Cached completion entries. pub entries: Vec, } @@ -1207,6 +1210,9 @@ pub struct App { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, pub use_bracketed_paste: bool, pub use_paste_burst_detection: bool, /// Set to `true` the first time a real `Event::Paste` arrives during a @@ -2103,6 +2109,7 @@ impl App { .unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)), mention_menu_limit: settings.mention_menu_limit, mention_walk_depth: settings.mention_walk_depth, + mention_menu_behavior: settings.mention_menu_behavior.clone(), 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 86d237c22..2c07f69e1 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -162,6 +162,25 @@ pub fn find_file_mention_completions( entries } +/// Deterministic directory-browser completion entry point. This deliberately +/// skips frecency so the popup remains stable for users navigating deep trees. +pub fn find_file_mention_browser_completions( + workspace: &Workspace, + partial: &str, + limit: usize, +) -> Vec { + let entries = workspace.browser_completions(partial, limit); + tracing::debug!( + target: "codewhale_tui::file_mention", + partial = %partial, + workspace = %workspace.root.display(), + cwd = ?std::env::current_dir().ok(), + match_count = entries.len(), + "file mention browser completion walk", + ); + entries +} + /// Build a `Workspace` for the running app: anchors at `app.workspace` and /// captures the process CWD so the resolver and completion walker honor the /// user's launch directory when it differs from `--workspace`. @@ -202,18 +221,24 @@ 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; + let behavior = app.mention_menu_behavior.clone(); 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 + && cache.behavior == behavior { return cache.entries.clone(); } let ws = Workspace::with_cwd_and_depth(workspace.clone(), cwd.clone(), walk_depth); - let entries = find_file_mention_completions(&ws, &partial, limit); + let entries = if behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, limit) + } else { + find_file_mention_completions(&ws, &partial, limit) + }; app.composer.mention_completion_cache = Some(MentionCompletionCache { workspace, @@ -221,6 +246,7 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec partial, limit, walk_depth, + behavior, entries: entries.clone(), }); @@ -268,7 +294,11 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { return false; }; let ws = workspace_for_app(app); - let candidates = find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT); + let candidates = if app.mention_menu_behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + } else { + find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + }; if candidates.is_empty() { app.status_message = Some(format!("No files match @{partial}")); return true; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 166031371..f71f62cec 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4737,6 +4737,24 @@ fn mention_popup_lists_workspace_matches_for_cursor_partial() { assert!(!entries.iter().any(|e| e == "README.md")); } +#[test] +fn mention_popup_browser_mode_lists_immediate_directory_children() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmpdir.path().join("src/nested")).unwrap(); + std::fs::write(tmpdir.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmpdir.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmpdir.path().join("README.md"), "readme").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_menu_behavior = "browser".to_string(); + app.input = "look at @src/".to_string(); + app.cursor_position = app.input.chars().count(); + + let entries = visible_mention_menu_entries(&mut app, 8); + assert_eq!(entries, vec!["src/lib.rs", "src/nested/"]); +} + #[test] fn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() { let tmpdir = TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2f79796f5..9fb24c60e 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -768,6 +768,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Composer, + key: "mention_menu_behavior".to_string(), + value: settings.mention_menu_behavior.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "mention_walk_depth".to_string(), diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index be5567963..9c3f6ff2c 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -274,6 +274,75 @@ impl Workspace { prefix_hits.truncate(limit); prefix_hits } + + /// Deterministic directory-browser completions for `@` mentions. + /// + /// Unlike [`Workspace::completions`], this mode does not fuzzy-rank across + /// the full workspace. It locks onto the directory part of `partial` and + /// returns only that directory's immediate children in case-insensitive + /// alphabetical order. + #[must_use] + pub fn browser_completions(&self, partial: &str, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let normalized = partial.replace('\\', "/"); + let trimmed = normalized.trim_start_matches('/'); + let (dir_part, name_part) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (dir.trim_end_matches('/'), name), + None => ("", trimmed), + }; + let dir = if dir_part.is_empty() { + self.root.clone() + } else { + self.root.join(dir_part) + }; + if !dir.is_dir() { + return Vec::new(); + } + + let show_hidden = name_part.starts_with('.'); + let needle = name_part.to_lowercase(); + let mut entries = Vec::new(); + + let mut builder = WalkBuilder::new(&dir); + builder + .hidden(!show_hidden) + .follow_links(false) + .max_depth(Some(1)); + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + + for entry in builder.build().flatten() { + let path = entry.path(); + if path == dir || path_is_excluded_from_discovery(&self.root, path) { + continue; + } + let Some(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() && !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy(); + if !needle.is_empty() && !name.to_lowercase().starts_with(&needle) { + continue; + } + let mut candidate = if dir_part.is_empty() { + name.to_string() + } else { + format!("{}/{}", dir_part, name) + }; + if file_type.is_dir() { + candidate.push('/'); + } + entries.push(candidate); + } + + entries.sort_by_key(|entry| entry.to_lowercase()); + entries.truncate(limit); + entries + } } /// Default directory depth walked when surfacing file-mention completions. @@ -1508,6 +1577,43 @@ mod tests { ); } + #[test] + fn browser_completions_show_only_immediate_children() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("src/nested")).unwrap(); + std::fs::write(tmp.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmp.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmp.path().join("README.md"), "readme").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let root_entries = ws.browser_completions("", 16); + assert_eq!(root_entries, vec!["README.md", "src/"]); + + let src_entries = ws.browser_completions("src/", 16); + assert_eq!(src_entries, vec!["src/lib.rs", "src/nested/"]); + assert!( + !src_entries.iter().any(|entry| entry.ends_with("deep.rs")), + "browser mode must not walk past immediate children: {src_entries:?}", + ); + } + + #[test] + fn browser_completions_hide_dot_entries_until_dot_query() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".agents")).unwrap(); + std::fs::write(tmp.path().join(".env"), "secret-ish fixture").unwrap(); + std::fs::write(tmp.path().join("app.rs"), "app").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let default_entries = ws.browser_completions("", 16); + assert_eq!(default_entries, vec!["app.rs"]); + + let dot_entries = ws.browser_completions(".", 16); + assert_eq!(dot_entries, vec![".agents/", ".env"]); + } + #[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 de9a03a6a..f58a69c8f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -506,6 +506,10 @@ Common settings keys: - `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. +- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how + `@`-mention completions are populated. `fuzzy` searches the workspace and + applies mention frecency. `browser` lists only the immediate children of the + currently typed directory segment in deterministic alphabetical order. - `show_thinking` (on/off) - `show_tool_details` (on/off) - `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome