Merge pull request #3432 from deseltrus/fix/slash-command-session-routing
fix: route non-builtin slash commands after TUI dispatch
This commit is contained in:
commit
df93cdc43c
4 changed files with 202 additions and 8 deletions
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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("!")) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue