From 1d42de8eab0612769418ee22aa0803d419b04177 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 19:25:03 +0000 Subject: [PATCH] fix(tui): constrained automatic file completion triggers Prevented prose punctuation, trailing spaces, and ambiguous slash-command tokens from activating file suggestions. Cancelled slash autocomplete immediately on trigger deletion and required selection before applying a sole forced file match. Fixes #5376 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/autocomplete.ts | 21 ++++++---- packages/tui/src/components/editor.ts | 35 +++------------- packages/tui/test/autocomplete.test.ts | 32 ++++++++++++++ .../test/editor-autocomplete-actions.test.ts | 42 +++++++++++++++++++ 5 files changed, 96 insertions(+), 38 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2a6e16a07..517276e04 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed automatic file completion treating punctuation, trailing spaces, and ambiguous slash-command text as paths, immediately dismissing slash autocomplete on Backspace and requiring selection before applying a sole forced file match ([#5376](https://github.com/can1357/oh-my-pi/issues/5376)). + ## [16.5.0] - 2026-07-13 ### Changed diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 7ffe104af..426d5cfb1 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -456,6 +456,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor, }; } + if (!isMidPromptSkillLookup && slashStart === leadingSlashStart && !commandText.slice(1).includes("/")) { + return null; + } + // A slash token with no matching command may still be an absolute // path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall // through to file-path completion. @@ -679,15 +683,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return pathPrefix; } - // For natural triggers, return if it looks like a path, ends with /, starts with ~/, . - // Only return empty string if the text looks like it's starting a path context - if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) { - return pathPrefix; - } - - // Return empty string only after a space (not for completely empty text) - // Empty text should not trigger file suggestions - that's for forced Tab completion - if (pathPrefix === "" && text.endsWith(" ")) { + // Automatic updates complete only unambiguous path syntax. Bare relative + // tokens remain available through explicit Tab completion. + if ( + pathPrefix.startsWith("/") || + pathPrefix.startsWith("./") || + pathPrefix.startsWith("../") || + pathPrefix.startsWith("~/") + ) { return pathPrefix; } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 7c53e13a3..157690e1d 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -2077,15 +2077,13 @@ export class Editor implements Component, Focusable { this.#resetKillSequence(); this.#recordUndoState(); - let removedMidPromptSlashTrigger = false; + let removedSlashTrigger = false; if (this.#state.cursorCol > 0) { const line = this.#state.lines[this.#state.cursorLine] || ""; const textBeforeCursor = line.slice(0, this.#state.cursorCol); const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor); - removedMidPromptSlashTrigger = - trailingSlashStart === this.#state.cursorCol - 1 && - (!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== ""); + removedSlashTrigger = trailingSlashStart === this.#state.cursorCol - 1; // An atomic placeholder token (image/paste marker) deletes as a unit, so a single // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text. const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1); @@ -2125,7 +2123,7 @@ export class Editor implements Component, Focusable { // Update or re-trigger autocomplete after backspace if (this.#autocompleteState) { - if (removedMidPromptSlashTrigger) { + if (removedSlashTrigger) { this.#cancelAutocomplete(); this.onAutocompleteUpdate?.(); } else { @@ -3046,17 +3044,17 @@ export class Editor implements Component, Focusable { } else if (this.#isInMidPromptSkillSlashContext()) { await this.#handleSlashCommandCompletion(); if (!this.#autocompleteState) { - await this.#forceFileAutocomplete(true); + await this.#forceFileAutocomplete(); } } else { - await this.#forceFileAutocomplete(true); + await this.#forceFileAutocomplete(); } } async #handleSlashCommandCompletion(): Promise { await this.#tryTriggerAutocomplete(); } - async #forceFileAutocomplete(explicitTab: boolean = false): Promise { + async #forceFileAutocomplete(): Promise { if (!this.#autocompleteProvider) return; // File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion. @@ -3076,27 +3074,6 @@ export class Editor implements Component, Focusable { if (requestId !== this.#autocompleteRequestId) return; if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) { - // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately - if (explicitTab && suggestions.items.length === 1) { - const item = suggestions.items[0]!; - const result = this.#autocompleteProvider.applyCompletion( - this.#state.lines, - this.#state.cursorLine, - this.#state.cursorCol, - item, - suggestions.prefix, - ); - - this.#state.lines = result.lines; - this.#state.cursorLine = result.cursorLine; - this.#setCursorCol(result.cursorCol); - - if (this.onChange) { - this.onChange(this.getText()); - } - return; - } - this.#autocompletePrefix = suggestions.prefix; this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items); this.#autocompleteState = "force"; diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index d7f6e723b..d7f77f213 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -265,6 +265,38 @@ describe("CombinedAutocompleteProvider", () => { }); }); + describe("natural file completion triggers", () => { + it("uses only explicit path contexts during automatic updates", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-natural-trigger-")); + try { + fs.writeFileSync(path.join(baseDir, ".secret"), "secret\n"); + fs.mkdirSync(path.join(baseDir, "src")); + fs.writeFileSync(path.join(baseDir, "src", "index.ts"), "export {};\n"); + const provider = new CombinedAutocompleteProvider( + [{ name: "model", description: "Switch model" }], + baseDir, + ); + + for (const line of [".", "Sentence .", "Sentence ", "use src/in", "/tmp"]) { + expect(await provider.getSuggestions([line], 0, line.length)).toBeNull(); + } + + const explicitPath = "./src/in"; + expect( + (await provider.getSuggestions([explicitPath], 0, explicitPath.length))?.items.map(item => item.value), + ).toContain("./src/index.ts"); + const forcedPath = "use src/in"; + expect( + (await provider.getForceFileSuggestions([forcedPath], 0, forcedPath.length))?.items.map( + item => item.value, + ), + ).toContain("src/index.ts"); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); + describe("absolute path completion", () => { let baseDir: string; diff --git a/packages/tui/test/editor-autocomplete-actions.test.ts b/packages/tui/test/editor-autocomplete-actions.test.ts index af2a0de49..74e20b3c0 100644 --- a/packages/tui/test/editor-autocomplete-actions.test.ts +++ b/packages/tui/test/editor-autocomplete-actions.test.ts @@ -150,6 +150,35 @@ describe("Editor slash autocomplete acceptance", () => { fs.rmSync(baseDir, { recursive: true, force: true }); } }); + it("shows a sole forced file suggestion before applying it", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "editor-single-file-tab-")); + try { + fs.writeFileSync(path.join(baseDir, "alpha.ts"), "export {};\n"); + const provider = new CombinedAutocompleteProvider([], baseDir); + const getForceFileSuggestions = provider.getForceFileSuggestions; + const requestHandled = Promise.withResolvers(); + provider.getForceFileSuggestions = async (lines, cursorLine, cursorCol) => { + const suggestions = await getForceFileSuggestions.call(provider, lines, cursorLine, cursorCol); + requestHandled.resolve(); + return suggestions; + }; + const editor = new Editor(defaultEditorTheme); + editor.setAutocompleteProvider(provider); + editor.setText("alp"); + + editor.handleInput("\t"); + await requestHandled.promise; + await Promise.resolve(); + + expect(editor.getText()).toBe("alp"); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput("\t"); + expect(editor.getText()).toBe("alpha.ts"); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); }); class SyncSlashProvider implements AutocompleteProvider { async getSuggestions( @@ -259,6 +288,19 @@ describe("Editor Enter handler sync slash completion", () => { expect(editor.isShowingAutocomplete()).toBe(false); }); + it("hides leading slash autocomplete immediately when Backspace removes the slash", async () => { + const editor = createSkillEditor(); + + editor.handleInput("/"); + await Promise.resolve(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput("\x7f"); + + expect(editor.getText()).toBe(""); + expect(editor.isShowingAutocomplete()).toBe(false); + }); + it("closes mid-prompt skill autocomplete on its own once the token stops being skill-shaped", async () => { const editor = createSkillEditor();