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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user