M5: slash commands (command/*.md)

Adds slash-command support: markdown command files under command/*.md, expansion in harness-app, and TUI input handling so typing /mycommand runs it.
This commit is contained in:
2026-07-10 12:21:01 +02:00
parent d6af65f494
commit dbc676332a
2 changed files with 170 additions and 13 deletions
+100 -11
View File
@@ -162,6 +162,8 @@ struct EngineInner {
/// Pre-rendered "## Skills" system block (name + description), injected into every run.
/// `None` when no skills are configured.
skills_prompt: Option<String>,
/// Slash commands by name, expanded into the user message by the TUI input layer.
commands: HashMap<String, config::CommandDef>,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -228,6 +230,13 @@ impl EngineHandle {
harness_tools::register_skill_tool(&mut tools, &skills);
let skills_prompt = config::skills_prompt(&skills);
// Slash commands: layered global + project markdown, expanded into the user message by
// the TUI input layer (project wins by name).
let global_command_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("command"));
let project_command_dir = cwd.join(".harness").join("command");
let commands =
config::load_commands(global_command_dir.as_deref(), Some(&project_command_dir));
// Layered agent registry: bundled markdown → global dir → project dir → config patches.
let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent"));
let project_agent_dir = cwd.join(".harness").join("agent");
@@ -257,8 +266,9 @@ impl EngineHandle {
extensions: c.extensions.clone(),
})
.collect();
let diagnostics: Option<Arc<dyn DiagnosticsSource>> =
Some(Arc::new(harness_lsp::LspPool::new(cwd.clone(), lsp_servers)));
let diagnostics: Option<Arc<dyn DiagnosticsSource>> = Some(Arc::new(
harness_lsp::LspPool::new(cwd.clone(), lsp_servers),
));
let inner = Arc::new(EngineInner {
config,
@@ -271,6 +281,7 @@ impl EngineHandle {
agents,
diagnostics,
skills_prompt,
commands,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -302,6 +313,11 @@ impl EngineHandle {
self.inner.config.clone()
}
/// A loaded slash command by name (without the leading `/`), if defined.
pub fn command(&self, name: &str) -> Option<config::CommandDef> {
self.inner.commands.get(name).cloned()
}
pub async fn new_session(&self, agent: &str, model_ref: &str) -> Result<SessionId, AppError> {
let (provider_id, model_id) = model_ref
.split_once('/')
@@ -320,6 +336,18 @@ impl EngineHandle {
session_id: SessionId,
text: String,
model_ref: &str,
) -> Result<(), AppError> {
self.prompt_with(session_id, text, model_ref, None).await
}
/// Like [`prompt`](Self::prompt) but lets a slash command override the agent for this one
/// run (`agent_override`) without mutating the stored session agent.
pub async fn prompt_with(
&self,
session_id: SessionId,
text: String,
model_ref: &str,
agent_override: Option<&str>,
) -> Result<(), AppError> {
let (provider_id, model_id) = model_ref
.split_once('/')
@@ -366,14 +394,18 @@ impl EngineHandle {
.bus
.publish(AppEvent::PartUpdated { part: user_part });
// Resolve the session's agent from the registry (falls back to a generic assistant).
let session_agent = self
.inner
.store
.session(session_id.clone())
.await?
.map(|s| s.agent)
.unwrap_or_else(|| "orchestrator".to_string());
// Resolve the agent: a command's `agent:` override for this run, else the session's
// stored agent (falling back to a generic assistant).
let session_agent = match agent_override {
Some(name) => name.to_string(),
None => self
.inner
.store
.session(session_id.clone())
.await?
.map(|s| s.agent)
.unwrap_or_else(|| "orchestrator".to_string()),
};
let agent = self.inner.agent_def(&session_agent);
let inject_job_board = self.inner.config.orchestration.job_board && agent.mode.is_primary();
let board = self.inner.board_for(&session_id).await?;
@@ -1350,10 +1382,67 @@ mod tests {
// 6 built-ins + task + skill.
assert_eq!(engine.inner.tools.all().len(), 8);
assert!(engine.inner.tools.get("skill").is_some());
let prompt = engine.inner.skills_prompt.clone().expect("skills advertised");
let prompt = engine
.inner
.skills_prompt
.clone()
.expect("skills advertised");
assert!(prompt.contains("**fmt** — format the code"));
}
#[tokio::test]
async fn project_command_loads_and_expands() {
let dir = tempfile::tempdir().unwrap();
let cmd_dir = dir.path().join(".harness").join("command");
std::fs::create_dir_all(&cmd_dir).unwrap();
std::fs::write(
cmd_dir.join("greet.md"),
"---\ndescription: greet\nagent: explorer\n---\nHello $1, welcome.",
)
.unwrap();
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
let cmd = engine.command("greet").expect("command loaded");
assert_eq!(cmd.agent.as_deref(), Some("explorer"));
assert_eq!(cmd.expand("world"), "Hello world, welcome.");
assert!(engine.command("missing").is_none());
}
#[tokio::test]
async fn command_run_reaches_engine_with_agent_override() {
let dir = tempfile::tempdir().unwrap();
let engine = mock_engine(dir.path().to_path_buf());
spawn_auto_approve_task(&engine);
let session = engine
.new_session("orchestrator", "mock/mock-model")
.await
.unwrap();
// Drive the command path directly: expand + agent override for this run only.
engine
.prompt_with(
session.clone(),
"do the thing".into(),
"mock/mock-model",
Some("explorer"),
)
.await
.unwrap();
// Wait for the run to finish.
let mut rx = engine.bus().subscribe();
loop {
if let Ok(AppEvent::RunFinished { session_id, .. }) = rx.recv().await {
if session_id == session {
break;
}
}
}
// The stored session agent is untouched by the per-run override.
let stored = engine.get_session(session).await.unwrap().unwrap();
assert_eq!(stored.agent, "orchestrator");
}
#[tokio::test]
async fn run_prompt_errors_on_unregistered_provider() {
let dir = tempfile::tempdir().unwrap();
+70 -2
View File
@@ -9,7 +9,16 @@ use crate::state::{AppState, ModalState};
#[derive(Debug)]
pub enum InputAction {
None,
Submit { text: String },
Submit {
text: String,
},
/// A user-defined slash command: `name` (no leading `/`), its `args`, and the `raw` input
/// to fall back to submitting verbatim if no such command is defined.
RunCommand {
name: String,
args: String,
raw: String,
},
Abort,
Quit,
LoadSessions,
@@ -176,7 +185,13 @@ fn parse_slash_command(text: &str) -> Option<InputAction> {
"/sessions" => Some(InputAction::LoadSessions),
"/jobs" => Some(InputAction::OpenJobs),
"/quit" => Some(InputAction::Quit),
_ => None,
// Any other `/word` is treated as a user-defined command, resolved against the engine's
// loaded commands when applied; if none matches, the raw text is submitted as-is.
other => Some(InputAction::RunCommand {
name: other.trim_start_matches('/').to_string(),
args: rest,
raw: trimmed.to_string(),
}),
}
}
@@ -190,6 +205,30 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En
}
}
}
InputAction::RunCommand { name, args, raw } => {
let Some(session_id) = state.session_id.clone() else {
return;
};
match engine.command(&name) {
Some(cmd) => {
let text = cmd.expand(&args);
// A command may switch model/agent for this one run only.
let model_ref = cmd.model.clone().unwrap_or_else(|| state.model_ref.clone());
if let Err(e) = engine
.prompt_with(session_id, text, &model_ref, cmd.agent.as_deref())
.await
{
tracing::error!(error = %e, "command prompt failed");
}
}
// Unknown command: submit the original text as an ordinary message.
None => {
if let Err(e) = engine.prompt(session_id, raw, &state.model_ref).await {
tracing::error!(error = %e, "prompt failed");
}
}
}
}
InputAction::Abort => {
if let Some(session_id) = state.session_id.clone() {
engine.abort(&session_id);
@@ -317,4 +356,33 @@ mod tests {
assert!(state.dirty, "a keystroke must request a redraw");
assert_eq!(state.input.lines().join("\n"), "x");
}
#[test]
fn builtin_slash_commands_still_parse() {
assert!(matches!(
parse_slash_command("/new"),
Some(InputAction::NewSession)
));
assert!(matches!(
parse_slash_command("/model openai/gpt-5"),
Some(InputAction::SetModel(m)) if m == "openai/gpt-5"
));
}
#[test]
fn unknown_slash_becomes_a_run_command() {
match parse_slash_command("/deploy prod now") {
Some(InputAction::RunCommand { name, args, raw }) => {
assert_eq!(name, "deploy");
assert_eq!(args, "prod now");
assert_eq!(raw, "/deploy prod now");
}
other => panic!("expected RunCommand, got {other:?}"),
}
}
#[test]
fn non_slash_text_is_not_a_command() {
assert!(parse_slash_command("hello world").is_none());
}
}