fix: route non-builtin slash commands after TUI dispatch

The TUI slash dispatcher started treating any unrecognized /command as handled before session.prompt() could resolve extension commands, prompt templates, or /skill:* inputs. That blocked valid non-builtin slash commands and also let /export swallow unrelated /export* prefixes.

Move unknown-command detection to the interactive entry points, allow only known builtins or session-resolved slash commands through, gate /skill:* on the skill-command setting, and tighten /export matching to exact command tokens.
This commit is contained in:
deseltrus 2026-04-03 06:43:51 +02:00
parent db4fa32854
commit b7e0173e50
4 changed files with 202 additions and 8 deletions

View file

@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setupEditorSubmitHandler } from "./input-controller.js";
type HostOptions = {
knownSlashCommands?: string[];
};
function getSlashCommandName(text: string): string {
const trimmed = text.trim();
const spaceIndex = trimmed.indexOf(" ");
return spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
}
function createHost(options: HostOptions = {}) {
const prompted: string[] = [];
const errors: string[] = [];
const warnings: string[] = [];
const history: string[] = [];
const knownSlashCommands = new Set(options.knownSlashCommands ?? []);
let editorText = "";
let settingsOpened = 0;
const editor = {
setText(text: string) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory(text: string) {
history.push(text);
},
};
const host = {
defaultEditor: editor as typeof editor & { onSubmit?: (text: string) => Promise<void> },
editor,
session: {
isBashRunning: false,
isCompacting: false,
isStreaming: false,
prompt: async (text: string) => {
prompted.push(text);
},
},
ui: {
requestRender() {},
},
getSlashCommandContext: () => ({
showSettingsSelector: () => {
settingsOpened += 1;
},
}),
handleBashCommand: async () => {},
showWarning(message: string) {
warnings.push(message);
},
showError(message: string) {
errors.push(message);
},
updateEditorBorderColor() {},
isExtensionCommand() {
return false;
},
isKnownSlashCommand(text: string) {
return knownSlashCommands.has(getSlashCommandName(text));
},
queueCompactionMessage() {},
updatePendingMessagesDisplay() {},
flushPendingBashComponents() {},
};
setupEditorSubmitHandler(host as any);
return {
host: host as typeof host & { defaultEditor: typeof editor & { onSubmit: (text: string) => Promise<void> } },
prompted,
errors,
warnings,
history,
getEditorText: () => editorText,
getSettingsOpened: () => settingsOpened,
};
}
test("input-controller: built-in slash commands stay in TUI dispatch", async () => {
const { host, prompted, errors, getSettingsOpened, getEditorText } = createHost();
await host.defaultEditor.onSubmit("/settings");
assert.equal(getSettingsOpened(), 1, "built-in /settings should open the settings selector");
assert.deepEqual(prompted, [], "built-in slash commands should not reach session.prompt");
assert.deepEqual(errors, [], "built-in slash commands should not show errors");
assert.equal(getEditorText(), "", "built-in slash commands should clear the editor after handling");
});
test("input-controller: extension slash commands fall through to session.prompt", async () => {
const { host, prompted, errors, history } = createHost({ knownSlashCommands: ["gsd"] });
await host.defaultEditor.onSubmit("/gsd help");
assert.deepEqual(prompted, ["/gsd help"], "known extension slash commands should reach session.prompt");
assert.deepEqual(errors, [], "known extension slash commands should not show unknown-command errors");
assert.deepEqual(history, ["/gsd help"], "known extension slash commands should still be added to history");
});
test("input-controller: prompt template slash commands fall through to session.prompt", async () => {
const { host, prompted, errors } = createHost({ knownSlashCommands: ["daily"] });
await host.defaultEditor.onSubmit("/daily focus area");
assert.deepEqual(prompted, ["/daily focus area"]);
assert.deepEqual(errors, []);
});
test("input-controller: skill slash commands fall through to session.prompt", async () => {
const { host, prompted, errors } = createHost({ knownSlashCommands: ["skill:create-skill"] });
await host.defaultEditor.onSubmit("/skill:create-skill routing bug");
assert.deepEqual(prompted, ["/skill:create-skill routing bug"]);
assert.deepEqual(errors, []);
});
test("input-controller: disabled skill slash commands stay unknown", async () => {
const { host, prompted, errors } = createHost();
await host.defaultEditor.onSubmit("/skill:create-skill routing bug");
assert.deepEqual(prompted, []);
assert.deepEqual(errors, ["Unknown command: /skill:create-skill. Use slash autocomplete to see available commands."]);
});
test("input-controller: /export prefix does not swallow unrelated slash commands", async () => {
const { host, prompted, errors } = createHost();
await host.defaultEditor.onSubmit("/exportfoo");
assert.deepEqual(prompted, []);
assert.deepEqual(errors, ["Unknown command: /exportfoo. Use slash autocomplete to see available commands."]);
});
test("input-controller: truly unknown slash commands stop before session.prompt", async () => {
const { host, prompted, errors, getEditorText } = createHost();
await host.defaultEditor.onSubmit("/definitely-not-a-command");
assert.deepEqual(prompted, [], "unknown slash commands should not reach session.prompt");
assert.deepEqual(
errors,
["Unknown command: /definitely-not-a-command. Use slash autocomplete to see available commands."],
);
assert.equal(getEditorText(), "", "unknown slash commands should clear the editor after showing the error");
});

View file

@ -8,6 +8,7 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
showError: (message: string) => void;
updateEditorBorderColor: () => void;
isExtensionCommand: (text: string) => boolean;
isKnownSlashCommand: (text: string) => boolean;
queueCompactionMessage: (text: string, mode: "steer" | "followUp") => void;
updatePendingMessagesDisplay: () => void;
flushPendingBashComponents: () => void;
@ -23,6 +24,12 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & {
host.editor.setText("");
return;
}
if (!host.isKnownSlashCommand(text)) {
const command = text.split(/\s/)[0];
host.showError(`Unknown command: ${command}. Use slash autocomplete to see available commands.`);
host.editor.setText("");
return;
}
}
if (text.startsWith("!")) {

View file

@ -2371,6 +2371,12 @@ export class InteractiveMode {
const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
if (!text) return;
if (text.startsWith("/") && !this.isKnownSlashCommand(text)) {
const command = text.split(/\s/)[0];
this.showError(`Unknown command: ${command}. Use slash autocomplete to see available commands.`);
return;
}
// Queue input during compaction (extension commands execute immediately)
if (this.session.isCompacting) {
if (this.isExtensionCommand(text)) {
@ -2653,6 +2659,12 @@ export class InteractiveMode {
}
private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
if (text.startsWith("/") && !this.isKnownSlashCommand(text)) {
const command = text.split(/\s/)[0];
this.showError(`Unknown command: ${command}. Use slash autocomplete to see available commands.`);
return;
}
this.compactionQueuedMessages.push({ text, mode });
this.editor.addToHistory?.(text);
this.editor.setText("");
@ -2671,6 +2683,32 @@ export class InteractiveMode {
return !!extensionRunner.getCommand(commandName);
}
private isKnownSlashCommand(text: string): boolean {
if (!text.startsWith("/")) return false;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
if (BUILTIN_SLASH_COMMANDS.some((command) => command.name === commandName)) {
return true;
}
if (this.isExtensionCommand(text)) {
return true;
}
if (this.session.promptTemplates.some((template) => template.name === commandName)) {
return true;
}
if (commandName.startsWith("skill:") && this.settingsManager.getEnableSkillCommands()) {
const skillName = commandName.slice("skill:".length);
return this.session.resourceLoader.getSkills().skills.some((skill) => skill.name === skillName);
}
return false;
}
private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
if (this.compactionQueuedMessages.length === 0) {
return;

View file

@ -136,7 +136,7 @@ export async function dispatchSlashCommand(
await ctx.handleModelCommand(searchTerm);
return true;
}
if (text.startsWith("/export")) {
if (text === "/export" || text.startsWith("/export ")) {
await handleExportCommand(text, ctx);
return true;
}
@ -236,13 +236,6 @@ export async function dispatchSlashCommand(
return true;
}
// If input starts with "/" but no command matched, show unknown command feedback
if (text.startsWith("/")) {
const command = text.split(/\s/)[0];
ctx.showError(`Unknown command: ${command}. Type /help for available commands.`);
return true;
}
return false;
}