From 1fcdc4f882e9f4ded170331b4e33bdb362558079 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 5 Jul 2026 09:05:45 +0000 Subject: [PATCH] fix(tui): preserved autocomplete for no-arg slash prompts - Propagated builtin allowArgs metadata into TUI autocomplete entries. - Let no-arg slash-looking prompts fall through to prompt-composer completions while keeping argument-capable commands scoped. - Added regressions for /settings @ and /settings #copy. --- .../src/modes/prompt-action-autocomplete.ts | 11 ++++-- .../src/slash-commands/builtin-registry.ts | 1 + .../coding-agent/src/slash-commands/types.ts | 2 ++ .../test/prompt-action-autocomplete.test.ts | 24 ++++++++++++- packages/tui/src/autocomplete.ts | 34 +++++++++++-------- packages/tui/test/autocomplete.test.ts | 21 +++++++++++- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index fab3c1135..9adcfc057 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -95,11 +95,13 @@ function getPromptActionPrefix(textBeforeCursor: string): string | null { } export class PromptActionAutocompleteProvider implements AutocompleteProvider { + #commands: SlashCommand[]; #baseProvider: CombinedAutocompleteProvider; #actions: PromptActionDefinition[]; #basePath: string; constructor(commands: SlashCommand[], basePath: string, actions: PromptActionDefinition[]) { + this.#commands = commands; this.#baseProvider = new CombinedAutocompleteProvider(commands, basePath); this.#basePath = basePath; this.#actions = actions; @@ -118,8 +120,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { leadingSlashStart !== null && !hasPromptTextBeforeCursorLine ? textBeforeCursor.slice(leadingSlashStart) : null; - if (commandText?.includes(" ")) { - return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol); + const spaceIndex = commandText?.indexOf(" ") ?? -1; + if (commandText !== null && spaceIndex !== -1) { + const commandName = commandText.slice(1, spaceIndex); + const command = this.#commands.find(cmd => cmd.name === commandName || cmd.aliases?.includes(commandName)); + if (command && (!("allowArgs" in command) || command.allowArgs !== false)) { + return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol); + } } const promptActionPrefix = getPromptActionPrefix(textBeforeCursor); diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 7b4cb5d92..4318b66f3 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -2437,6 +2437,7 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray = BU command => ({ name: command.name, aliases: command.aliases, + allowArgs: command.allowArgs === true, description: command.description, subcommands: command.subcommands, inlineHint: command.inlineHint, diff --git a/packages/coding-agent/src/slash-commands/types.ts b/packages/coding-agent/src/slash-commands/types.ts index 86b066b0b..3dfbe66f1 100644 --- a/packages/coding-agent/src/slash-commands/types.ts +++ b/packages/coding-agent/src/slash-commands/types.ts @@ -16,6 +16,8 @@ export interface BuiltinSlashCommand { name: string; aliases?: string[]; description: string; + /** Whether the command consumes text after the command name. */ + allowArgs?: boolean; /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */ subcommands?: SubcommandDef[]; /** Static inline hint when command takes a simple argument (no subcommands). */ diff --git a/packages/coding-agent/test/prompt-action-autocomplete.test.ts b/packages/coding-agent/test/prompt-action-autocomplete.test.ts index ad85348ba..8959a9cf8 100644 --- a/packages/coding-agent/test/prompt-action-autocomplete.test.ts +++ b/packages/coding-agent/test/prompt-action-autocomplete.test.ts @@ -113,7 +113,7 @@ describe("prompt action autocomplete", () => { it("treats # prompt-action tokens as literal text inside slash command arguments without completions", async () => { const provider = createPromptActionAutocompleteProvider({ - commands: [{ name: "rename", description: "Rename current session" }], + commands: [{ name: "rename", description: "Rename current session", allowArgs: true }], basePath: "/tmp", keybindings: AppKeybindingsManager.inMemory(), copyCurrentLine: () => {}, @@ -131,12 +131,34 @@ describe("prompt action autocomplete", () => { expect(suggestions).toBeNull(); }); + it("returns # prompt-action completions for matched slash commands that reject arguments", async () => { + const provider = createPromptActionAutocompleteProvider({ + commands: [{ name: "settings", description: "Open settings", allowArgs: false }], + basePath: "/tmp", + keybindings: AppKeybindingsManager.inMemory(), + copyCurrentLine: () => {}, + copyPrompt: () => {}, + undo: () => {}, + moveCursorToMessageEnd: () => {}, + moveCursorToMessageStart: () => {}, + moveCursorToLineStart: () => {}, + moveCursorToLineEnd: () => {}, + }); + + const line = "/settings #copy"; + const suggestions = await provider.getSuggestions([line], 0, line.length); + + expect(suggestions?.prefix).toBe("#copy"); + expect(suggestions?.items.map(item => item.label)).toEqual(["Copy current line", "Copy whole prompt"]); + }); + it("returns slash command argument completions instead of # prompt actions when the command defines them", async () => { const provider = createPromptActionAutocompleteProvider({ commands: [ { name: "rename", description: "Rename current session", + allowArgs: true, getArgumentCompletions: argumentPrefix => argumentPrefix === "repro #copy" ? [{ value: "repro #copy-title", label: "Keep #copy in the title" }] diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index ed2c2c084..63fad6c5a 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -182,6 +182,8 @@ export interface SlashCommand { aliases?: string[]; description?: string; argumentHint?: string; + /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */ + allowArgs?: boolean; /** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */ getAutocompleteDescription?: () => string | undefined; // Function to get argument completions for this command @@ -427,28 +429,30 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // A mid-prompt slash token with no matching skill may still be an // absolute path (`see /tmp`); fall through to file-path completion. } else if (!isMidPromptSkillLookup) { - // Submitted slash commands own their argument text. Prompt-composer - // completions like `@` file references must not leak into literal - // arguments such as `/rename `. + // Submitted slash commands own their argument text only when the + // matched command accepts args. No-arg slash-looking prompts such + // as `/settings @file` still fall through to prompt-composer + // completions because submit treats them as normal prompt text. const commandName = commandText.slice(1, spaceIndex); // Command without "/" const argumentText = commandText.slice(spaceIndex + 1); // Text after space const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName)); - if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { - return null; // No argument completion for this command - } + if (command && (!("allowArgs" in command) || command.allowArgs !== false)) { + if (!("getArgumentCompletions" in command) || !command.getArgumentCompletions) { + return null; // No argument completion for this command + } - const argumentSuggestions = await command.getArgumentCompletions(argumentText); - if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) { - return null; - } + const argumentSuggestions = await command.getArgumentCompletions(argumentText); + if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) { + return null; + } - return { - items: argumentSuggestions, - prefix: argumentText, - }; + return { + items: argumentSuggestions, + prefix: argumentText, + }; + } } - if (!isMidPromptSkillLookup) return null; } // Check for @ file reference (fuzzy search) - must be after a delimiter or at start diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 64d8c7de8..b814aefa5 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -124,7 +124,7 @@ describe("CombinedAutocompleteProvider", () => { try { fs.writeFileSync(path.join(baseDir, "copy-target.ts"), "export {};\n"); const provider = new CombinedAutocompleteProvider( - [{ name: "rename", description: "Rename current session" }], + [{ name: "rename", description: "Rename current session", allowArgs: true }], baseDir, ); const line = "/rename repro @"; @@ -136,6 +136,24 @@ describe("CombinedAutocompleteProvider", () => { } }); + it("returns @ file-reference completions for matched slash commands that reject arguments", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-settings-args-")); + try { + fs.writeFileSync(path.join(baseDir, "copy-target.ts"), "export {};\n"); + const provider = new CombinedAutocompleteProvider( + [{ name: "settings", description: "Open settings", allowArgs: false }], + baseDir, + ); + const line = "/settings @"; + const result = await provider.getSuggestions([line], 0, line.length); + + expect(result?.prefix).toBe("@"); + expect(result?.items.map(item => item.value)).toContain("@copy-target.ts"); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it("returns slash command argument completions instead of @ file references when the command defines them", async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-rename-args-")); try { @@ -145,6 +163,7 @@ describe("CombinedAutocompleteProvider", () => { { name: "rename", description: "Rename current session", + allowArgs: true, getArgumentCompletions: argumentPrefix => argumentPrefix === "repro @" ? [{ value: "repro @literal", label: "Keep @ in the title" }]