feat(tui): add mention browser completions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user