use crate::widgets::dropdown::{self, DropdownItem, DropdownState};
use ratatui::text::Line;
pub const SLASH_COMMANDS: &[(&str, &str, Option<&str>)] = &[
("/agent", "Switch to a sub-agent", Some("<name>")),
(
"/cancel",
"Cancel a background task by id (agent:N or process:N — visible in the bg-activity overlay)",
Some("<id>"),
),
(
"/compact",
"Summarize conversation to reclaim context",
None,
),
(
"/copy",
"Copy last response to clipboard (/copy 2 for 2nd-last)",
Some("[n]"),
),
(
"/debug-bundle",
"Write a self-contained debug .zip to ~/.config/koda/debug-bundles/",
None,
),
("/diff", "Show git diff (review, commit)", None),
("/exit", "Quit the session", None),
("/expand", "Show full output of last tool call", None),
("/help", "Show commands and shortcuts", None),
("/key", "Manage API keys", None),
(
"/mcp",
"Manage MCP servers (add, remove, list)",
Some("[add|remove|list]"),
),
("/memory", "View/save project & global memory", None),
("/model", "Pick a model (aliases + local)", None),
("/provider", "Browse all models from a provider", None),
(
"/purge",
"Delete archived history (e.g. /purge 90d)",
Some("<days>"),
),
("/sessions", "List/resume/delete sessions", None),
("/skills", "List available skills (search with query)", None),
("/undo", "Undo last turn's file changes", None),
("/verbose", "Toggle full tool output", None),
("/vim", "Toggle vim-mode editing in the input", None),
];
#[derive(Clone, Debug)]
pub struct SlashCommand {
pub command: &'static str,
pub description: &'static str,
pub arg_hint: Option<&'static str>,
}
impl DropdownItem for SlashCommand {
fn label(&self) -> &str {
self.command
}
fn description(&self) -> String {
self.description.to_string()
}
fn matches_filter(&self, filter: &str) -> bool {
self.command.starts_with(filter)
}
}
pub fn from_input(
commands: &'static [(&'static str, &'static str, Option<&'static str>)],
input: &str,
) -> Option<DropdownState<SlashCommand>> {
let items: Vec<SlashCommand> = commands
.iter()
.map(|(cmd, desc, arg_hint)| SlashCommand {
command: cmd,
description: desc,
arg_hint: *arg_hint,
})
.collect();
let mut dd = DropdownState::new(items, "\u{1f43b} Commands");
if dd.apply_filter(input) {
Some(dd)
} else {
None
}
}
pub fn build_menu_lines(state: &DropdownState<SlashCommand>) -> Vec<Line<'static>> {
dropdown::build_dropdown_lines(state)
}
pub fn matches_for(trimmed: &str) -> Vec<String> {
SLASH_COMMANDS
.iter()
.filter(|(cmd, _, _)| cmd.starts_with(trimmed) && *cmd != trimmed)
.map(|(cmd, _, _)| cmd.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_COMMANDS: &[(&str, &str, Option<&str>)] = &[
("/agent", "Agents", Some("<name>")),
("/compact", "Compact", None),
("/diff", "Diff", None),
("/exit", "Quit", None),
("/expand", "Expand", None),
("/model", "Pick model", None),
];
#[test]
fn from_input_all() {
let state = from_input(TEST_COMMANDS, "/").unwrap();
assert_eq!(state.filtered.len(), 6);
}
#[test]
fn from_input_filtered() {
let state = from_input(TEST_COMMANDS, "/m").unwrap();
assert_eq!(state.filtered.len(), 1);
assert_eq!(state.filtered[0].command, "/model");
}
#[test]
fn from_input_no_match() {
assert!(from_input(TEST_COMMANDS, "/z").is_none());
}
#[test]
fn selected_command() {
let state = from_input(TEST_COMMANDS, "/").unwrap();
assert_eq!(state.selected_item().unwrap().command, "/agent");
}
#[test]
fn matches_for_unique_prefix_returns_one() {
let m = matches_for("/dif");
assert_eq!(m, vec!["/diff".to_string()]);
}
#[test]
fn matches_for_complete_command_returns_none() {
let m = matches_for("/diff");
assert!(
!m.iter().any(|s| s == "/diff"),
"self-completion must be filtered out"
);
}
#[test]
fn matches_for_no_match_returns_empty() {
assert!(matches_for("/zzzznope").is_empty());
}
#[test]
fn matches_for_ambiguous_prefix_returns_all_options() {
let m = matches_for("/a");
assert!(m.iter().any(|s| s == "/agent"));
assert!(
m.iter().all(|s| s.starts_with("/a")),
"matches must all share the queried prefix"
);
}
}