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
.keys()
.map(|name| state::AutocompleteCandidate::skill_tag(name.clone()))
.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 = if respect_gitignore {
gitignore_respecting_file_autocomplete_paths(&root, max_candidates, max_visited_entries)
} else {
unfiltered_file_autocomplete_paths(&root, max_candidates, max_visited_entries)
};
paths.sort();
paths.dedup();
paths
.into_iter()
.map(state::AutocompleteCandidate::file_tag)
.collect()
}
fn unfiltered_file_autocomplete_paths(
root: &Path,
max_candidates: usize,
max_visited_entries: usize,
) -> Vec<String> {
let mut paths = Vec::new();
for entry in walkdir::WalkDir::new(root)
.follow_links(false)
.sort_by_file_name()
.into_iter()
.take(max_visited_entries)
.filter_map(Result::ok)
{
collect_file_autocomplete_path(root, entry.path(), entry.file_type().is_file(), &mut paths);
if paths.len() >= max_candidates {
break;
}
}
paths
}
fn gitignore_respecting_file_autocomplete_paths(
root: &Path,
max_candidates: usize,
max_visited_entries: usize,
) -> Vec<String> {
let mut builder = ignore::WalkBuilder::new(root);
builder
.follow_links(false)
.hidden(false)
.ignore(false)
.git_ignore(true)
.require_git(false)
.git_global(false)
.git_exclude(false)
.sort_by_file_name(|left, right| left.cmp(right));
let mut paths = Vec::new();
for entry in builder
.build()
.take(max_visited_entries)
.filter_map(Result::ok)
{
let is_file = entry
.file_type()
.is_some_and(|file_type| file_type.is_file());
collect_file_autocomplete_path(root, entry.path(), is_file, &mut paths);
if paths.len() >= max_candidates {
break;
}
}
paths
}
fn collect_file_autocomplete_path(
root: &Path,
path: &Path,
is_file: bool,
paths: &mut Vec<String>,
) {
if !is_file || path.is_absolute() != root.is_absolute() {
return;
}
let Ok(relative) = path.strip_prefix(root) else {
return;
};
let normalized = slash_normalized_relative_path(relative);
if !normalized.is_empty() {
paths.push(normalized);
}
}
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::Skill {
name: "rust-dev".to_string(),
path: PathBuf::from("/tmp/rust-dev/SKILL.md"),
frontmatter: Default::default(),
body: String::new(),
},
);
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 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",
"help",
"login",
"logout",
"mcp",
"models",
"new",
"prune-sessions",
"quit",
"rewind",
"sessions",
"setmodel",
"skills",
"subagents",
"system-prompt",
"tools",
"usage",
"tree",
"git-status"
]
);
assert!(names.contains(&"changes"));
assert!(names.contains(&"rewind"));
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_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"));
}
}