Merge PR #1033: feat(skill-command): route /skill:* through the submission keybinding

Slash skill invocations bypassed the Enter / Ctrl+Enter contract that
every other slash command honors. Now Enter steers, Ctrl+Enter queues
a follow-up, sharing one #invokeSkillCommand helper between the editor
submit handler and handleFollowUp. Compaction short-circuit ordering
preserved.

Closes #1033
This commit is contained in:
can1357
2026-05-13 02:32:52 +02:00
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("");