Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -10029,11 +10037,21 @@ impl App {
is_chat: bool,
) -> Vec<crate::autocomplete::Suggestion> {
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
Expand Down
83 changes: 82 additions & 1 deletion src/autocomplete/command.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -64,11 +66,22 @@ impl Suggestion {
}
}

#[derive(Default)]
pub struct CommandAuto {
commands: Vec<Suggestion>,
hidden_token_map: Vec<(String, String)>,
chat_only_commands: HashSet<String>,
mru: RefCell<SlashMru>,
}

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 {
Expand Down Expand Up @@ -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<Suggestion> {
let input_lower = input.to_lowercase();
let trimmed = input.trim();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut results: Vec<Suggestion> = Vec::new();

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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(&registry).with_in_memory_mru();
let before: Vec<String> = auto
.get_suggestions("", true)
.iter()
.map(|s| s.name.clone())
.collect();
auto.touch_mru("exit");
auto.touch_mru("compact");
let after: Vec<String> = 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(&registry).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");
}
}
1 change: 1 addition & 0 deletions src/autocomplete/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod command;
pub mod file;
pub mod mru;

pub use command::{CommandAuto, Suggestion, SuggestionKind};
pub use file::FileAuto;
Expand Down
207 changes: 207 additions & 0 deletions src/autocomplete/mru.rs
Original file line number Diff line number Diff line change
@@ -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<String, u64>,
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<HashMap<String, u64>> {
let dao = PrefsDAO::new().ok()?;
dao.get_slash_mru().ok()
}

fn load_legacy_file() -> Option<HashMap<String, u64>> {
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<String, u64>,
}
serde_json::from_slice::<Legacy>(&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<String, u64>) -> 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);
}
}
Loading
Loading