use crate::{commands::CommandRegistry, skills::SkillDiscovery, tui::state};
use std::path::{Component, Path};
pub(crate) fn tui_autocomplete_candidates(
commands: &CommandRegistry,
) -> Vec<state::AutocompleteCandidate> {
let mut candidates = commands
.list()
.into_iter()
.map(|command| {
state::AutocompleteCandidate::slash_command(
command.name.clone(),
command.description.clone(),
)
})
.collect::<Vec<_>>();
candidates.extend(tui_context_injection_autocomplete_candidates());
candidates
}
fn tui_context_injection_autocomplete_candidates() -> Vec<state::AutocompleteCandidate> {
crate::agent::prompt_injections::definitions()
.iter()
.map(|definition| {
state::AutocompleteCandidate::context_injection(definition.name, definition.description)
})
.collect()
}
pub(crate) fn tui_skill_autocomplete_candidates(
skills: &SkillDiscovery,
) -> Vec<state::AutocompleteCandidate> {
skills
.skills
.iter()
.map(|(name, skill)| {
state::AutocompleteCandidate::skill_tag_with_argument_hint(
name.clone(),
skill.frontmatter.get("argument-hint").map(String::as_str),
)
})
.collect()
}
pub(crate) fn refresh_skill_autocomplete_candidates(
candidates: &mut Vec<state::AutocompleteCandidate>,
skills: &SkillDiscovery,
) {
candidates.retain(|candidate| candidate.kind != state::AutocompleteKind::SkillTag);
candidates.extend(tui_skill_autocomplete_candidates(skills));
}
const MAX_FILE_AUTOCOMPLETE_CANDIDATES: usize = 2000;
const MAX_FILE_AUTOCOMPLETE_VISITED_ENTRIES: usize = 10_000;
pub(crate) fn tui_file_autocomplete_candidates(
cwd: &Path,
respect_gitignore: bool,
) -> Vec<state::AutocompleteCandidate> {
tui_file_autocomplete_candidates_with_limits(
cwd,
respect_gitignore,
MAX_FILE_AUTOCOMPLETE_CANDIDATES,
MAX_FILE_AUTOCOMPLETE_VISITED_ENTRIES,
)
}
fn tui_file_autocomplete_candidates_with_limits(
cwd: &Path,
respect_gitignore: bool,
max_candidates: usize,
max_visited_entries: usize,
) -> Vec<state::AutocompleteCandidate> {
if max_candidates == 0 || max_visited_entries == 0 {
return Vec::new();
}
let Ok(root) = cwd.canonicalize() else {
return Vec::new();
};
let mut paths = crate::tools::autocomplete_file_paths(
&root,
respect_gitignore,
max_candidates,
max_visited_entries,
)
.unwrap_or_default()
.iter()
.filter_map(|path| path.strip_prefix(&root).ok())
.map(slash_normalized_relative_path)
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
paths
.into_iter()
.map(state::AutocompleteCandidate::file_tag)
.collect()
}
fn slash_normalized_relative_path(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, path::PathBuf};
#[test]
fn skill_autocomplete_candidates_use_enabled_skill_discovery_and_refresh_in_place() {
let mut enabled = SkillDiscovery::default();
enabled.skills.insert(
"rust-dev".to_string(),
crate::skills::DiscoveredSkill {
name: "rust-dev".to_string(),
path: PathBuf::from("/tmp/rust-dev/SKILL.md"),
frontmatter: Default::default(),
},
);
let mut candidates = vec![
state::AutocompleteCandidate::slash_command("skills", "manage skills"),
state::AutocompleteCandidate::file_tag("src/lib.rs"),
state::AutocompleteCandidate::skill_tag("disabled-skill"),
];
refresh_skill_autocomplete_candidates(&mut candidates, &enabled);
assert!(candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::SlashCommand && candidate.name == "skills"
}));
assert!(candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::FileTag && candidate.name == "src/lib.rs"
}));
assert!(
!candidates
.iter()
.any(|candidate| candidate.name == "disabled-skill")
);
assert!(candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::SkillTag && candidate.name == "rust-dev"
}));
}
#[test]
fn skill_autocomplete_candidates_use_argument_hint_and_default_for_blank_or_missing() {
let mut discovered = SkillDiscovery::default();
for (name, argument_hint) in [
("hinted", Some(" path to files ")),
("blank", Some(" \n\t ")),
("missing", None),
("disabled", Some("not shown")),
] {
let frontmatter = argument_hint
.map(|hint| ("argument-hint".to_string(), hint.to_string()))
.into_iter()
.collect();
discovered.skills.insert(
name.to_string(),
crate::skills::DiscoveredSkill {
name: name.to_string(),
path: PathBuf::from(format!("/tmp/{name}/SKILL.md")),
frontmatter,
},
);
}
let enabled = crate::skills::filter_enabled_skills(
&discovered,
&std::collections::BTreeSet::from(["disabled".to_string()]),
);
let candidates = tui_skill_autocomplete_candidates(&enabled);
let candidate = |name: &str| candidates.iter().find(|candidate| candidate.name == name);
assert_eq!(candidates.len(), 3);
assert_eq!(candidate("hinted").unwrap().description, "path to files");
assert_eq!(candidate("blank").unwrap().description, "skill");
assert_eq!(candidate("missing").unwrap().description, "skill");
assert!(candidate("disabled").is_none());
}
#[test]
fn tui_autocomplete_includes_supported_commands() {
let registry = CommandRegistry::mvp();
let candidates = tui_autocomplete_candidates(®istry);
let slash_names = candidates
.iter()
.filter(|candidate| candidate.kind == state::AutocompleteKind::SlashCommand)
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
let registry_names = registry
.list()
.into_iter()
.map(|command| command.name.as_str())
.collect::<Vec<_>>();
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert_eq!(slash_names, registry_names);
assert!(slash_names.contains(&"prune-sessions"));
assert_eq!(
names,
vec![
"changes",
"compact",
"export",
"fast",
"help",
"login",
"logout",
"mcp",
"model",
"new",
"prune-sessions",
"quit",
"rewind",
"sessions",
"settings",
"side",
"skills",
"subagents",
"summarize-start",
"summarize-stop",
"system-prompt",
"theme",
"tools",
"update",
"usage",
"tree",
"git-status",
"diff-changes"
]
);
assert!(names.contains(&"changes"));
assert!(names.contains(&"rewind"));
assert!(names.contains(&"model"));
assert!(!names.contains(&"setmodel"));
assert!(names.contains(&"mcp"));
assert!(names.contains(&"login"));
assert!(names.contains(&"sessions"));
assert!(names.contains(&"system-prompt"));
assert!(candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::ContextInjection
&& candidate.name == "git-status"
&& candidate.description == "current Git status"
}));
assert!(candidates.iter().any(|candidate| {
candidate.kind == state::AutocompleteKind::ContextInjection
&& candidate.name == "tree"
&& candidate.description == "current directory tree"
}));
assert!(candidates.iter().all(|candidate| matches!(
candidate.kind,
state::AutocompleteKind::SlashCommand | state::AutocompleteKind::ContextInjection
)));
}
#[test]
fn tui_file_autocomplete_candidates_are_relative_slash_normalized_regular_files() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("src/nested")).unwrap();
fs::write(temp.path().join("README.md"), "readme").unwrap();
fs::write(temp.path().join("src/nested/main.rs"), "fn main() {}").unwrap();
fs::create_dir(temp.path().join("empty-dir")).unwrap();
let candidates = tui_file_autocomplete_candidates(temp.path(), true);
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["README.md", "src/nested/main.rs"]);
assert!(
candidates
.iter()
.all(|candidate| candidate.kind == state::AutocompleteKind::FileTag)
);
}
#[test]
fn tui_file_autocomplete_candidates_stop_at_visited_entry_limit() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("00-first")).unwrap();
fs::create_dir_all(temp.path().join("01-second")).unwrap();
fs::create_dir_all(temp.path().join("zz-late")).unwrap();
fs::write(temp.path().join("zz-late/target.rs"), "late").unwrap();
let candidates = tui_file_autocomplete_candidates_with_limits(temp.path(), true, 10, 3);
assert!(
candidates.is_empty(),
"late file should not be reached after the root plus first two sorted entries: {candidates:?}"
);
}
#[test]
fn tui_file_autocomplete_candidates_do_not_follow_symlinked_files() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("real.txt"), "real").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(temp.path().join("real.txt"), temp.path().join("link.txt"))
.unwrap();
let candidates = tui_file_autocomplete_candidates(temp.path(), true);
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"real.txt"));
#[cfg(unix)]
assert!(!names.contains(&"link.txt"));
}
#[test]
fn tui_file_autocomplete_candidates_respect_gitignore_by_default_policy() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("target")).unwrap();
fs::write(
temp.path().join(".gitignore"),
"target/\n*.log\n!important.log\n",
)
.unwrap();
fs::write(temp.path().join("target/generated.rs"), "generated").unwrap();
fs::write(temp.path().join("debug.log"), "debug").unwrap();
fs::write(temp.path().join("important.log"), "important").unwrap();
fs::write(temp.path().join("keep.rs"), "keep").unwrap();
let candidates = tui_file_autocomplete_candidates(temp.path(), true);
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&".gitignore"));
assert!(names.contains(&"keep.rs"));
assert!(names.contains(&"important.log"));
assert!(!names.contains(&"debug.log"));
assert!(!names.contains(&"target/generated.rs"));
}
#[test]
fn tui_file_autocomplete_candidates_can_include_gitignored_files() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("target")).unwrap();
fs::write(temp.path().join(".gitignore"), "target/\n*.log\n").unwrap();
fs::write(temp.path().join("target/generated.rs"), "generated").unwrap();
fs::write(temp.path().join("debug.log"), "debug").unwrap();
let candidates = tui_file_autocomplete_candidates(temp.path(), false);
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"debug.log"));
assert!(names.contains(&"target/generated.rs"));
}
#[test]
fn tui_file_autocomplete_excludes_git_internals_under_both_ignore_policies() {
let temp = tempfile::TempDir::new().unwrap();
for directory in [".git/objects", "nested/.git/objects", "src"] {
fs::create_dir_all(temp.path().join(directory)).unwrap();
}
for file in [
".git/objects/data",
"nested/.git/objects/data",
"src/main.rs",
] {
fs::write(temp.path().join(file), "contents").unwrap();
}
for respect_ignores in [true, false] {
let candidates = tui_file_autocomplete_candidates(temp.path(), respect_ignores);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].name, "src/main.rs");
}
fs::remove_dir_all(temp.path().join(".git")).unwrap();
fs::write(temp.path().join(".git"), "gitdir: elsewhere").unwrap();
assert!(
tui_file_autocomplete_candidates(temp.path(), false)
.iter()
.all(|candidate| candidate.name != ".git")
);
}
#[test]
fn tui_file_autocomplete_prioritizes_ordinary_trees_before_both_limits() {
let temp = tempfile::TempDir::new().unwrap();
for directory in [".cache", "app/.cache", "src"] {
fs::create_dir_all(temp.path().join(directory)).unwrap();
}
for index in 0..20 {
fs::write(temp.path().join(format!(".cache/{index}.rs")), "cache").unwrap();
fs::write(temp.path().join(format!("app/.cache/{index}.rs")), "cache").unwrap();
}
fs::write(temp.path().join("src/main.rs"), "source").unwrap();
for respect_ignores in [true, false] {
for (max_candidates, max_visited) in [(1, 100), (100, 4)] {
let candidates = tui_file_autocomplete_candidates_with_limits(
temp.path(),
respect_ignores,
max_candidates,
max_visited,
);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].name, "src/main.rs");
}
}
}
#[test]
fn tui_file_autocomplete_uses_ignore_and_git_exclude_rules() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".git/info")).unwrap();
fs::write(temp.path().join(".ignore"), "ignored.rs\n").unwrap();
fs::write(temp.path().join(".git/info/exclude"), "excluded.rs\n").unwrap();
for file in ["ignored.rs", "excluded.rs", ".env", "main.rs"] {
fs::write(temp.path().join(file), "contents").unwrap();
}
let filtered = tui_file_autocomplete_candidates(temp.path(), true);
let names = filtered
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&".env"));
assert!(names.contains(&"main.rs"));
assert!(!names.contains(&"ignored.rs"));
assert!(!names.contains(&"excluded.rs"));
let unfiltered = tui_file_autocomplete_candidates(temp.path(), false);
assert!(
unfiltered
.iter()
.any(|candidate| candidate.name == "ignored.rs")
);
assert!(
unfiltered
.iter()
.any(|candidate| candidate.name == "excluded.rs")
);
}
#[test]
fn tui_file_autocomplete_candidates_keep_dotfiles_when_not_ignored() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".github/workflows")).unwrap();
fs::write(temp.path().join(".github/workflows/ci.yml"), "ci").unwrap();
let candidates = tui_file_autocomplete_candidates(temp.path(), true);
let names = candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&".github/workflows/ci.yml"));
}
}