diff --git a/src/app.rs b/src/app.rs index ca75a2a..73b5781 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6115,6 +6115,11 @@ impl App { match parse_input(input) { InputType::Command(mut parsed) => { + // Popup Accept / autocomplete_and_submit land here — must record MRU + // (process_command_input is only used by some Enter paths). + if let Some(autocomplete) = self.input.autocomplete.as_ref() { + autocomplete.command_auto.touch_mru(&parsed.name); + } if self.command_registry.is_custom_command(&parsed.name) { parsed.prefs_data = self .prefs_dao @@ -6365,6 +6370,9 @@ impl App { } async fn process_command_input(&mut self, mut parsed: crate::command::parser::ParsedCommand) { + if let Some(autocomplete) = self.input.autocomplete.as_ref() { + autocomplete.command_auto.touch_mru(&parsed.name); + } if self.command_registry.is_custom_command(&parsed.name) { parsed.prefs_data = self .prefs_dao @@ -10029,11 +10037,21 @@ impl App { is_chat: bool, ) -> Vec { match trigger { - "slash" => crate::autocomplete::CommandAuto::new(&self.command_registry) - .get_suggestions(query, is_chat) - .into_iter() - .filter(|suggestion| !is_remote_browser_unsupported_command(&suggestion.name)) - .collect(), + "slash" => { + let suggestions = self + .input + .autocomplete + .as_ref() + .map(|ac| ac.command_auto.get_suggestions(query, is_chat)) + .unwrap_or_else(|| { + crate::autocomplete::CommandAuto::new(&self.command_registry) + .get_suggestions(query, is_chat) + }); + suggestions + .into_iter() + .filter(|suggestion| !is_remote_browser_unsupported_command(&suggestion.name)) + .collect() + } "mention" => { let query_lower = query.to_ascii_lowercase(); let mut suggestions = self diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index 39b3c36..f7aaaa6 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -1,4 +1,6 @@ +use crate::autocomplete::mru::SlashMru; use crate::command::registry::Registry; +use std::cell::RefCell; use std::collections::HashSet; #[derive(Clone, Debug, PartialEq, Eq)] @@ -64,11 +66,22 @@ impl Suggestion { } } -#[derive(Default)] pub struct CommandAuto { commands: Vec, hidden_token_map: Vec<(String, String)>, chat_only_commands: HashSet, + mru: RefCell, +} + +impl Default for CommandAuto { + fn default() -> Self { + Self { + commands: Vec::new(), + hidden_token_map: Vec::new(), + chat_only_commands: HashSet::new(), + mru: RefCell::new(SlashMru::new()), + } + } } impl CommandAuto { @@ -103,11 +116,27 @@ impl CommandAuto { commands, hidden_token_map, chat_only_commands, + mru: RefCell::new(SlashMru::new()), } } + /// Tests / ephemeral: never touches disk. + #[cfg(test)] + fn with_in_memory_mru(mut self) -> Self { + self.mru = RefCell::new(SlashMru::new_in_memory()); + self + } + + /// Record that a slash command was executed (boosts future search ranking). + pub fn touch_mru(&self, command_name: &str) { + let mut mru = self.mru.borrow_mut(); + mru.touch(command_name); + mru.persist_if_dirty(); + } + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { let input_lower = input.to_lowercase(); + let trimmed = input.trim(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut results: Vec = Vec::new(); @@ -135,6 +164,16 @@ impl CommandAuto { } } + // Empty `/` keeps registry order. Non-empty search: MRU recency boost. + if !trimmed.is_empty() && results.len() > 1 { + let mut mru = self.mru.borrow_mut(); + results.sort_by(|a, b| { + let score_b = mru.rank_score(&b.name); + let score_a = mru.rank_score(&a.name); + score_b.cmp(&score_a).then_with(|| a.name.cmp(&b.name)) + }); + } + results } } @@ -281,4 +320,46 @@ mod tests { assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "help"); } + + #[test] + fn empty_query_keeps_registry_order_even_with_mru() { + let registry = setup_registry(); + let auto = CommandAuto::new(®istry).with_in_memory_mru(); + let before: Vec = auto + .get_suggestions("", true) + .iter() + .map(|s| s.name.clone()) + .collect(); + auto.touch_mru("exit"); + auto.touch_mru("compact"); + let after: Vec = auto + .get_suggestions("", true) + .iter() + .map(|s| s.name.clone()) + .collect(); + assert_eq!(before, after); + } + + #[test] + fn search_ranks_recently_used_first() { + let mut registry = setup_registry(); + registry.register(Command { + name: "compact-mode".to_string(), + description: "Toggle compact mode".to_string(), + handler: dummy_handler, + hidden_tokens: vec![], + chat_only: true, + }); + let auto = CommandAuto::new(®istry).with_in_memory_mru(); + + // Without MRU, registry order: compact then compact-mode + let before = auto.get_suggestions("comp", true); + assert_eq!(before[0].name, "compact"); + assert_eq!(before[1].name, "compact-mode"); + + auto.touch_mru("compact-mode"); + let after = auto.get_suggestions("comp", true); + assert_eq!(after[0].name, "compact-mode"); + assert_eq!(after[1].name, "compact"); + } } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index 7e8c147..881e666 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -1,5 +1,6 @@ pub mod command; pub mod file; +pub mod mru; pub use command::{CommandAuto, Suggestion, SuggestionKind}; pub use file::FileAuto; diff --git a/src/autocomplete/mru.rs b/src/autocomplete/mru.rs new file mode 100644 index 0000000..091dc8f --- /dev/null +++ b/src/autocomplete/mru.rs @@ -0,0 +1,207 @@ +//! Slash-command MRU (most-recently-used) store. +//! +//! Flat per-command timestamps with a soft-decay recency score used as a +//! ranking boost during **search only**. Empty `/` menus keep registry order. +//! Persisted as the `slash_mru` prefs key in `data.db`. + +use crate::persistence::{get_data_dir, PrefsDAO}; +use std::collections::HashMap; +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Soft half-life (~7 days). +const HALF_LIFE_SECS: f64 = 7.0 * 86_400.0; +const MAX_ENTRIES: usize = 256; +/// Legacy sidecar; migrated into prefs once then deleted. +const LEGACY_STORE_FILE: &str = "slash_mru.json"; + +/// Persistent slash-command recency store. +#[derive(Debug, Clone)] +pub struct SlashMru { + by_command: HashMap, + loaded: bool, + dirty: bool, + persist_enabled: bool, +} + +impl Default for SlashMru { + fn default() -> Self { + Self::new() + } +} + +impl SlashMru { + pub fn new() -> Self { + Self { + by_command: HashMap::new(), + loaded: false, + dirty: false, + persist_enabled: true, + } + } + + /// Unit-test helper: never touches disk / DB. + pub fn new_in_memory() -> Self { + Self { + by_command: HashMap::new(), + loaded: true, + dirty: false, + persist_enabled: false, + } + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + + fn canonicalize(name: &str) -> String { + name.trim().trim_start_matches('/').to_ascii_lowercase() + } + + /// Soft-decay score in \[0, 1\]. `last_used == 0` → 0. + pub fn recency_score(last_used: u64, now: u64) -> u32 { + if last_used == 0 || now < last_used { + return 0; + } + let age = (now - last_used) as f64; + let score = 0.5_f64.powf(age / HALF_LIFE_SECS); + (score * 1_000_000.0).round() as u32 + } + + fn ensure_loaded(&mut self) { + if self.loaded || !self.persist_enabled { + return; + } + self.by_command = Self::load_from_prefs().unwrap_or_default(); + if self.by_command.is_empty() { + if let Some(legacy) = Self::load_legacy_file() { + self.by_command = legacy; + self.dirty = true; // rewrite into prefs, then drop sidecar + let _ = Self::delete_legacy_file(); + } + } + self.loaded = true; + } + + fn load_from_prefs() -> Option> { + let dao = PrefsDAO::new().ok()?; + dao.get_slash_mru().ok() + } + + fn load_legacy_file() -> Option> { + let path = get_data_dir().join(LEGACY_STORE_FILE); + let bytes = fs::read(&path).ok()?; + #[derive(serde::Deserialize)] + struct Legacy { + #[serde(default)] + by_command: HashMap, + } + serde_json::from_slice::(&bytes) + .ok() + .map(|l| l.by_command) + } + + fn delete_legacy_file() -> std::io::Result<()> { + let path = get_data_dir().join(LEGACY_STORE_FILE); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } + + pub fn last_used(&mut self, name: &str) -> u64 { + self.ensure_loaded(); + let key = Self::canonicalize(name); + self.by_command.get(&key).copied().unwrap_or(0) + } + + pub fn rank_score(&mut self, name: &str) -> u32 { + let last = self.last_used(name); + Self::recency_score(last, Self::now_secs()) + } + + pub fn touch(&mut self, name: &str) { + self.ensure_loaded(); + let key = Self::canonicalize(name); + if key.is_empty() { + return; + } + self.by_command.insert(key, Self::now_secs()); + if self.by_command.len() > MAX_ENTRIES { + let mut entries: Vec<_> = self + .by_command + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.truncate(MAX_ENTRIES); + self.by_command = entries.into_iter().collect(); + } + if self.persist_enabled { + self.dirty = true; + } + } + + pub fn persist_if_dirty(&mut self) { + if !self.dirty || !self.persist_enabled { + return; + } + if Self::write_to_prefs(&self.by_command).is_ok() { + self.dirty = false; + } + } + + fn write_to_prefs(by_command: &HashMap) -> anyhow::Result<()> { + let dao = PrefsDAO::new()?; + dao.set_slash_mru(by_command)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalize_strips_slash_and_lowercases() { + let mut mru = SlashMru::new_in_memory(); + mru.touch("/Model"); + assert!(mru.last_used("model") > 0); + assert_eq!(mru.last_used("/model"), mru.last_used("model")); + } + + #[test] + fn recency_decays_stale_entries() { + let now = 1_700_000_000_u64; + let recent = SlashMru::recency_score(now - 60, now); + let week_old = SlashMru::recency_score(now - 7 * 86_400, now); + let month_old = SlashMru::recency_score(now - 30 * 86_400, now); + assert!(recent > week_old); + assert!(week_old > month_old); + assert!(month_old > 0); + assert_eq!(SlashMru::recency_score(0, now), 0); + } + + #[test] + fn in_memory_never_dirties() { + let mut mru = SlashMru::new_in_memory(); + mru.touch("plan"); + assert!(!mru.dirty); + } + + #[test] + fn more_recent_command_scores_higher() { + let mut mru = SlashMru::new_in_memory(); + mru.by_command + .insert("compact-mode".to_string(), 1_700_000_000); + mru.by_command.insert("compact".to_string(), 1_700_000_100); + let now = 1_700_000_200; + let compact = SlashMru::recency_score(mru.by_command["compact"], now); + let mode = SlashMru::recency_score(mru.by_command["compact-mode"], now); + assert!(compact > mode); + } +} diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index 8276b78..41b0cb8 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -9,6 +9,7 @@ const MODEL_PREFS_KEY: &str = "model_preferences"; const ACTIVE_THEME_KEY: &str = "active_theme"; const TERMINAL_TITLE_ITEMS_KEY: &str = "terminal_title_items"; const COMPACT_MODE_KEY: &str = "compact_mode"; +const SLASH_MRU_KEY: &str = "slash_mru"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRef { @@ -247,6 +248,27 @@ impl PrefsDAO { self.set_pref(key, &json_str) } + /// Slash-command MRU map: canonical name → last-used unix seconds. + pub fn get_slash_mru(&self) -> Result> { + match self.get_pref(SLASH_MRU_KEY)? { + Some(json_str) => { + #[derive(serde::Deserialize)] + struct SlashMruPref { + #[serde(default)] + by_command: std::collections::HashMap, + } + let pref: SlashMruPref = serde_json::from_str(&json_str)?; + Ok(pref.by_command) + } + None => Ok(std::collections::HashMap::new()), + } + } + + pub fn set_slash_mru(&self, by_command: &std::collections::HashMap) -> Result<()> { + let value = serde_json::json!({ "by_command": by_command }); + self.set_pref(SLASH_MRU_KEY, &serde_json::to_string(&value)?) + } + pub fn toggle_favorite(&self, provider_id: String, model_id: String) -> Result { let mut prefs = self.get_model_preferences()?; let was_favorite = prefs.is_favorite(&provider_id, &model_id); @@ -374,6 +396,21 @@ mod tests { assert_ne!(ref1, ref3); } + #[test] + fn test_slash_mru_round_trip() { + let dao = setup_test_dao(); + assert!(dao.get_slash_mru().unwrap().is_empty()); + + let mut map = std::collections::HashMap::new(); + map.insert("connect".to_string(), 1_700_000_000); + map.insert("compact-mode".to_string(), 1_700_000_100); + dao.set_slash_mru(&map).unwrap(); + + let loaded = dao.get_slash_mru().unwrap(); + assert_eq!(loaded.get("connect"), Some(&1_700_000_000)); + assert_eq!(loaded.get("compact-mode"), Some(&1_700_000_100)); + } + #[test] fn test_active_theme_round_trip() { let dao = setup_test_dao();