From f29d54b7e04e5b106f243f75071f278e27b7963f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 14:55:12 -0400 Subject: [PATCH] fix(tui): prevent freeze when using @ file finder (#1832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @ file autocomplete triggered a synchronous native fuzzyFind call that walks the entire directory tree. On large repos this blocked the Node.js event loop and froze the TUI. Three changes fix this: 1. Skip the fuzzy search when the query is empty (bare "@" with nothing typed yet) — there is no point walking the full tree with no query. 2. Debounce the initial "@" keystroke instead of firing the search synchronously, so rapid typing cancels pending walks and the search only runs once the user pauses. 3. Deduplicate consecutive lookups using lastAutocompleteLookupPrefix (previously declared but unused) to avoid redundant synchronous searches when the prefix hasn't changed. Fixes #1824 Co-authored-by: Claude Opus 4.6 (1M context) --- .../pi-tui/src/__tests__/autocomplete.test.ts | 15 +++++++++++++++ packages/pi-tui/src/autocomplete.ts | 11 ++++++++++- packages/pi-tui/src/components/editor.ts | 17 ++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) 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,