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("");