From 73cd721665045b1646d2febc9497c93ffa82b1e6 Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Tue, 2 Jun 2026 06:39:59 +0800 Subject: [PATCH] feat(tui): add mention browser completions --- config.example.toml | 1 + crates/tui/src/settings.rs | 37 ++++++++ crates/tui/src/tui/app.rs | 7 ++ crates/tui/src/tui/file_mention.rs | 34 ++++++- crates/tui/src/tui/ui/tests.rs | 18 ++++ crates/tui/src/working_set.rs | 147 ++++++++++++++++++++++++++++- docs/CONFIGURATION.md | 4 + 7 files changed, 245 insertions(+), 3 deletions(-) diff --git a/config.example.toml b/config.example.toml index fecf15e3..2b2188ec 100644 --- a/config.example.toml +++ b/config.example.toml @@ -446,6 +446,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/settings.rs b/crates/tui/src/settings.rs index 8e82757d..29919554 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -230,6 +230,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 @@ -337,6 +340,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(), @@ -559,6 +563,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)?; } @@ -756,6 +763,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)); @@ -842,6 +853,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"), ( @@ -1024,6 +1039,18 @@ fn parse_percent_setting(key: &str, value: &str) -> Result { Ok(percent) } +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", @@ -1261,6 +1288,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") @@ -1268,14 +1296,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 8ee44f61..1093396f 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 @@ -2106,6 +2112,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 4e9e2d36..fdb721d2 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(no_file_mention_matches_status( &partial, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 7025f45b..ed30ca18 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4843,6 +4843,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/working_set.rs b/crates/tui/src/working_set.rs index be556796..4e4ce95e 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -17,7 +17,7 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; /// Repo-aware resolver for `@`-mentions and file pickers. @@ -274,6 +274,91 @@ 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 Some(safe_dir_part) = browser_completion_dir_part(dir_part) else { + return Vec::new(); + }; + let dir = if safe_dir_part.as_os_str().is_empty() { + self.root.clone() + } else { + self.root.join(&safe_dir_part) + }; + if !dir.is_dir() { + return Vec::new(); + } + let display_dir_part = safe_dir_part.to_string_lossy().replace('\\', "/"); + + 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 display_dir_part.is_empty() { + name.to_string() + } else { + format!("{display_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 + } +} + +fn browser_completion_dir_part(dir_part: &str) -> Option { + let mut safe = PathBuf::new(); + for component in Path::new(dir_part).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => safe.push(part), + Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None, + } + } + Some(safe) } /// Default directory depth walked when surfacing file-mention completions. @@ -1508,6 +1593,66 @@ 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 browser_completions_reject_path_escape_segments() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let sibling = tmp.path().join("outside"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(workspace.join("inside.rs"), "inside").unwrap(); + std::fs::write(sibling.join("secret.rs"), "outside").unwrap(); + + let ws = Workspace::with_cwd(workspace, None); + + assert_eq!(ws.browser_completions("", 16), vec!["inside.rs"]); + assert!( + ws.browser_completions("../", 16).is_empty(), + "browser mode must not list workspace siblings", + ); + assert!( + ws.browser_completions("../outside", 16).is_empty(), + "browser mode must not complete names from outside the workspace", + ); + } + #[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 e8463569..230d077b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -530,6 +530,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