diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts new file mode 100644 index 000000000..33f67c42a --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.test.ts @@ -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 }, + 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 } }, + 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"); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts index 7bb7f280b..3c7c1537d 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -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("!")) { diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 736da41d9..d3bd71f27 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { if (this.compactionQueuedMessages.length === 0) { return; diff --git a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts index c3e12d8a8..c510e63b4 100644 --- a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +++ b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -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; }