Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions crates/tui/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -332,6 +340,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions crates/tui/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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"),
(
Expand Down Expand Up @@ -932,6 +947,18 @@ fn parse_usize_setting(key: &str, value: &str) -> Result<usize> {
})
}

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",
Expand Down Expand Up @@ -1157,21 +1184,31 @@ 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")
.expect("set mention menu limit");
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]
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions crates/tui/src/tui/file_mention.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -202,25 +221,32 @@ 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,
cwd,
partial,
limit,
walk_depth,
behavior,
entries: entries.clone(),
});

Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/tui/views/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading