From a4258cfc2c7f2fb8cb8a939109d4eccdfbbbff0a Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 12 May 2026 21:23:00 +0000 Subject: [PATCH] feat(skill-command): route /skill:* through the submission keybinding Makes `/skill: [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 --- docs/skills.md | 7 +- .../src/modes/controllers/input-controller.ts | 111 ++++++++++++------ 2 files changed, 78 insertions(+), 40 deletions(-) diff --git a/docs/skills.md b/docs/skills.md index c556e14d5..0e046c498 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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: `, optional `User: `) +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: diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 77b99fda1..b3885662d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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: [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 { + 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 { 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("");