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:
@@ -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" }]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
Reference in New Issue
Block a user