use std::path::{Path, PathBuf};
pub const SLASH_COMMANDS: &[(&str, &str, Option<&str>)] = &[
("/agent", "Switch to a sub-agent", Some("<name>")),
(
"/compact",
"Summarize conversation to reclaim context",
None,
),
(
"/copy",
"Copy last response to clipboard (/copy 2 for 2nd-last)",
Some("[n]"),
),
("/diff", "Show git diff (review, commit)", None),
("/exit", "Quit the session", None),
("/expand", "Show full output of last tool call", None),
(
"/export",
"Export full session transcript to file",
Some("[file.md]"),
),
("/help", "Show commands and shortcuts", None),
("/key", "Manage API keys", None),
("/memory", "View/save project & global memory", None),
("/model", "Pick a model (aliases + local)", None),
("/provider", "Browse all models from a provider", None),
(
"/purge",
"Delete archived history (e.g. /purge 90d)",
Some("<days>"),
),
("/sessions", "List/resume/delete sessions", None),
("/skills", "List available skills (search with query)", None),
("/undo", "Undo last turn's file changes", None),
("/verbose", "Toggle full tool output", None),
];
pub struct InputCompleter {
matches: Vec<String>,
idx: usize,
token: String,
project_root: PathBuf,
model_names: Vec<String>,
}
impl InputCompleter {
pub fn new(project_root: PathBuf) -> Self {
Self {
matches: Vec::new(),
idx: 0,
token: String::new(),
project_root,
model_names: Vec::new(),
}
}
pub fn set_model_names(&mut self, names: Vec<String>) {
self.model_names = names;
}
pub fn complete(&mut self, current_text: &str) -> Option<String> {
let trimmed = current_text.trim_end();
if trimmed.starts_with('/') {
if let Some(partial) = trimmed.strip_prefix("/model ") {
return self.complete_model(partial);
}
return self.complete_slash(trimmed);
}
if let Some(at_pos) = find_last_at_token(trimmed) {
let partial = &trimmed[at_pos + 1..]; let prefix = &trimmed[..at_pos]; return self.complete_file(prefix, partial);
}
self.reset();
None
}
pub fn reset(&mut self) {
self.matches.clear();
self.idx = 0;
self.token.clear();
}
fn complete_slash(&mut self, trimmed: &str) -> Option<String> {
if trimmed != self.token && !self.matches.iter().any(|m| m == trimmed) {
self.token = trimmed.to_string();
self.matches = SLASH_COMMANDS
.iter()
.filter(|(cmd, _, _)| cmd.starts_with(trimmed) && *cmd != trimmed)
.map(|(cmd, _, _)| cmd.to_string())
.collect();
self.idx = 0;
}
if self.matches.is_empty() {
return None;
}
let result = self.matches[self.idx].clone();
self.idx = (self.idx + 1) % self.matches.len();
Some(result)
}
fn complete_model(&mut self, partial: &str) -> Option<String> {
let token_key = format!("/model {partial}");
if token_key != self.token {
self.token = token_key;
let alias_names = koda_core::model_alias::alias_names();
self.matches = alias_names
.iter()
.map(|s| s.to_string())
.chain(self.model_names.iter().cloned())
.filter(|name| name.contains(partial) && name.as_str() != partial)
.map(|name| format!("/model {name}"))
.collect();
self.idx = 0;
}
if self.matches.is_empty() {
return None;
}
let result = self.matches[self.idx].clone();
self.idx = (self.idx + 1) % self.matches.len();
Some(result)
}
fn complete_file(&mut self, prefix: &str, partial: &str) -> Option<String> {
let is_cycling = !self.matches.is_empty() && self.matches.iter().any(|m| m == partial);
if !is_cycling {
self.token = format!("@{partial}");
self.matches = list_path_matches(&self.project_root, partial);
self.idx = 0;
}
if self.matches.is_empty() {
return None;
}
let path = &self.matches[self.idx];
self.idx = (self.idx + 1) % self.matches.len();
Some(format!("{prefix}@{path}"))
}
}
pub fn find_last_at_token(text: &str) -> Option<usize> {
for (i, c) in text.char_indices().rev() {
if c == '@' && (i == 0 || matches!(text.as_bytes()[i - 1], b' ' | b'\n')) {
return Some(i);
}
}
None
}
pub fn list_path_matches_public(project_root: &Path, partial: &str) -> Vec<String> {
list_path_matches(project_root, partial)
}
fn list_path_matches(project_root: &Path, partial: &str) -> Vec<String> {
let (dir_part, file_prefix) = match partial.rfind('/') {
Some(pos) => (&partial[..=pos], &partial[pos + 1..]),
None => ("", partial),
};
let search_dir = if dir_part.is_empty() {
project_root.to_path_buf()
} else {
if dir_part.contains("..") {
return Vec::new();
}
project_root.join(dir_part)
};
let entries = match std::fs::read_dir(&search_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let lower_prefix = file_prefix.to_lowercase();
let mut scored: Vec<(i32, String)> = entries
.filter_map(|e| e.ok())
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
return None;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir
&& matches!(
name.as_str(),
"target" | "node_modules" | "__pycache__" | ".git"
)
{
return None;
}
let score = fuzzy_score(&lower_prefix, &name)?;
let path = if is_dir {
format!("{dir_part}{name}/")
} else {
format!("{dir_part}{name}")
};
Some((score, path))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
scored.into_iter().map(|(_, path)| path).collect()
}
fn fuzzy_score(query: &str, target: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
}
let query_chars: Vec<char> = query.chars().collect();
let target_chars: Vec<char> = target.chars().collect();
let mut qi = 0;
let mut score: i32 = 0;
let mut prev_match_pos: Option<usize> = None;
for (ti, &tc) in target_chars.iter().enumerate() {
if qi < query_chars.len() && tc.to_ascii_lowercase() == query_chars[qi] {
score += 1;
if qi == 0 && ti == 0 {
score += 100;
}
if ti > 0 && prev_match_pos == Some(ti - 1) {
score += 10;
}
if ti > 0 && matches!(target_chars[ti - 1], '_' | '-' | '.' | '/') {
score += 5;
}
if ti > 0 && target_chars[ti - 1].is_ascii_lowercase() && tc.is_ascii_uppercase() {
score += 6;
}
if let Some(prev) = prev_match_pos {
let gap = ti - prev - 1;
if gap > 0 {
score -= 3 + gap as i32; }
}
prev_match_pos = Some(ti);
qi += 1;
}
}
if qi == query_chars.len() {
Some(score)
} else {
None }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_complete_slash_d() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let first = c.complete("/d");
assert!(first.is_some());
assert!(first.unwrap().starts_with("/d"));
}
#[test]
fn test_complete_cycles() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let a = c.complete("/d");
let b = c.complete("/d");
assert!(a.is_some());
assert!(b.is_some());
}
#[test]
fn test_no_match() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
assert!(c.complete("/zzz").is_none());
}
#[test]
fn test_non_slash_no_at_returns_none() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
assert!(c.complete("hello").is_none());
}
#[test]
fn test_exact_match_no_complete() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
assert!(c.complete("/exit").is_none());
}
#[test]
fn test_at_file_completes() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
fs::write(tmp.path().join("mod.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("explain @m");
assert!(result.is_some());
let text = result.unwrap();
assert!(text.starts_with("explain @m"), "got: {text}");
assert!(
text.contains("main.rs") || text.contains("mod.rs"),
"got: {text}"
);
}
#[test]
fn test_at_file_in_subdir() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
fs::write(tmp.path().join("src/main.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@src/l");
assert_eq!(result, Some("@src/lib.rs".to_string()));
}
#[test]
fn test_at_file_dir_gets_trailing_slash() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@s");
assert_eq!(result, Some("@src/".to_string()));
}
#[test]
fn test_at_file_cycles() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("alpha.rs"), "").unwrap();
fs::write(tmp.path().join("beta.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let a = c.complete("@").unwrap();
let b = c.complete(&a).unwrap();
assert_ne!(a, b, "should cycle through different files");
let c_result = c.complete(&b).unwrap();
assert_eq!(c_result, a, "should cycle back to first");
assert_eq!(c_result, a, "should cycle back to first");
}
#[test]
fn test_at_file_skips_hidden() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".hidden"), "").unwrap();
fs::write(tmp.path().join("visible.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@");
assert_eq!(result, Some("@visible.rs".to_string()));
}
#[test]
fn test_at_file_case_insensitive() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Makefile"), "").unwrap();
fs::write(tmp.path().join("README.md"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@make");
assert_eq!(result, Some("@Makefile".to_string()));
c.reset();
let result = c.complete("@read");
assert_eq!(result, Some("@README.md".to_string()));
}
#[test]
fn test_at_file_preserves_prefix_text() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("config.toml"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("review this @c");
assert_eq!(result, Some("review this @config.toml".to_string()));
}
#[test]
fn test_model_complete() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
c.set_model_names(vec![
"gpt-4o".into(),
"gpt-4o-mini".into(),
"gpt-3.5-turbo".into(),
]);
let result = c.complete("/model gpt-4");
assert!(result.is_some());
let text = result.unwrap();
assert!(text.starts_with("/model gpt-4"), "got: {text}");
}
#[test]
fn test_model_complete_cycles() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
c.set_model_names(vec!["gpt-4o".into(), "gpt-4o-mini".into()]);
let a = c.complete("/model gpt");
let b = c.complete("/model gpt");
assert!(a.is_some());
assert!(b.is_some());
assert_ne!(a, b, "should cycle through models");
}
#[test]
fn test_model_no_names_returns_none() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
assert!(c.complete("/model gpt").is_none());
}
#[test]
fn test_model_no_match_returns_none() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
assert!(c.complete("/model zzz").is_none());
}
#[test]
fn test_model_substring_match() {
let tmp = tempdir().unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
c.set_model_names(vec!["claude-3-sonnet".into(), "claude-3-opus".into()]);
let result = c.complete("/model opus");
assert!(result.is_some());
let text = result.unwrap();
assert!(text.contains("opus"), "got: {text}");
}
#[test]
fn test_find_last_at_token() {
assert_eq!(find_last_at_token("@file"), Some(0));
assert_eq!(find_last_at_token("explain @file"), Some(8));
assert_eq!(find_last_at_token("email@domain"), None); assert_eq!(find_last_at_token("a @b @c"), Some(5)); assert_eq!(find_last_at_token("no at here"), None);
assert_eq!(find_last_at_token("line1\n@file"), Some(6));
assert_eq!(find_last_at_token("a\nb\n@c"), Some(4));
}
#[test]
fn test_at_file_after_newline() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("config.toml"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("explain this\n@c");
assert_eq!(result, Some("explain this\n@config.toml".to_string()));
}
#[test]
fn test_at_file_traversal_blocked() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("safe.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@../../etc/");
assert!(result.is_none(), "traversal should be blocked");
}
#[test]
fn test_fuzzy_score_basic() {
assert!(fuzzy_score("main", "main.rs").unwrap() > 100);
assert!(fuzzy_score("mrs", "main.rs").is_some());
assert!(fuzzy_score("xyz", "main.rs").is_none());
}
#[test]
fn test_fuzzy_score_prefix_wins() {
let prefix = fuzzy_score("ma", "main.rs").unwrap();
let fuzzy = fuzzy_score("ma", "format.rs").unwrap();
assert!(prefix > fuzzy, "prefix {prefix} should beat fuzzy {fuzzy}");
}
#[test]
fn test_fuzzy_at_file() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("main.rs"), "").unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::write(tmp.path().join("config.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@mrs");
assert_eq!(result, Some("@main.rs".to_string()));
}
#[test]
fn test_fuzzy_cargo_toml() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::write(tmp.path().join("config.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@ctml");
assert_eq!(result, Some("@Cargo.toml".to_string()));
}
#[test]
fn test_fuzzy_prefix_ranked_first() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("main.rs"), "").unwrap();
fs::write(tmp.path().join("format.rs"), "").unwrap();
let mut c = InputCompleter::new(tmp.path().to_path_buf());
let result = c.complete("@m");
assert_eq!(result, Some("@main.rs".to_string()));
}
#[test]
fn test_gap_penalty_tight_beats_scattered() {
let tight = fuzzy_score("mrs", "main.rs").unwrap();
let scattered = fuzzy_score("mrs", "my_really_long_script.rs").unwrap();
assert!(
tight > scattered,
"tight {tight} should beat scattered {scattered}"
);
}
#[test]
fn test_gap_penalty_consecutive_no_penalty() {
let consec = fuzzy_score("mai", "main.rs").unwrap();
let gapped = fuzzy_score("mai", "m_a_i.rs").unwrap();
assert!(
consec > gapped,
"consecutive {consec} should beat gapped {gapped}"
);
}
#[test]
fn test_camel_case_bonus() {
let camel = fuzzy_score("dm", "DropdownMenu").unwrap();
let flat = fuzzy_score("dm", "random_dm_file").unwrap();
assert!(camel > flat, "camelCase {camel} should beat flat {flat}");
}
}