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,