From 9a34bf1c95ce839c5135342bf9df67f53ba03f48 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 5 May 2026 12:40:31 +0200 Subject: [PATCH] feat(tui): More flexile autocomplete for paths --- packages/tui/src/autocomplete.ts | 48 ++++++++++++++++++++++---- packages/tui/src/components/editor.ts | 31 +++++++++++++---- packages/tui/test/autocomplete.test.ts | 43 +++++++++++++++++++++++ packages/tui/test/editor.test.ts | 36 +++++++++++++++++++ 4 files changed, 145 insertions(+), 13 deletions(-) diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 965dc3031..11b2a5a5a 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -4,7 +4,12 @@ import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fuzzyFilter } from "./fuzzy.js"; -const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); +const PATH_TOKEN_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); +const AUTOCOMPLETE_BOUNDARY_PUNCTUATION = new Set(["(", ")", "[", "]", "{", "}", "<", ">", ",", ";"]); + +export function isAutocompleteTokenBoundary(char: string | undefined): boolean { + return char === undefined || PATH_TOKEN_DELIMITERS.has(char) || AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(char); +} function toDisplayPath(value: string): string { return value.replace(/\\/g, "/"); @@ -44,13 +49,37 @@ function buildFdPathQuery(query: string): string { function findLastDelimiter(text: string): number { for (let i = text.length - 1; i >= 0; i -= 1) { - if (PATH_DELIMITERS.has(text[i] ?? "")) { + if (PATH_TOKEN_DELIMITERS.has(text[i] ?? "")) { return i; } } return -1; } +function isExplicitPathPrefixStart(prefix: string): boolean { + return prefix.startsWith(".") || prefix.startsWith("/") || prefix.startsWith("~/") || prefix === "~"; +} + +function findPathPrefixStartInToken(token: string): number { + let tokenStart = 0; + while (AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[tokenStart] ?? "")) { + tokenStart += 1; + } + + for (let i = tokenStart; i < token.length; i += 1) { + if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) { + continue; + } + + const suffix = token.slice(i + 1); + if (isExplicitPathPrefixStart(suffix)) { + tokenStart = i + 1; + } + } + + return tokenStart; +} + function findUnclosedQuoteStart(text: string): number | null { let inQuotes = false; let quoteStart = -1; @@ -68,7 +97,7 @@ function findUnclosedQuoteStart(text: string): number | null { } function isTokenStart(text: string, index: number): boolean { - return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); + return index === 0 || isAutocompleteTokenBoundary(text[index - 1]); } function extractQuotedPrefix(text: string): string | null { @@ -465,9 +494,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const lastDelimiterIndex = findLastDelimiter(text); const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + const token = text.slice(tokenStart); - if (text[tokenStart] === "@") { - return text.slice(tokenStart); + for (let i = 0; i < token.length; i += 1) { + const absoluteIndex = tokenStart + i; + if (token[i] === "@" && isTokenStart(text, absoluteIndex)) { + return text.slice(absoluteIndex); + } } return null; @@ -481,7 +514,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } const lastDelimiterIndex = findLastDelimiter(text); - const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); + const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + const token = text.slice(tokenStart); + const pathPrefixStart = tokenStart + findPathPrefixStartInToken(token); + const pathPrefix = text.slice(pathPrefixStart); // For forced extraction (Tab key), always return something if (forceExtract) { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 990469e57..393475bb6 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,4 +1,8 @@ -import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.js"; +import { + type AutocompleteProvider, + type AutocompleteSuggestions, + isAutocompleteTokenBoundary, +} from "../autocomplete.js"; import { getKeybindings } from "../keybindings.js"; import { decodePrintableKey, matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; @@ -1059,7 +1063,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; - if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") { + if (isAutocompleteTokenBoundary(charBeforeSymbol)) { this.tryTriggerAutocomplete(); } } @@ -1072,7 +1076,7 @@ export class Editor implements Component, Focusable { this.tryTriggerAutocomplete(); } // Check if we're in a symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1252,7 +1256,7 @@ export class Editor implements Component, Focusable { this.tryTriggerAutocomplete(); } // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1616,7 +1620,7 @@ export class Editor implements Component, Focusable { this.tryTriggerAutocomplete(); } // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -2052,6 +2056,20 @@ export class Editor implements Component, Focusable { return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/"); } + private isInSymbolAutocompleteContext(textBeforeCursor: string): boolean { + const atIndex = textBeforeCursor.lastIndexOf("@"); + const hashIndex = textBeforeCursor.lastIndexOf("#"); + const tokenStart = Math.max(atIndex, hashIndex); + if (tokenStart === -1) return false; + if (tokenStart > 0 && !isAutocompleteTokenBoundary(textBeforeCursor[tokenStart - 1])) return false; + + const token = textBeforeCursor.slice(tokenStart); + if (token.startsWith('@"')) { + return !token.slice(2).includes('"'); + } + return !/\s/.test(token); + } + // Autocomplete methods /** * Find the best autocomplete item index for the given prefix. @@ -2176,8 +2194,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); - return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + return this.isInSymbolAutocompleteContext(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; } private async runAutocompleteRequest( diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 14ad9930b..2c16a9386 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -471,6 +471,49 @@ describe("CombinedAutocompleteProvider", () => { const values = result?.items.map((item) => item.value); assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`); }); + + test("completes ./ paths after punctuation", async () => { + setupFolder(baseDir, { + files: { + "update.sh": "#!/bin/bash", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "(./up"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + + assert.notEqual(result, null, "Should return suggestions for ./ path after punctuation"); + assert.strictEqual(result?.prefix, "./up"); + const item = result?.items.find((entry) => entry.value === "./update.sh"); + assert.ok(item, "Should find update.sh suggestion"); + + const applied = provider.applyCompletion([line], 0, line.length, item!, result!.prefix); + assert.strictEqual(applied.lines[0], "(./update.sh"); + }); + + test("does not split path prefixes containing punctuation", async () => { + setupFolder(baseDir, { + files: { + "foo(bar).txt": "content", + "name[part].txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + + const parenLine = "./foo("; + const parenResult = await getSuggestions(provider, [parenLine], 0, parenLine.length, true); + assert.notEqual(parenResult, null, "Should return suggestions for path containing parentheses"); + assert.strictEqual(parenResult?.prefix, "./foo("); + assert.ok(parenResult?.items.some((item) => item.value === "./foo(bar).txt")); + + const bracketLine = "./name["; + const bracketResult = await getSuggestions(provider, [bracketLine], 0, bracketLine.length, true); + assert.notEqual(bracketResult, null, "Should return suggestions for path containing brackets"); + assert.strictEqual(bracketResult?.prefix, "./name["); + assert.ok(bracketResult?.items.some((item) => item.value === "./name[part].txt")); + }); }); describe("quoted path completion", () => { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 36f7ee7b2..fb5f4c58a 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2154,6 +2154,42 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("triggers @ autocomplete after punctuation while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + let requestedText = ""; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + requestedText = (lines[0] || "").slice(0, cursorCol); + return { + items: [{ value: "@main.ts", label: "main.ts" }], + prefix: "@mai", + }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("("); + editor.handleInput("@"); + editor.handleInput("m"); + editor.handleInput("a"); + editor.handleInput("i"); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(requestedText, "(@mai"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + it("debounces # autocomplete while typing", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let suggestionCalls = 0;