use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use reedline::{Completer, Span, Suggestion};
use std::path::Path;
pub struct SelfwareCompleter {
tool_names: Vec<String>,
commands: Vec<String>,
matcher: SkimMatcherV2,
}
impl SelfwareCompleter {
pub fn new(tool_names: Vec<String>, commands: Vec<String>) -> Self {
Self {
tool_names,
commands,
matcher: SkimMatcherV2::default(),
}
}
#[allow(dead_code)] fn complete_commands(&self, prefix: &str) -> Vec<Suggestion> {
self.complete_commands_with_span(prefix, 0, prefix.len())
}
fn complete_commands_with_span(
&self,
prefix: &str,
span_start: usize,
span_end: usize,
) -> Vec<Suggestion> {
let mut suggestions: Vec<(i64, Suggestion)> = self
.commands
.iter()
.filter_map(|cmd| {
self.matcher.fuzzy_match(cmd, prefix).map(|score| {
(
score,
Suggestion {
value: cmd.clone(),
description: Some(self.command_description(cmd)),
style: None,
extra: None,
span: Span::new(span_start, span_end),
append_whitespace: true,
match_indices: None,
display_override: None,
},
)
})
})
.collect();
suggestions.sort_by(|a, b| b.0.cmp(&a.0));
suggestions.into_iter().map(|(_, s)| s).collect()
}
#[allow(dead_code)] fn complete_tools(&self, prefix: &str) -> Vec<Suggestion> {
self.complete_tools_with_span(prefix, 0, prefix.len())
}
fn complete_tools_with_span(
&self,
prefix: &str,
span_start: usize,
span_end: usize,
) -> Vec<Suggestion> {
let mut suggestions: Vec<(i64, Suggestion)> = self
.tool_names
.iter()
.filter_map(|tool| {
self.matcher.fuzzy_match(tool, prefix).map(|score| {
(
score,
Suggestion {
value: tool.clone(),
description: Some(format!("Tool: {}", tool)),
style: None,
extra: None,
span: Span::new(span_start, span_end),
append_whitespace: true,
match_indices: None,
display_override: None,
},
)
})
})
.collect();
suggestions.sort_by(|a, b| b.0.cmp(&a.0));
suggestions.into_iter().map(|(_, s)| s).collect()
}
#[allow(dead_code)] fn complete_paths(&self, prefix: &str) -> Vec<Suggestion> {
self.complete_paths_with_span(prefix, 0, prefix.len())
}
fn complete_paths_with_span(
&self,
prefix: &str,
span_start: usize,
span_end: usize,
) -> Vec<Suggestion> {
let path = Path::new(prefix);
let (dir, file_prefix): (String, String) =
if prefix.ends_with('/') || prefix.ends_with('\\') {
(prefix.to_string(), String::new())
} else {
let parent = path.parent().map(|p| p.to_string_lossy().to_string());
let file = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
(parent.unwrap_or_else(|| ".".to_string()), file)
};
let dir_path = if dir.is_empty() { "." } else { &dir };
let mut suggestions = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir_path) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') && !file_prefix.starts_with('.') && !file_prefix.is_empty()
{
continue;
}
if let Some(score) = self.matcher.fuzzy_match(&name, &file_prefix) {
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let full_path = if dir == "." {
if is_dir {
format!("{}/", name)
} else {
name.clone()
}
} else if is_dir {
format!("{}/{}/", dir.trim_end_matches('/'), name)
} else {
format!("{}/{}", dir.trim_end_matches('/'), name)
};
suggestions.push((
score,
Suggestion {
value: full_path,
description: Some(if is_dir {
"Directory".to_string()
} else {
"File".to_string()
}),
style: None,
extra: None,
span: Span::new(span_start, span_end),
append_whitespace: !is_dir,
match_indices: None,
display_override: None,
},
));
}
}
}
suggestions.sort_by(|a, b| b.0.cmp(&a.0));
suggestions.into_iter().take(20).map(|(_, s)| s).collect()
}
fn command_description(&self, cmd: &str) -> String {
if let Some(desc) = super::command_registry::command_description(cmd) {
desc.to_string()
} else if cmd == "exit" || cmd == "quit" {
"Exit interactive mode".to_string()
} else {
"Command".to_string()
}
}
fn detect_context(&self, line: &str, pos: usize) -> CompletionContext {
let before_cursor = &line[..pos];
if before_cursor.starts_with("/analyze ")
|| before_cursor.starts_with("/review ")
|| before_cursor.starts_with("/ctx load ")
|| before_cursor.starts_with("/context load ")
|| before_cursor.starts_with("/chat save ")
|| before_cursor.starts_with("/chat resume ")
|| before_cursor.starts_with("/chat delete ")
|| before_cursor.starts_with("/theme ")
|| before_cursor.starts_with("/restore ")
{
let prefix = before_cursor.split_whitespace().last().unwrap_or("");
return CompletionContext::Path(prefix.to_string());
}
if before_cursor.starts_with('/') {
return CompletionContext::Command(before_cursor.to_string());
}
let words: Vec<&str> = before_cursor.split_whitespace().collect();
if let Some(last) = words.last() {
if let Some(path_prefix) = last.strip_prefix('@') {
return CompletionContext::FileReference(path_prefix.to_string());
}
if last.chars().all(|c| c.is_alphanumeric() || c == '_') {
return CompletionContext::Tool(last.to_string());
}
if last.contains('/') || last.contains('.') {
return CompletionContext::Path(last.to_string());
}
}
CompletionContext::None
}
fn complete_file_refs_with_span(
&self,
prefix: &str,
span_start: usize,
span_end: usize,
) -> Vec<Suggestion> {
let path_suggestions = self.complete_paths_with_span(prefix, span_start, span_end);
path_suggestions
.into_iter()
.map(|mut s| {
s.value = format!("@{}", s.value);
s.description = Some(
s.description
.map(|d| format!("Include {}", d.to_lowercase()))
.unwrap_or_else(|| "Include file".to_string()),
);
s
})
.collect()
}
}
enum CompletionContext {
Command(String),
Tool(String),
Path(String),
FileReference(String),
None,
}
impl Completer for SelfwareCompleter {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let before_cursor = &line[..pos];
match self.detect_context(line, pos) {
CompletionContext::Command(prefix) => self.complete_commands_with_span(&prefix, 0, pos),
CompletionContext::Tool(prefix) => {
let word_start = before_cursor
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
self.complete_tools_with_span(&prefix, word_start, pos)
}
CompletionContext::Path(prefix) => {
let word_start = before_cursor
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
self.complete_paths_with_span(&prefix, word_start, pos)
}
CompletionContext::FileReference(prefix) => {
let word_start = before_cursor
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
self.complete_file_refs_with_span(&prefix, word_start, pos)
}
CompletionContext::None => {
if before_cursor.is_empty() || before_cursor.len() < 2 {
self.complete_commands_with_span("/", 0, pos)
} else {
Vec::new()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_completer_creation() {
let completer = SelfwareCompleter::new(
vec!["file_read".into(), "git_status".into()],
vec!["/help".into(), "/status".into()],
);
assert_eq!(completer.tool_names.len(), 2);
assert_eq!(completer.commands.len(), 2);
}
#[test]
fn test_command_completions() {
let completer = SelfwareCompleter::new(
vec![],
vec!["/help".into(), "/status".into(), "/memory".into()],
);
let suggestions = completer.complete_commands("/he");
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.value == "/help"));
}
#[test]
fn test_command_completions_all() {
let completer = SelfwareCompleter::new(
vec![],
vec![
"/help".into(),
"/status".into(),
"/memory".into(),
"/clear".into(),
],
);
let suggestions = completer.complete_commands("/");
assert_eq!(suggestions.len(), 4);
}
#[test]
fn test_fuzzy_matching() {
let completer = SelfwareCompleter::new(
vec!["file_read".into(), "file_write".into(), "git_status".into()],
vec![],
);
let suggestions = completer.complete_tools("fr");
assert!(!suggestions.is_empty());
}
#[test]
fn test_fuzzy_matching_order() {
let completer = SelfwareCompleter::new(
vec!["file_read".into(), "file_write".into(), "git_status".into()],
vec![],
);
let suggestions = completer.complete_tools("file");
assert!(suggestions.len() >= 2);
assert!(suggestions[0].value.starts_with("file"));
}
#[test]
fn test_tool_completions_empty() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let suggestions = completer.complete_tools("anything");
assert!(suggestions.is_empty());
}
#[test]
fn test_command_description() {
let completer = SelfwareCompleter::new(vec![], vec![]);
assert_eq!(
completer.command_description("/help"),
"Show help and available commands"
);
assert_eq!(
completer.command_description("/status"),
"Show agent status and context usage"
);
assert_eq!(
completer.command_description("/memory"),
"Show memory hierarchy status"
);
assert_eq!(completer.command_description("/clear"), "Clear the screen");
assert_eq!(
completer.command_description("/tools"),
"List available tools"
);
assert_eq!(
completer.command_description("/analyze"),
"Analyze the current codebase"
);
assert_eq!(
completer.command_description("/review"),
"Review recent changes"
);
assert_eq!(
completer.command_description("/swarm"),
"Launch multi-agent swarm"
);
assert_eq!(
completer.command_description("/queue"),
"Enqueue a message for later processing"
);
assert_eq!(completer.command_description("/unknown"), "Command");
}
#[test]
fn test_detect_context_command() {
let completer = SelfwareCompleter::new(vec![], vec!["/help".into()]);
let ctx = completer.detect_context("/hel", 4);
assert!(matches!(ctx, CompletionContext::Command(prefix) if prefix == "/hel"));
}
#[test]
fn test_detect_context_path_after_analyze() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let ctx = completer.detect_context("/analyze ./src", 14);
assert!(matches!(ctx, CompletionContext::Path(prefix) if prefix == "./src"));
}
#[test]
fn test_detect_context_path_after_review() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let ctx = completer.detect_context("/review main.rs", 15);
assert!(matches!(ctx, CompletionContext::Path(prefix) if prefix == "main.rs"));
}
#[test]
fn test_detect_context_tool() {
let completer = SelfwareCompleter::new(vec!["file_read".into()], vec![]);
let ctx = completer.detect_context("file_re", 7);
assert!(matches!(ctx, CompletionContext::Tool(prefix) if prefix == "file_re"));
}
#[test]
fn test_detect_context_none() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let ctx = completer.detect_context("", 0);
assert!(matches!(ctx, CompletionContext::None));
}
#[test]
fn test_complete_interface() {
let mut completer = SelfwareCompleter::new(vec!["file_read".into()], vec!["/help".into()]);
let suggestions = completer.complete("/he", 3);
assert!(!suggestions.is_empty());
}
#[test]
fn test_complete_empty_line() {
let mut completer = SelfwareCompleter::new(vec![], vec!["/help".into(), "/status".into()]);
let suggestions = completer.complete("", 0);
assert!(!suggestions.is_empty());
}
#[test]
fn test_suggestion_has_description() {
let completer = SelfwareCompleter::new(vec![], vec!["/help".into()]);
let suggestions = completer.complete_commands("/help");
assert!(!suggestions.is_empty());
assert!(suggestions[0].description.is_some());
}
#[test]
fn test_path_completions_current_dir() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let suggestions = completer.complete_paths("./");
let _ = suggestions;
}
#[test]
fn test_path_completions_nonexistent() {
let completer = SelfwareCompleter::new(vec![], vec![]);
let suggestions = completer.complete_paths("/nonexistent/path/here/");
assert!(suggestions.is_empty());
}
}