feat: enforce allowed tools for custom commands

This commit is contained in:
Paulo Aboim Pinto
2026-05-28 00:38:21 +02:00
committed by Hunter Bown
parent 2e73634ab0
commit 568fbe2c54
10 changed files with 543 additions and 27 deletions
+2 -1
View File
@@ -31,7 +31,7 @@ mod skills;
mod stash;
mod status;
mod task;
mod user_commands;
pub mod user_commands;
use std::fmt::Write as _;
@@ -966,6 +966,7 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
///
/// `workspace` is used to also scan workspace-local command directories;
/// pass `None` when no workspace context is available.
#[allow(dead_code)]
pub fn all_command_names_matching(
prefix: &str,
workspace: Option<&std::path::Path>,
+281 -1
View File
@@ -5,6 +5,10 @@
//! (without `.md` extension) becomes a slash command. When invoked via
//! `/name`, the file contents are sent as a user message.
//!
//! Files may include optional YAML-like frontmatter between `---` markers.
//! Supported fields are `description`, `argument-hint`, and `allowed-tools`.
//! Frontmatter is stripped before the command body is sent to the model.
//!
//! ## Precedence
//!
//! Workspace-local directories shadow user-global by name:
@@ -95,6 +99,72 @@ pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
commands
}
pub(crate) fn parse_frontmatter(content: &str) -> (Vec<(String, String)>, &str) {
let Some(first_line_end) = content.find('\n') else {
return (Vec::new(), content);
};
let first = content[..first_line_end].trim_end_matches('\r');
if first.trim().chars().all(|ch| ch == '-') && first.trim().len() >= 3 {
let mut metadata = Vec::new();
let mut offset = first_line_end + 1;
let mut unclosed_body_start = None;
for raw_line in content[offset..].split_inclusive('\n') {
let line_start = offset;
let line = raw_line.trim_end_matches(['\r', '\n']);
offset += raw_line.len();
let trimmed = line.trim();
if unclosed_body_start.is_none() {
if trimmed.chars().all(|ch| ch == '-') && trimmed.len() >= 3 {
let body = content[offset..].trim_start_matches(['\r', '\n']);
return (metadata, body);
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_ascii_lowercase();
let raw_value = value.trim();
let value = if key == "allowed-tools" {
raw_value.to_string()
} else {
strip_matched_quotes(raw_value).to_string()
};
if !key.is_empty() {
metadata.push((key, value));
}
} else if !trimmed.is_empty() {
unclosed_body_start = Some(line_start);
}
}
}
let body_start = unclosed_body_start.unwrap_or(content.len());
let body = content[body_start..].trim_start_matches(['\r', '\n']);
return (metadata, body);
}
(Vec::new(), content)
}
fn strip_matched_quotes(value: &str) -> &str {
if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
return stripped;
}
if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
return stripped;
}
value
}
fn parse_allowed_tools(value: &str) -> Vec<String> {
value
.split(',')
.map(|tool| {
strip_matched_quotes(tool.trim())
.trim()
.to_ascii_lowercase()
})
.filter(|tool| !tool.is_empty())
.collect()
}
/// Check if the input matches a user-defined command and return the
/// content as a `SendMessage` action.
///
@@ -121,7 +191,23 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
for (name, content) in &user_commands {
if name == command {
let message = apply_template(content, args);
let (metadata, body) = parse_frontmatter(content);
app.goal.goal_objective = None;
app.goal.goal_started_at = None;
app.active_allowed_tools = None;
for (key, value) in &metadata {
match key.as_str() {
"description" => {
app.goal.goal_objective = Some(value.clone());
app.goal.goal_started_at = Some(std::time::Instant::now());
}
"allowed-tools" => {
app.active_allowed_tools = Some(parse_allowed_tools(value));
}
_ => {}
}
}
let message = apply_template(body, args);
return Some(CommandResult::action(AppAction::SendMessage(message)));
}
}
@@ -217,6 +303,30 @@ mod tests {
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
}
fn test_options(workspace: PathBuf) -> crate::tui::app::TuiOptions {
crate::tui::app::TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace,
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
}
}
#[test]
fn load_user_commands_scans_workspace_local_dir() {
let tmp = TempDir::new().unwrap();
@@ -363,4 +473,174 @@ mod tests {
"got: {matches:?}"
);
}
#[test]
fn frontmatter_is_stripped_before_dispatch() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"secure",
"---\ndescription: Secure scan\nallowed-tools: Bash, Read\n---\nRun $ARGUMENTS",
);
let mut app = App::new(test_options(ws), &Config::default());
let result = try_dispatch_user_command(&mut app, "/secure checks").unwrap();
match result.action {
Some(AppAction::SendMessage(msg)) => assert_eq!(msg, "Run checks"),
other => panic!("expected SendMessage action, got: {other:?}"),
}
}
#[test]
fn review_regression_unclosed_frontmatter_keeps_metadata_and_strips_header() {
let (metadata, body) = parse_frontmatter(
"---\ndescription: Broken command\nallowed-tools: Bash\nRun the safe body",
);
assert_eq!(
metadata,
vec![
("description".to_string(), "Broken command".to_string()),
("allowed-tools".to_string(), "Bash".to_string())
]
);
assert_eq!(body, "Run the safe body");
}
#[test]
fn review_regression_unclosed_frontmatter_without_metadata_strips_header() {
let (metadata, body) =
parse_frontmatter("---\nRun the command body without a closing delimiter");
assert!(metadata.is_empty());
assert_eq!(body, "Run the command body without a closing delimiter");
}
#[test]
fn review_regression_frontmatter_strips_only_matched_quote_pairs() {
let (metadata, body) = parse_frontmatter("---\ndescription: 'Read\"\n---\nrun");
assert_eq!(
metadata,
vec![("description".to_string(), "'Read\"".to_string())]
);
assert_eq!(body, "run");
}
#[test]
fn allowed_tools_frontmatter_sets_app_state() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"secure",
"---\nallowed-tools: Bash, Grep\n---\nrun tests",
);
let mut app = App::new(test_options(ws), &Config::default());
let _ = try_dispatch_user_command(&mut app, "/secure").unwrap();
assert_eq!(
app.active_allowed_tools,
Some(vec!["bash".to_string(), "grep".to_string()])
);
}
#[test]
fn review_regression_empty_allowed_tools_blocks_all_tools() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"locked",
"---\nallowed-tools: \"\"\n---\nrun nothing",
);
let mut app = App::new(test_options(ws), &Config::default());
let _ = try_dispatch_user_command(&mut app, "/locked").unwrap();
assert_eq!(app.active_allowed_tools, Some(Vec::new()));
}
#[test]
fn review_regression_allowed_tools_accepts_per_item_quotes() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"quoted",
"---\nallowed-tools: \"exec_shell\", 'read_file'\n---\nrun quoted tools",
);
let mut app = App::new(test_options(ws), &Config::default());
let _ = try_dispatch_user_command(&mut app, "/quoted").unwrap();
assert_eq!(
app.active_allowed_tools,
Some(vec!["exec_shell".to_string(), "read_file".to_string()])
);
}
#[test]
fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
let commands_dir = ws.join(".deepseek").join("commands");
write_command(
&commands_dir,
"described",
"---\ndescription: Scan repos\nallowed-tools: Bash\n---\nscan",
);
write_command(&commands_dir, "plain", "plain command");
let mut app = App::new(test_options(ws), &Config::default());
let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
assert_eq!(app.goal.goal_objective.as_deref(), Some("Scan repos"));
assert!(app.goal.goal_started_at.is_some());
assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
assert_eq!(app.goal.goal_objective, None);
assert_eq!(app.goal.goal_started_at, None);
assert_eq!(app.active_allowed_tools, None);
}
#[test]
fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
use crate::config::Config;
let tmp = TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
write_command(
&ws.join(".deepseek").join("commands"),
"git-scan",
"---\ndescription: Scan nested git repositories\nargument-hint: <root>\n---\nscan",
);
let mut app = App::new(test_options(ws.clone()), &Config::default());
let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
assert_eq!(
app.goal.goal_objective.as_deref(),
Some("Scan nested git repositories")
);
let commands = load_user_commands(Some(&ws));
let (_, content) = commands
.iter()
.find(|(name, _)| name == "git-scan")
.expect("git-scan command should load");
let (metadata, _) = parse_frontmatter(content);
assert!(metadata.contains(&(
"description".to_string(),
"Scan nested git repositories".to_string()
)));
assert!(metadata.contains(&("argument-hint".to_string(), "<root>".to_string())));
}
}
+9
View File
@@ -158,6 +158,9 @@ pub struct EngineConfig {
pub memory_path: PathBuf,
pub vision_config: Option<crate::config::VisionModelConfig>,
pub goal_objective: Option<String>,
/// Tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
pub allowed_tools: Option<Vec<String>>,
/// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`)
/// for the `## Environment` block in the system prompt. The
/// caller resolves this from `Settings` once at engine
@@ -223,6 +226,7 @@ impl Default for EngineConfig {
vision_config: None,
strict_tool_mode: false,
goal_objective: None,
allowed_tools: None,
locale_tag: "en".to_string(),
workshop: None,
search_provider: crate::config::SearchProvider::default(),
@@ -626,6 +630,7 @@ impl Engine {
approval_mode,
translation_enabled,
show_thinking,
allowed_tools,
} => {
self.handle_send_message(
content,
@@ -641,6 +646,7 @@ impl Engine {
approval_mode,
translation_enabled,
show_thinking,
allowed_tools,
)
.await;
}
@@ -848,6 +854,7 @@ impl Engine {
self.session.approval_mode,
self.config.translation_enabled,
self.config.show_thinking,
self.config.allowed_tools.clone(),
)
.await;
}
@@ -937,6 +944,7 @@ impl Engine {
approval_mode: crate::tui::approval::ApprovalMode,
translation_enabled: bool,
show_thinking: bool,
allowed_tools: Option<Vec<String>>,
) {
// Reset cancel token for fresh turn (in case previous was cancelled)
self.reset_cancel_token();
@@ -1034,6 +1042,7 @@ impl Engine {
false,
);
}
self.config.allowed_tools = allowed_tools;
self.session.reasoning_effort = reasoning_effort;
self.session.reasoning_effort_auto = reasoning_effort_auto;
self.session.auto_model = auto_model;
+86 -19
View File
@@ -1198,6 +1198,13 @@ impl Engine {
"Planning tool '{tool_name}' with input: {tool_input:?}"
));
let requested_tool_name = tool_name.clone();
let tool_def =
resolve_tool_definition(&mut tool_name, &tool_catalog, tool_registry);
if requested_tool_name != tool_name {
tool.name = tool_name.clone();
}
let interactive = (tool_name == "exec_shell"
&& tool_input
.get("interactive")
@@ -1229,25 +1236,10 @@ impl Engine {
)));
}
let requested_tool_name = tool_name.clone();
let mut tool_def = tool_catalog.iter().find(|def| def.name == tool_name);
// Resolve hallucinated tool names when the model emits a
// non-canonical variant (Read_file, readFile, read-file, etc.).
if tool_def.is_none()
&& let Some(registry) = tool_registry
&& let Some(canonical) = registry.resolve(&tool_name)
{
crate::logging::info(format!(
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
));
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
if tool_def.is_some() {
tool_name = canonical.to_string();
// Update the tool_uses entry so the result is
// attributed to the canonical name.
tool.name = tool_name.clone();
}
if !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) {
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is not in the allowed-tools list for the current command"
)));
}
if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) {
@@ -2122,6 +2114,40 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u
queued_completions > 0 || running_children > 0
}
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(allowed_tools) = allowed_tools else {
return true;
};
allowed_tools.contains(&tool_name.to_ascii_lowercase())
}
fn resolve_tool_definition<'a>(
tool_name: &mut String,
tool_catalog: &'a [Tool],
tool_registry: Option<&crate::tools::ToolRegistry>,
) -> Option<&'a Tool> {
let mut tool_def = tool_catalog
.iter()
.find(|def| def.name.as_str() == tool_name.as_str());
// Resolve hallucinated tool names before policy gates run, so aliases like
// ReadFile are checked against the canonical registered tool name.
if tool_def.is_none()
&& let Some(registry) = tool_registry
&& let Some(canonical) = registry.resolve(tool_name.as_str())
{
crate::logging::info(format!(
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
));
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
if tool_def.is_some() {
*tool_name = canonical.to_string();
}
}
tool_def
}
/// Issue #1727: decide whether to surface a "thinking-only, no output" status.
///
/// Reached when the assistant turn had no sendable content (no Text, no
@@ -2393,4 +2419,45 @@ mod tests {
"auto thinking should classify the user request, not stored metadata"
);
}
#[test]
fn allowed_tools_gate_blocks_unlisted_tool() {
let allowed = vec!["bash".to_string(), "grep".to_string()];
assert!(!command_allows_tool(Some(&allowed), "read"));
}
#[test]
fn allowed_tools_gate_allows_listed_tool_case_insensitively() {
let allowed = vec!["bash".to_string(), "read".to_string()];
assert!(command_allows_tool(Some(&allowed), "Read"));
}
#[test]
fn allowed_tools_gate_allows_all_tools_when_not_set() {
assert!(command_allows_tool(None, "write"));
}
#[test]
fn review_regression_allowed_tools_gate_blocks_all_tools_when_empty() {
let allowed = Vec::new();
assert!(!command_allows_tool(Some(&allowed), "bash"));
}
#[test]
fn review_regression_allowed_tools_gate_checks_canonical_tool_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf());
let registry = crate::tools::ToolRegistryBuilder::new()
.with_file_tools()
.build(context);
let catalog = registry.to_api_tools();
let mut tool_name = "ReadFile".to_string();
let tool_def = resolve_tool_definition(&mut tool_name, &catalog, Some(&registry));
assert!(tool_def.is_some());
assert_eq!(tool_name, "read_file");
let allowed = vec!["read_file".to_string()];
assert!(command_allows_tool(Some(&allowed), &tool_name));
}
}
+3
View File
@@ -32,6 +32,9 @@ pub enum Op {
approval_mode: ApprovalMode,
translation_enabled: bool,
show_thinking: bool,
/// Tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
allowed_tools: Option<Vec<String>>,
},
/// Cancel the current request
+2
View File
@@ -5280,6 +5280,7 @@ async fn run_exec_agent(
vision_config: config.vision_model_config(),
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
goal_objective: None,
allowed_tools: None,
locale_tag: crate::localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
@@ -5334,6 +5335,7 @@ async fn run_exec_agent(
mode,
model: effective_model.clone(),
goal_objective: None,
allowed_tools: None,
reasoning_effort: effective_reasoning_effort,
reasoning_effort_auto: auto_model,
auto_model,
+2
View File
@@ -1629,6 +1629,7 @@ impl RuntimeThreadManager {
auto_approve,
translation_enabled: false,
show_thinking,
allowed_tools: None,
approval_mode: if auto_approve {
crate::tui::approval::ApprovalMode::Auto
} else {
@@ -1989,6 +1990,7 @@ impl RuntimeThreadManager {
vision_config: self.config.vision_model_config(),
strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false),
goal_objective: None,
allowed_tools: None,
locale_tag: crate::localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
+4
View File
@@ -1108,6 +1108,9 @@ pub struct App {
pub goal: GoalState,
/// Session sub-state (cost, tokens, telemetry).
pub session: SessionState,
/// Active tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
pub active_allowed_tools: Option<Vec<String>>,
pub history: Vec<HistoryCell>,
pub history_version: u64,
/// Per-cell revision counter, kept in lockstep with `history`.
@@ -1856,6 +1859,7 @@ impl App {
viewport: ViewportState::default(),
goal: GoalState::default(),
session: SessionState::default(),
active_allowed_tools: None,
history: Vec::new(),
history_version: 0,
history_revisions: Vec::new(),
+3
View File
@@ -744,6 +744,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
app.goal.goal_completed,
),
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
allowed_tools: app.active_allowed_tools.clone(),
network_policy: config.network.clone().map(|toml_cfg| {
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
}),
@@ -1440,6 +1441,7 @@ async fn run_event_loop(
} => {
let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
app.suppress_stream_events_until_turn_complete = false;
app.active_allowed_tools = None;
if !matches!(status, crate::core::events::TurnOutcomeStatus::Completed)
|| draws_since_last_full_repaint >= PERIODIC_FULL_REPAINT_EVERY_N
{
@@ -4463,6 +4465,7 @@ async fn dispatch_user_message(
approval_mode: app.approval_mode,
translation_enabled: app.translation_enabled,
show_thinking: app.show_thinking,
allowed_tools: app.active_allowed_tools.clone(),
})
.await
{
+151 -6
View File
@@ -2080,14 +2080,26 @@ pub(crate) fn slash_completion_hints(
let mut entries: Vec<SlashMenuEntry> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let prefix_lower = prefix.to_ascii_lowercase();
let user_commands = if completing_skill_arg.is_none() {
commands::user_commands::load_user_commands(workspace)
} else {
Vec::new()
};
// ── Phase 1: prefix (starts_with) matches ─────────────────────────
// Highest priority — preserves existing exact-prefix completion.
if completing_skill_arg.is_none() {
for name in commands::all_command_names_matching(prefix, workspace) {
for name in all_command_names_matching_loaded(prefix, &user_commands) {
seen.insert(name.clone());
let command_key = name.trim_start_matches('/');
push_command_entry(&mut entries, &name, command_key, &prefix_lower, locale);
push_command_entry(
&mut entries,
&name,
command_key,
&prefix_lower,
locale,
&user_commands,
);
}
}
@@ -2106,7 +2118,14 @@ pub(crate) fn slash_completion_hints(
.any(|a| a.to_ascii_lowercase().contains(&prefix_lower));
if cmd_lower.contains(&prefix_lower) || alias_match {
seen.insert(name.clone());
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
push_command_entry(
&mut entries,
&name,
cmd.name,
&prefix_lower,
locale,
&user_commands,
);
}
}
}
@@ -2126,7 +2145,14 @@ pub(crate) fn slash_completion_hints(
.any(|a| fuzzy_chars_in_order(&prefix_lower, &a.to_ascii_lowercase()));
if fuzzy_chars_in_order(&prefix_lower, &cmd_lower) || alias_match {
seen.insert(name.clone());
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
push_command_entry(
&mut entries,
&name,
cmd.name,
&prefix_lower,
locale,
&user_commands,
);
}
}
}
@@ -2219,6 +2245,31 @@ pub(crate) fn slash_completion_hints(
entries.into_iter().take(limit).collect()
}
fn all_command_names_matching_loaded(
prefix: &str,
user_commands: &[(String, String)],
) -> Vec<String> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
let mut result: Vec<String> = commands::COMMANDS
.iter()
.filter(|cmd| {
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
})
.map(|cmd| format!("/{}", cmd.name))
.collect();
result.extend(
user_commands
.iter()
.filter(|(name, _)| name.starts_with(&prefix))
.map(|(name, _)| format!("/{name}")),
);
result.sort();
result.dedup();
result
}
/// Push a built-in command entry to the slash menu, resolving description
/// and alias hints.
fn push_command_entry(
@@ -2227,6 +2278,7 @@ fn push_command_entry(
command_key: &str,
prefix_lower: &str,
locale: crate::localization::Locale,
user_commands: &[(String, String)],
) {
let (description, alias_hint) = if let Some(info) = commands::get_command_info(command_key) {
let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
@@ -2256,7 +2308,25 @@ fn push_command_entry(
};
(desc, hint)
} else {
(String::from("User-defined command"), None)
let mut description = String::from("User-defined command");
let mut argument_hint = None;
if let Some((_, content)) = user_commands.iter().find(|(key, _)| key == command_key) {
let (metadata, _) = commands::user_commands::parse_frontmatter(content);
for (key, value) in metadata {
match key.as_str() {
"description" => description = value,
"argument-hint" => argument_hint = Some(value),
_ => {}
}
}
}
if let Some(hint) = argument_hint {
if !hint.trim().is_empty() {
description.push_str(" ");
description.push_str(hint.trim());
}
}
(description, None)
};
entries.push(SlashMenuEntry {
name: name.to_string(),
@@ -2501,7 +2571,8 @@ mod tests {
SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height,
composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area,
cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines,
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines,
wrap_text,
};
use crate::config::{ApiProvider, Config};
use crate::localization::Locale;
@@ -2761,6 +2832,80 @@ mod tests {
assert!(!hints.iter().any(|hint| hint.name == "/codewhale"));
}
#[test]
fn slash_completion_hints_use_user_command_frontmatter_description() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".deepseek").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("git-scan.md"),
"---\ndescription: Scan nested git repositories\n---\nscan",
)
.unwrap();
let hints = slash_completion_hints(
"/git",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/git-scan")
.expect("custom command should be present");
assert_eq!(entry.description, "Scan nested git repositories");
}
#[test]
fn slash_completion_hints_use_user_command_argument_hint() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".deepseek").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy.md"),
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy",
)
.unwrap();
let hints = slash_completion_hints(
"/deploy",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/deploy")
.expect("custom command should be present");
assert_eq!(entry.description, "Deploy target <env>");
}
#[test]
fn review_regression_push_command_entry_uses_preloaded_user_command_frontmatter() {
let user_commands = vec![(
"deploy".to_string(),
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy".to_string(),
)];
let mut entries = Vec::new();
push_command_entry(
&mut entries,
"/deploy",
"deploy",
"deploy",
Locale::En,
&user_commands,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "/deploy");
assert_eq!(entries[0].description, "Deploy target <env>");
}
#[test]
fn slash_completion_hints_hide_skills_from_top_level_menu() {
let cached_skills = vec![