diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d23d5f5d..8472f200 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix - Fixed slash-command Tab completion from immediately chaining into argument autocomplete after completing the command name, restoring flows like `/model` that submit into a selector dialog ([#2577](https://github.com/badlogic/pi-mono/issues/2577)) - Fixed stale content and incorrect viewport tracking after TUI content shrinks or transient components inflate the working area ([#2126](https://github.com/badlogic/pi-mono/pull/2126) by [@Perlence](https://github.com/Perlence)) +- Fixed `@` autocomplete to debounce editor-triggered searches, cancel in-flight `fd` lookups cleanly, and keep suggestions visible while results refresh ([#1278](https://github.com/badlogic/pi-mono/issues/1278)) ## [0.62.0] - 2026-03-23 diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 1e5937d5..e2dd4c3e 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "child_process"; +import { spawn } from "child_process"; import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; @@ -121,12 +121,13 @@ function buildCompletionValue( } // Use fd to walk directory tree (fast, respects .gitignore) -function walkDirectoryWithFd( +async function walkDirectoryWithFd( baseDir: string, fdPath: string, query: string, maxResults: number, -): Array<{ path: string; isDirectory: boolean }> { + signal: AbortSignal, +): Promise> { const args = [ "--base-directory", baseDir, @@ -146,41 +147,69 @@ function walkDirectoryWithFd( ".git/**", ]; - // Add query as pattern if provided if (query) { args.push(buildFdPathQuery(query)); } - const result = spawnSync(fdPath, args, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - maxBuffer: 10 * 1024 * 1024, - }); - - if (result.status !== 0 || !result.stdout) { - return []; - } - - const lines = result.stdout.trim().split("\n").filter(Boolean); - const results: Array<{ path: string; isDirectory: boolean }> = []; - - for (const line of lines) { - const displayLine = toDisplayPath(line); - const hasTrailingSeparator = displayLine.endsWith("/"); - const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine; - if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) { - continue; + return await new Promise((resolve) => { + if (signal.aborted) { + resolve([]); + return; } - // fd outputs directories with trailing / - const isDirectory = hasTrailingSeparator; - results.push({ - path: displayLine, - isDirectory, + const child = spawn(fdPath, args, { + stdio: ["ignore", "pipe", "pipe"], }); - } + let stdout = ""; + let resolved = false; - return results; + const finish = (results: Array<{ path: string; isDirectory: boolean }>) => { + if (resolved) return; + resolved = true; + signal.removeEventListener("abort", onAbort); + resolve(results); + }; + + const onAbort = () => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }; + + signal.addEventListener("abort", onAbort, { once: true }); + child.stdout.setEncoding("utf-8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + finish([]); + }); + child.on("close", (code) => { + if (signal.aborted || code !== 0 || !stdout) { + finish([]); + return; + } + + const lines = stdout.trim().split("\n").filter(Boolean); + const results: Array<{ path: string; isDirectory: boolean }> = []; + + for (const line of lines) { + const displayLine = toDisplayPath(line); + const hasTrailingSeparator = displayLine.endsWith("/"); + const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine; + if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) { + continue; + } + + results.push({ + path: displayLine, + isDirectory: hasTrailingSeparator, + }); + } + + finish(results); + }); + }); } export interface AutocompleteItem { @@ -197,6 +226,11 @@ export interface SlashCommand { getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null; } +export interface AutocompleteSuggestions { + items: AutocompleteItem[]; + prefix: string; // What we're matching against (e.g., "/" or "src/") +} + export interface AutocompleteProvider { // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available @@ -204,10 +238,8 @@ export interface AutocompleteProvider { lines: string[], cursorLine: number, cursorCol: number, - ): { - items: AutocompleteItem[]; - prefix: string; // What we're matching against (e.g., "/" or "src/") - } | null; + options: { signal: AbortSignal; force?: boolean }, + ): Promise; // Apply the selected item // Returns the new text and cursor position @@ -240,19 +272,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { this.fdPath = fdPath; } - getSuggestions( + async getSuggestions( lines: string[], cursorLine: number, cursorCol: number, - ): { items: AutocompleteItem[]; prefix: string } | null { + options: { signal: AbortSignal; force?: boolean }, + ): Promise { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); - // Check for @ file reference (fuzzy search) - must be after a delimiter or at start const atPrefix = this.extractAtPrefix(textBeforeCursor); if (atPrefix) { const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix); - const suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix }); + const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, { + isQuotedPrefix, + signal: options.signal, + }); if (suggestions.length === 0) return null; return { @@ -261,13 +296,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { }; } - // Check for slash commands - if (textBeforeCursor.startsWith("/")) { + if (!options.force && textBeforeCursor.startsWith("/")) { const spaceIndex = textBeforeCursor.indexOf(" "); if (spaceIndex === -1) { - // No space yet - complete command names with fuzzy matching - const prefix = textBeforeCursor.slice(1); // Remove the "/" + const prefix = textBeforeCursor.slice(1); const commandItems = this.commands.map((cmd) => ({ name: "name" in cmd ? cmd.name : cmd.value, label: "name" in cmd ? cmd.name : cmd.label, @@ -286,57 +319,42 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { items: filtered, prefix: textBeforeCursor, }; - } else { - // Space found - complete command arguments - const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/" - const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space - - const command = this.commands.find((cmd) => { - const name = "name" in cmd ? cmd.name : cmd.value; - return name === commandName; - }); - if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { - return null; // No argument completion for this command - } - - const argumentSuggestions = command.getArgumentCompletions(argumentText); - if (!argumentSuggestions || argumentSuggestions.length === 0) { - return null; - } - - return { - items: argumentSuggestions, - prefix: argumentText, - }; } - } - // Check for file paths - triggered by Tab or if we detect a path pattern - const pathMatch = this.extractPathPrefix(textBeforeCursor, false); + const commandName = textBeforeCursor.slice(1, spaceIndex); + const argumentText = textBeforeCursor.slice(spaceIndex + 1); - if (pathMatch !== null) { - const suggestions = this.getFileSuggestions(pathMatch); - if (suggestions.length === 0) return null; + const command = this.commands.find((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + return name === commandName; + }); + if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { + return null; + } - // Check if we have an exact match that is a directory - // In that case, we might want to return suggestions for the directory content instead - // But only if the prefix ends with / - if (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith("/")) { - // Exact match found (e.g. user typed "src" and "src/" is the only match) - // We still return it so user can select it and add / - return { - items: suggestions, - prefix: pathMatch, - }; + const argumentSuggestions = command.getArgumentCompletions(argumentText); + if (!argumentSuggestions || argumentSuggestions.length === 0) { + return null; } return { - items: suggestions, - prefix: pathMatch, + items: argumentSuggestions, + prefix: argumentText, }; } - return null; + const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false); + if (pathMatch === null) { + return null; + } + + const suggestions = this.getFileSuggestions(pathMatch); + if (suggestions.length === 0) return null; + + return { + items: suggestions, + prefix: pathMatch, + }; } applyCompletion( @@ -684,9 +702,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Fuzzy file search using fd (fast, respects .gitignore) - private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] { - if (!this.fdPath) { - // fd not available, return empty results + private async getFuzzyFileSuggestions( + query: string, + options: { isQuotedPrefix: boolean; signal: AbortSignal }, + ): Promise { + if (!this.fdPath || options.signal.aborted) { return []; } @@ -694,9 +714,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const scopedQuery = this.resolveScopedFuzzyQuery(query); const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; const fdQuery = scopedQuery?.query ?? query; - const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100); + const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal); + if (options.signal.aborted) { + return []; + } - // Score entries const scoredEntries = entries .map((entry) => ({ ...entry, @@ -704,14 +726,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { })) .filter((entry) => entry.score > 0); - // Sort by score (descending) and take top 20 scoredEntries.sort((a, b) => b.score - a.score); const topEntries = scoredEntries.slice(0, 20); - // Build suggestions const suggestions: AutocompleteItem[] = []; for (const { path: entryPath, isDirectory } of topEntries) { - // fd already includes trailing / for directories const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; const displayPath = scopedQuery ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash) @@ -737,35 +756,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } } - // Force file completion (called on Tab key) - always returns suggestions - getForceFileSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - ): { items: AutocompleteItem[]; prefix: string } | null { - const currentLine = lines[cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, cursorCol); - - // Don't trigger if we're typing a slash command at the start of the line - if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { - return null; - } - - // Force extract path prefix - this will always return something - const pathMatch = this.extractPathPrefix(textBeforeCursor, true); - if (pathMatch !== null) { - const suggestions = this.getFileSuggestions(pathMatch); - if (suggestions.length === 0) return null; - - return { - items: suggestions, - prefix: pathMatch, - }; - } - - return null; - } - // Check if we should trigger file completion (called on Tab key) shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { const currentLine = lines[cursorLine] || ""; diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index e3fd327b..b2139d5e 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,4 +1,4 @@ -import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; +import type { AutocompleteProvider, AutocompleteSuggestions, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getKeybindings } from "../keybindings.js"; import { decodeKittyPrintable, matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; @@ -212,6 +212,8 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { maxPrimaryColumnWidth: 32, }; +const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; + export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], @@ -241,6 +243,11 @@ export class Editor implements Component, Focusable { private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; private autocompleteMaxVisible: number = 5; + private autocompleteAbort?: AbortController; + private autocompleteDebounceTimer?: ReturnType; + private autocompleteRequestTask: Promise = Promise.resolve(); + private autocompleteStartToken: number = 0; + private autocompleteRequestId: number = 0; // Paste tracking for large pastes private pastes: Map = new Map(); @@ -316,6 +323,7 @@ export class Editor implements Component, Focusable { } setAutocompleteProvider(provider: AutocompleteProvider): void { + this.cancelAutocomplete(); this.autocompleteProvider = provider; } @@ -922,6 +930,7 @@ export class Editor implements Component, Focusable { } setText(text: string): void { + this.cancelAutocomplete(); this.lastAction = null; this.historyIndex = -1; // Exit history browsing mode const normalized = this.normalizeText(text); @@ -939,6 +948,7 @@ export class Editor implements Component, Focusable { */ insertTextAtCursor(text: string): void { if (!text) return; + this.cancelAutocomplete(); this.pushUndoSnapshot(); this.lastAction = null; this.historyIndex = -1; @@ -1065,6 +1075,7 @@ export class Editor implements Component, Focusable { } private handlePaste(pastedText: string): void { + this.cancelAutocomplete(); this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; @@ -1120,6 +1131,7 @@ export class Editor implements Component, Focusable { } private addNewLine(): void { + this.cancelAutocomplete(); this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; @@ -1155,6 +1167,7 @@ export class Editor implements Component, Focusable { } private submitValue(): void { + this.cancelAutocomplete(); const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim(); this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; @@ -2019,10 +2032,34 @@ export class Editor implements Component, Focusable { } private tryTriggerAutocomplete(explicitTab: boolean = false): void { + this.requestAutocomplete({ force: false, explicitTab }); + } + + private handleTabCompletion(): void { if (!this.autocompleteProvider) return; - // Check if we should trigger file completion on Tab - if (explicitTab) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + + if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { + this.handleSlashCommandCompletion(); + } else { + this.forceFileAutocomplete(true); + } + } + + private handleSlashCommandCompletion(): void { + this.requestAutocomplete({ force: false, explicitTab: true }); + } + + private forceFileAutocomplete(explicitTab: boolean = false): void { + this.requestAutocomplete({ force: true, explicitTab }); + } + + private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void { + if (!this.autocompleteProvider) return; + + if (options.force) { const provider = this.autocompleteProvider as CombinedAutocompleteProvider; const shouldTrigger = !provider.shouldTriggerFileCompletion || @@ -2032,139 +2069,162 @@ export class Editor implements Component, Focusable { } } - const suggestions = this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); + this.cancelAutocompleteRequest(); + const startToken = ++this.autocompleteStartToken; - if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); - - // If typed prefix exactly matches one of the suggestions, select that item - const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); - if (bestMatchIndex >= 0) { - this.autocompleteList.setSelectedIndex(bestMatchIndex); - } - - this.autocompleteState = "regular"; - } else { - this.cancelAutocomplete(); - } - } - - private handleTabCompletion(): void { - if (!this.autocompleteProvider) return; - - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - - // Check if we're in a slash command context - if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { - this.handleSlashCommandCompletion(); - } else { - this.forceFileAutocomplete(true); - } - } - - private handleSlashCommandCompletion(): void { - this.tryTriggerAutocomplete(true); - } - - /* -https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883 -17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 -536643416/job/55932288317 havea look at .gi - */ - private forceFileAutocomplete(explicitTab: boolean = false): void { - if (!this.autocompleteProvider) return; - - // Check if provider supports force file suggestions via runtime check - const provider = this.autocompleteProvider as { - getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"]; - }; - if (typeof provider.getForceFileSuggestions !== "function") { - this.tryTriggerAutocomplete(true); + const debounceMs = this.getAutocompleteDebounceMs(options); + if (debounceMs > 0) { + this.autocompleteDebounceTimer = setTimeout(() => { + this.autocompleteDebounceTimer = undefined; + void this.startAutocompleteRequest(startToken, options); + }, debounceMs); return; } - const suggestions = provider.getForceFileSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); + void this.startAutocompleteRequest(startToken, options); + } - if (suggestions && suggestions.items.length > 0) { - // If there's exactly one suggestion, apply it immediately - if (explicitTab && suggestions.items.length === 1) { - const item = suggestions.items[0]!; - this.pushUndoSnapshot(); - this.lastAction = null; - 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()); + private async startAutocompleteRequest( + startToken: number, + options: { force: boolean; explicitTab: boolean }, + ): Promise { + const previousTask = this.autocompleteRequestTask; + this.autocompleteRequestTask = (async () => { + await previousTask; + if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) { return; } - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); + const controller = new AbortController(); + this.autocompleteAbort = controller; + const requestId = ++this.autocompleteRequestId; + const snapshotText = this.getText(); + const snapshotLine = this.state.cursorLine; + const snapshotCol = this.state.cursorCol; - // If typed prefix exactly matches one of the suggestions, select that item - const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); - if (bestMatchIndex >= 0) { - this.autocompleteList.setSelectedIndex(bestMatchIndex); - } - - this.autocompleteState = "force"; - } else { - this.cancelAutocomplete(); - } + await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options); + })(); + await this.autocompleteRequestTask; } - private cancelAutocomplete(): void { + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { + if (options.explicitTab || options.force) { + return 0; + } + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor); + return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + } + + private async runAutocompleteRequest( + requestId: number, + controller: AbortController, + snapshotText: string, + snapshotLine: number, + snapshotCol: number, + options: { force: boolean; explicitTab: boolean }, + ): Promise { + if (!this.autocompleteProvider) return; + + const suggestions = await this.autocompleteProvider.getSuggestions( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + { signal: controller.signal, force: options.force }, + ); + + if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) { + return; + } + + this.autocompleteAbort = undefined; + + if (!suggestions || suggestions.items.length === 0) { + this.cancelAutocomplete(); + this.tui.requestRender(); + return; + } + + if (options.force && options.explicitTab && suggestions.items.length === 1) { + const item = suggestions.items[0]!; + this.pushUndoSnapshot(); + this.lastAction = null; + 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()); + this.tui.requestRender(); + return; + } + + this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular"); + this.tui.requestRender(); + } + + private isAutocompleteRequestCurrent( + requestId: number, + controller: AbortController, + snapshotText: string, + snapshotLine: number, + snapshotCol: number, + ): boolean { + return ( + !controller.signal.aborted && + requestId === this.autocompleteRequestId && + this.getText() === snapshotText && + this.state.cursorLine === snapshotLine && + this.state.cursorCol === snapshotCol + ); + } + + private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void { + this.autocompletePrefix = suggestions.prefix; + this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); + + const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); + if (bestMatchIndex >= 0) { + this.autocompleteList.setSelectedIndex(bestMatchIndex); + } + + this.autocompleteState = state; + } + + private cancelAutocompleteRequest(): void { + this.autocompleteStartToken += 1; + if (this.autocompleteDebounceTimer) { + clearTimeout(this.autocompleteDebounceTimer); + this.autocompleteDebounceTimer = undefined; + } + this.autocompleteAbort?.abort(); + this.autocompleteAbort = undefined; + } + + private clearAutocompleteUi(): void { this.autocompleteState = null; this.autocompleteList = undefined; this.autocompletePrefix = ""; } + private cancelAutocomplete(): void { + this.cancelAutocompleteRequest(); + this.clearAutocompleteUi(); + } + public isShowingAutocomplete(): boolean { return this.autocompleteState !== null; } private updateAutocomplete(): void { if (!this.autocompleteState || !this.autocompleteProvider) return; - - if (this.autocompleteState === "force") { - this.forceFileAutocomplete(); - return; - } - - const suggestions = this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - ); - if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - // Always create new SelectList to ensure update - this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); - - // If typed prefix exactly matches one of the suggestions, select that item - const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); - if (bestMatchIndex >= 0) { - this.autocompleteList.setSelectedIndex(bestMatchIndex); - } - } else { - this.cancelAutocomplete(); - } + this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false }); } } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index ea145e4b..025ecde4 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -4,6 +4,7 @@ export { type AutocompleteItem, type AutocompleteProvider, + type AutocompleteSuggestions, CombinedAutocompleteProvider, type SlashCommand, } from "./autocomplete.js"; diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 9693b975..7116f72f 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -46,15 +46,23 @@ const requireFdPath = (): string => { return fdPath; }; +const getSuggestions = ( + provider: CombinedAutocompleteProvider, + lines: string[], + cursorLine: number, + cursorCol: number, + force: boolean = false, +) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force }); + describe("CombinedAutocompleteProvider", () => { describe("extractPathPrefix", () => { - it("extracts / from 'hey /' when forced", () => { + it("extracts / from 'hey /' when forced", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["hey /"]; const cursorLine = 0; const cursorCol = 5; // After the "/" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); assert.notEqual(result, null, "Should return suggestions for root directory"); if (result) { @@ -62,13 +70,13 @@ describe("CombinedAutocompleteProvider", () => { } }); - it("extracts /A from '/A' when forced", () => { + it("extracts /A from '/A' when forced", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/A"]; const cursorLine = 0; const cursorCol = 2; // After the "A" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); console.log("Result:", result); // This might return null if /A doesn't match anything, which is fine @@ -78,25 +86,25 @@ describe("CombinedAutocompleteProvider", () => { } }); - it("does not trigger for slash commands", () => { + it("does not trigger for slash commands", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/model"]; const cursorLine = 0; const cursorCol = 6; // After "model" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); console.log("Result:", result); assert.strictEqual(result, null, "Should not trigger for slash commands"); }); - it("triggers for absolute paths after slash command argument", () => { + it("triggers for absolute paths after slash command argument", async () => { const provider = new CombinedAutocompleteProvider([], "/tmp"); const lines = ["/command /"]; const cursorLine = 0; const cursorCol = 10; // After the second "/" - const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol); + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); console.log("Result:", result); assert.notEqual(result, null, "Should trigger for absolute paths in command arguments"); @@ -123,7 +131,7 @@ describe("CombinedAutocompleteProvider", () => { rmSync(rootDir, { recursive: true, force: true }); }); - test("returns all files and folders for empty @ query", () => { + test("returns all files and folders for empty @ query", async () => { setupFolder(baseDir, { dirs: ["src"], files: { @@ -133,13 +141,13 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value).sort(); assert.deepStrictEqual(values, ["@README.md", "@src/"].sort()); }); - test("matches file with extension in query", () => { + test("matches file with extension in query", async () => { setupFolder(baseDir, { files: { "file.txt": "content", @@ -148,13 +156,13 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@file.txt"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("@file.txt")); }); - test("filters are case insensitive", () => { + test("filters are case insensitive", async () => { setupFolder(baseDir, { dirs: ["src"], files: { @@ -164,13 +172,13 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@re"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value).sort(); assert.deepStrictEqual(values, ["@README.md"]); }); - test("ranks directories before files", () => { + test("ranks directories before files", async () => { setupFolder(baseDir, { dirs: ["src"], files: { @@ -180,7 +188,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@src"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const firstValue = result?.items[0]?.value; const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt"); @@ -188,7 +196,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(hasSrcFile); }); - test("returns nested file paths", () => { + test("returns nested file paths", async () => { setupFolder(baseDir, { files: { "src/index.ts": "export {};\n", @@ -197,13 +205,13 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@index"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("@src/index.ts")); }); - test("matches deeply nested paths", () => { + test("matches deeply nested paths", async () => { setupFolder(baseDir, { files: { "packages/tui/src/autocomplete.ts": "export {};", @@ -213,14 +221,14 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@tui/src/auto"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("@packages/tui/src/autocomplete.ts")); assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts")); }); - test("matches directory in middle of path with --full-path", () => { + test("matches directory in middle of path with --full-path", async () => { setupFolder(baseDir, { files: { "src/components/Button.tsx": "export {};", @@ -230,14 +238,14 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@components/"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("@src/components/Button.tsx")); assert.ok(!values?.includes("@src/utils/helpers.ts")); }); - test("scopes fuzzy search to relative directories and searches recursively", () => { + test("scopes fuzzy search to relative directories and searches recursively", async () => { setupFolder(outsideDir, { files: { "nested/alpha.ts": "export {};", @@ -248,7 +256,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@../outside/a"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("@../outside/nested/alpha.ts")); @@ -256,7 +264,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts")); }); - test("quotes paths with spaces for @ suggestions", () => { + test("quotes paths with spaces for @ suggestions", async () => { setupFolder(baseDir, { dirs: ["my folder"], files: { @@ -266,13 +274,13 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@my"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value); assert.ok(values?.includes('@"my folder/"')); }); - test("includes hidden paths but excludes .git", () => { + test("includes hidden paths but excludes .git", async () => { setupFolder(baseDir, { dirs: [".pi", ".github", ".git"], files: { @@ -284,7 +292,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = "@"; - const result = provider.getSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length); const values = result?.items.map((item) => item.value) ?? []; assert.ok(values.includes("@.pi/")); @@ -292,7 +300,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/"))); }); - test("continues autocomplete inside quoted @ paths", () => { + test("continues autocomplete inside quoted @ paths", async () => { setupFolder(baseDir, { files: { "my folder/test.txt": "content", @@ -302,7 +310,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = '@"my folder/"'; - const result = provider.getSuggestions([line], 0, line.length - 1); + const result = await getSuggestions(provider, [line], 0, line.length - 1); assert.notEqual(result, null, "Should return suggestions for quoted folder path"); const values = result?.items.map((item) => item.value); @@ -310,7 +318,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(values?.includes('@"my folder/other.txt"')); }); - test("applies quoted @ completion without duplicating closing quote", () => { + test("applies quoted @ completion without duplicating closing quote", async () => { setupFolder(baseDir, { files: { "my folder/test.txt": "content", @@ -320,7 +328,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); const line = '@"my folder/te"'; const cursorCol = line.length - 1; - const result = provider.getSuggestions([line], 0, cursorCol); + const result = await getSuggestions(provider, [line], 0, cursorCol); assert.notEqual(result, null, "Should return suggestions for quoted @ path"); const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"'); @@ -342,7 +350,7 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); - test("preserves ./ prefix when completing paths", () => { + test("preserves ./ prefix when completing paths", async () => { setupFolder(baseDir, { files: { "update.sh": "#!/bin/bash", @@ -352,14 +360,14 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir); const line = "./up"; - const result = provider.getForceFileSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length, true); assert.notEqual(result, null, "Should return suggestions for ./ path"); const values = result?.items.map((item) => item.value); assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`); }); - test("preserves ./ prefix for directory completions", () => { + test("preserves ./ prefix for directory completions", async () => { setupFolder(baseDir, { dirs: ["src"], files: { @@ -369,7 +377,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir); const line = "./sr"; - const result = provider.getForceFileSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length, true); assert.notEqual(result, null, "Should return suggestions for ./ directory path"); const values = result?.items.map((item) => item.value); @@ -388,7 +396,7 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); - test("quotes paths with spaces for direct completion", () => { + test("quotes paths with spaces for direct completion", async () => { setupFolder(baseDir, { dirs: ["my folder"], files: { @@ -398,14 +406,14 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir); const line = "my"; - const result = provider.getForceFileSuggestions([line], 0, line.length); + const result = await getSuggestions(provider, [line], 0, line.length, true); assert.notEqual(result, null, "Should return suggestions for path completion"); const values = result?.items.map((item) => item.value); assert.ok(values?.includes('"my folder/"')); }); - test("continues completion inside quoted paths", () => { + test("continues completion inside quoted paths", async () => { setupFolder(baseDir, { files: { "my folder/test.txt": "content", @@ -415,7 +423,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir); const line = '"my folder/"'; - const result = provider.getForceFileSuggestions([line], 0, line.length - 1); + const result = await getSuggestions(provider, [line], 0, line.length - 1, true); assert.notEqual(result, null, "Should return suggestions for quoted folder path"); const values = result?.items.map((item) => item.value); @@ -423,7 +431,7 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(values?.includes('"my folder/other.txt"')); }); - test("applies quoted completion without duplicating closing quote", () => { + test("applies quoted completion without duplicating closing quote", async () => { setupFolder(baseDir, { files: { "my folder/test.txt": "content", @@ -433,7 +441,7 @@ describe("CombinedAutocompleteProvider", () => { const provider = new CombinedAutocompleteProvider([], baseDir); const line = '"my folder/te"'; const cursorCol = line.length - 1; - const result = provider.getForceFileSuggestions([line], 0, cursorCol); + const result = await getSuggestions(provider, [line], 0, cursorCol, true); assert.notEqual(result, null, "Should return suggestions for quoted path"); const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"'); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 0ad92141..e157b834 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -33,6 +33,11 @@ function applyCompletion( }; } +async function flushAutocomplete(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); +} + describe("Editor component", () => { describe("Prompt history navigation", () => { it("does nothing on Up arrow when history is empty", () => { @@ -1641,7 +1646,7 @@ describe("Editor component", () => { let suggestionCalls = 0; const mockProvider: AutocompleteProvider = { - getSuggestions: () => { + getSuggestions: async () => { suggestionCalls += 1; return null; }, @@ -1902,12 +1907,12 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "hello"); }); - it("undoes autocomplete", () => { + it("undoes autocomplete", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Create a mock autocomplete provider const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const prefix = text.slice(0, cursorCol); if (prefix === "di") { @@ -1930,11 +1935,7 @@ describe("Editor component", () => { // Press Tab to trigger autocomplete editor.handleInput("\t"); - // Autocomplete should be showing with "dist/" suggestion - assert.strictEqual(editor.isShowingAutocomplete(), true); - - // Press Tab again to accept the suggestion - editor.handleInput("\t"); + await flushAutocomplete(); assert.strictEqual(editor.getText(), "dist/"); assert.strictEqual(editor.isShowingAutocomplete(), false); @@ -1945,15 +1946,14 @@ describe("Editor component", () => { }); describe("Autocomplete", () => { - it("auto-applies single force-file suggestion without showing menu", () => { + it("auto-applies single force-file suggestion without showing menu", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); - // Create a mock provider with getForceFileSuggestions that returns single item - const mockProvider: AutocompleteProvider & { - getForceFileSuggestions: AutocompleteProvider["getSuggestions"]; - } = { - getSuggestions: () => null, - getForceFileSuggestions: (lines, _cursorLine, cursorCol) => { + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { + if (!options.force) { + return null; + } const text = lines[0] || ""; const prefix = text.slice(0, cursorCol); if (prefix === "Work") { @@ -1978,6 +1978,7 @@ describe("Editor component", () => { // Press Tab - should auto-apply without showing menu editor.handleInput("\t"); + await flushAutocomplete(); assert.strictEqual(editor.getText(), "Workspace/"); assert.strictEqual(editor.isShowingAutocomplete(), false); @@ -1986,15 +1987,14 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "Work"); }); - it("shows menu when force-file has multiple suggestions", () => { + it("shows menu when force-file has multiple suggestions", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); - // Create a mock provider with getForceFileSuggestions that returns multiple items - const mockProvider: AutocompleteProvider & { - getForceFileSuggestions: AutocompleteProvider["getSuggestions"]; - } = { - getSuggestions: () => null, - getForceFileSuggestions: (lines, _cursorLine, cursorCol) => { + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { + if (!options.force) { + return null; + } const text = lines[0] || ""; const prefix = text.slice(0, cursorCol); if (prefix === "src") { @@ -2021,7 +2021,8 @@ describe("Editor component", () => { // Press Tab - should show menu because there are multiple suggestions editor.handleInput("\t"); - assert.strictEqual(editor.getText(), "src"); // Text unchanged + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "src"); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Tab again to accept first suggestion @@ -2030,12 +2031,9 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), false); }); - it("keeps suggestions open when typing in force mode (Tab-triggered)", () => { + it("keeps suggestions open when typing in force mode (Tab-triggered)", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); - // Mock provider with both getSuggestions and getForceFileSuggestions - // getSuggestions only returns results for path-like patterns - // getForceFileSuggestions always extracts prefix and filters const allFiles = [ { value: "readme.md", label: "readme.md" }, { value: "package.json", label: "package.json" }, @@ -2043,29 +2041,14 @@ describe("Editor component", () => { { value: "dist/", label: "dist/" }, ]; - const mockProvider: AutocompleteProvider & { - getForceFileSuggestions: ( - lines: string[], - cursorLine: number, - cursorCol: number, - ) => { items: { value: string; label: string }[]; prefix: string } | null; - } = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { const text = lines[0] || ""; const prefix = text.slice(0, cursorCol); - // Only return suggestions for path-like patterns (contains / or starts with .) - if (prefix.includes("/") || prefix.startsWith(".")) { - const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase())); - if (filtered.length > 0) { - return { items: filtered, prefix }; - } + const shouldMatch = options.force || prefix.includes("/") || prefix.startsWith("."); + if (!shouldMatch) { + return null; } - return null; - }, - getForceFileSuggestions: (lines, _cursorLine, cursorCol) => { - const text = lines[0] || ""; - const prefix = text.slice(0, cursorCol); - // Always filter files by prefix const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase())); if (filtered.length > 0) { return { items: filtered, prefix }; @@ -2079,15 +2062,18 @@ describe("Editor component", () => { // Press Tab on empty prompt - should show all files (force mode) editor.handleInput("\t"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Type "r" - should narrow to "readme.md" (force mode keeps suggestions open) editor.handleInput("r"); + await flushAutocomplete(); assert.strictEqual(editor.getText(), "r"); assert.strictEqual(editor.isShowingAutocomplete(), true); // Type "e" - should still show "readme.md" editor.handleInput("e"); + await flushAutocomplete(); assert.strictEqual(editor.getText(), "re"); assert.strictEqual(editor.isShowingAutocomplete(), true); @@ -2097,12 +2083,85 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), false); }); - it("hides autocomplete when backspacing slash command to empty", () => { + it("debounces @ autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const text = (lines[0] || "").slice(0, cursorCol); + return { + items: [{ value: "@main.ts", label: "main.ts" }], + prefix: text, + }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("@"); + await new Promise((resolve) => setTimeout(resolve, 50)); + editor.handleInput("m"); + await new Promise((resolve) => setTimeout(resolve, 50)); + editor.handleInput("a"); + await new Promise((resolve) => setTimeout(resolve, 50)); + editor.handleInput("i"); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + await new Promise((resolve) => setTimeout(resolve, 250)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("aborts active @ autocomplete when typing continues", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let aborts = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (_lines, _cursorLine, _cursorCol, options) => { + return await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve({ items: [{ value: "@main.ts", label: "main.ts" }], prefix: "@main" }); + }, 500); + options.signal.addEventListener( + "abort", + () => { + aborts += 1; + clearTimeout(timeout); + resolve(null); + }, + { once: true }, + ); + }); + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("@"); + editor.handleInput("m"); + editor.handleInput("a"); + editor.handleInput("i"); + await new Promise((resolve) => setTimeout(resolve, 250)); + editor.handleInput("n"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.strictEqual(aborts, 1); + }); + + it("hides autocomplete when backspacing slash command to empty", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider with slash commands const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const prefix = text.slice(0, cursorCol); // Only return slash command suggestions when line starts with / @@ -2126,21 +2185,23 @@ describe("Editor component", () => { // Type "/" - should show slash command suggestions editor.handleInput("/"); + await flushAutocomplete(); assert.strictEqual(editor.getText(), "/"); assert.strictEqual(editor.isShowingAutocomplete(), true); // Backspace to delete "/" - should hide autocomplete completely editor.handleInput("\x7f"); // Backspace + await flushAutocomplete(); assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.isShowingAutocomplete(), false); }); - it("applies exact typed slash-argument value on Enter even when first item is highlighted", () => { + it("applies exact typed slash-argument value on Enter even when first item is highlighted", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider for /argtest command with argument completions const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const beforeCursor = text.slice(0, cursorCol); @@ -2181,6 +2242,7 @@ describe("Editor component", () => { editor.handleInput("o"); assert.strictEqual(editor.getText(), "/argtest two"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Enter - should apply the exact typed value "two", not the first item @@ -2190,12 +2252,12 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "/argtest two"); }); - it("selects first prefix match on Enter when typed arg is not exact match", () => { + it("selects first prefix match on Enter when typed arg is not exact match", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider for /argtest command with argument completions const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const beforeCursor = text.slice(0, cursorCol); @@ -2233,6 +2295,7 @@ describe("Editor component", () => { editor.handleInput(" "); editor.handleInput("t"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Enter - "t" prefix matches "two" (first in list), so "two" is applied @@ -2240,12 +2303,12 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "/argtest two"); }); - it("highlights unique prefix match as user types (before full exact match)", () => { + it("highlights unique prefix match as user types (before full exact match)", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider that returns all items unfiltered (like real extensions do) const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const beforeCursor = text.slice(0, cursorCol); @@ -2281,6 +2344,7 @@ describe("Editor component", () => { editor.handleInput("w"); assert.strictEqual(editor.getText(), "/argtest tw"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Enter - "tw" uniquely matches "two", so "two" should be applied @@ -2288,12 +2352,12 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "/argtest two"); }); - it("selects first prefix match when multiple items match", () => { + it("selects first prefix match when multiple items match", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider that returns all items unfiltered const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const beforeCursor = text.slice(0, cursorCol); @@ -2326,6 +2390,7 @@ describe("Editor component", () => { editor.handleInput(" "); editor.handleInput("t"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Enter - "t" matches "two" first, so "two" is selected @@ -2333,12 +2398,12 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "/argtest two"); }); - it("works for built-in-style command argument completion path (model-like)", () => { + it("works for built-in-style command argument completion path (model-like)", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); // Mock provider for /model command with model completions const mockProvider: AutocompleteProvider = { - getSuggestions: (lines, _cursorLine, cursorCol) => { + getSuggestions: async (lines, _cursorLine, cursorCol) => { const text = lines[0] || ""; const beforeCursor = text.slice(0, cursorCol); @@ -2386,6 +2451,7 @@ describe("Editor component", () => { editor.handleInput("i"); assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); // Press Enter - should retain exact typed value, not apply first highlighted item @@ -2395,7 +2461,7 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); }); - it("does not show argument completions when command has no argument completer", () => { + it("does not show argument completions when command has no argument completer", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); const provider = new CombinedAutocompleteProvider([ { name: "help", description: "Show help" }, @@ -2410,6 +2476,7 @@ describe("Editor component", () => { editor.handleInput("/"); editor.handleInput("h"); editor.handleInput("e"); + await flushAutocomplete(); assert.strictEqual(editor.isShowingAutocomplete(), true); editor.handleInput("\t");