fix(tui): rejected stale mid-prompt skill autocomplete on non-matching tokens

The staleness guard previously accepted any clean trailing slash token, so
the 100 ms debounce window let a bare-`/` skill popup rewrite a
subsequently typed `/tmp` to `/skill:…`. Score the live token against
the selected skill's value and reject the branch when it would no longer
surface — Tab then falls through to file completion.

Fixes #4619
This commit is contained in:
roboomp
2026-07-05 19:22:56 +00:00
parent 5cdc525d0a
commit ea7c911ff2
3 changed files with 25 additions and 2 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): bool
return getCommandAliases(cmd).includes(commandName);
}
function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
if (lowerPrefix.length === 0) return 1;
if (lowerPrefix === lowerTarget) return 1000;
// Flat score for every prefix match so same-prefix commands keep registry
+11 -1
View File
@@ -3,6 +3,7 @@ import {
type AutocompleteProvider,
findLeadingSlashCommandStart,
findTrailingSlashCommandStart,
scoreCommandTextMatch,
} from "../autocomplete";
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -2902,8 +2903,17 @@ export class Editor implements Component, Focusable {
const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
if (currentTrailingStart !== null) {
const token = currentTextBeforeCursor.slice(currentTrailingStart);
if (!token.includes(" ") && !token.slice(1).includes("/")) return true;
if (!token.includes(" ") && !token.slice(1).includes("/")) {
// Guard the timing window where the popup was built for an earlier
// query (e.g. bare `/`) and the user typed further characters before
// the 100 ms debounced refresh fired: accept the stale skill only
// when the current query would still surface it. `tmp` after a bare
// slash therefore falls through to file completion instead of
// rewriting the user's `/tmp` to `/skill:…`.
if (scoreCommandTextMatch(token.slice(1).toLowerCase(), item.value.toLowerCase()) > 0) return true;
}
}
return false;
}
if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null) {
@@ -188,6 +188,19 @@ describe("Editor Enter handler sync slash completion", () => {
expect(editor.isShowingAutocomplete()).toBe(false);
});
it("does not apply a stale mid-prompt skill suggestion when the live token stops matching", async () => {
const editor = createSkillEditor();
await openMidPromptSkillAutocomplete(editor, "see ");
// Race the 100 ms debounce: type a non-skill token before the popup refreshes.
editor.handleInput("tmp");
editor.handleInput("\t");
// The stale `skill:security-scan` popup must not rewrite `/tmp` to `/skill:…`.
expect(editor.getText()).toBe("see /tmp");
expect(editor.isShowingAutocomplete()).toBe(false);
});
it("opens mid-prompt skill autocomplete and inserts the skill token without wiping the draft on Tab", async () => {
const editor = new Editor(defaultEditorTheme);
editor.setAutocompleteProvider(