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