#[allow(unused_imports)]
use crate::sync_util::LockExt;
use crossterm::style::Color;
use smallvec::SmallVec;
use crate::cli::Cli;
use crate::config::Config;
use crate::context::ContextFiles;
#[cfg(feature = "mcp")]
use crate::extras::mcp::McpClientManager;
use crate::permission::ask::AskSender;
use crate::permission::checker::PermCheck;
use crate::provider::{AnyAgent, AnyClient};
use crate::sandbox::Sandbox;
#[cfg(feature = "semantic")]
use crate::semantic::SemanticManager;
use crate::session::{MessageRole, Session};
use crate::ui::events::render_session;
use crate::ui::input::InputEditor;
use crate::ui::renderer::Renderer;
use crate::ui::theme;
mod cmd;
#[cfg(feature = "slash-completion")]
mod completion;
#[cfg(feature = "slash-completion")]
pub use completion::{CompletionResult, format_completion_preview, ghost_suffix, try_complete};
#[cfg(all(feature = "slash-completion", feature = "plugin"))]
pub use completion::register_plugin_commands;
#[inline]
pub(super) fn c_agent() -> Color {
theme::agent()
}
#[inline]
pub(super) fn c_result() -> Color {
theme::result()
}
#[inline]
pub(super) fn c_error() -> Color {
theme::error()
}
pub(super) struct SlashCtx<'a> {
pub agent: &'a mut AnyAgent,
pub client: &'a AnyClient,
pub renderer: &'a mut Renderer,
pub session: &'a mut Session,
pub cli: &'a Cli,
pub cfg: &'a Config,
pub context: &'a mut ContextFiles,
pub show_reasoning: &'a mut bool,
pub is_running: &'a mut bool,
pub input: &'a mut InputEditor,
pub permission: &'a Option<PermCheck>,
pub ask_tx: &'a Option<AskSender>,
pub question_tx: &'a Option<crate::agent::tools::question::QuestionSender>,
pub plan_tx: &'a Option<crate::agent::tools::plan::PlanSwitchSender>,
pub todo_tools_enabled: &'a mut bool,
pub bg_store: &'a Option<crate::agent::tools::background::BackgroundStore>,
pub sandbox: &'a Sandbox,
#[cfg(unix)]
pub user_tx: &'a tokio::sync::mpsc::UnboundedSender<crate::event::UserEvent>,
#[cfg(feature = "loop")]
pub loop_state: &'a mut Option<crate::extras::r#loop::LoopState>,
#[cfg(feature = "mcp")]
pub mcp_manager: Option<&'a McpClientManager>,
#[cfg(feature = "semantic")]
pub semantic_manager: Option<&'a SemanticManager>,
#[cfg(feature = "lsp")]
pub lsp_manager: Option<&'a std::sync::Arc<crate::lsp::manager::LspManager>>,
pub plan_phase: &'a mut Option<crate::agent::plan::runtime::PlanPhaseHandle>,
}
fn align_cut_to_user_boundary(
messages: &[crate::session::SessionMessage],
cut_idx: usize,
) -> usize {
let mut i = cut_idx;
while i < messages.len() && messages[i].role != MessageRole::User {
i += 1;
}
i
}
#[derive(Debug, Default)]
pub struct UndoOutcome {
pub removed: usize,
pub had_tool_calls: bool,
}
pub fn undo_last(session: &mut Session) -> UndoOutcome {
let len = session.messages.len();
if len == 0 {
return UndoOutcome::default();
}
let mut outcome = UndoOutcome::default();
let pop = |session: &mut Session, outcome: &mut UndoOutcome| {
if let Some(last) = session.messages.last()
&& !last.tool_calls.is_empty()
{
outcome.had_tool_calls = true;
}
session.pop_last_message();
outcome.removed += 1;
};
if session.messages[len - 1].role == MessageRole::Assistant {
pop(session, &mut outcome);
if session
.messages
.last()
.is_some_and(|m| m.role == MessageRole::User)
{
pop(session, &mut outcome);
}
return outcome;
}
if session.messages[len - 1].role == MessageRole::User {
pop(session, &mut outcome);
}
outcome
}
pub enum CompressOutcome {
Compacted,
NoOp { reason: &'static str },
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_compress(
instructions: Option<&str>,
forced: bool,
agent: &mut AnyAgent,
client: &AnyClient,
renderer: &mut Renderer,
session: &mut Session,
cli: &Cli,
cfg: &Config,
context: &mut ContextFiles,
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
question_tx: &Option<crate::agent::tools::question::QuestionSender>,
plan_tx: &Option<crate::agent::tools::plan::PlanSwitchSender>,
_user_tx: &tokio::sync::mpsc::UnboundedSender<crate::event::UserEvent>,
bg_store: &Option<crate::agent::tools::background::BackgroundStore>,
sandbox: &Sandbox,
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
#[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>,
#[cfg(feature = "lsp")] lsp_manager: Option<&std::sync::Arc<crate::lsp::manager::LspManager>>,
) -> anyhow::Result<CompressOutcome> {
renderer.write_line("compressing...", c_agent())?;
renderer.write_line("", Color::White)?;
let reserve = cfg.resolve_reserve_tokens();
let keep_recent = cfg.resolve_keep_recent_tokens();
let max_tokens = session.context_window.saturating_sub(reserve);
if !forced && session.total_estimated_tokens <= max_tokens {
renderer.write_line("context within limits, no compression needed", c_agent())?;
return Ok(CompressOutcome::NoOp {
reason: "context within limits",
});
}
let mut accumulated = 0u64;
let mut cut_idx = session.messages.len();
for (i, msg) in session.messages.iter().enumerate().rev() {
if accumulated >= keep_recent {
cut_idx = i + 1;
break;
}
accumulated = accumulated.saturating_add(msg.estimated_tokens);
}
cut_idx = align_cut_to_user_boundary(&session.messages, cut_idx);
if cut_idx == 0 {
renderer.write_line("nothing to compress (entire context is recent)", c_agent())?;
return Ok(CompressOutcome::NoOp {
reason: "entire context is within keep_recent_tokens — lower it to compress further",
});
}
let messages_to_summarize = &session.messages[..cut_idx];
let previous_summary = session.compactions.last().map(|c| c.summary.as_str());
let provider_insights = agent.memory_provider().map(|p| {
let pre_compress_transcript =
crate::agent::review::build_transcript_from_slice(messages_to_summarize);
crate::agent::review::fire_pre_compress(p.as_ref(), &pre_compress_transcript)
});
let augmented_instructions: Option<String> = match (instructions, provider_insights) {
(Some(user), Some(extra)) if !extra.trim().is_empty() => {
Some(format!("{}\n\nProvider insights:\n{}", user, extra))
}
(None, Some(extra)) if !extra.trim().is_empty() => {
Some(format!("Provider insights:\n{}", extra))
}
(Some(user), _) => Some(user.to_string()),
_ => None,
};
let summary = client
.compress_messages(
&session.model,
messages_to_summarize,
previous_summary,
augmented_instructions.as_deref(),
)
.await?;
let tokens_before: u64 = messages_to_summarize
.iter()
.map(|m| m.estimated_tokens)
.sum();
let summary_tokens_est = crate::session::Session::estimate_tokens(&summary);
let net_saved: i64 = tokens_before as i64 - summary_tokens_est as i64;
if net_saved < 0 {
renderer.write_line(
&format!(
"compress aborted — summary ({}t) is LARGER than the {} messages it would replace ({}t); net cost +{}t. Compression rejected. Consider lowering keep_recent_tokens or refining compress instructions, then re-run /compress.",
summary_tokens_est,
cut_idx,
tokens_before,
-net_saved,
),
c_error(),
)?;
return Ok(CompressOutcome::NoOp {
reason: "summary would be larger than the messages it replaces",
});
}
let pruned_branches = session.compress_reporting(summary, cut_idx, tokens_before);
let model = client.completion_model(session.model.to_string());
*agent = crate::provider::build_agent(
model,
cli,
cfg,
context,
permission.clone(),
ask_tx.clone(),
question_tx.clone(),
plan_tx.clone(),
bg_store.clone(),
#[cfg(feature = "lsp")]
lsp_manager.cloned(),
sandbox.clone(),
#[cfg(feature = "mcp")]
mcp_manager,
#[cfg(feature = "semantic")]
semantic_manager,
Some(session.id.to_string()),
)
.await;
renderer.write_line("prompt cleared (back to default behavior)", c_agent())?;
render_session(renderer, session, cli, cfg, context)?;
if pruned_branches > 0 {
renderer.write_line(
&format!(
"discarded {} forked branch node{} that were rooted in the compressed region",
pruned_branches,
if pruned_branches == 1 { "" } else { "s" },
),
c_error(),
)?;
}
{
renderer.write_line(
&format!(
"compressed {} messages (saved ~{} tokens; summary uses {}t)",
cut_idx, net_saved, summary_tokens_est,
),
c_agent(),
)?;
}
Ok(CompressOutcome::Compacted)
}
fn split_command_parts(text: &str) -> SmallVec<[&str; 3]> {
text.split_whitespace().collect()
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_slash(
text: &str,
agent: &mut AnyAgent,
client: &AnyClient,
renderer: &mut Renderer,
session: &mut Session,
cli: &Cli,
cfg: &Config,
context: &mut ContextFiles,
show_reasoning: &mut bool,
is_running: &mut bool,
input: &mut InputEditor,
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
question_tx: &Option<crate::agent::tools::question::QuestionSender>,
plan_tx: &Option<crate::agent::tools::plan::PlanSwitchSender>,
todo_tools_enabled: &mut bool,
bg_store: &Option<crate::agent::tools::background::BackgroundStore>,
sandbox: &Sandbox,
#[cfg(unix)] user_tx: &tokio::sync::mpsc::UnboundedSender<crate::event::UserEvent>,
#[cfg(feature = "loop")] loop_state: &mut Option<crate::extras::r#loop::LoopState>,
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
#[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>,
#[cfg(feature = "lsp")] lsp_manager: Option<&std::sync::Arc<crate::lsp::manager::LspManager>>,
plan_phase: &mut Option<crate::agent::plan::runtime::PlanPhaseHandle>,
) -> anyhow::Result<()> {
let parts: SmallVec<[&str; 3]> = split_command_parts(text);
let mut ctx = SlashCtx {
agent,
client,
renderer,
session,
cli,
cfg,
context,
show_reasoning,
is_running,
input,
permission,
ask_tx,
question_tx,
plan_tx,
todo_tools_enabled,
bg_store,
sandbox,
#[cfg(unix)]
user_tx,
#[cfg(feature = "loop")]
loop_state,
#[cfg(feature = "mcp")]
mcp_manager,
#[cfg(feature = "semantic")]
semantic_manager,
#[cfg(feature = "lsp")]
lsp_manager,
plan_phase,
};
match parts[0] {
"/model" => cmd::model::cmd_model(&mut ctx, &parts).await?,
"/sessions" => cmd::sessions::cmd_sessions(&mut ctx, &parts).await?,
"/reasoning" => cmd::model::cmd_reasoning(&mut ctx).await?,
"/mode" => cmd::mode::cmd_mode(&mut ctx, &parts).await?,
#[cfg(feature = "mcp")]
"/mcp" => cmd::mcp::cmd_mcp(&mut ctx, &parts).await?,
"/toggle" => cmd::toggle::cmd_toggle(&mut ctx, &parts).await?,
"/compress" | "/compact" => {
let instructions = if parts.len() > 1 {
Some(parts[1..].join(" "))
} else {
None
};
let instr_str = instructions.clone().unwrap_or_default();
return Err(anyhow::anyhow!("DEFER_COMPRESS:{}", instr_str));
}
"/loop" => cmd::loop_cmd::cmd_loop(&mut ctx, &parts, text).await?,
"/prompt" => cmd::prompt::cmd_prompt(&mut ctx, &parts).await?,
"/agent" | "/agents" => cmd::agent::cmd_agent(&mut ctx, &parts).await?,
"/plan" => cmd::plan::cmd_plan(&mut ctx, &parts, text).await?,
"/plugins" => cmd::plugins::cmd_plugins(&mut ctx).await?,
#[cfg(feature = "git-worktree")]
"/worktree" => cmd::worktree::cmd_worktree(&mut ctx, &parts).await?,
#[cfg(feature = "git-worktree")]
"/wt-merge" => return cmd::worktree::cmd_wt_merge(&mut ctx, &parts).await,
#[cfg(feature = "git-worktree")]
"/wt-exit" => return cmd::worktree::cmd_wt_exit(&mut ctx, &parts).await,
"/regen-prompts" => cmd::regen::cmd_regen_prompts(&mut ctx).await?,
"/quit" => return cmd::quit::cmd_quit(&mut ctx).await,
"/spec" => cmd::spec::cmd_spec(&mut ctx, &parts).await?,
"/tasks" => cmd::tasks::cmd_tasks(&mut ctx).await?,
"/cache" => cmd::cache::cmd_cache(&mut ctx).await?,
"/clear" => cmd::clear::cmd_clear(&mut ctx).await?,
"/tree" => cmd::tree::cmd_tree(&mut ctx, &parts).await?,
"/fork" => cmd::fork::cmd_fork(&mut ctx, &parts).await?,
"/clone" => cmd::clone::cmd_clone(&mut ctx, &parts).await?,
"/panel" => cmd::panel::cmd_panel(&mut ctx, &parts).await?,
"/display" => cmd::panel::cmd_display(&mut ctx, &parts).await?,
"/btw" => cmd::btw::cmd_btw(&mut ctx, &parts).await?,
"/cd" => cmd::cd::cmd_cd(&mut ctx, text).await?,
"/undo" => cmd::undo::cmd_undo(&mut ctx).await?,
"/retry" => cmd::retry::cmd_retry(&mut ctx).await?,
"/allow" => cmd::allow::cmd_allow(&mut ctx, &parts, text).await?,
"/why" => cmd::allow::why::cmd_why(&mut ctx, &parts).await?,
"/help" => cmd::help::cmd_help(&mut ctx).await?,
"/memory" => cmd::memory::cmd_memory(&mut ctx, &parts).await?,
"/kill" => cmd::kill::cmd_kill(&mut ctx, &parts).await?,
#[cfg(unix)]
"/sandbox" => cmd::sandbox::cmd_sandbox(&mut ctx, &parts).await?,
#[cfg(feature = "dap")]
"/debug" => cmd::debug::cmd_debug(&mut ctx, &parts).await?,
#[cfg(feature = "dap")]
"/dap-repl" => cmd::debug::cmd_dap_repl(&mut ctx, &parts).await?,
_ => {
if is_known_slash_command(parts[0]) {
ctx.renderer.write_line(
&format!(
"internal error: {} is listed in slash_command_names() but has no dispatch arm in handle_slash — wire it up or remove from the list",
parts[0]
),
c_error(),
)?;
return Ok(());
}
#[cfg(feature = "plugin")]
if let Some(pm_arc) = crate::plugin::hook::global() {
let cmd = parts[0].trim_start_matches('/');
let args = parts.get(1..).map(|p| p.join(" ")).unwrap_or_default();
let handler = {
let mut mgr = pm_arc.lock_ignore_poison();
mgr.list_commands()
.into_iter()
.find(|(name, _)| name == cmd)
.map(|(_, h)| h)
};
if let Some(handler_fn) = handler {
let result = {
let mut mgr = pm_arc.lock_ignore_poison();
mgr.invoke_command(&handler_fn, &args)
};
match result {
Ok(Some(text)) => {
let safe = crate::ui::ansi::strip_escapes(
&text,
crate::ui::ansi::StripPolicy::KEEP_NEWLINE,
);
for line in safe.lines() {
ctx.renderer.write_line(line, c_agent())?;
}
}
Ok(None) => {
}
Err(e) => {
ctx.renderer.write_line(
&format!("[plugin] {} failed: {}", cmd, e),
c_error(),
)?;
}
}
return Ok(());
}
}
ctx.renderer.write_line(
&format!("unknown command: {} (try /help)", parts[0]),
c_error(),
)?;
}
}
Ok(())
}
pub fn slash_command_names() -> Vec<&'static str> {
let mut cmds = vec![
"/agent",
"/agents",
"/allow",
"/btw",
"/cache",
"/cd",
"/clear",
"/clone",
"/compact",
"/compress",
"/display",
"/fork",
"/help",
"/kill",
"/memory",
"/mode",
"/model",
"/panel",
"/plan",
"/plugins",
"/prompt",
"/quit",
"/reasoning",
"/regen-prompts",
"/retry",
#[cfg(unix)]
"/sandbox",
"/sessions",
"/spec",
"/tasks",
"/toggle",
"/tree",
"/undo",
"/why",
];
#[cfg(feature = "git-worktree")]
{
cmds.push("/worktree");
cmds.push("/wt-exit");
cmds.push("/wt-merge");
}
#[cfg(feature = "mcp")]
cmds.push("/mcp");
cmds.push("/loop");
#[cfg(feature = "dap")]
cmds.push("/debug");
#[cfg(feature = "dap")]
cmds.push("/dap-repl");
cmds.sort_unstable();
cmds
}
pub fn is_known_slash_command(name: &str) -> bool {
slash_command_names().contains(&name)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "slash-completion")]
use super::completion::all_commands;
use super::*;
use crate::session::{Session, SessionMessage};
#[test]
fn split_command_parts_collapses_extra_whitespace() {
assert_eq!(
split_command_parts("/sessions delete abc123").as_slice(),
["/sessions", "delete", "abc123"]
);
assert_eq!(
split_command_parts("/sessions delete abc123").as_slice(),
["/sessions", "delete", "abc123"]
);
assert_eq!(
split_command_parts(" /toggle thinking on ").as_slice(),
["/toggle", "thinking", "on"]
);
let p = split_command_parts("/compress keep the auth flow");
assert_eq!(p[0], "/compress");
assert_eq!(p[1..].join(" "), "keep the auth flow");
}
#[cfg(feature = "slash-completion")]
#[test]
fn ghost_suffix_completes_a_unique_prefix() {
assert_eq!(ghost_suffix("/disp").as_deref(), Some("lay"));
}
#[cfg(feature = "slash-completion")]
#[test]
fn ghost_suffix_returns_none_when_not_completable() {
assert_eq!(ghost_suffix("/"), None); assert_eq!(ghost_suffix("not-a-command"), None); assert_eq!(ghost_suffix("/display extra"), None); assert_eq!(ghost_suffix("/zzzznope"), None); }
fn msg(role: MessageRole, content: &str) -> SessionMessage {
let mut s = Session::new("p", "m", 0);
s.add_message(role, content);
s.messages.pop().unwrap()
}
#[test]
fn align_cut_advances_past_assistant_to_next_user() {
let messages = vec![
msg(MessageRole::User, "u0"),
msg(MessageRole::Assistant, "a0"),
msg(MessageRole::User, "u1"),
msg(MessageRole::Assistant, "a1"), msg(MessageRole::User, "u2"),
msg(MessageRole::Assistant, "a2"),
];
assert_eq!(align_cut_to_user_boundary(&messages, 3), 4);
}
#[test]
fn align_cut_idempotent_when_already_on_user() {
let messages = vec![
msg(MessageRole::User, "u0"),
msg(MessageRole::Assistant, "a0"),
msg(MessageRole::User, "u1"),
];
assert_eq!(align_cut_to_user_boundary(&messages, 2), 2);
assert_eq!(align_cut_to_user_boundary(&messages, 0), 0);
}
#[test]
fn align_cut_past_end_clamps() {
let messages = vec![msg(MessageRole::User, "u0")];
assert_eq!(align_cut_to_user_boundary(&messages, 1), 1);
assert_eq!(align_cut_to_user_boundary(&messages, 5), 5);
}
#[test]
fn align_cut_returns_end_when_no_user_in_tail() {
let messages = vec![
msg(MessageRole::User, "u0"),
msg(MessageRole::Assistant, "a0"),
msg(MessageRole::System, "system note"),
msg(MessageRole::Assistant, "a1"),
];
assert_eq!(align_cut_to_user_boundary(&messages, 2), messages.len());
}
#[test]
fn align_cut_skips_system_to_user() {
let messages = vec![
msg(MessageRole::System, "prior summary"),
msg(MessageRole::User, "u0"),
msg(MessageRole::Assistant, "a0"),
];
assert_eq!(align_cut_to_user_boundary(&messages, 0), 1);
}
#[cfg(feature = "slash-completion")]
#[test]
fn no_completion_without_slash() {
assert!(try_complete("hello", 5).is_none());
}
#[cfg(feature = "slash-completion")]
#[test]
fn empty_buffer_returns_none() {
assert!(try_complete("", 0).is_none());
}
#[cfg(feature = "slash-completion")]
#[test]
fn complete_partial_command() {
let r = try_complete("/mod", 4).unwrap();
assert_eq!(r.new_buffer, "/mode");
assert_eq!(r.new_cursor, 5);
}
#[cfg(feature = "slash-completion")]
#[test]
fn cycles_between_partial_matches() {
let r = try_complete("/mod", 4).unwrap();
assert!(r.new_buffer.starts_with("/mod"));
}
#[cfg(feature = "slash-completion")]
#[test]
fn cycles_beyond_single_match() {
let r1 = try_complete("/", 1).unwrap();
let r2 = try_complete(&r1.new_buffer, r1.new_cursor).unwrap();
assert_ne!(r1.new_buffer, r2.new_buffer);
assert!(!r2.new_buffer.is_empty());
assert!(r2.new_buffer.starts_with('/'));
}
#[cfg(feature = "slash-completion")]
#[test]
fn cycles_from_full_command() {
let r = try_complete("/btw", 4).unwrap();
assert_ne!(r.new_buffer, "/btw");
assert!(r.new_buffer.starts_with('/'));
}
#[cfg(feature = "slash-completion")]
#[test]
fn cycles_through_all_commands() {
let mut seen = std::collections::HashSet::new();
let mut buf = "/".to_string();
let mut cur = 1;
for _ in 0..100 {
let result = try_complete(&buf, cur);
if result.is_none() {
break;
}
let r = result.unwrap();
buf = r.new_buffer;
cur = r.new_cursor;
seen.insert(buf.clone());
}
let all = all_commands();
assert_eq!(
seen.len(),
all.len(),
"should cycle through all builtin commands"
);
}
#[cfg(feature = "slash-completion")]
#[test]
fn unknown_command_returns_none() {
assert!(try_complete("/nonexistent", 12).is_none());
}
#[cfg(feature = "slash-completion")]
#[test]
fn commands_are_sorted() {
let cmds = all_commands();
for pair in cmds.windows(2) {
assert!(
pair[0] <= pair[1],
"{} should be before {}",
pair[0],
pair[1]
);
}
}
#[cfg(feature = "slash-completion")]
#[test]
fn preview_includes_upcoming_commands() {
let r = try_complete("/", 1).unwrap();
let all = &r.all_commands;
let cur = r.current_index;
let upcoming = &all[(cur + 1)..];
assert!(
!upcoming.is_empty(),
"should have commands after the current one"
);
}
#[cfg(feature = "slash-completion")]
#[test]
fn complete_with_cursor_mid_word_produces_clean_buffer() {
let r = try_complete("/mod", 2).unwrap();
let candidates = all_commands()
.into_iter()
.filter(|c| c.starts_with("/mod"))
.collect::<Vec<_>>();
assert!(
candidates.contains(&r.new_buffer),
"{:?} must be one of the /mod* commands {:?} — no Frankenstein concatenation",
r.new_buffer,
candidates,
);
assert_eq!(
r.new_cursor,
r.new_buffer.len(),
"cursor should land at end of replacement",
);
}
#[cfg(feature = "slash-completion")]
#[test]
fn complete_with_cursor_at_start_produces_clean_buffer() {
let r = try_complete("/mod", 0).unwrap();
let candidates = all_commands()
.into_iter()
.filter(|c| c.starts_with("/mod"))
.collect::<Vec<_>>();
assert!(
candidates.contains(&r.new_buffer),
"{:?} must be a /mod* command (clean replacement, no /mod residual): candidates {:?}",
r.new_buffer,
candidates,
);
}
#[cfg(feature = "slash-completion")]
#[test]
fn complete_preserves_trailing_args() {
let r = try_complete("/mod standard", 4).unwrap();
assert!(
r.new_buffer.ends_with(" standard"),
"args after the command should be preserved: {:?}",
r.new_buffer
);
}
#[cfg(feature = "slash-completion")]
#[test]
fn no_completion_when_cursor_in_args() {
let buf = "/btw some arbitrary text";
let cursor = buf.len();
assert!(try_complete(buf, cursor).is_none());
}
#[test]
fn is_known_slash_command_agrees_with_canonical_list() {
for name in slash_command_names() {
assert!(
is_known_slash_command(name),
"{name} is in slash_command_names() but is_known_slash_command rejects it",
);
}
assert!(!is_known_slash_command("/not-a-real-command"));
assert!(!is_known_slash_command(""));
assert!(!is_known_slash_command("/"));
}
#[test]
fn slash_command_names_is_sorted() {
let cmds = slash_command_names();
for pair in cmds.windows(2) {
assert!(
pair[0] <= pair[1],
"{} should sort before {}",
pair[0],
pair[1]
);
}
}
#[test]
fn always_on_commands_appear_in_canonical_list() {
const ALWAYS_ON: &[&str] = &[
"/agent",
"/agents",
"/allow",
"/btw",
"/cd",
"/clear",
"/clone",
"/compact",
"/compress",
"/display",
"/fork",
"/help",
"/kill",
"/memory",
"/mode",
"/model",
"/panel",
"/plan",
"/plugins",
"/prompt",
"/quit",
"/reasoning",
"/regen-prompts",
"/retry",
"/sessions",
"/tasks",
"/toggle",
"/tree",
"/undo",
"/why",
];
let list = slash_command_names();
for name in ALWAYS_ON {
assert!(
list.contains(name),
"{name} must appear in slash_command_names() — it's an always-on dispatch arm in handle_slash",
);
}
}
}