From 6e49c700965c7c0ec1c1e502f4af5ef99d6d699b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 6 May 2026 10:54:54 +0200 Subject: [PATCH] fix(tui): preserve autocomplete punctuation context --- packages/tui/src/autocomplete.ts | 30 ++++++++++--- packages/tui/test/autocomplete.test.ts | 59 +++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 11b2a5a5a..e2591288f 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -57,22 +57,38 @@ function findLastDelimiter(text: string): number { } function isExplicitPathPrefixStart(prefix: string): boolean { - return prefix.startsWith(".") || prefix.startsWith("/") || prefix.startsWith("~/") || prefix === "~"; + return ( + prefix.startsWith("./") || + prefix.startsWith("../") || + prefix.startsWith("/") || + prefix.startsWith("~/") || + prefix === "~" + ); +} + +function isLeadingPunctuationRun(token: string, endIndex: number): boolean { + for (let i = 0; i <= endIndex; i += 1) { + if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) { + return false; + } + } + return true; +} + +function isLeadingDotfilePrefixStart(token: string, punctuationIndex: number, suffix: string): boolean { + return suffix.startsWith(".") && !suffix.startsWith("./") && isLeadingPunctuationRun(token, punctuationIndex); } 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) { + for (let i = 0; i < token.length; i += 1) { if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) { continue; } const suffix = token.slice(i + 1); - if (isExplicitPathPrefixStart(suffix)) { + if (isExplicitPathPrefixStart(suffix) || isLeadingDotfilePrefixStart(token, i, suffix)) { tokenStart = i + 1; } } @@ -496,7 +512,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; const token = text.slice(tokenStart); - for (let i = 0; i < token.length; i += 1) { + for (let i = token.length - 1; i >= 0; i -= 1) { const absoluteIndex = tokenStart + i; if (token[i] === "@" && isTokenStart(text, absoluteIndex)) { return text.slice(absoluteIndex); diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 2c16a9386..6cb3eae24 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, it, test } from "node:test"; @@ -114,6 +114,38 @@ describe("CombinedAutocompleteProvider", () => { }); }); + describe("@ prefix extraction", () => { + let rootDir = ""; + let baseDir = ""; + let fakeFdPath = ""; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-at-")); + baseDir = join(rootDir, "cwd"); + mkdirSync(baseDir, { recursive: true }); + fakeFdPath = join(rootDir, "fake-fd.js"); + writeFileSync(fakeFdPath, '#!/usr/bin/env node\nprocess.stdout.write("old.ts\\nmain.ts\\n");\n'); + chmodSync(fakeFdPath, 0o755); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + test("uses the nearest @ after punctuation", async () => { + const provider = new CombinedAutocompleteProvider([], baseDir, fakeFdPath); + const line = "(@old),(@mai"; + const result = await getSuggestions(provider, [line], 0, line.length); + + assert.notEqual(result, null, "Should return suggestions for the current @ prefix"); + assert.strictEqual(result?.prefix, "@mai"); + assert.deepStrictEqual( + result?.items.map((item) => item.value), + ["@main.ts"], + ); + }); + }); + describe("fd @ file suggestions", { skip: !isFdInstalled }, () => { let rootDir = ""; let baseDir = ""; @@ -495,6 +527,7 @@ describe("CombinedAutocompleteProvider", () => { test("does not split path prefixes containing punctuation", async () => { setupFolder(baseDir, { files: { + "(foo).txt": "content", "foo(bar).txt": "content", "name[part].txt": "content", }, @@ -508,6 +541,30 @@ describe("CombinedAutocompleteProvider", () => { assert.strictEqual(parenResult?.prefix, "./foo("); assert.ok(parenResult?.items.some((item) => item.value === "./foo(bar).txt")); + const completedParenLine = "./foo(bar).t"; + const completedParenResult = await getSuggestions( + provider, + [completedParenLine], + 0, + completedParenLine.length, + true, + ); + assert.notEqual(completedParenResult, null, "Should preserve punctuation before file extensions"); + assert.strictEqual(completedParenResult?.prefix, "./foo(bar).t"); + assert.ok(completedParenResult?.items.some((item) => item.value === "./foo(bar).txt")); + + const leadingPunctuationLine = "(foo).t"; + const leadingPunctuationResult = await getSuggestions( + provider, + [leadingPunctuationLine], + 0, + leadingPunctuationLine.length, + true, + ); + assert.notEqual(leadingPunctuationResult, null, "Should preserve leading punctuation in filenames"); + assert.strictEqual(leadingPunctuationResult?.prefix, "(foo).t"); + assert.ok(leadingPunctuationResult?.items.some((item) => item.value === "(foo).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");