From 8b7b8bc651bd5ba813c63db9b5ea1b2c7267b055 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sat, 14 Mar 2026 23:56:22 -0500 Subject: [PATCH] fix: debounce @ file autocomplete to prevent TUI freeze on large codebases (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous fuzzyFind() native call blocks the event loop during @ file autocomplete. On large codebases (e.g. Java projects with deep directory trees), each call can take seconds. Since updateAutocomplete() was called on every keystroke while autocomplete was active, rapid typing would cascade into dozens of blocking searches — freezing the TUI for minutes. This made it appear that arrow keys caused the freeze, when the actual cause was accumulated backlog from processing buffered input. Debounce all @ file reference autocomplete paths (character input, backspace, forward delete, and re-trigger after cancellation) with a 150ms delay so only the final keystroke triggers the expensive search. Slash command autocomplete remains synchronous since it's cheap. --- packages/pi-tui/src/components/editor.ts | 77 ++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 827e49ef2..768439289 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -150,6 +150,12 @@ export class Editor implements Component, Focusable { private autocompletePrefix: string = ""; private autocompleteMaxVisible: number = 5; + // Debounce for @ file autocomplete to prevent blocking the event loop + // with synchronous fuzzyFind calls on every keystroke + private autocompleteDebounceTimer: ReturnType | null = null; + private lastAutocompleteLookupPrefix: string | null = null; + private static readonly AUTOCOMPLETE_DEBOUNCE_MS = 150; + // Paste tracking for large pastes private pastes: Map = new Map(); private pasteCounter: number = 0; @@ -965,9 +971,10 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in an @ file reference context + // Check if we're in an @ file reference context (debounce to avoid + // blocking the event loop with synchronous fuzzyFind on every keystroke) else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.debouncedTriggerAutocomplete(); } } } else { @@ -975,6 +982,23 @@ export class Editor implements Component, Focusable { } } + /** + * Debounced version of tryTriggerAutocomplete for @ file reference context. + * Prevents synchronous fuzzyFind calls from blocking the event loop on every keystroke. + */ + private debouncedTriggerAutocomplete(): void { + if (this.autocompleteDebounceTimer) { + clearTimeout(this.autocompleteDebounceTimer); + this.autocompleteDebounceTimer = null; + } + + this.autocompleteDebounceTimer = setTimeout(() => { + this.autocompleteDebounceTimer = null; + this.tryTriggerAutocomplete(); + this.tui.requestRender(); + }, Editor.AUTOCOMPLETE_DEBOUNCE_MS); + } + private handlePaste(pastedText: string): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; @@ -1133,9 +1157,9 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // @ file reference context + // @ file reference context (debounced to avoid blocking event loop) else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.debouncedTriggerAutocomplete(); } } } @@ -1440,9 +1464,9 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // @ file reference context + // @ file reference context (debounced to avoid blocking event loop) else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.debouncedTriggerAutocomplete(); } } } @@ -2020,6 +2044,15 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ this.autocompleteState = null; this.autocompleteList = undefined; this.autocompletePrefix = ""; + this.clearAutocompleteDebounce(); + } + + private clearAutocompleteDebounce(): void { + if (this.autocompleteDebounceTimer) { + clearTimeout(this.autocompleteDebounceTimer); + this.autocompleteDebounceTimer = null; + } + this.lastAutocompleteLookupPrefix = null; } public isShowingAutocomplete(): boolean { @@ -2034,6 +2067,38 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ return; } + // Check if we're in an @ file reference context — these trigger expensive + // synchronous fuzzyFind calls that block the event loop. Debounce them so + // rapid typing doesn't cascade into dozens of blocking searches. + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + if (this.autocompletePrefix.startsWith("@") || textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { + this.debouncedUpdateAutocompleteSuggestions(); + return; + } + + this.applyAutocompleteSuggestions(); + } + + private debouncedUpdateAutocompleteSuggestions(): void { + // Clear any pending debounce + if (this.autocompleteDebounceTimer) { + clearTimeout(this.autocompleteDebounceTimer); + this.autocompleteDebounceTimer = null; + } + + this.autocompleteDebounceTimer = setTimeout(() => { + this.autocompleteDebounceTimer = null; + // Guard: autocomplete may have been cancelled during debounce wait + if (!this.autocompleteState || !this.autocompleteProvider) return; + this.applyAutocompleteSuggestions(); + this.tui.requestRender(); + }, Editor.AUTOCOMPLETE_DEBOUNCE_MS); + } + + private applyAutocompleteSuggestions(): void { + if (!this.autocompleteProvider) return; + const suggestions = this.autocompleteProvider.getSuggestions( this.state.lines, this.state.cursorLine,