use super::commands::{slash_commands, subcommands_for};
use crate::{
core::extensions::suggestion_candidates,
modes::interactive::{
app::{App, FileScanResult, SuggestionItem, SuggestionKind},
run_in_event_loop,
},
utils::fuzzy::fuzzy_filter,
};
use ignore::WalkState;
use regex::Regex;
use std::{
cmp::Ordering,
collections::BinaryHeap,
path::Path,
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
},
};
const SCAN_MAX_VISITED: usize = 50_000;
const SCAN_TOP_N: usize = 20;
const SCAN_THREADS: usize = 4;
const PATH_WRAPPERS: [(char, char); 5] =
[('(', ')'), ('[', ']'), ('{', '}'), ('<', '>'), ('`', '`')];
impl App {
pub(super) fn show_suggestions(
&mut self,
kind: SuggestionKind,
start: usize,
query: String,
items: Vec<SuggestionItem>,
allow_empty: bool,
) {
if items.is_empty() && !allow_empty {
if self.suggestion.active
&& self.suggestion.kind == kind
&& self.suggestion.query == query
{
return; }
self.suggestion.deselect();
return;
}
if self.suggestion.active
&& self.suggestion.kind == kind
&& self.suggestion.query == query
&& self.suggestion.items.len() == items.len()
{
self.suggestion.selected = self.suggestion.selected.min(items.len().saturating_sub(1));
return;
}
self.suggestion.active = true;
self.suggestion.kind = kind;
self.suggestion.items = items;
self.suggestion.selected = 0;
self.suggestion.start = start;
self.suggestion.query = query;
}
}
fn basename(path: &str) -> &str {
path.rfind('/').map(|i| &path[i + 1..]).unwrap_or(path)
}
fn score_entry(file_path: &str, query: &str, is_dir: bool) -> i32 {
let lower_file = basename(file_path).to_lowercase();
let lower_query = query.to_lowercase();
let mut score = 0;
if lower_file == lower_query {
score = 100;
} else if lower_file.starts_with(&lower_query) {
score = 80;
} else if lower_file.contains(&lower_query) {
score = 50;
} else if file_path.to_lowercase().contains(&lower_query) {
score = 30;
}
if is_dir && score > 0 {
score += 10;
}
score
}
#[derive(PartialEq, Eq)]
struct RankedCandidate {
score: i32,
depth: usize,
path: String,
is_dir: bool,
}
impl Ord for RankedCandidate {
fn cmp(&self, other: &Self) -> Ordering {
other
.score
.cmp(&self.score)
.then_with(|| self.depth.cmp(&other.depth))
.then_with(|| self.path.len().cmp(&other.path.len()))
.then_with(|| self.path.cmp(&other.path))
}
}
impl PartialOrd for RankedCandidate {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn file_suggestions_walk(tail: &str, cwd: &str, cancel: &AtomicBool) -> Vec<SuggestionItem> {
let normalized = tail.replace('\\', "/");
let Some((base_dir, query, display_base)) = split_base_and_query(&normalized, cwd) else {
return Vec::new();
};
if base_dir.is_empty() {
return Vec::new();
}
let mut builder = ignore::WalkBuilder::new(&base_dir);
builder
.hidden(false)
.follow_links(true)
.parents(true)
.add_custom_ignore_filename(".fdignore")
.threads(SCAN_THREADS)
.filter_entry(|e| e.file_name() != ".git");
let visited = AtomicUsize::new(0);
let top = Mutex::new(BinaryHeap::<RankedCandidate>::new());
builder.build_parallel().run(|| {
Box::new(|entry| {
if cancel.load(AtomicOrdering::Relaxed) {
return WalkState::Quit;
}
if visited.fetch_add(1, AtomicOrdering::Relaxed) >= SCAN_MAX_VISITED {
return WalkState::Quit;
}
let Ok(entry) = entry else {
return WalkState::Continue;
};
if entry.depth() == 0 {
return WalkState::Continue; }
let Ok(rel) = entry.path().strip_prefix(&base_dir) else {
return WalkState::Continue;
};
let rel = rel.to_string_lossy().replace('\\', "/");
if rel.is_empty() {
return WalkState::Continue;
}
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let score = if query.is_empty() {
1
} else {
score_entry(&rel, &query, is_dir)
};
if score <= 0 {
return WalkState::Continue;
}
let mut top = top.lock().unwrap();
top.push(RankedCandidate {
score,
depth: entry.depth(),
path: rel,
is_dir,
});
if top.len() > SCAN_TOP_N {
top.pop(); }
WalkState::Continue
})
});
top.into_inner()
.unwrap()
.into_sorted_vec()
.into_iter()
.map(|c| candidate_item(&c.path, c.is_dir, &display_base))
.collect()
}
fn split_base_and_query(normalized: &str, cwd: &str) -> Option<(String, String, String)> {
match normalized.rfind('/') {
Some(slash) => {
let display_base = normalized[..=slash].to_string();
let query = normalized[slash + 1..].to_string();
let base_dir = if display_base.starts_with("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
format!(
"{}/{}",
home,
display_base.trim_start_matches("~/").trim_end_matches('/')
)
} else if display_base.starts_with('/') {
let trimmed = display_base.trim_end_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
trimmed.to_string()
}
} else {
format!("{}/{}", cwd, display_base.trim_end_matches('/'))
};
if !Path::new(&base_dir).is_dir() {
return None;
}
Some((base_dir, query, display_base))
}
None => Some((cwd.to_string(), normalized.to_string(), String::new())),
}
}
fn candidate_item(rel: &str, is_dir: bool, display_base: &str) -> SuggestionItem {
let display_path = if display_base.is_empty() {
rel.to_string()
} else if display_base == "/" {
format!("/{rel}")
} else {
format!("{display_base}{rel}")
};
let entry_name = basename(rel);
let completion = if is_dir {
format!("{display_path}/")
} else {
display_path.clone()
};
let completion = if completion.chars().any(is_autocomplete_separator) {
format!("\"{completion}\"")
} else {
completion
};
SuggestionItem {
name: if is_dir {
format!("{entry_name}/")
} else {
entry_name.to_string()
},
description: display_path,
insert: completion,
}
}
impl App {
pub(super) fn refresh_suggestions(&mut self) {
if self.editor.history_index.is_some() {
self.suggestion.deselect();
return;
}
if self.last_suggestion_version == self.editor.content_version {
return;
}
self.last_suggestion_version = self.editor.content_version;
let line = self
.editor
.lines
.get(self.editor.cursor_line)
.cloned()
.unwrap_or_default();
let col = self.editor.cursor_col.min(line.chars().count());
let prefix: String = line.chars().take(col).collect();
if self.refresh_history_suggestions(&prefix) {
self.cancel_file_scan(); return;
}
if self.refresh_command_suggestions(&prefix) {
self.cancel_file_scan();
return;
}
if self.refresh_subcommand_suggestions(&prefix) {
self.cancel_file_scan();
return;
}
if self.refresh_file_suggestions(&prefix) {
return;
}
self.cancel_file_scan();
self.suggestion.deselect();
}
fn refresh_command_suggestions(&mut self, prefix: &str) -> bool {
let command_text = prefix.trim_start();
let offset = prefix.chars().count() - command_text.chars().count();
if let Some(query) = command_text.strip_prefix('/')
&& !query.contains(char::is_whitespace)
{
let q = query.to_lowercase();
let mut candidates: Vec<(String, String)> = slash_commands();
for skill in &self.skills {
candidates.push((format!("skill:{}", skill.name), skill.description.clone()));
}
let explicit_skill = q.starts_with("skill:");
let items: Vec<SuggestionItem> = fuzzy_filter(&candidates, &q, move |c| {
if !explicit_skill && c.0.starts_with("skill:") {
&c.0["skill:".len()..]
} else {
c.0.as_str()
}
})
.into_iter()
.map(|(name, desc)| SuggestionItem {
name: name.clone(),
description: desc.clone(),
insert: name.clone(),
})
.collect();
self.show_suggestions(SuggestionKind::Commands, offset, q, items, false);
return true;
}
false
}
fn refresh_subcommand_suggestions(&mut self, prefix: &str) -> bool {
let command_text = prefix.trim_start();
let offset = prefix.chars().count() - command_text.chars().count();
let Some(rest) = command_text.strip_prefix('/') else {
return false;
};
let Some((name, after)) = rest.split_once(char::is_whitespace) else {
return false;
};
if name.is_empty() {
return false;
}
let query = after.trim_start();
if query.contains(char::is_whitespace) {
return false; }
let candidates = subcommands_for(&name.to_lowercase());
if candidates.is_empty() {
return false;
}
let start = offset + 1 + name.chars().count(); let q = query.to_lowercase();
let items: Vec<SuggestionItem> = fuzzy_filter(&candidates, &q, |c| c.name)
.into_iter()
.map(|sub| SuggestionItem {
name: sub.name.to_string(),
description: sub.description.to_string(),
insert: sub.name.to_string(),
})
.collect();
if items.is_empty() {
return false;
}
self.show_suggestions(SuggestionKind::Subcommands, start, q, items, false);
true
}
fn refresh_file_suggestions(&mut self, prefix: &str) -> bool {
if let Some(at) = prefix.rfind('@') {
if at > 0 && !is_at_token_start(prefix, at) {
return false;
}
let at_char = prefix[..at].chars().count();
let rest: String = prefix.chars().skip(at_char + 1).collect();
if !rest.contains(is_autocomplete_separator) {
let cwd = self.cwd.clone();
let query = rest.clone();
let start = at_char;
self.cancel_file_scan();
let (mentions, exclusive) = mention_candidates(&query, prefix);
if exclusive {
if mentions.is_empty() {
self.suggestion.deselect();
} else {
self.show_suggestions(
SuggestionKind::Files,
start,
query.clone(),
mentions,
false,
);
}
return true;
}
if !mentions.is_empty() {
self.show_suggestions(
SuggestionKind::Files,
start,
query.clone(),
mentions,
false,
);
}
let cancel = Arc::new(AtomicBool::new(false));
self.file_scan_cancel = Some(Arc::clone(&cancel));
tokio::task::spawn_blocking(move || {
let items = file_suggestions_walk(&query, &cwd, &cancel);
if cancel.load(AtomicOrdering::Relaxed) {
return; }
_ = run_in_event_loop(move |ui| {
ui.apply_file_scan_result(FileScanResult {
kind: SuggestionKind::Files,
start,
query,
items,
})
});
});
return true;
}
}
false
}
fn cancel_file_scan(&mut self) {
if let Some(cancel) = self.file_scan_cancel.take() {
cancel.store(true, AtomicOrdering::Relaxed);
}
}
}
fn mention_candidates(query: &str, line_prefix: &str) -> (Vec<SuggestionItem>, bool) {
let cands = suggestion_candidates(query, line_prefix);
let items = cands
.items
.into_iter()
.map(|s| SuggestionItem {
name: s.name,
description: s.description,
insert: s.insert,
})
.collect();
(items, cands.exclusive)
}
impl App {
pub(crate) fn apply_file_scan_result(&mut self, result: FileScanResult) {
let line = self
.editor
.lines
.get(self.editor.cursor_line)
.cloned()
.unwrap_or_default();
let col = self.editor.cursor_col.min(line.chars().count());
let prefix: String = line.chars().take(col).collect();
let Some(at) = prefix.rfind('@') else {
return;
};
let at_char = prefix[..at].chars().count();
let rest: String = prefix.chars().skip(at_char + 1).collect();
let current = if rest.contains(is_autocomplete_separator) {
String::new()
} else {
rest
};
if at_char != result.start || current != result.query {
return;
}
let (mentions, exclusive) = mention_candidates(&result.query, &prefix);
if exclusive {
if mentions.is_empty() {
self.suggestion.deselect();
} else {
self.show_suggestions(result.kind, result.start, result.query, mentions, false);
}
return;
}
let mut items = mentions;
items.extend(result.items);
self.show_suggestions(result.kind, result.start, result.query, items, false);
}
pub(super) fn suggestion_next(&mut self) {
let n = self.suggestion.items.len();
if n > 0 {
self.suggestion.selected = (self.suggestion.selected + 1) % n;
}
}
pub(super) fn suggestion_prev(&mut self) {
let n = self.suggestion.items.len();
if n > 0 {
let cur = self.suggestion.selected.min(n - 1);
self.suggestion.selected = (cur + n - 1) % n;
}
}
pub(super) fn apply_suggestion(&mut self) {
if !self.suggestion.active {
return;
}
if self.suggestion.kind == SuggestionKind::HistoryAction {
if let Some(item) = self.suggestion.items.get(self.suggestion.selected) {
let text = format!("/history {}", item.insert);
self.editor.replace_all(&text);
}
self.suggestion.deselect();
return;
}
let Some(item) = self.suggestion.items.get(self.suggestion.selected).cloned() else {
self.suggestion.deselect();
return;
};
if self.suggestion.kind == SuggestionKind::History {
self.apply_history_suggestion(&item.insert);
return;
}
let replacement = item.insert;
let suffix = match self.suggestion.kind {
SuggestionKind::Commands | SuggestionKind::Subcommands => " ".to_string(),
SuggestionKind::Files if !replacement.ends_with('/') => " ".to_string(),
SuggestionKind::Files | SuggestionKind::History | SuggestionKind::HistoryAction => {
String::new()
}
};
let expected_trigger = match self.suggestion.kind {
SuggestionKind::Commands => '/',
SuggestionKind::Subcommands => ' ',
SuggestionKind::Files | SuggestionKind::History | SuggestionKind::HistoryAction => '@',
};
let line_chars: Vec<char> = self
.editor
.lines
.get(self.editor.cursor_line)
.map(|line| line.chars().collect())
.unwrap_or_default();
let col = self.editor.cursor_col.min(line_chars.len());
let start = self.suggestion.start.min(col.saturating_sub(1));
if col == 0
|| start >= line_chars.len()
|| start >= col
|| line_chars[start] != expected_trigger
{
self.suggestion.deselect();
return;
}
let trigger = line_chars[start];
let editor = &mut self.editor;
let new_chars: Vec<char> = line_chars[..start]
.iter()
.copied()
.chain(std::iter::once(trigger))
.chain(replacement.chars())
.chain(suffix.chars())
.chain(line_chars[col..].iter().copied())
.collect();
editor.lines[editor.cursor_line] = new_chars.into_iter().collect();
editor.cursor_col = start + 1 + replacement.chars().count() + suffix.chars().count();
editor.content_changed();
self.suggestion.deselect();
}
}
fn is_cjk_punctuation(c: char) -> bool {
static PUNCT: OnceLock<Regex> = OnceLock::new();
static CJK_SCRIPT: OnceLock<Regex> = OnceLock::new();
let punct = PUNCT.get_or_init(|| Regex::new(r"\p{Punctuation}").expect("punct"));
let script = CJK_SCRIPT.get_or_init(|| {
regex::Regex::new(
r"[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]",
)
.expect("cjk script")
});
let s = c.to_string();
(script.is_match(&s) && punct.is_match(&s))
|| matches!(
c,
',' | '.'
| ':'
| ';'
| '!'
| '?'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '“'
| '”'
| '‘'
| '’'
| '…'
| '—'
)
}
fn is_autocomplete_separator(c: char) -> bool {
c.is_whitespace() || is_cjk_punctuation(c)
}
fn wrapper_closer(c: char) -> Option<char> {
PATH_WRAPPERS
.iter()
.find(|(open, _)| *open == c)
.map(|(_, close)| *close)
}
fn strip_leading_wrappers(token: &str) -> &str {
let mut rest = token;
while let Some(first) = rest.chars().next() {
let Some(closer) = wrapper_closer(first) else {
break;
};
let tail = &rest[first.len_utf8()..];
if tail.contains(closer) {
break;
}
rest = tail;
}
rest
}
fn is_at_token_start(text: &str, index: usize) -> bool {
let mut start = index;
while start > 0 {
let Some(prev) = text[..start].chars().next_back() else {
break;
};
if wrapper_closer(prev).is_none() {
break;
}
start -= prev.len_utf8();
}
start == 0
|| text[..start]
.chars()
.next_back()
.is_some_and(is_autocomplete_separator)
}
pub(super) fn last_word(text: &str) -> Option<(String, usize)> {
let start = match text.rfind(is_autocomplete_separator) {
Some(i) => i + text[i..].chars().next().map(char::len_utf8).unwrap_or(1),
None => 0,
};
let word = strip_leading_wrappers(&text[start..]);
if word.is_empty() {
None
} else {
Some((word.to_string(), text.len() - word.len()))
}
}
pub(super) fn complete_path(prefix: &str, cwd: &str) -> Option<String> {
let (dir_part, name_part) = match prefix.rfind('/') {
Some(i) => {
let dir = if prefix.starts_with('/') {
prefix[..=i].to_string()
} else {
format!("{}/{}", cwd, &prefix[..=i])
};
(dir, prefix[i + 1..].to_string())
}
None => (format!("{}/", cwd), prefix.to_string()),
};
let entries: Vec<String> = std::fs::read_dir(&dir_part)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let mut matches: Vec<String> = entries
.into_iter()
.filter(|n| n.starts_with(&name_part))
.collect();
matches.sort();
if matches.is_empty() {
return None;
}
if matches.len() == 1 {
let is_dir = Path::new(&format!("{}{}", dir_part, matches[0])).is_dir();
return Some(if is_dir {
format!("{}/", matches[0])
} else {
format!("{} ", matches[0])
});
}
let common = common_prefix(&matches);
if common.len() > name_part.len() {
Some(common)
} else {
None
}
}
fn common_prefix(v: &[String]) -> String {
if v.is_empty() {
return String::new();
}
let bytes = v[0].as_bytes();
let mut len = bytes.len();
for s in &v[1..] {
let b = s.as_bytes();
let mut i = 0;
while i < len && i < b.len() && bytes[i] == b[i] {
i += 1;
}
len = i;
}
v[0][..len].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::SubcommandDef;
use crate::modes::interactive::app::App;
#[test]
fn apply_suggestion_replaces_trigger_text() {
let mut a = App::new();
a.editor.insert_text("/mo");
a.suggestion.active = true;
a.suggestion.kind = SuggestionKind::Commands;
a.suggestion.start = 0;
a.suggestion.items = vec![SuggestionItem {
name: "model".to_string(),
description: String::new(),
insert: "model".to_string(),
}];
a.suggestion.selected = 0;
a.apply_suggestion();
assert_eq!(a.editor.text(), "/model ", "命令补全后应加空格(对齐 pi)");
assert!(!a.suggestion.active);
}
#[test]
fn score_ranks_filenames_before_paths() {
assert_eq!(score_entry("main.rs", "main.rs", false), 100);
assert_eq!(score_entry("main.rs", "main", false), 80);
assert_eq!(score_entry("xmain.rs", "main", false), 50);
assert_eq!(score_entry("src/main", "src", false), 30);
assert!(score_entry("main.rs", "main", false) > score_entry("xmain.rs", "main", false));
assert!(score_entry("xmain.rs", "main", false) > score_entry("src/main", "src", false));
assert_eq!(score_entry("adir", "ad", true), 90);
}
#[test]
fn cjk_punctuation_is_autocomplete_separator() {
assert!(is_autocomplete_separator(','));
assert!(is_autocomplete_separator('。'));
assert!(is_autocomplete_separator(' '));
assert!(!is_autocomplete_separator('文'));
assert!(!is_autocomplete_separator('a'));
}
#[test]
fn last_word_splits_on_cjk_punctuation() {
assert_eq!(last_word("查看,文档/说").unwrap().0, "文档/说");
assert_eq!(last_word("hello world").unwrap().0, "world");
assert_eq!(last_word("文档").unwrap().0, "文档");
}
#[test]
fn last_word_strips_leading_wrappers() {
for (text, word, start) in [
("(~/Dev", "~/Dev", 1),
("`src/ma", "src/ma", 1),
("<[a]b", "[a]b", 1),
("见 (./a", "./a", 5),
] {
let (w, s) = last_word(text).unwrap();
assert_eq!((w.as_str(), s), (word, start), "input: {text}");
}
assert_eq!(last_word("app/[slug]/pa").unwrap().0, "app/[slug]/pa");
assert!(last_word("(").is_none());
}
#[test]
fn at_after_wrapper_is_token_start() {
assert!(is_at_token_start("(@src", 1));
assert!(is_at_token_start("`@src", 1));
assert!(is_at_token_start("见 (@src", 5));
assert!(is_at_token_start("@src", 0));
assert!(!is_at_token_start("user@example.com", 4));
assert!(!is_at_token_start("app/(a)@x", 7));
}
#[tokio::test]
async fn at_file_suggestions_trigger_after_wrapper() {
let mut a = App::new();
a.editor.insert_text("见 (@src");
a.refresh_suggestions();
assert!(
a.file_scan_cancel.is_some(),
"包装符后的 @ 应被当作 token 起始并派发文件扫描"
);
a.cancel_file_scan();
let mut b = App::new();
b.editor.insert_text("user@example.com");
b.refresh_suggestions();
assert!(!b.suggestion.active);
assert!(b.file_scan_cancel.is_none());
b.cancel_file_scan();
}
#[test]
fn candidate_item_quotes_paths_with_separators() {
let f = candidate_item("my dir/file.txt", false, "");
assert_eq!(f.name, "file.txt");
assert_eq!(f.description, "my dir/file.txt");
assert_eq!(f.insert, "\"my dir/file.txt\"");
let plain = candidate_item("plain.txt", false, "");
assert_eq!(plain.insert, "plain.txt");
let dir = candidate_item("src", true, "");
assert_eq!(dir.name, "src/");
assert_eq!(dir.insert, "src/");
let scoped = candidate_item("main.rs", false, "sub/");
assert_eq!(scoped.description, "sub/main.rs");
assert_eq!(scoped.insert, "sub/main.rs");
}
#[test]
fn ranked_candidates_order_by_score_then_depth_length_name() {
let mk = |score, depth, path: &str| RankedCandidate {
score,
depth,
path: path.to_string(),
is_dir: false,
};
let mut heap = BinaryHeap::from(vec![
mk(80, 2, "a/bbbb.rs"),
mk(30, 1, "a.rs"),
mk(80, 1, "longer.rs"),
mk(80, 1, "a.rs"),
]);
let got: Vec<i32> = std::iter::from_fn(|| heap.pop().map(|c| c.score)).collect();
assert_eq!(got, vec![30, 80, 80, 80]);
let sorted: Vec<String> = BinaryHeap::from(vec![
mk(80, 2, "a/bbbb.rs"),
mk(80, 1, "longer.rs"),
mk(80, 1, "a.rs"),
])
.into_sorted_vec()
.into_iter()
.map(|c| c.path)
.collect();
assert_eq!(sorted, vec!["a.rs", "longer.rs", "a/bbbb.rs"]);
}
fn write_file(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, body).unwrap();
}
#[test]
fn slash_lo_ranks_login_before_clone() {
let candidates: Vec<(&str, &str)> =
vec![("clone", "Fork session"), ("login", "Store an API key")];
let got: Vec<&str> = fuzzy_filter(&candidates, "lo", |c| c.0)
.into_iter()
.map(|c| c.0)
.collect();
assert_eq!(got, vec!["login", "clone"]);
}
#[test]
fn skill_commands_rank_by_bare_name() {
use crate::core::skills::Skill;
let mk = |name: &str, desc: &str| Skill {
name: name.to_string(),
description: desc.to_string(),
path: std::path::PathBuf::new(),
base_dir: std::path::PathBuf::new(),
disable_model_invocation: false,
source: "test".to_string(),
};
let mut a = App::new();
a.skills = vec![
mk("deep-research", "Multi-agent deep research"),
mk("research-idea", "Refine a raw idea"),
];
a.editor.insert_text("/idea");
a.refresh_suggestions();
let names: Vec<&str> = a.suggestion.items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"skill:research-idea"),
"应命中 skill:research-idea: {names:?}"
);
assert!(
!names.contains(&"skill:deep-research"),
"skill:deep-research 不应因 skill: 前缀的 i 误命中: {names:?}"
);
}
#[test]
fn cursor_movement_does_not_trigger_suggestions() {
let mut a = App::new();
a.editor.insert_text("/login hfei");
a.refresh_suggestions(); let version = a.editor.content_version;
for _ in 0..5 {
a.editor.move_left();
}
assert_eq!(
a.editor.cursor_col, 6,
"光标应停在 login 后(/login 为 6 字符)"
);
assert_eq!(
a.editor.content_version, version,
"移动光标不应改变内容版本"
);
a.refresh_suggestions();
assert!(!a.suggestion.active, "纯光标移动不得触发建议面板");
}
#[test]
fn history_browsing_does_not_activate_suggestions() {
let mut a = App::new();
a.editor.history.push("/model".to_string());
a.editor.clear();
a.editor.history_prev();
assert_eq!(a.editor.text(), "/model");
assert!(a.editor.history_index.is_some());
a.refresh_suggestions();
assert!(!a.suggestion.active, "历史浏览期间不应激活 / 候选");
a.editor.insert_char('x');
assert_eq!(a.editor.history_index, None, "编辑后应退出历史浏览");
a.editor.clear();
a.editor.insert_text("/mod");
a.refresh_suggestions();
assert!(a.suggestion.active, "编辑后 / 前缀应恢复激活候选");
}
#[test]
fn file_scan_result_applies_when_query_matches() {
let mut a = App::new();
a.editor.insert_text("@ma");
a.apply_file_scan_result(FileScanResult {
kind: SuggestionKind::Files,
start: 0,
query: "ma".to_string(),
items: vec![SuggestionItem {
name: "main.rs".to_string(),
description: "main.rs".to_string(),
insert: "main.rs".to_string(),
}],
});
assert!(a.suggestion.active);
assert_eq!(a.suggestion.query, "ma");
assert_eq!(a.suggestion.items.len(), 1);
}
#[test]
fn stale_file_scan_result_is_dropped() {
let mut a = App::new();
a.editor.insert_text("@ma");
a.apply_file_scan_result(FileScanResult {
kind: SuggestionKind::Files,
start: 0,
query: "mab".to_string(), items: vec![SuggestionItem {
name: "mab.rs".to_string(),
description: "mab.rs".to_string(),
insert: "mab.rs".to_string(),
}],
});
assert!(!a.suggestion.active);
}
#[test]
fn stale_empty_query_scan_on_cleared_line_is_dropped() {
let mut a = App::new();
a.editor.insert_text("@");
a.editor.clear();
a.apply_file_scan_result(FileScanResult {
kind: SuggestionKind::Files,
start: 0,
query: String::new(),
items: vec![SuggestionItem {
name: "main.rs".to_string(),
description: "main.rs".to_string(),
insert: "main.rs".to_string(),
}],
});
assert!(!a.suggestion.active, "行内已无 @:应丢弃过期扫描结果");
}
#[test]
fn stale_scan_with_shifted_at_is_dropped() {
let mut a = App::new();
a.editor.insert_text("@ma");
a.editor.home();
a.editor.insert_text("see ");
a.apply_file_scan_result(FileScanResult {
kind: SuggestionKind::Files,
start: 0,
query: "ma".to_string(),
items: vec![SuggestionItem {
name: "main.rs".to_string(),
description: "main.rs".to_string(),
insert: "main.rs".to_string(),
}],
});
assert!(!a.suggestion.active, "@ 位置已变化:应丢弃过期扫描结果");
}
struct SubcmdDemoExt;
impl crate::core::extensions::Extension for SubcmdDemoExt {
fn name(&self) -> &str {
"subcmd-demo-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<crate::core::extensions::ExtensionCommand> {
vec![crate::core::extensions::ExtensionCommand {
name: "subcmd-demo".to_string(),
description: "demo subcommands".to_string(),
busy_safe: true,
subcommands: vec![
SubcommandDef {
name: "pause",
description: "Pause it",
},
SubcommandDef {
name: "resume",
description: "Resume it",
},
SubcommandDef {
name: "clear",
description: "Clear it",
},
],
}]
}
}
fn ensure_subcmd_ext() {
static REG: std::sync::Once = std::sync::Once::new();
REG.call_once(|| crate::core::extensions::register_extension(SubcmdDemoExt));
crate::core::extensions::set_extension_enabled("subcmd-demo-ext", true);
}
fn subcmd_env() -> (
std::sync::MutexGuard<'static, ()>,
crate::test_support::AgentDirGuard,
) {
let lock = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let ad = crate::test_support::AgentDirGuard::temp();
ensure_subcmd_ext();
(lock, ad)
}
fn item_names(a: &App) -> Vec<&str> {
a.suggestion.items.iter().map(|i| i.name.as_str()).collect()
}
#[test]
fn subcommand_panel_appears_after_command_space() {
let _env = subcmd_env();
let mut a = App::new();
a.editor.insert_text("/subcmd-demo");
a.refresh_suggestions();
assert!(a.suggestion.active);
assert_eq!(a.suggestion.kind, SuggestionKind::Commands);
a.editor.insert_char(' ');
a.refresh_suggestions();
assert!(a.suggestion.active, "`/subcmd-demo ` 应弹出子命令面板");
assert_eq!(a.suggestion.kind, SuggestionKind::Subcommands);
assert_eq!(item_names(&a), vec!["pause", "resume", "clear"]);
assert_eq!(a.suggestion.items[0].description, "Pause it");
assert_eq!(a.suggestion.selected, 0);
let mut a = App::new();
a.editor.insert_text("/subcmd-demo");
a.refresh_suggestions();
a.apply_suggestion();
assert_eq!(a.editor.text(), "/subcmd-demo ");
a.refresh_suggestions();
assert_eq!(a.suggestion.kind, SuggestionKind::Subcommands);
}
#[test]
fn subcommand_query_filters_and_second_space_closes() {
let _env = subcmd_env();
let mut a = App::new();
a.editor.insert_text("/subcmd-demo p");
a.refresh_suggestions();
assert_eq!(a.suggestion.kind, SuggestionKind::Subcommands);
assert_eq!(item_names(&a), vec!["pause"], "p 应只匹配 pause");
a.editor.insert_char(' ');
a.refresh_suggestions();
assert!(!a.suggestion.active, "子命令后第二个空格应关闭面板");
}
#[test]
fn subcommand_apply_completes_with_trailing_space() {
let _env = subcmd_env();
let mut a = App::new();
a.editor.insert_text("/subcmd-demo cl");
a.refresh_suggestions();
assert!(a.suggestion.active);
a.apply_suggestion();
assert_eq!(a.editor.text(), "/subcmd-demo clear ");
assert!(!a.suggestion.active, "补全后尾随空格导致面板关闭");
let mut a = App::new();
a.editor.insert_text("/subcmd-demo cl");
a.refresh_suggestions();
a.apply_suggestion();
assert_eq!(a.editor.text(), "/subcmd-demo clear ");
}
#[test]
fn slash_suggestions_allow_leading_whitespace() {
let _env = subcmd_env();
let mut a = App::new();
a.editor.insert_text(" /subcmd-de");
a.refresh_suggestions();
assert!(a.suggestion.active, "前导空白后仍应弹出命令面板");
assert_eq!(a.suggestion.kind, SuggestionKind::Commands);
assert_eq!(a.suggestion.start, 3, "锚点应指向 '/' 而不是 0");
a.apply_suggestion();
assert_eq!(a.editor.text(), " /subcmd-demo ");
let mut a = App::new();
a.editor.insert_text(" /subcmd-demo cl");
a.refresh_suggestions();
assert_eq!(a.suggestion.kind, SuggestionKind::Subcommands);
a.apply_suggestion();
assert_eq!(a.editor.text(), " /subcmd-demo clear ");
}
#[test]
fn no_subcommand_panel_without_declared_subcommands() {
let _env = subcmd_env();
let mut a = App::new();
a.editor.insert_text("/model ");
a.refresh_suggestions();
assert!(!a.suggestion.active, "无子命令的 /model 不应弹面板");
let mut a = App::new();
a.editor.insert_text("/nope ");
a.refresh_suggestions();
assert!(!a.suggestion.active);
let mut a = App::new();
a.editor.insert_text("/subcmd-demo zz");
a.refresh_suggestions();
assert!(!a.suggestion.active, "无匹配子命令应关闭面板");
}
#[test]
fn apply_suggestion_on_empty_line_is_safe() {
let mut a = App::new();
a.editor.insert_text("@");
a.suggestion.active = true;
a.suggestion.kind = SuggestionKind::Files;
a.suggestion.start = 0;
a.suggestion.items = vec![SuggestionItem {
name: "main.rs".to_string(),
description: "main.rs".to_string(),
insert: "main.rs".to_string(),
}];
a.suggestion.selected = 0;
a.editor.clear();
a.apply_suggestion();
assert_eq!(a.editor.text(), "");
assert!(!a.suggestion.active);
}
#[test]
fn apply_suggestion_with_no_items_deactivates_panel() {
let mut a = App::new();
a.editor.insert_text("/agents workflowz");
a.suggestion.active = true;
a.suggestion.kind = SuggestionKind::Subcommands;
a.suggestion.start = 7;
a.suggestion.items.clear();
a.suggestion.selected = 0;
a.apply_suggestion();
assert!(!a.suggestion.active, "无候选时也应关闭候选面板");
assert!(a.suggestion.items.is_empty());
}
#[test]
fn apply_suggestion_with_mismatched_trigger_is_skipped() {
let mut a = App::new();
a.editor.insert_text("hello");
a.suggestion.active = true;
a.suggestion.kind = SuggestionKind::Files;
a.suggestion.start = 3;
a.suggestion.items = vec![SuggestionItem {
name: "main.rs".to_string(),
description: "main.rs".to_string(),
insert: "main.rs".to_string(),
}];
a.suggestion.selected = 0;
a.apply_suggestion();
assert_eq!(a.editor.text(), "hello", "触发符不匹配时不得改写输入");
assert!(!a.suggestion.active);
}
#[test]
fn walk_scan_and_score_without_external_fd() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_file(root, "sub/main.rs", "x");
write_file(root, "src/clone.txt", "x");
write_file(root, "login.txt", "x");
let cwd = root.to_str().unwrap();
let never = AtomicBool::new(false);
let inserts = |items: &[SuggestionItem]| -> Vec<String> {
items.iter().map(|i| i.insert.clone()).collect()
};
let items = file_suggestions_walk("lo", cwd, &never);
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names[0], "login.txt", "got: {names:?}");
assert!(names.contains(&"clone.txt"), "got: {names:?}");
assert_eq!(
inserts(&file_suggestions_walk("lo", cwd, &never)),
inserts(&items)
);
let items = file_suggestions_walk("sub/ma", cwd, &never);
let got = inserts(&items);
assert!(got.contains(&"sub/main.rs".to_string()), "got: {got:?}");
}
#[test]
fn walk_honors_gitignore_fdignore_hidden_and_skips_git_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git")).unwrap(); std::fs::write(root.join(".gitignore"), "secret.txt\n").unwrap();
std::fs::write(root.join(".fdignore"), "fdignored.txt\n").unwrap();
write_file(root, "secret.txt", "x");
write_file(root, "fdignored.txt", "x");
write_file(root, "kept.txt", "x");
write_file(root, ".hidden.txt", "x");
write_file(root, ".git/config", "x");
let items = file_suggestions_walk("", root.to_str().unwrap(), &AtomicBool::new(false));
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"kept.txt"), "got: {names:?}");
assert!(
names.contains(&".hidden.txt"),
"--hidden:隐藏文件可见: {names:?}"
);
assert!(!names.contains(&"secret.txt"), ".gitignore 生效: {names:?}");
assert!(
!names.contains(&"fdignored.txt"),
".fdignore 生效: {names:?}"
);
assert!(!names.contains(&".git/"), ".git 目录不参与候选: {names:?}");
}
#[test]
fn walk_ignores_gitignore_outside_git_repo() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".gitignore"), "secret.txt\n").unwrap();
std::fs::write(root.join(".ignore"), "plainignored.txt\n").unwrap();
write_file(root, "secret.txt", "x");
write_file(root, "plainignored.txt", "x");
let items = file_suggestions_walk("", root.to_str().unwrap(), &AtomicBool::new(false));
let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
assert!(
names.contains(&"secret.txt"),
"非 git 仓:.gitignore 不生效: {names:?}"
);
assert!(
!names.contains(&"plainignored.txt"),
".ignore 生效: {names:?}"
);
}
#[test]
fn walk_cancel_stops_scan() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "main.rs", "x");
let cancel = AtomicBool::new(true); let items = file_suggestions_walk("", dir.path().to_str().unwrap(), &cancel);
assert!(items.is_empty(), "取消后不应产出候选: {items:?}");
}
#[test]
fn split_base_and_query_handles_root_and_scoped() {
let (base, query, display) = split_base_and_query("/etc", "/tmp").unwrap();
assert_eq!(base, "/");
assert_eq!(query, "etc");
assert_eq!(display, "/");
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap();
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
let (base, query, display) = split_base_and_query("sub/ma", root).unwrap();
assert_eq!(base, format!("{root}/sub"));
assert_eq!(query, "ma");
assert_eq!(display, "sub/");
assert!(split_base_and_query("nope/x", root).is_none());
}
#[test]
fn at_file_candidate_appends_to_cursor_like_tab() {
let mut a = App::new();
a.editor.insert_text("see @fi");
a.suggestion.active = true;
a.suggestion.kind = SuggestionKind::Files;
a.suggestion.start = 4; a.suggestion.items = vec![SuggestionItem {
name: "file.txt".to_string(),
description: "file".to_string(),
insert: "file.txt".to_string(),
}];
a.suggestion.selected = 0;
a.apply_suggestion();
assert_eq!(
a.editor.text(),
"see @file.txt ",
"文件补全后应加空格(对齐 pi)"
);
assert!(!a.suggestion.active);
}
#[tokio::test]
async fn at_input_full_chain_activates_suggestion() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("login.txt"), "x").unwrap();
std::fs::write(dir.path().join("clone.txt"), "x").unwrap();
let (ui_tx, mut ui_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::modes::interactive::UiTask>();
crate::modes::interactive::set_event_loop_tx(ui_tx);
let mut st = App::new();
st.cwd = dir.path().to_str().unwrap().to_string();
st.editor.insert_text("@");
st.refresh_suggestions();
assert!(st.file_scan_cancel.is_some(), "应记录进行中的扫描中止开关");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let task = tokio::time::timeout_at(deadline, ui_rx.recv())
.await
.expect("timeout waiting for file scan")
.expect("channel closed");
task(&mut st);
if st.suggestion.active {
break;
}
}
crate::modes::interactive::clear_event_loop_tx();
assert!(st.suggestion.active, "@ 候选应激活");
assert_eq!(st.suggestion.kind, SuggestionKind::Files);
let names: Vec<&str> = st
.suggestion
.items
.iter()
.map(|i| i.name.as_str())
.collect();
assert!(names.contains(&"login.txt"), "got: {:?}", names);
}
#[tokio::test]
async fn new_scan_cancels_previous_one() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("login.txt"), "x").unwrap();
let mut st = App::new();
st.cwd = dir.path().to_str().unwrap().to_string();
st.editor.insert_text("@lo");
st.refresh_suggestions();
let first = st.file_scan_cancel.clone().expect("第一次扫描");
st.editor.insert_text("g"); st.refresh_suggestions();
assert!(
first.load(AtomicOrdering::Relaxed),
"新扫描派发时旧扫描应被作废"
);
let second = st.file_scan_cancel.clone().expect("第二次扫描");
assert!(
!second.load(AtomicOrdering::Relaxed),
"新扫描自身不应被作废"
);
st.editor.clear();
st.refresh_suggestions();
assert!(st.file_scan_cancel.is_none(), "取消后不再持有中止开关");
assert!(second.load(AtomicOrdering::Relaxed), "关闭候选应作废扫描");
}
}