diff --git a/packages/pi-tui/src/__tests__/autocomplete.test.ts b/packages/pi-tui/src/__tests__/autocomplete.test.ts index c3c44ac74..c4a44db76 100644 --- a/packages/pi-tui/src/__tests__/autocomplete.test.ts +++ b/packages/pi-tui/src/__tests__/autocomplete.test.ts @@ -119,6 +119,21 @@ describe("CombinedAutocompleteProvider — @ file prefix extraction", () => { const result = provider.getSuggestions(["check @nonexistent_xyz"], 0, 22); assert.ok(result === null || result.items.length >= 0); }); + + it("returns null for bare @ with no query to avoid full tree walk (#1824)", () => { + const provider = makeProvider([], process.cwd()); + // A bare "@" produces an empty rawPrefix after stripping the "@". + // This must return null to avoid a synchronous full filesystem walk + // via the native fuzzyFind addon, which freezes the TUI on large repos. + const result = provider.getSuggestions(["@"], 0, 1); + assert.equal(result, null, "bare @ should not trigger fuzzy file search"); + }); + + it("returns null for @ after space with no query (#1824)", () => { + const provider = makeProvider([], process.cwd()); + const result = provider.getSuggestions(["look at @"], 0, 9); + assert.equal(result, null, "@ after space with no query should not trigger fuzzy file search"); + }); }); describe("CombinedAutocompleteProvider — applyCompletion", () => { diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index 52ea67c25..d0969921f 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -573,9 +573,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] { try { const scopedQuery = this.resolveScopedFuzzyQuery(query); - const searchPath = scopedQuery?.baseDir ?? this.basePath; const searchQuery = scopedQuery?.query ?? query; + // Skip the expensive filesystem walk when the query is empty. + // An empty query (bare "@" with nothing typed yet) would walk the + // entire directory tree via the native fuzzyFind call, blocking + // the event loop and freezing the TUI on large repos. + if (searchQuery.length === 0 && !scopedQuery) { + return []; + } + + const searchPath = scopedQuery?.baseDir ?? this.basePath; + const result = fuzzyFind({ query: searchQuery, path: searchPath, diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 0b4b2b525..c9cefb83c 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -967,13 +967,19 @@ export class Editor implements Component, Focusable { this.tryTriggerAutocomplete(); } // Auto-trigger for "@" file reference (fuzzy search) + // Debounced: the bare "@" triggers a fuzzyFind call that does a + // synchronous filesystem walk via the native addon. Firing it + // immediately on the keystroke blocks the event loop and freezes + // the TUI on large repos. Debouncing lets subsequent keystrokes + // cancel the pending search so the walk only runs once the user + // pauses typing. else if (char === "@") { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Only trigger if @ is after whitespace or at start of line const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2]; if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") { - this.tryTriggerAutocomplete(); + this.debouncedTriggerAutocomplete(); } } // Also auto-trigger when typing letters in a slash command context @@ -2116,6 +2122,15 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ private applyAutocompleteSuggestions(): void { if (!this.autocompleteProvider) return; + // Deduplicate: skip the (potentially expensive synchronous) lookup + // when the prefix hasn't changed since the last call. + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + if (this.lastAutocompleteLookupPrefix !== null && this.lastAutocompleteLookupPrefix === textBeforeCursor) { + return; + } + this.lastAutocompleteLookupPrefix = textBeforeCursor; + const suggestions = this.autocompleteProvider.getSuggestions( this.state.lines, this.state.cursorLine,