use crate::tui::prompt;
const MAX_AUTOCOMPLETE_MATCHES: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutocompleteKind {
SlashCommand,
FileTag,
SkillTag,
ContextInjection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AutocompleteCandidate {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) kind: AutocompleteKind,
}
impl AutocompleteCandidate {
pub(crate) fn slash_command(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
kind: AutocompleteKind::SlashCommand,
}
}
pub(crate) fn file_tag(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: "file".to_string(),
kind: AutocompleteKind::FileTag,
}
}
pub(crate) fn skill_tag(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: "skill".to_string(),
kind: AutocompleteKind::SkillTag,
}
}
pub(crate) fn context_injection(
name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
kind: AutocompleteKind::ContextInjection,
}
}
pub(crate) fn insertion(&self) -> String {
match self.kind {
AutocompleteKind::SlashCommand => format!("/{}", self.name),
AutocompleteKind::FileTag => format!("@{}", self.name),
AutocompleteKind::SkillTag => format!("${}", self.name),
AutocompleteKind::ContextInjection => format!("#{}", self.name),
}
}
pub(crate) fn display(&self) -> String {
self.insertion()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AutocompleteState {
pub(crate) candidates: Vec<AutocompleteCandidate>,
pub(crate) selected: usize,
pub(crate) token_start: usize,
pub(crate) token_end: usize,
pub(crate) token: String,
}
pub(crate) fn visible(autocomplete: &Option<AutocompleteState>) -> bool {
autocomplete
.as_ref()
.is_some_and(|autocomplete| !autocomplete.candidates.is_empty())
}
pub(crate) fn clear(
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
) {
*autocomplete = None;
*dismissed_token = None;
}
pub(crate) fn hide(autocomplete: &mut Option<AutocompleteState>) {
*autocomplete = None;
}
pub(crate) fn recompute(
input: &str,
cursor: usize,
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &Option<String>,
candidates: &[AutocompleteCandidate],
) {
let Some(active) = active_token_range(input, cursor) else {
*autocomplete = None;
return;
};
let token = input[active.start..active.end].to_string();
if dismissed_token.as_deref() == Some(token.as_str()) {
*autocomplete = None;
return;
}
let query = &token[1..];
let previous_name = autocomplete
.as_ref()
.and_then(|autocomplete| autocomplete.candidates.get(autocomplete.selected))
.map(|candidate| (candidate.kind, candidate.name.as_str()));
let matches = match active.kind {
AutocompleteKind::SlashCommand => slash_matches(query, candidates),
AutocompleteKind::FileTag => fuzzy_matches(query, candidates, AutocompleteKind::FileTag),
AutocompleteKind::SkillTag => fuzzy_matches(query, candidates, AutocompleteKind::SkillTag),
AutocompleteKind::ContextInjection => context_injection_matches(query, candidates),
};
if matches.is_empty() {
*autocomplete = None;
return;
}
let selected = previous_name
.and_then(|(kind, name)| {
matches
.iter()
.position(|candidate| candidate.kind == kind && candidate.name == name)
})
.unwrap_or(0)
.min(matches.len().saturating_sub(1));
*autocomplete = Some(AutocompleteState {
candidates: matches,
selected,
token_start: active.start,
token_end: active.end,
token,
});
}
fn slash_matches(query: &str, candidates: &[AutocompleteCandidate]) -> Vec<AutocompleteCandidate> {
candidates
.iter()
.filter(|candidate| {
candidate.kind == AutocompleteKind::SlashCommand && candidate.name.starts_with(query)
})
.take(MAX_AUTOCOMPLETE_MATCHES)
.cloned()
.collect()
}
fn context_injection_matches(
query: &str,
candidates: &[AutocompleteCandidate],
) -> Vec<AutocompleteCandidate> {
candidates
.iter()
.filter(|candidate| {
candidate.kind == AutocompleteKind::ContextInjection
&& candidate.name.starts_with(query)
})
.take(MAX_AUTOCOMPLETE_MATCHES)
.cloned()
.collect()
}
fn fuzzy_matches(
query: &str,
candidates: &[AutocompleteCandidate],
kind: AutocompleteKind,
) -> Vec<AutocompleteCandidate> {
let query = query.to_ascii_lowercase();
let mut scored: Vec<_> = candidates
.iter()
.filter(|candidate| candidate.kind == kind)
.filter_map(|candidate| {
fuzzy_score(&query, &candidate.name).map(|score| (score, candidate.clone()))
})
.collect();
scored.sort_by(|(left_score, left), (right_score, right)| {
left_score
.cmp(right_score)
.then_with(|| left.name.cmp(&right.name))
});
scored
.into_iter()
.take(MAX_AUTOCOMPLETE_MATCHES)
.map(|(_, candidate)| candidate)
.collect()
}
fn fuzzy_score(query: &str, candidate: &str) -> Option<usize> {
if query.is_empty() {
return Some(0);
}
let candidate = candidate.to_ascii_lowercase();
if candidate.starts_with(query) {
return Some(candidate.len().saturating_sub(query.len()));
}
if let Some(index) = candidate.rfind('/') {
let basename = &candidate[index + 1..];
if basename.starts_with(query) {
return Some(100 + basename.len().saturating_sub(query.len()));
}
}
if let Some(index) = candidate.find(query) {
return Some(200 + index + candidate.len().saturating_sub(query.len()));
}
let mut score = 500usize;
let mut last_match: Option<usize> = None;
let mut search_from = 0usize;
for query_ch in query.chars() {
let haystack = &candidate[search_from..];
let (offset, _) = haystack
.char_indices()
.find(|(_, candidate_ch)| *candidate_ch == query_ch)?;
let index = search_from + offset;
score = score.saturating_add(index.saturating_sub(last_match.unwrap_or(index)));
last_match = Some(index);
search_from = index + query_ch.len_utf8();
}
Some(score + candidate.len())
}
pub(crate) fn dismiss_for_current_token(
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
) {
if let Some(autocomplete) = autocomplete.take() {
*dismissed_token = Some(autocomplete.token);
}
}
pub(crate) fn move_selection_up(autocomplete: &mut Option<AutocompleteState>) {
if let Some(autocomplete) = autocomplete {
autocomplete.selected = autocomplete.selected.saturating_sub(1);
}
}
pub(crate) fn move_selection_down(autocomplete: &mut Option<AutocompleteState>) {
if let Some(autocomplete) = autocomplete {
if autocomplete.candidates.is_empty() {
autocomplete.selected = 0;
} else {
let max = autocomplete.candidates.len().saturating_sub(1);
autocomplete.selected = (autocomplete.selected + 1).min(max);
}
}
}
pub(crate) fn accept(
edit: &mut prompt::PromptEdit<'_>,
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let Some(autocomplete_state) = autocomplete.take() else {
return false;
};
let Some(candidate) = autocomplete_state
.candidates
.get(autocomplete_state.selected)
else {
return false;
};
let mut replacement = candidate.insertion();
let token_at_end = autocomplete_state.token_end == edit.input.len();
if matches!(
candidate.kind,
AutocompleteKind::FileTag | AutocompleteKind::SkillTag
) && token_at_end
{
replacement.push(' ');
}
edit.input.replace_range(
autocomplete_state.token_start..autocomplete_state.token_end,
&replacement,
);
*edit.cursor = autocomplete_state.token_start + replacement.len();
*edit.target_column = None;
*edit.cursor_visible = true;
*dismissed_token = None;
prompt::ensure_cursor_visible(
edit.input,
*edit.cursor,
edit.scroll,
visible_rows,
wrap_width,
);
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ActiveToken {
start: usize,
end: usize,
kind: AutocompleteKind,
}
fn active_token_range(input: &str, cursor: usize) -> Option<ActiveToken> {
if let Some((start, end)) = active_slash_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::SlashCommand,
});
}
if let Some((start, end)) = active_file_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::FileTag,
});
}
if let Some((start, end)) = active_skill_token_range(input, cursor) {
return Some(ActiveToken {
start,
end,
kind: AutocompleteKind::SkillTag,
});
}
active_hash_token_range(input, cursor).map(|(start, end)| ActiveToken {
start,
end,
kind: AutocompleteKind::ContextInjection,
})
}
fn active_slash_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
if !input.starts_with('/') {
return None;
}
let cursor = prompt::clamp_char_boundary(input, cursor);
let token_end = input
.char_indices()
.find_map(|(index, ch)| ch.is_whitespace().then_some(index))
.unwrap_or(input.len());
if cursor < token_end || (token_end == input.len() && cursor == token_end) {
Some((0, token_end))
} else {
None
}
}
fn active_file_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '@')
}
fn active_skill_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '$')
}
fn active_hash_token_range(input: &str, cursor: usize) -> Option<(usize, usize)> {
active_anywhere_token_range(input, cursor, '#')
}
fn active_anywhere_token_range(input: &str, cursor: usize, marker: char) -> Option<(usize, usize)> {
let cursor = prompt::clamp_char_boundary(input, cursor);
let start = input[..cursor]
.char_indices()
.rev()
.take_while(|(_, ch)| !is_tag_token_terminator(*ch))
.find_map(|(index, ch)| (ch == marker).then_some(index))?;
let end = input[start..]
.char_indices()
.skip(1)
.find_map(|(offset, ch)| is_tag_token_terminator(ch).then_some(start + offset))
.unwrap_or(input.len());
if cursor <= end {
Some((start, end))
} else {
None
}
}
fn is_tag_token_terminator(ch: char) -> bool {
ch.is_whitespace()
|| matches!(
ch,
'"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';' | ':'
)
}
#[cfg(test)]
mod tests {
use super::*;
fn names(candidates: &[AutocompleteCandidate]) -> Vec<&str> {
candidates
.iter()
.map(|candidate| candidate.name.as_str())
.collect()
}
fn skill_candidates() -> Vec<AutocompleteCandidate> {
vec![
AutocompleteCandidate::skill_tag("ratatui-rust-development"),
AutocompleteCandidate::skill_tag("rust-dev"),
AutocompleteCandidate::skill_tag("simplify-isomorphically-typescript"),
AutocompleteCandidate::context_injection("tree", "current directory tree"),
AutocompleteCandidate::context_injection("git-status", "current Git status"),
AutocompleteCandidate::file_tag("src/rust-dev.md"),
AutocompleteCandidate::slash_command("skills", "manage skills"),
]
}
#[test]
fn insertion_and_display_include_kind_prefixes() {
let slash = AutocompleteCandidate::slash_command("new", "session");
let file = AutocompleteCandidate::file_tag("src/lib.rs");
let skill = AutocompleteCandidate::skill_tag("rust-dev");
let context = AutocompleteCandidate::context_injection("tree", "current directory tree");
assert_eq!(slash.insertion(), "/new");
assert_eq!(file.insertion(), "@src/lib.rs");
assert_eq!(skill.insertion(), "$rust-dev");
assert_eq!(context.insertion(), "#tree");
assert_eq!(context.display(), "#tree");
assert_eq!(skill.display(), "$rust-dev");
assert_eq!(skill.description, "skill");
}
#[test]
fn skill_tags_trigger_anywhere_and_replace_at_end_with_space() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"please use $rust",
"please use $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
let state = autocomplete.as_ref().unwrap();
assert_eq!(state.token, "$rust");
assert_eq!(state.token_start, "please use ".len());
assert_eq!(state.candidates[0].kind, AutocompleteKind::SkillTag);
assert_eq!(state.candidates[0].name, "rust-dev");
let mut input = "please use $rust".to_string();
let mut cursor = input.len();
let mut target_column = None;
let mut scroll = 0;
let mut cursor_visible = true;
let mut dismissed = None;
let mut edit = prompt::PromptEdit {
input: &mut input,
cursor: &mut cursor,
target_column: &mut target_column,
scroll: &mut scroll,
cursor_visible: &mut cursor_visible,
};
assert!(accept(&mut edit, &mut autocomplete, &mut dismissed, 1, 80));
assert_eq!(input, "please use $rust-dev ");
assert_eq!(cursor, input.len());
}
#[test]
fn context_injection_tags_trigger_anywhere_and_replace_without_submitting() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"please include #git",
"please include #git".len(),
&mut autocomplete,
&None,
&candidates,
);
let state = autocomplete.as_ref().unwrap();
assert_eq!(state.token, "#git");
assert_eq!(state.candidates.len(), 1);
assert_eq!(state.candidates[0].kind, AutocompleteKind::ContextInjection);
assert_eq!(state.candidates[0].name, "git-status");
let mut input = "please include #git now".to_string();
let mut cursor = "please include #git".len();
let mut target_column = None;
let mut scroll = 0;
let mut cursor_visible = true;
let mut dismissed = None;
recompute(&input, cursor, &mut autocomplete, &None, &candidates);
let mut edit = prompt::PromptEdit {
input: &mut input,
cursor: &mut cursor,
target_column: &mut target_column,
scroll: &mut scroll,
cursor_visible: &mut cursor_visible,
};
assert!(accept(&mut edit, &mut autocomplete, &mut dismissed, 1, 80));
assert_eq!(input, "please include #git-status now");
assert_eq!(cursor, "please include #git-status".len());
}
#[test]
fn context_injection_hash_without_matches_hides_suggestions() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"ask #unknown",
"ask #unknown".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_none());
}
#[test]
fn skill_tag_accept_preserves_following_text() {
let candidates = skill_candidates();
let mut autocomplete = None;
let input = "use $rust, then continue";
recompute(
input,
"use $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_some());
let mut input = input.to_string();
let mut cursor = "use $rust".len();
let mut target_column = None;
let mut scroll = 0;
let mut cursor_visible = true;
let mut dismissed = None;
let mut edit = prompt::PromptEdit {
input: &mut input,
cursor: &mut cursor,
target_column: &mut target_column,
scroll: &mut scroll,
cursor_visible: &mut cursor_visible,
};
assert!(accept(&mut edit, &mut autocomplete, &mut dismissed, 1, 80));
assert_eq!(input, "use $rust-dev, then continue");
assert_eq!(cursor, "use $rust-dev".len());
}
#[test]
fn slash_file_and_skill_tokens_do_not_cross_trigger() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute("/sk", 3, &mut autocomplete, &None, &candidates);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::SlashCommand
);
recompute(
"open @rust",
"open @rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::FileTag
);
recompute(
"open $rust",
"open $rust".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::SkillTag
);
recompute(
"include #git",
"include #git".len(),
&mut autocomplete,
&None,
&candidates,
);
assert_eq!(
autocomplete.as_ref().unwrap().candidates[0].kind,
AutocompleteKind::ContextInjection
);
}
#[test]
fn cursor_outside_skill_token_hides_suggestions() {
let candidates = skill_candidates();
let mut autocomplete = None;
recompute(
"$rust done",
"$rust done".len(),
&mut autocomplete,
&None,
&candidates,
);
assert!(autocomplete.is_none());
}
#[test]
fn skill_fuzzy_ranking_is_deterministic_and_bounded() {
let candidates = (0..60)
.map(|index| AutocompleteCandidate::skill_tag(format!("skill-{index:02}")))
.chain([
AutocompleteCandidate::skill_tag("rust-dev"),
AutocompleteCandidate::skill_tag("ratatui-rust-development"),
AutocompleteCandidate::skill_tag("development-rust"),
])
.collect::<Vec<_>>();
let mut autocomplete = None;
recompute("$rust", 5, &mut autocomplete, &None, &candidates);
assert_eq!(
names(&autocomplete.as_ref().unwrap().candidates[..3]),
vec!["rust-dev", "development-rust", "ratatui-rust-development"]
);
recompute("$", 1, &mut autocomplete, &None, &candidates);
let matches = &autocomplete.as_ref().unwrap().candidates;
assert_eq!(matches.len(), MAX_AUTOCOMPLETE_MATCHES);
assert_eq!(matches[0].name, "development-rust");
assert_eq!(matches[1].name, "ratatui-rust-development");
}
}