feat(tui): add mention browser completions

This commit is contained in:
cyq
2026-06-02 06:39:59 +08:00
committed by Hunter Bown
parent 908a25d0f6
commit 73cd721665
7 changed files with 245 additions and 3 deletions
+1
View File
@@ -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
+37
View File
@@ -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<f64> {
Ok(percent)
}
fn normalize_mention_menu_behavior(value: &str) -> Result<String> {
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]
+7
View File
@@ -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<String>,
}
@@ -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,
+32 -2
View File
@@ -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<String> {
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<String>
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<String>
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,
+18
View File
@@ -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");
+146 -1
View File
@@ -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<String> {
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<PathBuf> {
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();
+4
View File
@@ -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