use super::expand_user_text;
use crate::{
PROJECT_SCOPE_NAME,
cli::args::VALID_THINKING_LEVELS,
core::{
self,
changelog::CHANGELOG,
extensions::{SubcommandDef, dispatch_agent_event},
keybindings,
keybindings::KEYBINDINGS,
prompt_templates::{self, PromptTemplate},
provider::AgentMessage,
session_manager, settings_manager,
skills::{self, Skill},
},
extensions::skills::bundled_skill_names,
modes::interactive::{
agent_actor::AgentCommand,
app::{App, MsgLevel, SysSpan, WorkingKind},
giff::{self, GiffView},
theme::{Theme, available_theme_names},
},
utils::{glyphs::DEF_DONE, paths::resolve_path, wheel::WheelScrollAccelerator},
};
use std::{
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
pub type ExtensionKeybindingHandler = fn(&mut App);
type SlashCommandHandler = fn(&mut App, &str) -> bool;
const BUILTIN_BUSY_SAFE: &[&str] = &["dock"];
const BUILTIN_COMMANDS: &[(&str, &str, &[SubcommandDef])] = &[
("quit", "Quit the TUI", &[]),
("compact", "Compact the context", &[]),
("copy", "Copy last agent message to clipboard", &[]),
("new", "Start a new session", &[]),
("hotkeys", "Show keybindings", &[]),
("thinking", "Set thinking level (<level>)", &[]),
("session", "Show current session info", &[]),
("changelog", "Show changelog entries", &[]),
("resume", "List/switch sessions (<index>)", &[]),
(
"history",
"Search prompt history (live filter; @clear / @clear-all to delete)",
&[],
),
("trust", "Trust the current project", &[]),
(
"bug",
"Report a bug (collects diagnostics, exports a zip)",
&[],
),
("tree", "Show session tree / navigate (<entryId> [-s])", &[]),
("label", "Set/clear session label (<entryId> [label])", &[]),
("fork", "Fork current session", &[]),
(
"clone",
"Duplicate the current session at the current position",
&[],
),
("share", "Share session", &[]),
("export", "Export session (<out> [.html|.jsonl])", &[]),
("theme", "Show/switch theme (<name>)", &[]),
(
"reload",
"Reload keybindings, extensions, skills, prompts, themes, and context files",
&[],
),
("settings", "Open settings", &[]),
("model", "Select model (<id>)", &[]),
(
"scoped-models",
"Enable/disable models for Ctrl+P cycling",
&[],
),
("name", "Rename session (<name>)", &[]),
("import", "Import session (<path>)", &[]),
("login", "Store an API key for a provider", &[]),
("logout", "Remove stored API key for a provider", &[]),
("extension", "Enable/disable extensions", &[]),
("dock", "Toggle the dock panel", &[]),
("giff", "Review uncommitted changes", &[]),
];
static EXT_COMMAND_HANDLERS: OnceLock<Mutex<Vec<(String, String, SlashCommandHandler)>>> =
OnceLock::new();
static EXT_KEYBINDING_HANDLERS: OnceLock<Mutex<Vec<(String, String, ExtensionKeybindingHandler)>>> =
OnceLock::new();
struct ReloadedAssets {
context_files: Vec<(String, String)>,
skills: Vec<Skill>,
prompt_templates: Vec<PromptTemplate>,
system_prompt: Option<String>,
append_system_prompt: Option<String>,
}
impl App {
pub(super) fn handle_slash_command(&mut self, cmd: &str) -> bool {
let raw = cmd;
let cmd = cmd.trim().to_lowercase();
let mut quit = false;
match cmd.as_str() {
"quit" | "q" => quit = self.cmd_quit(),
"compact" => self.cmd_compact(raw),
"reload" => self.cmd_reload(),
"copy" => self.cmd_copy(),
"new" => self.cmd_new(),
"session" => self.cmd_session(),
"changelog" => self.cmd_changelog(),
"resume" => self.cmd_resume(),
"history" => self.cmd_history(raw),
"trust" => self.cmd_trust(),
"tree" => self.cmd_tree(""),
"share" => self.cmd_share(),
"theme" => self.cmd_theme(),
"settings" => self.cmd_settings(),
"model" => self.cmd_model(),
"extension" => self.cmd_extension(),
"login" => self.cmd_login(),
"logout" => self.cmd_logout(),
"export" => self.cmd_export(None),
"thinking" => self.cmd_thinking(&cmd),
"scoped-models" => self.cmd_scoped_models(),
"hotkeys" => self.cmd_hotkeys(),
"dock" => self.cmd_dock(),
"giff" => self.cmd_giff(),
"bug" => self.cmd_bug(raw),
"fork" => self.cmd_fork(),
"clone" => self.cmd_clone(),
_ if cmd.starts_with("compact ") => self.cmd_compact(raw),
_ if cmd.starts_with("thinking ") => self.cmd_thinking(raw),
_ if cmd.starts_with("export ") => self.cmd_export(Some(raw)),
_ if cmd.starts_with("theme ") => self.cmd_theme_named(raw),
_ if cmd.starts_with("tree ") => self.cmd_tree(raw),
_ if cmd.starts_with("label ") => self.cmd_label(raw),
_ if cmd.starts_with("model ") => self.cmd_model_named(raw),
_ if cmd.starts_with("name ") => self.cmd_name(raw),
_ if cmd.starts_with("session ") => self.cmd_session_switch(raw),
_ if cmd.starts_with("history ") => self.cmd_history(raw),
_ if cmd.starts_with("bug ") => self.cmd_bug(raw),
_ if cmd.starts_with("import ") => self.cmd_import(raw),
_ => match extension_command_handler(command_name(&cmd)) {
Some(handler) => quit = handler(self, raw.trim()),
None => self.cmd_unknown(&cmd),
},
}
self.dirty = true;
quit
}
fn cmd_quit(&mut self) -> bool {
self.quit_requested = true;
true
}
fn cmd_giff(&mut self) {
match giff::collect_uncommitted() {
Ok((changes, _, _)) if changes.is_empty() => {
self.push_msg("No uncommitted changes.".to_string(), MsgLevel::Info);
}
Ok((changes, left, right)) => {
self.giff = Some(GiffView::new(
changes,
left,
right,
&self.theme,
&self.giff_restore,
));
self.full_redraw = true;
self.dirty = true;
}
Err(err) => self.push_msg(format!("/giff: {err}"), MsgLevel::Error),
}
}
fn cmd_compact(&mut self, raw: &str) {
let instructions = raw.trim_start_matches("compact").trim();
self.pending_compact = true;
self.compact_instructions = if instructions.is_empty() {
None
} else {
Some(instructions.to_string())
};
}
fn cmd_reload(&mut self) {
self.reload_all_resources();
self.push_msg(
format!(
"{DEF_DONE} Reloaded keybindings, extensions, skills, prompts, themes, and context files"
),
MsgLevel::Success,
);
}
pub(super) fn reload_project_resources(&mut self) {
self.reload_all_resources();
}
fn reload_all_resources(&mut self) {
let agent_dir = settings_manager::agent_dir();
self.set_show_thinking(!settings_manager::read_settings_hide_thinking());
self.autocomplete_max_visible = settings_manager::read_settings_autocomplete_max_visible();
self.wheel_accel = WheelScrollAccelerator::for_terminal(
settings_manager::read_settings_fullscreen_wheel_scroll_lines(),
);
self.history_max_entries = settings_manager::read_settings_history_max_entries();
self.editor.cap_history(self.history_max_entries);
self.reload_keybindings_with_conflict_warning(&agent_dir);
core::extensions::reload_from_settings();
for warning in core::extensions::registration_issues() {
self.push_msg(warning, MsgLevel::Warning);
}
let home_dir = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"));
let assets = self.reload_content_assets(&agent_dir, &home_dir);
for w in prompt_templates::take_load_warnings() {
self.push_msg(w, MsgLevel::Warning);
}
let ReloadedAssets {
context_files,
skills,
prompt_templates,
system_prompt,
append_system_prompt,
} = assets;
self.skills = skills.clone();
self.prompt_templates = prompt_templates;
self.worker.send(AgentCommand::Reload {
context_files,
skills,
system_prompt,
append_system_prompt,
});
self.reload_theme();
}
pub(super) fn reload_skills(&mut self) {
let agent_dir = settings_manager::agent_dir();
let home_dir = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"));
let ReloadedAssets {
context_files,
skills,
prompt_templates,
system_prompt,
append_system_prompt,
} = self.reload_content_assets(&agent_dir, &home_dir);
self.skills = skills.clone();
self.prompt_templates = prompt_templates;
self.worker.send(AgentCommand::Reload {
context_files,
skills,
system_prompt,
append_system_prompt,
});
}
fn reload_keybindings_with_conflict_warning(&mut self, agent_dir: &Path) {
let kb = keybindings::set_global(keybindings::KeybindingsManager::load(agent_dir));
if !kb.conflicts().is_empty() {
let conflicts: Vec<String> = kb
.conflicts()
.iter()
.map(|c| {
format!(
"{} is bound to multiple actions: {}",
c.key,
c.keybindings.join(", ")
)
})
.collect();
self.push_msg(
format!("keybindings.json conflicts: {}", conflicts.join("; ")),
MsgLevel::Warning,
);
}
}
fn reload_content_assets(&self, agent_dir: &Path, home_dir: &Path) -> ReloadedAssets {
let (
cwd,
trusted,
no_context_files,
skill_paths,
prompt_paths,
include_default_skills,
include_default_prompt_templates,
) = (
self.cwd.clone(),
self.reload_ctx.trusted,
self.reload_ctx.no_context_files,
self.reload_ctx.skill_paths.clone(),
self.reload_ctx.prompt_paths.clone(),
self.reload_ctx.include_default_skills,
self.reload_ctx.include_default_prompt_templates,
);
let context_files = if no_context_files {
Vec::new()
} else {
let mut files = core::context::load_project_context_files(&cwd, agent_dir);
if !trusted {
files.retain(|(p, _)| Path::new(p).starts_with(agent_dir));
}
files
};
let settings = settings_manager::read_settings();
let skills = skills::load_skills(
&cwd,
agent_dir,
home_dir,
&skill_paths,
&bundled_skill_names(),
include_default_skills,
trusted,
&settings.skills,
);
let prompt_templates = prompt_templates::load_prompt_templates(
&cwd,
agent_dir,
&prompt_paths,
include_default_prompt_templates,
);
let append_system_prompt = if self.reload_ctx.cli_append_system_prompt.is_some() {
self.reload_ctx.cli_append_system_prompt.clone()
} else {
core::context::discover_append_system_prompt(&cwd, agent_dir, trusted)
};
let system_prompt = core::context::discover_system_prompt(&cwd, agent_dir, trusted)
.filter(|s| !s.trim().is_empty())
.or_else(|| self.reload_ctx.cli_system_prompt.clone());
ReloadedAssets {
context_files,
skills,
prompt_templates,
system_prompt,
append_system_prompt,
}
}
fn reload_theme(&mut self) {
let theme_name = self.theme.name.clone();
let mut theme = Theme::load(&theme_name);
theme.syntax_highlight = settings_manager::read_settings_syntax_highlight();
theme.mermaid = settings_manager::read_settings_mermaid();
theme.latex = settings_manager::read_settings_latex();
self.theme = theme;
}
fn cmd_copy(&mut self) {
self.copy_last_assistant_message();
}
fn cmd_new(&mut self) {
self.messages.clear();
self.system_messages.clear();
self.expand_overrides.clear(); self.reset_text_selection(); self.clear_streaming();
self.context_tokens_override = None;
self.current_session_path = None;
self.scroll = 0;
self.max_scroll = 0;
self.last_render_total = 0;
self.refresh_context_percent();
self.invalidate_render();
dispatch_agent_event(&serde_json::json!({ "type": "session:new" }));
self.worker.send(AgentCommand::NewSession);
self.push_msg(format!("{DEF_DONE} New session started"), MsgLevel::Success);
}
fn cmd_session(&mut self) {
self.worker.send(AgentCommand::SessionStats);
}
fn cmd_resume(&mut self) {
self.open_session_panel();
}
fn cmd_trust(&mut self) {
self.open_project_trust_dialog();
}
fn cmd_tree(&mut self, _cmd: &str) {
self.worker.send(AgentCommand::Tree);
}
fn cmd_label(&mut self, cmd: &str) {
let rest = cmd_arg(cmd, "label");
let (id, label) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let label = label.trim();
self.worker.send(AgentCommand::Label {
entry_id: id.to_string(),
label: if label.is_empty() {
None
} else {
Some(label.to_string())
},
});
}
fn cmd_fork(&mut self) {
let Some(path) = self.current_session_path.clone() else {
self.push_msg("no current session".to_string(), MsgLevel::Error);
return;
};
self.worker.send(AgentCommand::ForkFrom { path, dir: None });
}
fn cmd_clone(&mut self) {
self.worker.send(AgentCommand::ForkClone);
}
fn cmd_share(&mut self) {
self.busy = true;
self.working_kind = WorkingKind::Sharing;
self.clear_status();
self.worker.send(AgentCommand::Share {
theme: Some(self.theme.name.clone()),
});
}
fn cmd_export(&mut self, out: Option<&str>) {
let out = out.and_then(parse_path_arg);
let theme = Some(self.theme.name.clone());
self.worker.send(AgentCommand::Export { out, theme });
}
fn cmd_theme(&mut self) {
self.open_theme_panel();
}
fn cmd_theme_named(&mut self, cmd: &str) {
let name = cmd_arg(cmd, "theme").to_string();
let cwd = self.cwd.clone();
let available =
available_theme_names(&cwd, &settings_manager::agent_dir(), !self.no_themes);
if available.iter().any(|n| n == &name) {
self.theme = Theme::load(&name);
self.invalidate_render(); self.push_msg_dedup(
format!("switched theme: {}", self.theme.name),
"switched theme: ",
MsgLevel::Info,
);
if let Err(e) = settings_manager::write_theme(&name) {
self.push_msg(format!("failed to persist theme: {}", e), MsgLevel::Error);
}
} else {
self.push_msg(
format!(
"unknown theme: {} (available: {})",
name,
available.join(", ")
),
MsgLevel::Warning,
);
}
}
fn cmd_settings(&mut self) {
self.panel.close();
self.session_selector.close();
self.ext_settings.close();
self.settings_selector.open(
self.show_thinking,
&self.theme.name,
self.autocomplete_max_visible,
self.thinking_level.as_deref().unwrap_or("off"),
);
}
fn cmd_model(&mut self) {
self.open_model_panel();
}
fn cmd_extension(&mut self) {
self.open_extension_panel();
}
fn cmd_login(&mut self) {
self.open_login_auth_panel();
}
fn cmd_logout(&mut self) {
self.open_logout_panel();
}
fn cmd_model_named(&mut self, cmd: &str) {
let arg = cmd_arg(cmd, "model").to_string();
if !self.model_cycle.is_empty() {
let arg_l = arg.to_lowercase();
let matched = self.model_cycle.iter().find_map(|m| {
if m.canonical().to_lowercase() == arg_l || m.id.to_lowercase() == arg_l {
Some((m.provider.clone(), m.id.clone()))
} else {
None
}
});
let Some((provider, model_id)) = matched else {
self.push_msg(
format!("model not found in scope: {arg}"),
MsgLevel::Warning,
);
return;
};
self.maybe_append_to_scope(&provider, &model_id);
self.worker.send(AgentCommand::SwitchModel {
provider,
model_id,
persist: false,
});
return;
}
let provider = self.current_provider.clone().unwrap_or_default();
self.worker.send(AgentCommand::SwitchModel {
provider,
model_id: arg,
persist: false,
});
}
fn cmd_name(&mut self, cmd: &str) {
let name = cmd_arg(cmd, "name").to_string();
self.worker.send(AgentCommand::SetSessionName { name });
}
fn cmd_session_switch(&mut self, cmd: &str) {
let arg = cmd_arg(cmd, "session").to_string();
let cwd = self.cwd.clone();
let agent_dir = settings_manager::agent_dir();
let dir = session_manager::default_session_dir(&cwd, &agent_dir);
let sessions = session_manager::list_sessions(&dir);
match arg.parse::<usize>() {
Ok(idx) if idx >= 1 && idx <= sessions.len() => {
let path = sessions[idx - 1].to_string_lossy().to_string();
self.worker.send(AgentCommand::ResumeSession { path });
}
Ok(0) => {
self.push_msg(
"invalid index: 0 (available 1-{})".replace("{}", &sessions.len().to_string()),
MsgLevel::Warning,
);
}
_ => {
self.push_msg(
format!("invalid index: {} (available 1-{})", arg, sessions.len()),
MsgLevel::Warning,
);
}
}
}
fn cmd_import(&mut self, cmd: &str) {
let raw = cmd_arg(cmd, "import");
if raw.is_empty() {
self.push_msg("Usage: /import <path.jsonl>".to_string(), MsgLevel::Error);
return;
}
let path_arg = raw.strip_prefix('@').unwrap_or(raw);
if path_arg.is_empty() {
self.push_msg("Usage: /import <path.jsonl>".to_string(), MsgLevel::Error);
return;
}
let source = resolve_path(path_arg, &self.cwd);
if !source.is_file() {
self.push_msg(
format!(
"import failed: Failed to read session file: {}: No such file or directory",
source.display()
),
MsgLevel::Error,
);
return;
}
self.stage_import_confirm(source);
}
fn cmd_history(&mut self, raw: &str) {
let arg = cmd_arg(raw, "history").trim().to_string();
if arg == "@clear" || arg == "@clear-all" {
self.open_history_clear_confirm(arg == "@clear-all");
return;
}
let line = if arg.is_empty() {
"/history".to_string()
} else {
format!("/history {}", arg)
};
self.editor.replace_all(&line);
self.dirty = true;
}
fn cmd_unknown(&mut self, cmd: &str) {
let text = format!("/{}", cmd);
let expanded = expand_user_text(self, &text);
self.messages.push(AgentMessage::user_text(&expanded));
self.status_start = Some(expanded);
}
fn cmd_thinking(&mut self, cmd: &str) {
let arg = cmd_arg(cmd, "thinking").to_string();
if arg.is_empty() {
self.open_thinking_panel();
return;
}
if !VALID_THINKING_LEVELS.contains(&arg.as_str()) {
self.push_msg(
format!(
"Invalid thinking level \"{}\". Valid values: {}",
arg,
VALID_THINKING_LEVELS.join(", ")
),
MsgLevel::Error,
);
return;
}
self.worker.send(AgentCommand::SetThinking {
level: arg,
persist: false,
});
}
fn cmd_hotkeys(&mut self) {
let bindings = KEYBINDINGS;
let mut lines = vec![format!(
"Keybindings (edit ~/{PROJECT_SCOPE_NAME}/keybindings.json to change):"
)];
for kb in bindings {
if kb.default_keys.is_empty() {
continue;
}
lines.push(format!(
" {}: {}",
kb.default_keys.join(", "),
kb.description
));
}
lines.push(" ctrl+x: copy last assistant message".to_string());
self.push_msg(lines.join("\n"), MsgLevel::Info);
}
fn cmd_changelog(&mut self) {
self.push_msg_rich(
vec![
SysSpan {
fg: Some("accent".to_string()),
bold: true,
text: format!("What's New in v{}?\n\n", env!("CARGO_PKG_VERSION")),
},
SysSpan::plain(CHANGELOG),
],
MsgLevel::Info,
);
}
fn cmd_dock(&mut self) {
if !self.dock_visible {
if core::extensions::dock_sections().is_empty() {
self.push_msg(
"No dock content. Enable an extension that provides it (e.g. /plan)."
.to_string(),
MsgLevel::Info,
);
} else {
self.dock_visible = true;
self.dock_offset = 0;
self.dirty = true;
}
} else {
self.dock_visible = false;
self.dock_offset = 0;
self.dock_area = None;
self.dirty = true;
}
}
fn cmd_scoped_models(&mut self) {
self.open_scoped_models_panel();
}
}
fn parse_path_arg(cmd_with_prefix: &str) -> Option<String> {
let space = cmd_with_prefix.find(' ')?;
let args = cmd_with_prefix[space + 1..].trim_start();
if args.is_empty() {
return None;
}
let first = args.chars().next()?;
if first == '"' || first == '\'' {
let rest = &args[first.len_utf8()..];
return rest
.find(first)
.map(|i| rest[..i].to_string())
.filter(|s| !s.is_empty());
}
Some(args.split_whitespace().next().unwrap_or("").to_string())
}
pub(super) fn slash_commands() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = BUILTIN_COMMANDS
.iter()
.map(|(n, d, _)| (n.to_string(), d.to_string()))
.collect();
let builtin: Vec<&str> = BUILTIN_COMMANDS.iter().map(|(n, _, _)| *n).collect();
for cmd in core::extensions::registered_commands() {
if builtin.contains(&cmd.name.as_str()) {
continue;
}
out.push((cmd.name, cmd.description));
}
out
}
pub(super) fn subcommands_for(name: &str) -> Vec<SubcommandDef> {
if let Some((_, _, subs)) = BUILTIN_COMMANDS.iter().find(|(n, _, _)| *n == name) {
return subs.to_vec();
}
core::extensions::registered_commands()
.into_iter()
.find(|c| c.name == name)
.map(|c| c.subcommands)
.unwrap_or_default()
}
fn cmd_arg<'a>(raw: &'a str, keyword: &str) -> &'a str {
let trimmed = raw.trim_start();
match trimmed.to_lowercase().strip_prefix(&keyword.to_lowercase()) {
Some(_) => trimmed[keyword.len()..].trim_start(),
None => trimmed,
}
}
fn ext_command_handlers() -> &'static Mutex<Vec<(String, String, SlashCommandHandler)>> {
EXT_COMMAND_HANDLERS.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn register_slash_command(ext: &str, cmd: &str, handler: SlashCommandHandler) {
let mut table = ext_command_handlers().lock().unwrap();
table.retain(|(e, c, _)| e != ext || c != cmd);
table.push((ext.to_string(), cmd.to_string(), handler));
}
pub(super) fn is_busy_safe_command(cmd: &str) -> bool {
let name = command_name(cmd).to_lowercase();
BUILTIN_BUSY_SAFE.contains(&name.as_str()) || core::extensions::command_busy_safe(&name)
}
fn command_name(cmd: &str) -> &str {
cmd.trim()
.split_once(char::is_whitespace)
.map_or_else(|| cmd.trim(), |(name, _)| name)
}
fn extension_command_handler(cmd: &str) -> Option<SlashCommandHandler> {
let provider = core::extensions::command_provider(cmd)?;
let table = ext_command_handlers().lock().unwrap();
table
.iter()
.find(|(ext, name, _)| ext == &provider && name == cmd)
.map(|(_, _, h)| *h)
}
fn ext_keybinding_handlers() -> &'static Mutex<Vec<(String, String, ExtensionKeybindingHandler)>> {
EXT_KEYBINDING_HANDLERS.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn register_extension_keybinding(ext: &str, id: &str, handler: ExtensionKeybindingHandler) {
let mut table = ext_keybinding_handlers().lock().unwrap();
table.retain(|(e, i, _)| e != ext || i != id);
table.push((ext.to_string(), id.to_string(), handler));
}
pub(super) fn extension_keybinding_handler(
ext: &str,
id: &str,
) -> Option<ExtensionKeybindingHandler> {
let table = ext_keybinding_handlers().lock().unwrap();
table
.iter()
.find(|(e, i, _)| e == ext && i == id)
.map(|(_, _, h)| *h)
}
#[cfg(test)]
mod tests {
#![allow(clippy::arc_with_non_send_sync)]
use super::*;
use crate::modes::interactive::agent_actor::WorkerHandle;
use crate::modes::interactive::app::{App, SuggestionKind};
use crate::modes::interactive::panel::PanelKind;
use crate::utils::fuzzy::fuzzy_filter;
fn test_agent_cmd() -> (
WorkerHandle,
crate::modes::interactive::agent_actor::CommandRx,
) {
crate::test_support::pin_test_agent_dir();
let (cmd_tx, cmd_rx) = crate::modes::interactive::agent_actor::channels();
(WorkerHandle::new(cmd_tx), cmd_rx)
}
#[test]
fn model_named_does_not_block_while_agent_locked() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
crate::core::auth::write_auth_key("deepseek", "sk-test").unwrap();
let mut st = App::new();
st.worker = agent;
st.handle_slash_command("model deepseek-v4-pro");
let cmd = cmd_rx.try_recv().expect("应发出 SwitchModel 命令");
assert!(
matches!(cmd, crate::modes::interactive::agent_actor::AgentCommand::SwitchModel { ref model_id, .. } if model_id == "deepseek-v4-pro"),
"应发 SwitchModel: {cmd:?}"
);
crate::core::auth::remove_auth("deepseek").ok();
}
#[test]
fn model_named_only_matches_within_scope() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
crate::core::auth::write_auth_key("deepseek", "sk-test").unwrap();
let mut st = App::new();
st.worker = agent;
st.model_cycle = vec![crate::core::model_scope::ScopedModel {
provider: "deepseek".to_string(),
id: "deepseek-flash".to_string(),
thinking: None,
}];
st.handle_slash_command("model deepseek-v4-pro");
assert!(
cmd_rx.try_recv().is_err(),
"scope 外模型不应下发 SwitchModel"
);
assert!(
st.system_messages.iter().any(|m| {
crate::modes::interactive::app::sys_spans_text(&m.1.spans)
.contains("not found in scope")
}),
"应提示 not found in scope"
);
st.handle_slash_command("model deepseek-flash");
let cmd = cmd_rx.try_recv().expect("应发 SwitchModel");
assert!(
matches!(
&cmd,
crate::modes::interactive::agent_actor::AgentCommand::SwitchModel { provider, model_id, .. }
if provider == "deepseek" && model_id == "deepseek-flash"
),
"应发 SwitchModel: {cmd:?}"
);
assert_eq!(st.model_cycle.len(), 1, "已在 scope 不应重复追加");
crate::core::auth::remove_auth("deepseek").ok();
}
#[test]
fn thinking_command_opens_panel_or_sets_directly() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
let mut st = App::new();
st.worker = agent;
assert!(
slash_commands().iter().any(|(n, _)| n == "thinking"),
"/ 候选列表应包含 thinking"
);
st.handle_slash_command("thinking");
assert!(
st.panel.active && st.panel.top().map(|l| l.kind) == Some(PanelKind::Thinking),
"/thinking 无参应打开选择面板"
);
st.panel.close();
st.handle_slash_command("thinking high");
let cmd = cmd_rx.try_recv().expect("应发出 SetThinking 命令");
assert!(
matches!(
cmd,
crate::modes::interactive::agent_actor::AgentCommand::SetThinking { ref level, .. }
if level == "high"
),
"应发 SetThinking high: {cmd:?}"
);
let mut drained = 0;
while cmd_rx.try_recv().is_ok() {
drained += 1;
}
st.handle_slash_command("thinking bogus");
let extra = cmd_rx.try_recv().is_ok();
assert!(!extra, "非法级别不应下发命令");
assert!(drained == 0, "drained: {drained}");
}
#[test]
fn model_named_still_switches_when_idle() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
crate::core::auth::write_auth_key("deepseek", "sk-test").unwrap();
let mut st = App::new();
st.worker = agent;
st.handle_slash_command("model deepseek-v4-pro");
let cmd = cmd_rx.try_recv().expect("应发出 SwitchModel 命令");
assert!(
matches!(cmd, crate::modes::interactive::agent_actor::AgentCommand::SwitchModel { ref model_id, .. } if model_id == "deepseek-v4-pro"),
"应发 SwitchModel: {cmd:?}"
);
crate::core::auth::remove_auth("deepseek").ok();
}
fn cleanup_plan_state() {
crate::extensions::plan_mode::set_enabled(false);
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
}
fn ensure_plan_registered() {
crate::extensions::plan_mode::ensure_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
}
fn plan_agent() -> WorkerHandle {
WorkerHandle::null()
}
#[test]
fn new_command_clears_mirror_and_sends_new_session() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
let mut st = App::new();
st.worker = agent;
st.messages
.push(crate::core::provider::AgentMessage::user_text("旧内容"));
st.context_window = 1000;
st.context_percent = 88.0;
st.system_messages.push((
0,
crate::modes::interactive::app::SysMsg::new(
MsgLevel::Info,
vec![crate::modes::interactive::app::SysSpan::plain("stale")],
),
));
let quit = st.handle_slash_command("new");
assert!(!quit);
assert!(st.messages.is_empty(), "旧消息应被立即清空");
assert!(st.current_session_path.is_none(), "新会话未落盘不应有路径");
assert!(
st.context_percent < 88.0,
"/new 后 context 使用率应即时刷新: {}",
st.context_percent
);
let cmd = cmd_rx.try_recv().expect("应发出 NewSession 命令");
assert!(
matches!(
cmd,
crate::modes::interactive::agent_actor::AgentCommand::NewSession
),
"应发 NewSession: {cmd:?}"
);
let last_txt = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last_txt
.as_deref()
.unwrap_or("")
.contains("New session started"),
"got: {last_txt:?}"
);
}
#[test]
fn plan_command_toggles_tools_and_restores() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _agent = plan_agent(); ensure_plan_registered();
cleanup_plan_state();
let mut st = App::new();
let quit = st.handle_slash_command("plan");
assert!(!quit);
assert!(crate::extensions::plan_mode::is_enabled());
let last_txt = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last_txt
.as_deref()
.unwrap_or("")
.contains("Plan mode enabled"),
"got: {last_txt:?}"
);
let quit = st.handle_slash_command("plan");
assert!(!quit);
assert!(!crate::extensions::plan_mode::is_enabled());
cleanup_plan_state();
}
#[test]
fn plan_command_with_arg_enters_mode_and_sends_prompt() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _agent = plan_agent();
ensure_plan_registered();
crate::extensions::plan_mode::reset_state_for_tests();
cleanup_plan_state();
let mut st = App::new();
assert!(!crate::extensions::plan_mode::is_enabled());
let quit = st.handle_slash_command("plan 分析 login 流程");
assert!(!quit);
assert!(
crate::extensions::plan_mode::is_enabled(),
"带参数应先进入 plan mode"
);
assert_eq!(
st.status_start.as_deref(),
Some("分析 login 流程"),
"参数应作为输入触发下一轮"
);
assert_eq!(
st.messages.last().map(|m| m.text()).as_deref(),
Some("分析 login 流程"),
"参数应入消息流"
);
let msg_count = st.system_messages.len();
let quit = st.handle_slash_command("PLAN Fix the Bug");
assert!(!quit);
assert_eq!(st.system_messages.len(), msg_count, "已开启不应重复提示");
assert_eq!(st.status_start.as_deref(), Some("Fix the Bug"));
st.status_start = None;
let quit = st.handle_slash_command("plan ");
assert!(!quit);
assert!(
!crate::extensions::plan_mode::is_enabled(),
"空白参数按无参数处理(切换关闭)"
);
cleanup_plan_state();
}
#[test]
fn todos_command_toggles_dock_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _agent = plan_agent();
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
ensure_plan_registered();
cleanup_plan_state();
crate::extensions::plan_mode::reset_state_for_tests();
let mut st = App::new();
let quit = st.handle_slash_command("todos");
assert!(!quit);
assert!(
!crate::extensions::plan_mode::is_dock_shown(),
"空列表不应打开面板"
);
let last = st.messages.last().unwrap().text().to_string();
assert_eq!(last, "/todos", "无 todos 时 /todos 按未知命令发送");
assert!(
!st.system_messages.iter().any(|(_, s)| {
crate::modes::interactive::app::sys_spans_text(&s.spans).contains("No todos")
}),
"无 todos 时不再提示 No todos(命令未注册)"
);
crate::extensions::plan_mode::set_enabled(true);
{
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
}));
}
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(names.contains(&"todos".to_string()), "got: {names:?}");
let msg_count = st.system_messages.len();
let quit = st.handle_slash_command("todos");
assert!(!quit);
assert!(
crate::extensions::plan_mode::is_dock_shown(),
"面板未显示:/todos 应显示 plan 段"
);
assert!(st.dock_visible, "面板未显示:/todos 应打开面板");
assert_eq!(
st.system_messages.len(),
msg_count,
"/todos 打开面板不得往聊天区输出"
);
let quit = st.handle_slash_command("todos");
assert!(!quit);
assert!(
!crate::extensions::plan_mode::is_dock_shown(),
"面板显示中 /todos 应隐藏 plan 段"
);
assert!(st.dock_visible, "toggle 段显隐不动面板开关");
let quit = st.handle_slash_command("todos");
assert!(!quit);
assert!(
crate::extensions::plan_mode::is_dock_shown(),
"面板显示中 /todos 应重新显示 plan 段"
);
let _ = crate::core::extensions::take_pending_ui();
cleanup_plan_state();
}
#[test]
fn dock_panel_renders_above_status_and_auto_hides_on_empty() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _sub = crate::extensions::subagent::test_lock();
let _agent = plan_agent();
ensure_plan_registered();
cleanup_plan_state();
crate::extensions::plan_mode::reset_state_for_tests();
crate::extensions::plan_mode::set_enabled(true);
{
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
let steps: Vec<String> = (1..=20).map(|i| format!("{i}. Step number {i}")).collect();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": format!("Plan:\n{}", steps.join("\n")) }] }
]
}));
}
use ratatui::backend::TestBackend;
let h = 24u16;
let backend = TestBackend::new(60, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut st = App::new();
crate::extensions::plan_mode::show_dock_section();
st.dock_visible = true;
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String {
(0..60)
.map(|c| buf.cell((c, y)).unwrap().symbol().to_string())
.collect()
};
let (status_h, pending_h, input_h) =
crate::modes::interactive::render::layout_heights(&st, 60);
assert_eq!(pending_h, 0);
assert_eq!(status_h, 1);
assert_eq!(input_h, 3);
let dock_start = h - (14 + status_h + input_h);
assert!(
row(dock_start).contains("Plan Progress (0/20)"),
"标题行: {:?}",
row(dock_start)
);
assert!(
row(dock_start + 13).contains("13. ☐ Step number 13"),
"最后可见行: {:?}",
row(dock_start + 13)
);
assert!(
row(dock_start + 14).trim().is_empty() || row(dock_start + 14).contains("Working"),
"面板之下是状态栏: {:?}",
row(dock_start + 14)
);
assert_eq!(buf.cell((59, dock_start + 2)).unwrap().symbol(), "█");
assert!(st.dock_area.is_some(), "可见时记录 dock_area");
crate::extensions::plan_mode::set_enabled(false);
terminal
.draw(|f| crate::modes::interactive::render::render_frame(f, &mut st))
.unwrap();
assert!(!st.dock_visible, "内容为空应自动隐藏");
assert!(st.dock_area.is_none(), "隐藏后应清 dock_area");
let _ = crate::core::extensions::take_pending_ui();
cleanup_plan_state();
}
#[test]
fn dock_command_toggles_panel_with_content_guard() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _sub = crate::extensions::subagent::test_lock();
let _agent = plan_agent();
ensure_plan_registered();
cleanup_plan_state();
crate::extensions::plan_mode::reset_state_for_tests();
let mut st = App::new();
let quit = st.handle_slash_command("dock");
assert!(!quit);
assert!(!st.dock_visible, "无内容不应打开面板");
let last_txt = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last_txt
.as_deref()
.unwrap_or("")
.contains("No dock content"),
"got: {last_txt:?}"
);
crate::extensions::plan_mode::set_enabled(true);
crate::extensions::plan_mode::show_dock_section();
{
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Do it\n2. Verify" }] }
]
}));
}
let quit = st.handle_slash_command("dock");
assert!(!quit);
assert!(st.dock_visible, "有内容应打开面板");
let quit = st.handle_slash_command("dock");
assert!(!quit);
assert!(!st.dock_visible, "再次 /dock 应关闭面板");
assert_eq!(st.dock_offset, 0, "关闭时复位偏移");
let _ = crate::core::extensions::take_pending_ui();
cleanup_plan_state();
}
#[test]
fn other_model_command_switches_without_deadlock() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd(); crate::core::auth::write_auth_key("deepseek", "sk-test").unwrap();
let mut st = App::new();
st.worker = agent;
st.handle_slash_command("model deepseek-v4-pro");
let cmd = cmd_rx.try_recv().expect("应发出 SwitchModel 命令");
assert!(
matches!(cmd, crate::modes::interactive::agent_actor::AgentCommand::SwitchModel { ref model_id, .. } if model_id == "deepseek-v4-pro"),
"应发 SwitchModel: {cmd:?}"
);
crate::core::auth::remove_auth("deepseek").ok();
}
#[test]
fn slash_query_fuzzy_matches_command_names() {
let q = "odel";
let commands = slash_commands();
let names: Vec<&str> = commands
.iter()
.filter(|(n, _)| {
!fuzzy_filter(std::slice::from_ref(&(n.as_str(), "")), q, |c| c.0).is_empty()
})
.map(|(n, _)| n.as_str())
.collect();
assert!(names.contains(&"model"));
assert!(!names.contains(&"quit"));
}
#[test]
fn unknown_slash_command_sends_as_user_message() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut st = App::new();
let quit = st.handle_slash_command("fji");
assert!(!quit);
let last = st.messages.last().unwrap();
assert_eq!(last.role, "user", "未知命令进入用户消息流");
assert_eq!(last.text(), "/fji");
assert_eq!(st.status_start.as_deref(), Some("/fji"), "触发 prompt 启动");
assert!(
!st.messages
.iter()
.any(|m| m.text().contains("unknown command")),
"不再提示 unknown command"
);
}
#[test]
fn reload_command_reloads_resources() {
use std::ffi::OsString;
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct EnvGuard {
prev: Option<OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var("PRUX_AGENT_DIR", v) },
None => unsafe { std::env::remove_var("PRUX_AGENT_DIR") },
}
}
}
let dir = tempfile::tempdir().unwrap();
let agent_dir = dir.path().join("agent");
std::fs::create_dir_all(agent_dir.join("skills").join("demo")).unwrap();
std::fs::create_dir_all(agent_dir.join("prompts")).unwrap();
std::fs::write(
agent_dir.join("skills").join("demo").join("SKILL.md"),
"---\nname: demo\ndescription: reload demo\n---\n# Demo Body\n",
)
.unwrap();
std::fs::write(
agent_dir.join("prompts").join("review.md"),
"---\ndescription: review prompt\n---\nReview this code.\n",
)
.unwrap();
std::fs::write(
agent_dir.join("settings.json"),
r#"{"hideThinkingBlock": true, "autocompleteMaxVisible": 9}"#,
)
.unwrap();
std::fs::write(
agent_dir.join("keybindings.json"),
r#"{"tui.editor.cursorUp": "ctrl+p", "tui.editor.cursorDown": "ctrl+p"}"#,
)
.unwrap();
let guard = EnvGuard {
prev: std::env::var_os("PRUX_AGENT_DIR"),
};
unsafe { std::env::set_var("PRUX_AGENT_DIR", agent_dir.as_os_str()) };
{
let mut st = App::new();
st.reload_ctx = crate::modes::interactive::app::ReloadCtx {
skill_paths: Vec::new(),
prompt_paths: Vec::new(),
include_default_skills: true,
include_default_prompt_templates: true,
no_context_files: false,
trusted: true,
trust_override: Some(true),
cli_system_prompt: None,
cli_append_system_prompt: None,
};
assert!(st.skills.is_empty() && st.prompt_templates.is_empty());
let quit = st.handle_slash_command("reload");
assert!(!quit);
assert!(
st.skills.iter().any(|s| s.name == "demo"),
"got: {:?}",
st.skills.iter().map(|s| &s.name).collect::<Vec<_>>()
);
assert!(
st.prompt_templates.iter().any(|t| t.name == "review"),
"got: {:?}",
st.prompt_templates
.iter()
.map(|t| &t.name)
.collect::<Vec<_>>()
);
assert!(!st.show_thinking, "hideThinkingBlock=true 应同步");
assert_eq!(st.autocomplete_max_visible, 9);
assert!(
!crate::core::keybindings::get_global()
.conflicts()
.is_empty(),
"keybindings.json 冲突应被检测到"
);
let last_txt = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last_txt
.as_deref()
.map(|t| t.contains("Reloaded"))
.unwrap_or(false),
"got: {:?}",
last_txt
);
}
crate::core::keybindings::set_global(
crate::core::keybindings::KeybindingsManager::new_default(),
);
drop(guard);
}
#[test]
fn reload_command_passes_context_files_and_skills_to_worker() {
use std::ffi::OsString;
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct EnvGuard {
prev: Option<OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var("PRUX_AGENT_DIR", v) },
None => unsafe { std::env::remove_var("PRUX_AGENT_DIR") },
}
}
}
let dir = tempfile::tempdir().unwrap();
let agent_dir = dir.path().join("agent");
std::fs::create_dir_all(agent_dir.join("skills").join("demo")).unwrap();
std::fs::write(
agent_dir.join("skills").join("demo").join("SKILL.md"),
"---\nname: demo\ndescription: reload demo\n---\n# Demo Body\n",
)
.unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "# Project context\n").unwrap();
std::fs::write(agent_dir.join("AGENTS.md"), "# Global context\n").unwrap();
let guard = EnvGuard {
prev: std::env::var_os("PRUX_AGENT_DIR"),
};
unsafe { std::env::set_var("PRUX_AGENT_DIR", agent_dir.as_os_str()) };
let (agent, mut cmd_rx) = crate::modes::interactive::agent_actor::channels();
let mut st = App::new();
st.cwd = dir.path().to_string_lossy().to_string();
st.reload_ctx = crate::modes::interactive::app::ReloadCtx {
skill_paths: Vec::new(),
prompt_paths: Vec::new(),
include_default_skills: true,
include_default_prompt_templates: true,
no_context_files: false,
trusted: true,
trust_override: Some(true),
cli_system_prompt: None,
cli_append_system_prompt: None,
};
st.worker = crate::modes::interactive::agent_actor::WorkerHandle::new(agent);
let quit = st.handle_slash_command("reload");
assert!(!quit);
let cmd = cmd_rx.try_recv().expect("应发出 Reload 命令");
match cmd {
crate::modes::interactive::agent_actor::AgentCommand::Reload {
context_files,
skills,
..
} => {
assert!(
context_files.iter().any(|(p, _)| p.ends_with("AGENTS.md")),
"worker 侧应收到上下文文件: {context_files:?}"
);
assert!(
skills.iter().any(|s| s.name == "demo"),
"worker 侧应收到重载 skills: {:?}",
skills.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
other => panic!("应发 Reload: {other:?}"),
}
drop(guard);
}
#[test]
fn slash_list_excludes_short_and_exit() {
let names: Vec<&str> = BUILTIN_COMMANDS.iter().map(|(n, _, _)| *n).collect();
assert!(!names.contains(&"q"));
assert!(!names.contains(&"h"));
assert!(!names.contains(&"exit"));
assert!(names.contains(&"quit"));
}
#[test]
fn slash_list_covers_handled_builtins() {
let names: Vec<&str> = BUILTIN_COMMANDS.iter().map(|(n, _, _)| *n).collect();
for cmd in [
"dock",
"scoped-models",
"label",
"theme",
"extension",
"import",
"fork",
"clone",
] {
assert!(names.contains(&cmd), "{cmd} 应有补全候选");
}
assert!(names.contains(&"changelog"), "changelog 应有补全候选");
}
#[test]
fn changelog_command_shows_embedded_current_version() {
let _ad = crate::test_support::AgentDirGuard::temp();
let mut st = App::new();
let quit = st.handle_slash_command("changelog");
assert!(!quit);
let text = st
.system_messages
.last()
.map(|(_, m)| crate::modes::interactive::app::sys_spans_text(&m.spans))
.unwrap_or_default();
assert!(text.contains("What's New"), "got: {text}");
assert!(
text.contains(crate::core::changelog::VERSION),
"got: {text}"
);
assert!(
text.contains(crate::core::changelog::CHANGELOG.trim()),
"got: {text}"
);
assert!(st.messages.is_empty(), "changelog 不应进入 LLM 上下文");
}
#[test]
fn plan_todos_commands_follow_extension_lifecycle() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _agent = plan_agent();
ensure_plan_registered();
cleanup_plan_state();
assert!(crate::core::extensions::set_extension_enabled(
"plan-mode",
false
));
crate::core::settings_manager::write_disabled_extensions(&["plan-mode".to_string()]).ok();
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(!names.contains(&"plan".to_string()), "got: {names:?}");
assert!(!names.contains(&"todos".to_string()), "got: {names:?}");
let mut st = App::new();
let quit = st.handle_slash_command("plan");
assert!(!quit);
assert!(!crate::extensions::plan_mode::is_enabled());
let last = st.messages.last().unwrap().text().to_string();
assert_eq!(last, "/plan", "禁用后 /plan 按未知命令发送");
let quit = st.handle_slash_command("todos");
assert!(!quit);
let last = st.messages.last().unwrap().text().to_string();
assert_eq!(last, "/todos", "禁用后 /todos 按未知命令发送");
assert!(crate::core::extensions::set_extension_enabled(
"plan-mode",
true
));
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(names.contains(&"plan".to_string()), "got: {names:?}");
assert!(
!names.contains(&"todos".to_string()),
"无 todos 时不应暴露 /todos: {names:?}"
);
let mut st = App::new();
let quit = st.handle_slash_command("plan");
assert!(!quit);
assert!(
crate::extensions::plan_mode::is_enabled(),
"启用后 /plan 生效"
);
let _ = st.handle_slash_command("plan");
assert!(
!crate::extensions::plan_mode::is_enabled(),
"再次 /plan 关闭"
);
let quit = st.handle_slash_command("todos");
assert!(!quit);
let last = st.messages.last().unwrap().text().to_string();
assert_eq!(last, "/todos", "无 todos 时 /todos 按未知命令发送");
cleanup_plan_state();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn extension_commands_removed_in_minimal_mode() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
ensure_plan_registered();
crate::core::extensions::set_extension_enabled("plan-mode", true);
crate::core::settings_manager::write_extension_mode("all").ok();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
crate::core::extensions::set_extension_mode(
crate::core::extensions::ExtensionMode::Minimal,
);
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(!names.contains(&"plan".to_string()), "got: {names:?}");
assert!(!names.contains(&"todos".to_string()), "got: {names:?}");
crate::core::extensions::set_extension_mode(crate::core::extensions::ExtensionMode::All);
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(names.contains(&"plan".to_string()), "got: {names:?}");
crate::core::settings_manager::write_extension_mode("all").ok();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn builtin_commands_override_extension_commands() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct ModelOverrideExt;
impl crate::core::extensions::Extension for ModelOverrideExt {
fn name(&self) -> &str {
"model-override-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<crate::core::extensions::ExtensionCommand> {
vec![crate::core::extensions::ExtensionCommand {
name: "model".to_string(),
description: "shadow builtin model".to_string(),
busy_safe: false,
subcommands: Vec::new(),
}]
}
}
crate::core::extensions::register_extension(ModelOverrideExt);
crate::core::extensions::set_extension_enabled("model-override-ext", true);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
let commands = slash_commands();
let model_count = commands.iter().filter(|(n, _)| n == "model").count();
assert_eq!(model_count, 1, "重名扩展命令应被去重:{commands:?}");
let mut st = App::new();
let quit = st.handle_slash_command("model");
assert!(!quit);
assert!(st.panel.active, "内置 /model 打开面板");
st.panel.close();
crate::core::extensions::set_extension_enabled("model-override-ext", false);
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn register_slash_command_adds_handler_dynamically() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
struct EchoCmdExt;
impl crate::core::extensions::Extension for EchoCmdExt {
fn name(&self) -> &str {
"echo-cmd-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<crate::core::extensions::ExtensionCommand> {
vec![crate::core::extensions::ExtensionCommand {
name: "echo-cmd".to_string(),
description: "echo".to_string(),
busy_safe: false,
subcommands: Vec::new(),
}]
}
}
crate::core::extensions::register_extension(EchoCmdExt);
crate::core::extensions::set_extension_enabled("echo-cmd-ext", true);
register_slash_command("echo-cmd-ext", "echo-cmd", |st, _| {
st.push_msg("echo-cmd executed".to_string(), MsgLevel::Info);
false
});
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
let mut st = App::new();
let quit = st.handle_slash_command("echo-cmd");
assert!(!quit);
let last_txt = st
.system_messages
.last()
.map(|(_, s)| crate::modes::interactive::app::sys_spans_text(&s.spans));
assert!(
last_txt
.as_deref()
.unwrap_or("")
.contains("echo-cmd executed"),
"got: {last_txt:?}"
);
crate::core::extensions::set_extension_enabled("echo-cmd-ext", false);
let names: Vec<String> = slash_commands().into_iter().map(|(n, _)| n).collect();
assert!(!names.contains(&"echo-cmd".to_string()), "got: {names:?}");
let mut st = App::new();
let quit = st.handle_slash_command("echo-cmd");
assert!(!quit);
let last = st.messages.last().unwrap().text().to_string();
assert_eq!(last, "/echo-cmd", "禁用后按未知命令");
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
}
#[test]
fn extension_detail_lines_display_commands() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _plan = crate::extensions::plan_mode::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _agent = plan_agent();
ensure_plan_registered();
cleanup_plan_state();
let lines = crate::core::extensions::extension_detail_lines("plan-mode");
assert!(
lines.iter().any(|l| l.contains("commands: plan")),
"got: {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("todos")),
"无 todos 不应列出 /todos: {lines:?}"
);
crate::extensions::plan_mode::set_enabled(true);
{
use crate::core::extensions::Extension;
let ext = crate::extensions::plan_mode::PlanMode::new();
ext.on_agent_event(&serde_json::json!({
"type": "agent_end",
"messages": [
{ "role": "assistant", "content": [{ "type": "text", "text": "Plan:\n1. Inspect code\n2. Verify result" }] }
]
}));
}
let _ = crate::core::extensions::take_pending_ui();
let lines = crate::core::extensions::extension_detail_lines("plan-mode");
assert!(
lines.iter().any(|l| l.contains("commands: plan, todos")),
"got: {lines:?}"
);
crate::extensions::plan_mode::reset_state_for_tests();
let dir = crate::core::settings_manager::agent_dir();
let _ = std::fs::remove_file(dir.join("plan-mode.json"));
}
#[test]
fn settings_command_opens_selector() {
let mut st = App::new();
st.panel.active = true;
let quit = st.handle_slash_command("settings");
assert!(!quit);
assert!(st.settings_selector.active);
assert!(!st.panel.active, "打开 settings 应关闭 panel");
assert!(!st.settings_selector.items.is_empty());
assert!(st.settings_selector.filtered.len() >= 8);
st.settings_selector.close();
}
#[test]
fn skills_extension_declares_and_registers_slash_command() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::core::extensions::register_extension(crate::extensions::skills::Skills::new());
crate::core::extensions::set_extension_enabled(crate::extensions::skills::EXT, true);
assert!(
slash_commands().iter().any(|(n, _)| n == "skills"),
"扩展应声明 /skills 候选: {:?}",
slash_commands()
);
assert!(
extension_command_handler("skills").is_some(),
"on_registered 应装好 /skills 的 TUI handler"
);
}
#[test]
fn slash_skill_command_expands_in_tui() {
use crate::core::skills::load_skills;
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills").join("demo");
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::write(
skills_dir.join("SKILL.md"),
"---\nname: demo\ndescription: d\n---\n# Body\n",
)
.unwrap();
let skills = load_skills(
dir.path().to_str().unwrap(),
dir.path(),
dir.path(),
&[],
&[],
true,
false,
&[],
);
assert!(!skills.is_empty(), "skills should be discovered");
let mut st = App::new();
st.skills = skills;
let quit = st.handle_slash_command("skill:demo do it");
assert!(!quit);
let last = st.messages.last().unwrap().text();
assert!(last.contains("<skill name=\"demo\""), "got: {}", last);
assert!(last.contains("# Body"), "got: {}", last);
assert!(last.ends_with("do it"), "got: {}", last);
}
#[test]
fn skill_commands_appear_in_slash_suggestions() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
use crate::core::skills::Skill;
let mut st = App::new();
st.skills = vec![
Skill {
name: "code-review".to_string(),
description: "review code".to_string(),
path: "/tmp/skills/code-review/SKILL.md".into(),
base_dir: "/tmp/skills/code-review".into(),
disable_model_invocation: false,
source: "/tmp/skills".to_string(),
},
Skill {
name: "demo".to_string(),
description: "test skill".to_string(),
path: "/tmp/skills/demo/SKILL.md".into(),
base_dir: "/tmp/skills/demo".into(),
disable_model_invocation: true,
source: "/tmp/skills".to_string(),
},
];
st.editor.insert_text("/skill:");
st.refresh_suggestions();
assert!(st.suggestion.active);
assert_eq!(st.suggestion.kind, SuggestionKind::Commands);
let names: Vec<&str> = st
.suggestion
.items
.iter()
.map(|i| i.name.as_str())
.collect();
assert!(names.contains(&"skill:code-review"), "got: {:?}", names);
assert!(names.contains(&"skill:demo"), "got: {:?}", names);
assert!(
st.suggestion
.items
.iter()
.any(|i| i.name == "skill:code-review" && i.description == "review code")
);
st.editor.clear();
st.editor.insert_text("/review");
st.refresh_suggestions();
assert!(
st.suggestion
.items
.iter()
.any(|i| i.name == "skill:code-review")
);
}
#[test]
fn session_switch_clears_old_system_prompts() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (agent, mut cmd_rx) = test_agent_cmd();
let agent_dir = crate::core::settings_manager::agent_dir();
let dir = crate::core::session_manager::default_session_dir("/tmp", &agent_dir);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let mut s1 =
crate::core::session_manager::Session::create("/tmp", Some(dir.clone()), true).unwrap();
s1.append_message(&crate::core::provider::AgentMessage {
role: "assistant".into(),
thinking_level: None,
content: vec![],
..crate::core::provider::AgentMessage::user_text("")
});
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s2 =
crate::core::session_manager::Session::create("/tmp", Some(dir.clone()), true).unwrap();
s2.append_message(&crate::core::provider::AgentMessage {
role: "assistant".into(),
thinking_level: None,
content: vec![],
..crate::core::provider::AgentMessage::user_text("")
});
let s2_file = s2
.get_session_file()
.map(|p| p.to_string_lossy().to_string())
.unwrap();
let mut st = App::new();
st.worker = agent;
st.cwd = "/tmp".to_string(); st.system_messages.push((
1,
crate::modes::interactive::app::SysMsg::new(
MsgLevel::Info,
vec![crate::modes::interactive::app::SysSpan::plain(
"stale prompt",
)],
),
));
let s1_file = s1
.get_session_file()
.map(|p| p.to_string_lossy().to_string())
.unwrap();
st.handle_slash_command("session 1");
let cmd1 = cmd_rx.try_recv().expect("第一次应发 ResumeSession");
assert!(
matches!(cmd1, crate::modes::interactive::agent_actor::AgentCommand::ResumeSession { ref path, .. } if *path == s2_file),
"应切到最新会话: {cmd1:?}"
);
st.handle_slash_command("session 2");
let cmd2 = cmd_rx.try_recv().expect("第二次应发 ResumeSession");
assert!(
matches!(cmd2, crate::modes::interactive::agent_actor::AgentCommand::ResumeSession { ref path, .. } if *path == s1_file),
"应切到较旧会话: {cmd2:?}"
);
}
#[test]
fn cmd_arg_preserves_argument_case() {
assert_eq!(
cmd_arg(
"import /home/blue/2026-09-04T13-38-45-057Z_a.jsonl",
"import"
),
"/home/blue/2026-09-04T13-38-45-057Z_a.jsonl"
);
assert_eq!(
cmd_arg("Import /Home/Case.jsonl", "import"),
"/Home/Case.jsonl"
);
assert_eq!(cmd_arg("name mySession", "name"), "mySession");
assert_eq!(cmd_arg("thinking high", "thinking"), "high");
assert_eq!(cmd_arg("model gpt-4.1", "model"), "gpt-4.1");
assert_eq!(cmd_arg("import", "import"), "", "无参数应为空");
}
#[test]
fn export_path_arg_parses_prefix_and_quotes() {
assert_eq!(
parse_path_arg("export ~/out.html"),
Some("~/out.html".to_string())
);
assert_eq!(
parse_path_arg("export \"/tmp/a b.html\""),
Some("/tmp/a b.html".to_string())
);
assert_eq!(parse_path_arg("export"), None, "无空格无参数");
assert_eq!(parse_path_arg("export "), None, "仅前缀无参数");
assert_eq!(parse_path_arg("export foo bar"), Some("foo".to_string()));
}
#[test]
fn compact_defers_without_text_area_prompt() {
let mut st = App::new();
st.handle_slash_command("compact");
assert!(st.pending_compact, "应待执行压缩");
assert!(st.compact_instructions.is_none(), "无参数不留自定义指令");
assert!(st.system_messages.is_empty(), "压缩中不应在文本区推送提示");
let mut st = App::new();
st.handle_slash_command("compact focus on API changes");
assert!(st.pending_compact);
assert_eq!(
st.compact_instructions.as_deref(),
Some("focus on API changes")
);
assert!(st.system_messages.is_empty());
}
}