use super::super::app::ChatApp;
#[derive(Clone, Debug, PartialEq)]
pub enum SlashCommand {
Copy,
Log,
Browse,
Config,
Model,
Archive,
Clear,
Theme,
Resume,
Dump,
DumpProcessed,
Teammate,
}
impl SlashCommand {
pub fn display_label(&self) -> String {
match self {
SlashCommand::Copy => "/copy".to_string(),
SlashCommand::Log => "/log".to_string(),
SlashCommand::Browse => "/browse".to_string(),
SlashCommand::Config => "/config".to_string(),
SlashCommand::Model => "/model".to_string(),
SlashCommand::Archive => "/archive".to_string(),
SlashCommand::Clear => "/clear".to_string(),
SlashCommand::Theme => "/theme".to_string(),
SlashCommand::Resume => "/resume".to_string(),
SlashCommand::Dump => "/dump".to_string(),
SlashCommand::DumpProcessed => "/dump-processed".to_string(),
SlashCommand::Teammate => "/teammate".to_string(),
}
}
pub fn description(&self) -> String {
match self {
SlashCommand::Copy => "复制最后一条 AI 回复".to_string(),
SlashCommand::Log => "打开日志窗口".to_string(),
SlashCommand::Browse => "浏览历史消息".to_string(),
SlashCommand::Config => "打开配置界面".to_string(),
SlashCommand::Model => "切换模型".to_string(),
SlashCommand::Archive => "归档当前对话".to_string(),
SlashCommand::Clear => "新建对话".to_string(),
SlashCommand::Theme => "切换主题".to_string(),
SlashCommand::Resume => "恢复历史会话".to_string(),
SlashCommand::Dump => "导出真实传给 AI 的 system prompt 和 messages".to_string(),
SlashCommand::DumpProcessed => {
"导出经 micro_compact + sanitize 处理后的最终请求数据".to_string()
}
SlashCommand::Teammate => "Teammate 面板".to_string(),
}
}
pub fn all() -> Vec<SlashCommand> {
vec![
SlashCommand::Copy,
SlashCommand::Log,
SlashCommand::Browse,
SlashCommand::Config,
SlashCommand::Model,
SlashCommand::Archive,
SlashCommand::Clear,
SlashCommand::Theme,
SlashCommand::Resume,
SlashCommand::Dump,
SlashCommand::DumpProcessed,
SlashCommand::Teammate,
]
}
}
pub fn get_filtered_slash_commands(filter: &str) -> Vec<SlashCommand> {
let filter_lower = filter.to_lowercase();
SlashCommand::all()
.into_iter()
.filter(|cmd| {
if filter_lower.is_empty() {
return true;
}
cmd.display_label().to_lowercase().contains(&filter_lower)
})
.collect()
}
#[derive(Clone, Debug)]
pub enum AtPopupItem {
Category(String),
Skill(String),
Command(String),
File(String),
}
impl AtPopupItem {}
pub fn update_at_filter(app: &mut ChatApp) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let start = app.ui.at_popup_start_pos + 1; if start <= cursor_pos && cursor_pos <= chars.len() {
app.ui.at_popup_filter = chars[start..cursor_pos].iter().collect();
} else {
app.ui.at_popup_filter.clear();
}
app.ui.at_popup_selected = 0;
}
pub fn get_filtered_all_items(app: &ChatApp) -> Vec<AtPopupItem> {
let raw_filter = app.ui.at_popup_filter.as_str();
if raw_filter.is_empty() {
return vec![
AtPopupItem::Category("skill:".to_string()),
AtPopupItem::Category("command:".to_string()),
AtPopupItem::Category("file:".to_string()),
];
}
let mut items: Vec<AtPopupItem> = Vec::new();
let filter = raw_filter.to_lowercase();
for label in &["skill:", "command:", "file:"] {
if label.contains(&filter) {
items.push(AtPopupItem::Category(label.to_string()));
}
}
let skills: Vec<String> = app
.state
.loaded_skills
.iter()
.filter(|s| {
!app.state
.agent_config
.disabled_skills
.iter()
.any(|d| d == &s.frontmatter.name)
})
.map(|s| s.frontmatter.name.clone())
.filter(|name| name.to_lowercase().contains(&filter))
.take(5)
.collect();
for s in skills {
items.push(AtPopupItem::Skill(s));
}
let commands: Vec<String> = app
.state
.loaded_commands
.iter()
.filter(|c| {
!app.state
.agent_config
.disabled_commands
.iter()
.any(|d| d == &c.frontmatter.name)
})
.map(|c| c.frontmatter.name.clone())
.filter(|name| name.to_lowercase().contains(&filter))
.take(5)
.collect();
for c in commands {
items.push(AtPopupItem::Command(c));
}
let file_items = get_filtered_files_for_at(raw_filter);
for path in file_items {
items.push(AtPopupItem::File(path));
}
items.truncate(20);
items
}
fn get_filtered_files_for_at(filter: &str) -> Vec<String> {
let expanded;
let effective_filter = if filter == "~" {
expanded = "~/".to_string();
&expanded
} else if filter.starts_with("~/") {
expanded = expand_tilde(filter);
&expanded
} else {
filter
};
let filter_lower = effective_filter.to_lowercase();
if let Some(last_slash) = effective_filter.rfind('/') {
let dir_part = &effective_filter[..=last_slash];
let prefix = &effective_filter[last_slash + 1..];
let dir_path = if dir_part.is_empty() {
std::path::PathBuf::from(".")
} else {
std::path::PathBuf::from(expand_tilde(dir_part))
};
if dir_path.is_dir() {
let prefix_lower = prefix.to_lowercase();
let mut entries: Vec<String> = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(&dir_path) {
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') && !prefix.starts_with('.') {
continue;
}
if !prefix_lower.is_empty() && !name.to_lowercase().starts_with(&prefix_lower) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
entries.push(format!("{}{}/", dir_part, name));
} else {
entries.push(format!("{}{}", dir_part, name));
}
}
}
entries.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.to_lowercase().cmp(&b.to_lowercase()),
}
});
entries.truncate(10);
return entries;
}
}
let search_filter = if filter_lower.ends_with('/') {
let trimmed = &filter_lower[..filter_lower.len() - 1];
if let Some(last_slash) = trimmed.rfind('/') {
&trimmed[last_slash + 1..]
} else {
trimmed
}
} else if let Some(last_slash) = filter_lower.rfind('/') {
&filter_lower[last_slash + 1..]
} else {
&filter_lower
};
if search_filter.is_empty() {
return Vec::new();
}
let search_root = std::path::PathBuf::from(".");
let walker = ignore::WalkBuilder::new(&search_root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.max_depth(Some(8))
.build();
let mut scored_files: Vec<(i32, String)> = Vec::new();
for entry in walker.flatten() {
let path = entry.path();
if path == std::path::Path::new(".") {
continue;
}
let rel = path
.strip_prefix(&search_root)
.unwrap_or(path)
.to_string_lossy();
let rel_str = rel.as_ref();
if !filter_lower.starts_with('.') && rel_str.split('/').any(|seg| seg.starts_with('.')) {
continue;
}
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(score) = fuzzy_match_enhanced(&file_name, search_filter, rel_str) {
let is_dir = path.is_dir();
let display = if is_dir {
format!("{}/", rel_str)
} else {
rel_str.to_string()
};
scored_files.push((score, display));
}
}
scored_files.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase()))
});
scored_files
.into_iter()
.take(10)
.map(|(_, path)| path)
.collect()
}
fn fuzzy_match_enhanced(file_name: &str, filter: &str, rel_path: &str) -> Option<i32> {
let base_score = fuzzy_match(file_name, filter)?;
let file_name_lower = file_name.to_lowercase();
let position_bonus = if file_name_lower.starts_with(filter) {
-50 } else if file_name_lower.contains(filter) {
-20 } else {
0
};
let ext_bonus = if let Some(ext) = std::path::Path::new(file_name).extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
match ext_str.as_str() {
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "kt" | "swift" => -15,
"json" | "yaml" | "yml" | "toml" | "md" => -10,
_ => 0,
}
} else {
0
};
let depth = rel_path.matches('/').count() as i32;
Some(base_score * 10 + depth + position_bonus + ext_bonus)
}
pub fn complete_at_direct(app: &mut ChatApp, item: &AtPopupItem) {
let mention = match item {
AtPopupItem::Skill(name) => format!("@skill:{} ", name),
AtPopupItem::Command(name) => format!("@command:{} ", name),
AtPopupItem::File(path) => format!("@file:{} ", path),
AtPopupItem::Category(_) => return, };
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let before: String = chars[..app.ui.at_popup_start_pos].iter().collect();
let after: String = if cursor_pos < chars.len() {
chars[cursor_pos..].iter().collect()
} else {
String::new()
};
let new_cursor = before.chars().count() + mention.chars().count();
app.ui
.set_input_text(&format!("{}{}{}", before, mention, after), new_cursor);
}
pub fn update_skill_filter(app: &mut ChatApp) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let start = app.ui.skill_popup_start_pos + 7;
if start <= cursor_pos && cursor_pos <= chars.len() {
app.ui.skill_popup_filter = chars[start..cursor_pos].iter().collect();
} else {
app.ui.skill_popup_filter.clear();
}
app.ui.skill_popup_selected = 0;
}
pub fn get_filtered_skill_names(app: &ChatApp) -> Vec<String> {
let filter = app.ui.skill_popup_filter.to_lowercase();
app.state
.loaded_skills
.iter()
.filter(|s| {
!app.state
.agent_config
.disabled_skills
.iter()
.any(|d| d == &s.frontmatter.name)
})
.map(|s| s.frontmatter.name.clone())
.filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter))
.collect()
}
pub fn complete_skill_mention(app: &mut ChatApp, skill_name: &str) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let before: String = chars[..app.ui.skill_popup_start_pos].iter().collect();
let after: String = if cursor_pos < chars.len() {
chars[cursor_pos..].iter().collect()
} else {
String::new()
};
let replacement = format!("@skill:{} ", skill_name);
let new_cursor = before.chars().count() + replacement.chars().count();
app.ui
.set_input_text(&format!("{}{}{}", before, replacement, after), new_cursor);
}
pub fn update_file_filter(app: &mut ChatApp) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let start = app.ui.file_popup_start_pos + 6;
if start <= cursor_pos && cursor_pos <= chars.len() {
app.ui.file_popup_filter = chars[start..cursor_pos].iter().collect();
} else {
app.ui.file_popup_filter.clear();
}
app.ui.file_popup_selected = 0;
}
fn expand_tilde(path: &str) -> String {
if (path == "~" || path.starts_with("~/"))
&& let Some(home) = dirs::home_dir()
{
return format!("{}{}", home.display(), &path[1..]);
}
path.to_string()
}
fn fuzzy_match(text: &str, filter: &str) -> Option<i32> {
if filter.is_empty() {
return Some(0);
}
let text_lower: Vec<char> = text.to_lowercase().chars().collect();
let filter_lower: Vec<char> = filter.to_lowercase().chars().collect();
let mut ti = 0;
let mut score: i32 = 0;
let mut last_match: Option<usize> = None;
for &fc in &filter_lower {
let mut found = false;
while ti < text_lower.len() {
if text_lower[ti] == fc {
if let Some(lm) = last_match {
score += (ti - lm - 1) as i32;
}
last_match = Some(ti);
ti += 1;
found = true;
break;
}
ti += 1;
}
if !found {
return None;
}
}
Some(score)
}
pub fn get_filtered_files(app: &ChatApp) -> Vec<String> {
let filter = &app.ui.file_popup_filter;
let expanded;
let effective_filter = if filter == "~" {
expanded = "~/".to_string();
&expanded
} else {
filter
};
if let Some(last_slash) = effective_filter.rfind('/') {
let dir_part = &effective_filter[..=last_slash];
let prefix = &effective_filter[last_slash + 1..];
let dir_path = if dir_part.is_empty() {
std::path::PathBuf::from(".")
} else {
std::path::PathBuf::from(expand_tilde(dir_part))
};
if dir_path.is_dir() {
let prefix_lower = prefix.to_lowercase();
let mut entries: Vec<String> = Vec::new();
if let Ok(read_dir) = std::fs::read_dir(&dir_path) {
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') && !prefix.starts_with('.') {
continue;
}
if !prefix_lower.is_empty() && !name.to_lowercase().starts_with(&prefix_lower) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
entries.push(format!("{}{}/", dir_part, name));
} else {
entries.push(format!("{}{}", dir_part, name));
}
}
}
entries.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.to_lowercase().cmp(&b.to_lowercase()),
}
});
entries.truncate(15);
return entries;
}
}
let search_root = std::path::PathBuf::from(".");
let mut scored: Vec<(i32, String)> = Vec::new();
let walker = ignore::WalkBuilder::new(&search_root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.max_depth(Some(8))
.build();
for entry in walker.flatten() {
let path = entry.path();
if path == std::path::Path::new(".") {
continue;
}
let rel = path
.strip_prefix(&search_root)
.unwrap_or(path)
.to_string_lossy();
let rel_str = rel.as_ref();
if !effective_filter.starts_with('.') && rel_str.split('/').any(|seg| seg.starts_with('.'))
{
continue;
}
let is_dir = path.is_dir();
let display = if is_dir {
format!("{}/", rel_str)
} else {
rel_str.to_string()
};
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(score) = fuzzy_match(&file_name, effective_filter) {
let depth = rel_str.matches('/').count() as i32;
scored.push((score * 10 + depth, display));
}
}
scored.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase()))
});
scored.truncate(15);
scored.into_iter().map(|(_, path)| path).collect()
}
pub fn update_command_filter(app: &mut ChatApp) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let start = app.ui.command_popup_start_pos + 9;
if start <= cursor_pos && cursor_pos <= chars.len() {
app.ui.command_popup_filter = chars[start..cursor_pos].iter().collect();
} else {
app.ui.command_popup_filter.clear();
}
app.ui.command_popup_selected = 0;
}
pub fn get_filtered_command_names(app: &ChatApp) -> Vec<String> {
let filter = app.ui.command_popup_filter.to_lowercase();
app.state
.loaded_commands
.iter()
.filter(|c| {
!app.state
.agent_config
.disabled_commands
.iter()
.any(|d| d == &c.frontmatter.name)
})
.map(|c| c.frontmatter.name.clone())
.filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter))
.collect()
}
pub fn complete_command_mention(app: &mut ChatApp, command_name: &str) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let before: String = chars[..app.ui.command_popup_start_pos].iter().collect();
let after: String = if cursor_pos < chars.len() {
chars[cursor_pos..].iter().collect()
} else {
String::new()
};
let replacement = format!("@command:{} ", command_name);
let new_cursor = before.chars().count() + replacement.chars().count();
app.ui
.set_input_text(&format!("{}{}{}", before, replacement, after), new_cursor);
}
pub fn complete_file_mention(app: &mut ChatApp, file_path: &str) {
let chars: Vec<char> = app.ui.input_text().chars().collect();
let cursor_pos = app.ui.cursor_char_idx();
let before: String = chars[..app.ui.file_popup_start_pos].iter().collect();
let after: String = if cursor_pos < chars.len() {
chars[cursor_pos..].iter().collect()
} else {
String::new()
};
let replacement = format!("@file:{} ", file_path);
let new_cursor = before.chars().count() + replacement.chars().count();
app.ui
.set_input_text(&format!("{}{}{}", before, replacement, after), new_cursor);
}