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.
This commit is contained in:
roboomp
2026-07-05 09:05:45 +00:00
parent ed63b62889
commit 1fcdc4f882
6 changed files with 74 additions and 19 deletions
@@ -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);
@@ -2437,6 +2437,7 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BU
command => ({
name: command.name,
aliases: command.aliases,
allowArgs: command.allowArgs === true,
description: command.description,
subcommands: command.subcommands,
inlineHint: command.inlineHint,
@@ -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). */
@@ -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" }]
+19 -15
View File
@@ -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 <title>`.
// 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
+20 -1
View File
@@ -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" }]