feat(skill-command): route /skill:* through the submission keybinding

Makes `/skill:<name> [args]` work identically under both submission
keybindings, mirroring how free text is already routed during streaming:

- `/skill:foo` + Enter, streaming     -> steer queue (interrupt)
- `/skill:foo` + Ctrl+Enter, streaming -> followUp queue
- `/skill:foo` + Enter, idle           -> idle prompt
- `/skill:foo` + Ctrl+Enter, idle      -> idle prompt (was: literal text)

A single private helper `#invokeSkillCommand(text, streamingBehavior)`
on `InputController` handles the dispatch; the Enter submit handler
calls it with "steer", and `handleFollowUp` calls it with "followUp"
after the compaction short-circuit so a skill typed during compaction
rides the same `queueCompactionMessage` queue as free text.

Behavior deltas vs upstream/main:
- Enter on `/skill:foo` during streaming now steers (was: queued as
  followUp). Users who relied on the followUp default can press
  Ctrl+Enter -- the same key they already use for free-text follow-ups.
- Ctrl+Enter on `/skill:foo` is new capability; previously the
  literal string `/skill:foo ...` was sent as plain followUp text and
  the skill was never invoked.

Op: extend
This commit is contained in:
metaphorics
2026-05-12 21:23:00 +00:00
committed by can1357
parent 6d16e93b86
commit a4258cfc2c
2 changed files with 78 additions and 40 deletions
+6 -1
View File
@@ -136,9 +136,14 @@ If `skills.enableSkillCommands` is true, interactive mode registers one slash co
- reads the skill file directly from `filePath`
- strips frontmatter
- injects skill body as a follow-up custom message
- injects skill body as a custom message
- delivery mode follows the **submission keybinding**:
- **Enter** → invokes the skill on the `steer` queue while streaming (matches free-text Enter, which also steers), or as a normal idle prompt when the agent is not streaming
- **Ctrl+Enter** (`app.message.followUp`) → invokes the skill on the `followUp` queue while streaming, or as a normal idle prompt when the agent is not streaming
- appends metadata (`Skill: <path>`, optional `User: <args>`)
There is no flag, mode-selector, or frontmatter knob to override this — the keybinding *is* the choice, identical to how free text is routed during streaming (`input-controller.ts:243-249` for Enter, `input-controller.ts:462-500` for Ctrl+Enter; both dispatch through `#invokeSkillCommand`).
## `skill://` URL behavior
`src/internal-urls/skill-protocol.ts` supports:
@@ -240,45 +240,12 @@ export class InputController {
text = slashResult;
}
// Handle skill commands (/skill:name [args])
if (text.startsWith("/skill:")) {
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skillPath = this.ctx.skillCommands?.get(commandName);
if (skillPath) {
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
try {
const content = await Bun.file(skillPath).text();
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const metaLines = [`Skill: ${skillPath}`];
if (args) {
metaLines.push(`User: ${args}`);
}
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
const skillName = commandName.slice("skill:".length);
const details: SkillPromptDetails = {
name: skillName || commandName,
path: skillPath,
args: args || undefined,
lineCount: body ? body.split("\n").length : 0,
};
await this.ctx.session.promptCustomMessage(
{
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: message,
display: true,
details,
attribution: "user",
},
{ streamingBehavior: "followUp" },
);
} catch (err) {
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
}
return;
}
// Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
// free-text Enter semantics applied a few lines below at the streaming
// branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
// same helper with `"followUp"`.
if (await this.#invokeSkillCommand(text, "steer")) {
return;
}
// Handle bash command (! for normal, !! for excluded from context)
@@ -439,16 +406,82 @@ export class InputController {
}
}
/**
* Dispatch a `/skill:<name> [args]` invocation through `promptCustomMessage`
* using the supplied `streamingBehavior`. Returns true if the text was a
* recognised skill command and was dispatched. A failure to load the skill
* file is surfaced via `showError` but still returns true — the editor was
* already cleared on the success path, so falling through to plain-text
* handling at that point would double-submit. Returns false when the text
* isn't a `/skill:` prefix or the command name isn't a registered skill,
* so the caller can fall through to plain-text handling (this branch
* leaves the editor state untouched). `streamingBehavior` is only consulted
* while the agent is streaming; the idle path of `promptCustomMessage`
* ignores it.
*/
async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
if (!text.startsWith("/skill:")) return false;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skillPath = this.ctx.skillCommands?.get(commandName);
if (!skillPath) return false;
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
try {
const content = await Bun.file(skillPath).text();
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const metaLines = [`Skill: ${skillPath}`];
if (args) {
metaLines.push(`User: ${args}`);
}
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
const skillName = commandName.slice("skill:".length);
const details: SkillPromptDetails = {
name: skillName || commandName,
path: skillPath,
args: args || undefined,
lineCount: body ? body.split("\n").length : 0,
};
await this.ctx.session.promptCustomMessage(
{
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: message,
display: true,
details,
attribution: "user",
},
{ streamingBehavior },
);
} catch (err) {
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
}
return true;
}
/** Send editor text as a follow-up message (queued behind current stream). */
async handleFollowUp(): Promise<void> {
const text = this.ctx.editor.getText().trim();
if (!text) return;
// Compaction first: while compacting, free text gets queued via
// `queueCompactionMessage`, and `/skill:*` rides the same queue so a
// skill typed during compaction is not lost or short-circuited through
// `promptCustomMessage`. The skill text is queued verbatim; whether
// the queued entry is later re-parsed into a skill invocation is a
// separate concern owned by the compaction-resume path.
if (this.ctx.session.isCompacting) {
this.ctx.queueCompactionMessage(text, "followUp");
return;
}
// Skill commands invoke through the custom-message path regardless of
// which keybinding submitted them. Enter routes them as `steer`;
// Ctrl+Enter (this handler) routes them as `followUp`.
if (await this.#invokeSkillCommand(text, "followUp")) {
return;
}
if (this.ctx.session.isStreaming) {
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");