use colored::*;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::validate::Validator;
use rustyline::Helper;
use std::borrow::Cow::{self, Borrowed, Owned};
use std::fs;
use std::path::{Path, PathBuf};
struct CommandCompleter<'a> {
commands: Vec<String>,
config: &'a crate::config::Config,
role: &'a str,
}
impl<'a> CommandCompleter<'a> {
fn new(config: &'a crate::config::Config, role: &'a str) -> Self {
let commands = crate::session::chat::COMMANDS
.iter()
.map(|&s| s.to_string())
.collect();
Self {
commands,
config,
role,
}
}
fn get_context_filters() -> Vec<&'static str> {
vec!["all", "assistant", "user", "tool", "large"]
}
fn get_mcp_subcommands() -> Vec<&'static str> {
vec!["list", "info", "full", "health", "dump", "validate"]
}
fn get_cache_subcommands() -> Vec<&'static str> {
vec!["stats", "clear", "threshold"]
}
fn get_log_levels() -> Vec<&'static str> {
vec!["none", "info", "debug"]
}
fn get_available_roles(&self) -> Vec<String> {
self.config.roles.iter().map(|r| r.name.clone()).collect()
}
fn is_valid_context_filter(filter: &str) -> bool {
Self::get_context_filters().contains(&filter)
}
fn is_valid_mcp_subcommand(subcommand: &str) -> bool {
Self::get_mcp_subcommands().contains(&subcommand)
}
fn is_valid_cache_subcommand(subcommand: &str) -> bool {
Self::get_cache_subcommands().contains(&subcommand)
}
fn is_valid_log_level(level: &str) -> bool {
Self::get_log_levels().contains(&level)
}
fn is_valid_role(&self, role: &str) -> bool {
self.config.roles.iter().any(|r| r.name == role)
}
fn is_image_file(path: &str) -> bool {
let supported_extensions = [
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico", ".svg",
".avif", ".heic", ".heif",
];
let path_lower = path.to_lowercase();
supported_extensions
.iter()
.any(|ext| path_lower.ends_with(ext))
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
home.join(stripped)
} else {
PathBuf::from(path)
}
} else if path == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
} else {
PathBuf::from(path)
}
}
fn complete_file_path(file_part: &str) -> Vec<Pair> {
if file_part.is_empty() {
return Self::list_directory_contents(".");
}
let expanded_path = Self::expand_tilde(file_part);
let expanded_str = expanded_path.to_string_lossy();
if file_part.ends_with('/') || file_part.ends_with('\\') {
return Self::list_directory_contents(&expanded_str);
}
let (parent_dir, filename_part) = if let Some(parent) = expanded_path.parent() {
let parent_str = parent.to_str().unwrap_or(".");
let actual_parent = if parent_str.is_empty() {
"."
} else {
parent_str
};
let filename_part = expanded_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
(actual_parent, filename_part)
} else {
(".", file_part)
};
let mut candidates = Self::list_directory_contents(parent_dir);
if !filename_part.is_empty() {
let filename_lower = filename_part.to_lowercase();
candidates.retain(|candidate| {
let name = Path::new(&candidate.replacement)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
name.starts_with(&filename_lower)
});
}
for candidate in &mut candidates {
if file_part.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
if let Ok(relative) = PathBuf::from(&candidate.replacement).strip_prefix(&home)
{
candidate.replacement = format!("~/{}", relative.to_string_lossy());
}
}
} else if file_part.starts_with('/') {
} else {
if let Ok(relative) = PathBuf::from(&candidate.replacement)
.strip_prefix(std::env::current_dir().unwrap_or_default())
{
candidate.replacement = relative.to_string_lossy().to_string();
}
}
}
candidates
}
fn list_directory_contents(dir_path: &str) -> Vec<Pair> {
let mut candidates = Vec::new();
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
let path_str = path.to_string_lossy().to_string();
if path.is_dir() {
let display = format!(
"{}/",
path.file_name().and_then(|n| n.to_str()).unwrap_or("")
);
candidates.push(Pair {
display: display.clone(),
replacement: format!("{}/", path_str),
});
} else if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if Self::is_image_file(filename) {
candidates.push(Pair {
display: filename.to_string(),
replacement: path_str,
});
}
}
}
}
candidates.sort_by(|a, b| {
let a_is_dir = a.replacement.ends_with('/');
let b_is_dir = b.replacement.ends_with('/');
match (a_is_dir, b_is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.replacement.cmp(&b.replacement),
}
});
candidates
}
fn filter_and_limit_candidates(candidates: Vec<Pair>, file_part: &str) -> Vec<Pair> {
if candidates.is_empty() {
return candidates;
}
let common_prefix = Self::find_common_prefix(&candidates);
let mut result = candidates;
if common_prefix.len() > file_part.len() {
let partial_completion = Pair {
display: format!("{} (partial)", common_prefix),
replacement: common_prefix,
};
result.insert(0, partial_completion);
}
const MAX_TOTAL: usize = 10; result.truncate(MAX_TOTAL);
result
}
fn find_common_prefix(candidates: &[Pair]) -> String {
if candidates.is_empty() {
return String::new();
}
let first = &candidates[0].replacement;
let mut common_len = first.len();
for candidate in candidates.iter().skip(1) {
let replacement = &candidate.replacement;
let mut len = 0;
for (a, b) in first.chars().zip(replacement.chars()) {
if a == b {
len += a.len_utf8();
} else {
break;
}
}
common_len = common_len.min(len);
}
first[..common_len].to_string()
}
}
impl<'a> Completer for CommandCompleter<'a> {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> Result<(usize, Vec<Self::Candidate>), ReadlineError> {
if line.starts_with("/image ") {
let image_prefix = "/image ";
let prefix_len = image_prefix.len();
let file_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates = Self::complete_file_path(file_part);
let filtered_candidates = Self::filter_and_limit_candidates(candidates, file_part);
Ok((prefix_len, filtered_candidates))
} else if line.starts_with("/prompt ") {
let prompt_prefix = "/prompt ";
let prefix_len = prompt_prefix.len();
let template_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = self
.config
.prompts
.iter()
.filter(|prompt| prompt.name.starts_with(template_part))
.map(|prompt| Pair {
display: if let Some(ref description) = prompt.description {
format!("{} - {}", prompt.name, description)
} else {
prompt.name.clone()
},
replacement: prompt.name.clone(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/run ") {
let run_prefix = "/run ";
let prefix_len = run_prefix.len();
let command_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let available_commands =
crate::session::chat::list_available_commands(self.config, self.role);
let candidates: Vec<Pair> = available_commands
.iter()
.filter(|cmd| cmd.starts_with(command_part))
.map(|cmd| Pair {
display: cmd.clone(),
replacement: cmd.clone(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/context ") {
let context_prefix = "/context ";
let prefix_len = context_prefix.len();
let filter_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = Self::get_context_filters()
.iter()
.filter(|filter| filter.starts_with(filter_part))
.map(|filter| Pair {
display: filter.to_string(),
replacement: filter.to_string(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/mcp ") {
let mcp_prefix = "/mcp ";
let prefix_len = mcp_prefix.len();
let subcommand_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = Self::get_mcp_subcommands()
.iter()
.filter(|subcommand| subcommand.starts_with(subcommand_part))
.map(|subcommand| Pair {
display: subcommand.to_string(),
replacement: subcommand.to_string(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/cache ") {
let cache_prefix = "/cache ";
let prefix_len = cache_prefix.len();
let subcommand_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = Self::get_cache_subcommands()
.iter()
.filter(|subcommand| subcommand.starts_with(subcommand_part))
.map(|subcommand| Pair {
display: subcommand.to_string(),
replacement: subcommand.to_string(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/loglevel ") {
let loglevel_prefix = "/loglevel ";
let prefix_len = loglevel_prefix.len();
let level_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = Self::get_log_levels()
.iter()
.filter(|level| level.starts_with(level_part))
.map(|level| Pair {
display: level.to_string(),
replacement: level.to_string(),
})
.collect();
Ok((prefix_len, candidates))
} else if line.starts_with("/role ") {
let role_prefix = "/role ";
let prefix_len = role_prefix.len();
let role_part = if pos > prefix_len {
&line[prefix_len..pos]
} else {
""
};
let candidates: Vec<Pair> = self
.get_available_roles()
.iter()
.filter(|role| role.starts_with(role_part))
.map(|role| Pair {
display: role.clone(),
replacement: role.clone(),
})
.collect();
Ok((prefix_len, candidates))
} else if !line.starts_with('/') {
Ok((0, vec![]))
} else {
let command_part = &line[..pos.min(line.len())];
let candidates: Vec<Pair> = self
.commands
.iter()
.filter(|cmd| cmd.starts_with(command_part))
.map(|cmd| Pair {
display: cmd.clone(),
replacement: cmd.clone(),
})
.collect();
let common_prefix = Self::find_common_prefix(&candidates);
let mut result = candidates;
if common_prefix.len() > command_part.len() {
let partial = Pair {
display: format!("{} (partial)", common_prefix),
replacement: common_prefix,
};
result.insert(0, partial);
}
Ok((0, result))
}
}
}
impl<'a> Hinter for CommandCompleter<'a> {
type Hint = String;
fn hint(&self, line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<Self::Hint> {
if line.is_empty() || !line.starts_with('/') {
return None;
}
if line == "/image" {
return Some(" <path_to_image>".to_string());
}
if line == "/prompt" {
return Some(" <template_name>".to_string());
}
if line == "/run" {
return Some(" <command_name>".to_string());
}
if line == "/context" {
return Some(" [all|assistant|user|tool|large]".to_string());
}
if line == "/mcp" {
return Some(" [list|info|full|health|dump|validate]".to_string());
}
if line == "/cache" {
return Some(" [stats|clear|threshold]".to_string());
}
if line == "/loglevel" {
return Some(" [none|info|debug]".to_string());
}
if line == "/role" {
return Some(" <role_name>".to_string());
}
if line == "/model" {
return Some(" <model_name>".to_string());
}
if line.starts_with("/image ") && line.len() > 7 {
let file_part = &line[7..]; if file_part.is_empty() {
return Some("Start typing image file path...".to_string());
}
return None; }
if line.starts_with("/prompt ") && line.len() > 8 {
let template_part = &line[8..]; if template_part.is_empty() {
return Some("Start typing prompt template name...".to_string());
}
return None; }
if line.starts_with("/run ") && line.len() > 5 {
let command_part = &line[5..]; if command_part.is_empty() {
return Some("Start typing command name...".to_string());
}
return None; }
if line.starts_with("/context ") && line.len() > 9 {
let filter_part = &line[9..]; if filter_part.is_empty() {
return Some("all|assistant|user|tool|large".to_string());
}
return None; }
if line.starts_with("/mcp ") && line.len() > 5 {
let subcommand_part = &line[5..]; if subcommand_part.is_empty() {
return Some("list|info|full|health|dump|validate".to_string());
}
return None; }
if line.starts_with("/cache ") && line.len() > 7 {
let subcommand_part = &line[7..]; if subcommand_part.is_empty() {
return Some("stats|clear|threshold".to_string());
}
return None; }
if line.starts_with("/loglevel ") && line.len() > 10 {
let level_part = &line[10..]; if level_part.is_empty() {
return Some("none|info|debug".to_string());
}
return None; }
if line.starts_with("/role ") && line.len() > 6 {
let role_part = &line[6..]; if role_part.is_empty() {
let roles = self.get_available_roles();
if !roles.is_empty() {
return Some(roles.join("|"));
}
return Some("Start typing role name...".to_string());
}
return None; }
if line.starts_with("/model ") && line.len() > 7 {
let model_part = &line[7..]; if model_part.is_empty() {
return Some("Start typing model name...".to_string());
}
return None; }
self.commands
.iter()
.find(|cmd| cmd.starts_with(line))
.map(|cmd| cmd[line.len()..].to_string())
}
}
impl<'a> Highlighter for CommandCompleter<'a> {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
if line.starts_with('/') {
if line.starts_with("/image ") && line.len() > 7 {
let image_cmd = "/image";
let file_part = &line[7..];
if !file_part.is_empty()
&& Path::new(file_part).exists()
&& Self::is_image_file(file_part)
{
return Owned(format!(
"{} {}",
image_cmd.green(),
file_part.bright_green()
));
} else if !file_part.is_empty() {
return Owned(format!("{} {}", image_cmd.green(), file_part.yellow()));
} else {
return Owned(format!("{} ", image_cmd.green()));
}
}
if line.starts_with("/prompt ") && line.len() > 8 {
let prompt_cmd = "/prompt";
let template_part = &line[8..];
if !template_part.is_empty() {
let template_exists = self
.config
.prompts
.iter()
.any(|prompt| prompt.name == template_part);
if template_exists {
return Owned(format!(
"{} {}",
prompt_cmd.green(),
template_part.bright_green()
));
} else {
return Owned(format!("{} {}", prompt_cmd.green(), template_part.yellow()));
}
} else {
return Owned(format!("{} ", prompt_cmd.green()));
}
}
if line.starts_with("/run ") && line.len() > 5 {
let run_cmd = "/run";
let command_part = &line[5..];
if !command_part.is_empty() {
let command_exists =
crate::session::chat::command_exists(self.config, self.role, command_part);
if command_exists {
return Owned(format!(
"{} {}",
run_cmd.green(),
command_part.bright_green()
));
} else {
return Owned(format!("{} {}", run_cmd.green(), command_part.yellow()));
}
} else {
return Owned(format!("{} ", run_cmd.green()));
}
}
if line.starts_with("/context ") && line.len() > 9 {
let context_cmd = "/context";
let filter_part = &line[9..];
if !filter_part.is_empty() {
if Self::is_valid_context_filter(filter_part) {
return Owned(format!(
"{} {}",
context_cmd.green(),
filter_part.bright_green()
));
} else {
return Owned(format!("{} {}", context_cmd.green(), filter_part.yellow()));
}
} else {
return Owned(format!("{} ", context_cmd.green()));
}
}
if line.starts_with("/mcp ") && line.len() > 5 {
let mcp_cmd = "/mcp";
let subcommand_part = &line[5..];
if !subcommand_part.is_empty() {
if Self::is_valid_mcp_subcommand(subcommand_part) {
return Owned(format!(
"{} {}",
mcp_cmd.green(),
subcommand_part.bright_green()
));
} else {
return Owned(format!("{} {}", mcp_cmd.green(), subcommand_part.yellow()));
}
} else {
return Owned(format!("{} ", mcp_cmd.green()));
}
}
if line.starts_with("/cache ") && line.len() > 7 {
let cache_cmd = "/cache";
let subcommand_part = &line[7..];
if !subcommand_part.is_empty() {
if Self::is_valid_cache_subcommand(subcommand_part) {
return Owned(format!(
"{} {}",
cache_cmd.green(),
subcommand_part.bright_green()
));
} else {
return Owned(format!(
"{} {}",
cache_cmd.green(),
subcommand_part.yellow()
));
}
} else {
return Owned(format!("{} ", cache_cmd.green()));
}
}
if line.starts_with("/loglevel ") && line.len() > 10 {
let loglevel_cmd = "/loglevel";
let level_part = &line[10..];
if !level_part.is_empty() {
if Self::is_valid_log_level(level_part) {
return Owned(format!(
"{} {}",
loglevel_cmd.green(),
level_part.bright_green()
));
} else {
return Owned(format!("{} {}", loglevel_cmd.green(), level_part.yellow()));
}
} else {
return Owned(format!("{} ", loglevel_cmd.green()));
}
}
if line.starts_with("/role ") && line.len() > 6 {
let role_cmd = "/role";
let role_part = &line[6..];
if !role_part.is_empty() {
if self.is_valid_role(role_part) {
return Owned(format!("{} {}", role_cmd.green(), role_part.bright_green()));
} else {
return Owned(format!("{} {}", role_cmd.green(), role_part.yellow()));
}
} else {
return Owned(format!("{} ", role_cmd.green()));
}
}
if line.starts_with("/model ") && line.len() > 7 {
let model_cmd = "/model";
let model_part = &line[7..];
if !model_part.is_empty() {
return Owned(format!(
"{} {}",
model_cmd.green(),
model_part.bright_green()
));
} else {
return Owned(format!("{} ", model_cmd.green()));
}
}
let is_valid_command = self
.commands
.iter()
.any(|cmd| line == cmd || cmd.starts_with(line));
if is_valid_command {
Owned(line.green().to_string())
} else {
Borrowed(line)
}
} else {
Borrowed(line)
}
}
fn highlight_char(&self, _line: &str, _pos: usize, _kind: CmdKind) -> bool {
false
}
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Owned(hint.bright_black().to_string())
}
}
impl<'a> Validator for CommandCompleter<'a> {}
pub struct CommandHelper<'a> {
completer: CommandCompleter<'a>,
hinter: Option<HistoryHinter>,
}
impl<'a> CommandHelper<'a> {
pub fn new(config: &'a crate::config::Config, role: &'a str) -> Self {
Self {
completer: CommandCompleter::new(config, role),
hinter: Some(HistoryHinter {}),
}
}
}
impl<'a> Helper for CommandHelper<'a> {}
impl<'a> Completer for CommandHelper<'a> {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &rustyline::Context<'_>,
) -> Result<(usize, Vec<Self::Candidate>), ReadlineError> {
self.completer.complete(line, pos, ctx)
}
}
impl<'a> Hinter for CommandHelper<'a> {
type Hint = String;
fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<Self::Hint> {
if line.starts_with('/') {
self.completer.hint(line, pos, ctx)
} else if let Some(hinter) = &self.hinter {
hinter.hint(line, pos, ctx)
} else {
None
}
}
}
impl<'a> Highlighter for CommandHelper<'a> {
fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
self.completer.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
self.completer.highlight_char(line, pos, kind)
}
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
self.completer.highlight_hint(hint)
}
}
impl<'a> Validator for CommandHelper<'a> {}