use std::collections::HashMap;
use std::time::{Duration, Instant};
use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
use nucleo::{Config, Matcher, Utf32Str};
const MRU_HALF_LIFE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const MRU_BOOST: f64 = 1.0;
fn decay_for(elapsed: Duration) -> f64 {
if elapsed >= MRU_HALF_LIFE * 10 {
return 0.0;
}
0.5_f64.powf(elapsed.as_secs_f64() / MRU_HALF_LIFE.as_secs_f64())
}
#[derive(Debug, Clone)]
pub struct SlashCommand {
pub name: String,
pub description: String,
pub long_help: String,
pub category: SlashCategory,
pub takes_args: bool,
pub args_required: bool,
}
impl SlashCommand {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
long_help: String::new(),
category: SlashCategory::General,
takes_args: false,
args_required: false,
}
}
pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
self.long_help = help.into();
self
}
pub fn with_category(mut self, cat: SlashCategory) -> Self {
self.category = cat;
self
}
pub fn with_args(mut self, takes: bool, required: bool) -> Self {
self.takes_args = takes;
self.args_required = required;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlashCategory {
General,
Model,
Session,
Tools,
Theme,
System,
}
#[derive(Debug, Clone, Default)]
struct MruEntry {
last_used: Option<Instant>,
boost: f64,
}
#[derive(Debug, Clone)]
pub struct RankedCommand {
pub command: SlashCommand,
pub score: u32,
pub match_indices: Vec<u32>,
}
#[derive(Debug)]
pub struct SlashDropdown {
commands: Vec<SlashCommand>,
mru: HashMap<String, MruEntry>,
last_query: String,
matcher: Matcher,
}
impl SlashDropdown {
pub fn new(commands: Vec<SlashCommand>) -> Self {
let matcher = Matcher::new(Config::DEFAULT);
let mut mru = HashMap::with_capacity(commands.len());
for cmd in &commands {
mru.insert(cmd.name.clone(), MruEntry::default());
}
Self {
commands,
mru,
last_query: String::new(),
matcher,
}
}
pub fn matches(&mut self, input: &str) -> Option<Vec<RankedCommand>> {
let query = extract_query(input)?;
self.last_query = query.clone();
let pattern = Pattern::new(
&query,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
);
let mut scored: Vec<RankedCommand> = Vec::with_capacity(self.commands.len());
for cmd in &self.commands {
let haystack = Utf32Str::Ascii(cmd.name.as_bytes());
let mut indices = Vec::new();
let score = pattern.indices(haystack, &mut self.matcher, &mut indices);
if score.is_none() {
continue;
}
let mut s = score.unwrap_or(0);
if let Some(entry) = self.mru.get(&cmd.name)
&& let Some(last) = entry.last_used
{
let boost = (entry.boost * decay_for(last.elapsed()) * 100.0) as u32;
s = s.saturating_add(boost);
}
scored.push(RankedCommand {
command: cmd.clone(),
score: s,
match_indices: indices,
});
}
scored.sort_by_key(|cmd| std::cmp::Reverse(cmd.score));
Some(scored)
}
pub fn record_use(&mut self, name: &str) {
let entry = self.mru.entry(name.to_string()).or_default();
entry.last_used = Some(Instant::now());
entry.boost += MRU_BOOST;
}
pub fn ghost_complete(&self, query: &str) -> String {
if query.is_empty() {
return String::new();
}
let matches: Vec<&SlashCommand> = self
.commands
.iter()
.filter(|c| c.name.starts_with(query) && c.name.len() > query.len())
.collect();
if matches.len() != 1 {
return String::new(); }
let candidate = matches[0].name.as_str();
for (i, c) in candidate.char_indices() {
if i >= query.len() {
return candidate[i..].to_string();
}
if query.as_bytes()[i] != c as u8 {
return candidate[i..].to_string();
}
}
String::new()
}
pub fn len(&self) -> usize {
self.commands.len()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
pub fn commands(&self) -> &[SlashCommand] {
&self.commands
}
}
fn extract_query(input: &str) -> Option<String> {
let bytes = input.as_bytes();
let mut last_slash: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
if b == b'/' {
let preceded_by_space = i == 0 || bytes[i - 1] == b' ';
if preceded_by_space {
last_slash = Some(i);
}
}
}
let start = last_slash? + 1;
let rest = &input[start..];
if rest.contains(' ') {
return None;
}
Some(rest.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_commands() -> Vec<SlashCommand> {
vec![
SlashCommand::new("commit", "Commit changes").with_args(true, true),
SlashCommand::new("compress", "Compress conversation").with_args(false, false),
SlashCommand::new("help", "Show help").with_category(SlashCategory::General),
SlashCommand::new("model", "Switch model").with_category(SlashCategory::Model),
SlashCommand::new("theme", "Switch theme").with_category(SlashCategory::Theme),
]
}
#[test]
fn extract_query_at_start() {
assert_eq!(extract_query("/comm"), Some("comm".into()));
}
#[test]
fn extract_query_after_space() {
assert_eq!(extract_query("hello /help"), Some("help".into()));
}
#[test]
fn extract_query_no_slash() {
assert_eq!(extract_query("hello world"), None);
}
#[test]
fn extract_query_with_args_returns_none() {
assert_eq!(extract_query("/commit foo bar"), None);
}
#[test]
fn extract_query_mid_text_slash() {
assert_eq!(extract_query("path/to/file"), None);
}
#[test]
fn matches_returns_ranked_results() {
let mut dd = SlashDropdown::new(test_commands());
let results = dd.matches("/co").expect("should match");
assert!(!results.is_empty());
let names: Vec<&str> = results.iter().map(|r| r.command.name.as_str()).collect();
assert!(names.contains(&"commit"));
assert!(names.contains(&"compress"));
}
#[test]
fn matches_empty_query_returns_all() {
let mut dd = SlashDropdown::new(test_commands());
let results = dd.matches("/").expect("should match");
assert_eq!(results.len(), 5);
}
#[test]
fn matches_no_match_returns_empty() {
let mut dd = SlashDropdown::new(test_commands());
let results = dd.matches("/zzzzz").expect("should match");
assert!(results.is_empty());
}
#[test]
fn record_use_boosts_ranking() {
let mut dd = SlashDropdown::new(test_commands());
dd.record_use("compress"); let results = dd.matches("/c").expect("should match");
assert!(!results.is_empty());
assert_eq!(results[0].command.name, "compress");
}
#[test]
fn ghost_complete_unique_match() {
let dd = SlashDropdown::new(test_commands());
assert_eq!(dd.ghost_complete("comm"), "it"); assert_eq!(dd.ghost_complete("compr"), "ess"); }
#[test]
fn ghost_complete_ambiguous_returns_empty() {
let dd = SlashDropdown::new(test_commands());
assert_eq!(dd.ghost_complete("com"), "");
}
#[test]
fn ghost_complete_no_match_returns_empty() {
let dd = SlashDropdown::new(test_commands());
assert_eq!(dd.ghost_complete("xyz"), "");
}
#[test]
fn ghost_complete_full_match_returns_empty() {
let dd = SlashDropdown::new(test_commands());
assert_eq!(dd.ghost_complete("help"), "");
}
#[test]
fn len_and_commands_work() {
let dd = SlashDropdown::new(test_commands());
assert_eq!(dd.len(), 5);
assert!(!dd.is_empty());
assert_eq!(dd.commands().len(), 5);
}
#[test]
fn slash_command_builder() {
let cmd = SlashCommand::new("test", "desc")
.with_long_help("long help")
.with_category(SlashCategory::Tools)
.with_args(true, false);
assert_eq!(cmd.name, "test");
assert_eq!(cmd.long_help, "long help");
assert_eq!(cmd.category, SlashCategory::Tools);
assert!(cmd.takes_args);
assert!(!cmd.args_required);
}
#[test]
fn decay_for_zero_elapsed_is_one() {
assert!((decay_for(Duration::ZERO) - 1.0).abs() < 0.001);
}
#[test]
fn decay_for_one_half_life_is_half() {
assert!((decay_for(MRU_HALF_LIFE) - 0.5).abs() < 0.001);
}
#[test]
fn decay_for_very_long_elapsed_is_zero() {
assert_eq!(decay_for(MRU_HALF_LIFE * 20), 0.0);
}
}