use crate::tui::prompt_editor::{self, PromptEditResult, PromptEditor};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const MAX_AUTOCOMPLETE_MATCHES: usize = 50;
const MAX_ARGUMENT_HINT_DISPLAY_WIDTH: usize = 160;
const MAX_ARGUMENT_HINT_BYTES: usize = MAX_ARGUMENT_HINT_DISPLAY_WIDTH * 4;
const ELLIPSIS: &str = "…";
#[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 skill_tag_with_argument_hint(
name: impl Into<String>,
argument_hint: Option<&str>,
) -> Self {
let name = name.into();
let Some(argument_hint) = argument_hint.and_then(sanitize_argument_hint) else {
return Self::skill_tag(name);
};
Self {
name,
description: argument_hint,
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()
}
}
fn is_default_ignorable(character: char) -> bool {
matches!(
character,
'\u{00ad}'
| '\u{034f}'
| '\u{061c}'
| '\u{115f}'..='\u{1160}'
| '\u{17b4}'..='\u{17b5}'
| '\u{180b}'..='\u{180f}'
| '\u{200b}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}'
| '\u{3164}'
| '\u{fe00}'..='\u{fe0f}'
| '\u{feff}'
| '\u{ffa0}'
| '\u{fff9}'..='\u{fffb}'
| '\u{1bca0}'..='\u{1bca3}'
| '\u{1d173}'..='\u{1d17a}'
| '\u{e0001}'
| '\u{e0020}'..='\u{e007f}'
| '\u{e0100}'..='\u{e01ef}'
)
}
fn sanitize_argument_hint(argument_hint: &str) -> Option<String> {
let controls_sanitized = crate::output::sanitize_display_controls(argument_hint);
let scalar_normalized: String = controls_sanitized
.chars()
.filter(|character| !is_default_ignorable(*character))
.collect();
let visible: String = scalar_normalized
.graphemes(true)
.filter(|grapheme| {
let grapheme = *grapheme;
UnicodeWidthStr::width(grapheme) > 0 || grapheme.chars().any(char::is_whitespace)
})
.collect();
let normalized = visible.split_whitespace().collect::<Vec<_>>().join(" ");
let redacted = crate::output::redact_sensitive_text(&normalized);
let mut helper = String::with_capacity(MAX_ARGUMENT_HINT_BYTES.min(redacted.len()));
let mut graphemes = Vec::new();
let mut display_width = 0;
let mut byte_len = 0;
let mut needs_separator = false;
let mut truncated = false;
'words: for word in redacted.split_whitespace() {
if needs_separator
&& !append_argument_hint_grapheme(
&mut helper,
&mut graphemes,
" ",
&mut display_width,
&mut byte_len,
)
{
truncated = true;
break;
}
for grapheme in word.graphemes(true) {
if !append_argument_hint_grapheme(
&mut helper,
&mut graphemes,
grapheme,
&mut display_width,
&mut byte_len,
) {
truncated = true;
break 'words;
}
}
needs_separator = true;
}
if helper.is_empty() {
return None;
}
if truncated {
while (display_width + UnicodeWidthStr::width(ELLIPSIS) > MAX_ARGUMENT_HINT_DISPLAY_WIDTH
|| byte_len + ELLIPSIS.len() > MAX_ARGUMENT_HINT_BYTES)
&& let Some((grapheme, width)) = graphemes.pop()
{
helper.truncate(helper.len() - grapheme.len());
display_width -= width;
byte_len -= grapheme.len();
}
helper.push_str(ELLIPSIS);
}
Some(helper)
}
fn append_argument_hint_grapheme<'a>(
output: &mut String,
graphemes: &mut Vec<(&'a str, usize)>,
grapheme: &'a str,
display_width: &mut usize,
byte_len: &mut usize,
) -> bool {
let width = UnicodeWidthStr::width(grapheme);
if display_width.saturating_add(width) > MAX_ARGUMENT_HINT_DISPLAY_WIDTH
|| byte_len.saturating_add(grapheme.len()) > MAX_ARGUMENT_HINT_BYTES
{
return false;
}
output.push_str(grapheme);
graphemes.push((grapheme, width));
*display_width += width;
*byte_len += grapheme.len();
true
}
#[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: &mut Option<String>,
candidates: &[AutocompleteCandidate],
) {
let Some(active) = active_token_range(input, cursor) else {
clear(autocomplete, dismissed_token);
return;
};
let token = input[active.start..active.end].to_string();
if dismissed_token.as_deref() == Some(token.as_str()) {
*autocomplete = None;
return;
}
*dismissed_token = None;
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 => file_matches(query, candidates),
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 file_matches(query: &str, candidates: &[AutocompleteCandidate]) -> Vec<AutocompleteCandidate> {
let query = query.to_ascii_lowercase();
let mut scored: Vec<_> = candidates
.iter()
.filter(|candidate| candidate.kind == AutocompleteKind::FileTag)
.filter_map(|candidate| {
let path = candidate.name.to_ascii_lowercase();
file_query_score(&query, &path).map(|score| (score, candidate))
})
.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.clone())
.collect()
}
fn file_path_visible(query: &str, path: &str) -> bool {
let mut component_start = 0;
for (directory_end, _) in path.match_indices('/') {
let directory = &path[component_start..directory_end];
if directory == ".git" {
return false;
}
if directory.starts_with('.') {
let Some(target) = query.strip_prefix(&path[..component_start]) else {
return false;
};
let (component, has_child) = target
.split_once('/')
.map_or((target, false), |(component, _)| (component, true));
if component.len() <= 1
|| !component.starts_with('.')
|| !directory.starts_with(component)
|| (has_child && component != directory)
{
return false;
}
}
component_start = directory_end + 1;
}
true
}
fn file_query_score(query: &str, path: &str) -> Option<(u8, usize, usize)> {
if !file_path_visible(query, path) {
return None;
}
if query.is_empty() {
return Some((0, 0, 0));
}
let basename = path.rsplit('/').next().unwrap_or(path);
let path_match = path.find(query);
if query.contains('/') {
if let Some(index) = path_match {
return Some((0, index, path.len()));
}
} else {
if basename == query {
return Some((0, 0, path.len()));
}
if basename.starts_with(query) {
return Some((1, basename.len(), path.len()));
}
if let Some(index) = path_match {
return Some((2, index, path.len()));
}
}
fuzzy_score(query, path).map(|score| (3, score, path.len()))
}
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(
editor: &mut PromptEditor,
autocomplete: &mut Option<AutocompleteState>,
dismissed_token: &mut Option<String>,
visible_rows: u16,
wrap_width: u16,
) -> PromptEditResult {
let Some(autocomplete_state) = autocomplete.take() else {
return PromptEditResult::Unchanged;
};
let Some(candidate) = autocomplete_state
.candidates
.get(autocomplete_state.selected)
else {
return PromptEditResult::Unchanged;
};
let mut replacement = candidate.insertion();
let token_at_end = autocomplete_state.token_end == editor.visible_text().len();
if matches!(
candidate.kind,
AutocompleteKind::FileTag | AutocompleteKind::SkillTag
) && token_at_end
{
replacement.push(' ');
}
let result = editor.replace_visible_byte_range(
autocomplete_state.token_start,
autocomplete_state.token_end,
&replacement,
visible_rows,
wrap_width,
);
match result {
PromptEditResult::RejectedTooLarge | PromptEditResult::Failed => {
*autocomplete = Some(autocomplete_state);
}
PromptEditResult::Changed | PromptEditResult::Unchanged => {
*dismissed_token = None;
}
}
result
}
#[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_editor::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_editor::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,
'"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | ';' | ':'
)
}