From dc8539a0015560f64de644f9142c025246852de4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 30 Jan 2026 00:11:12 +0100 Subject: [PATCH] fix(tui): support quoted paths with spaces in autocomplete Fixes #1077 --- packages/tui/CHANGELOG.md | 6 + packages/tui/src/autocomplete.ts | 224 ++++++++++++++++--------- packages/tui/test/autocomplete.test.ts | 81 +++++++++ 3 files changed, 236 insertions(+), 75 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7889d0d0..fadbb249 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Fixed autocomplete for paths with spaces by supporting quoted path tokens ([#1077](https://github.com/badlogic/pi-mono/issues/1077)) + ## [0.50.3] - 2026-01-29 ## [0.50.2] - 2026-01-29 diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index cd7b17bd..6af1fa2b 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -4,6 +4,86 @@ import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fuzzyFilter } from "./fuzzy.js"; +const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); + +function findLastDelimiter(text: string): number { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (PATH_DELIMITERS.has(text[i] ?? "")) { + return i; + } + } + return -1; +} + +function findUnclosedQuoteStart(text: string): number | null { + let inQuotes = false; + let quoteStart = -1; + + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '"') { + inQuotes = !inQuotes; + if (inQuotes) { + quoteStart = i; + } + } + } + + return inQuotes ? quoteStart : null; +} + +function isTokenStart(text: string, index: number): boolean { + return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); +} + +function extractQuotedPrefix(text: string): string | null { + const quoteStart = findUnclosedQuoteStart(text); + if (quoteStart === null) { + return null; + } + + if (quoteStart > 0 && text[quoteStart - 1] === "@") { + if (!isTokenStart(text, quoteStart - 1)) { + return null; + } + return text.slice(quoteStart - 1); + } + + if (!isTokenStart(text, quoteStart)) { + return null; + } + + return text.slice(quoteStart); +} + +function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } { + if (prefix.startsWith('@"')) { + return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true }; + } + if (prefix.startsWith('"')) { + return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true }; + } + if (prefix.startsWith("@")) { + return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false }; + } + return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false }; +} + +function buildCompletionValue( + path: string, + options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean }, +): string { + const needsQuotes = options.isQuotedPrefix || path.includes(" "); + const prefix = options.isAtPrefix ? "@" : ""; + + if (!needsQuotes) { + return `${prefix}${path}`; + } + + const openQuote = `${prefix}"`; + const closeQuote = options.isDirectory ? "" : '"'; + return `${openQuote}${path}${closeQuote}`; +} + // Use fd to walk directory tree (fast, respects .gitignore) function walkDirectoryWithFd( baseDir: string, @@ -118,17 +198,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); - // Check for @ file reference (fuzzy search) - must be after a space or at start - const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/); - if (atMatch) { - const prefix = atMatch[1] ?? "@"; // The @... part - const query = prefix.slice(1); // Remove the @ - const suggestions = this.getFuzzyFileSuggestions(query); + // 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 }); if (suggestions.length === 0) return null; return { items: suggestions, - prefix: prefix, + prefix: atPrefix, }; } @@ -281,24 +360,31 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { }; } - // Extract a path-like prefix from the text before cursor - private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { - // Check for @ file attachment syntax first - const atMatch = text.match(/@([^\s]*)$/); - if (atMatch) { - return atMatch[0]; // Return the full @path pattern + // Extract @ prefix for fuzzy file suggestions + private extractAtPrefix(text: string): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix?.startsWith('@"')) { + return quotedPrefix; } - // Simple approach: find the last whitespace/delimiter and extract the word after it - // This avoids catastrophic backtracking from nested quantifiers - const lastDelimiterIndex = Math.max( - text.lastIndexOf(" "), - text.lastIndexOf("\t"), - text.lastIndexOf('"'), - text.lastIndexOf("'"), - text.lastIndexOf("="), - ); + const lastDelimiterIndex = findLastDelimiter(text); + const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + if (text[tokenStart] === "@") { + return text.slice(tokenStart); + } + + return null; + } + + // Extract a path-like prefix from the text before cursor + private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix) { + return quotedPrefix; + } + + const lastDelimiterIndex = findLastDelimiter(text); const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); // For forced extraction (Tab key), always return something @@ -338,39 +424,34 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { try { let searchDir: string; let searchPrefix: string; - let expandedPrefix = prefix; - let isAtPrefix = false; - - // Handle @ file attachment prefix - if (prefix.startsWith("@")) { - isAtPrefix = true; - expandedPrefix = prefix.slice(1); // Remove the @ - } + const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix); + let expandedPrefix = rawPrefix; // Handle home directory expansion if (expandedPrefix.startsWith("~")) { expandedPrefix = this.expandHomePath(expandedPrefix); } - if ( - expandedPrefix === "" || - expandedPrefix === "./" || - expandedPrefix === "../" || - expandedPrefix === "~" || - expandedPrefix === "~/" || - expandedPrefix === "/" || - prefix === "@" - ) { + const isRootPrefix = + rawPrefix === "" || + rawPrefix === "./" || + rawPrefix === "../" || + rawPrefix === "~" || + rawPrefix === "~/" || + rawPrefix === "/" || + (isAtPrefix && rawPrefix === ""); + + if (isRootPrefix) { // Complete from specified position - if (prefix.startsWith("~") || expandedPrefix === "/") { + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { searchDir = join(this.basePath, expandedPrefix); } searchPrefix = ""; - } else if (expandedPrefix.endsWith("/")) { + } else if (rawPrefix.endsWith("/")) { // If prefix ends with /, show contents of that directory - if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { searchDir = join(this.basePath, expandedPrefix); @@ -380,7 +461,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Split into directory and file prefix const dir = dirname(expandedPrefix); const file = basename(expandedPrefix); - if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) { + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = dir; } else { searchDir = join(this.basePath, dir); @@ -409,58 +490,46 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { let relativePath: string; const name = entry.name; + const displayPrefix = rawPrefix; - // Handle @ prefix path construction - if (isAtPrefix) { - const pathWithoutAt = expandedPrefix; - if (pathWithoutAt.endsWith("/")) { - relativePath = `@${pathWithoutAt}${name}`; - } else if (pathWithoutAt.includes("/")) { - if (pathWithoutAt.startsWith("~/")) { - const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/ - const dir = dirname(homeRelativeDir); - relativePath = `@~/${dir === "." ? name : join(dir, name)}`; - } else { - relativePath = `@${join(dirname(pathWithoutAt), name)}`; - } - } else { - if (pathWithoutAt.startsWith("~")) { - relativePath = `@~/${name}`; - } else { - relativePath = `@${name}`; - } - } - } else if (prefix.endsWith("/")) { + if (displayPrefix.endsWith("/")) { // If prefix ends with /, append entry to the prefix - relativePath = prefix + name; - } else if (prefix.includes("/")) { + relativePath = displayPrefix + name; + } else if (displayPrefix.includes("/")) { // Preserve ~/ format for home directory paths - if (prefix.startsWith("~/")) { - const homeRelativeDir = prefix.slice(2); // Remove ~/ + if (displayPrefix.startsWith("~/")) { + const homeRelativeDir = displayPrefix.slice(2); // Remove ~/ const dir = dirname(homeRelativeDir); relativePath = `~/${dir === "." ? name : join(dir, name)}`; - } else if (prefix.startsWith("/")) { + } else if (displayPrefix.startsWith("/")) { // Absolute path - construct properly - const dir = dirname(prefix); + const dir = dirname(displayPrefix); if (dir === "/") { relativePath = `/${name}`; } else { relativePath = `${dir}/${name}`; } } else { - relativePath = join(dirname(prefix), name); + relativePath = join(dirname(displayPrefix), name); } } else { // For standalone entries, preserve ~/ if original prefix was ~/ - if (prefix.startsWith("~")) { + if (displayPrefix.startsWith("~")) { relativePath = `~/${name}`; } else { relativePath = name; } } + const pathValue = isDirectory ? `${relativePath}/` : relativePath; + const value = buildCompletionValue(pathValue, { + isDirectory, + isAtPrefix, + isQuotedPrefix, + }); + suggestions.push({ - value: isDirectory ? `${relativePath}/` : relativePath, + value, label: name + (isDirectory ? "/" : ""), }); } @@ -506,7 +575,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Fuzzy file search using fd (fast, respects .gitignore) - private getFuzzyFileSuggestions(query: string): AutocompleteItem[] { + private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] { if (!this.fdPath) { // fd not available, return empty results return []; @@ -533,9 +602,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // fd already includes trailing / for directories const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; const entryName = basename(pathWithoutSlash); + const value = buildCompletionValue(entryPath, { + isDirectory, + isAtPrefix: true, + isQuotedPrefix: options.isQuotedPrefix, + }); suggestions.push({ - value: `@${entryPath}`, + value, label: entryName + (isDirectory ? "/" : ""), description: pathWithoutSlash, }); diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 3423f250..cd68c609 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -230,5 +230,86 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(values?.includes("@src/components/Button.tsx")); assert.ok(!values?.includes("@src/utils/helpers.ts")); }); + + test("quotes paths with spaces for @ suggestions", () => { + setupFolder(baseDir, { + dirs: ["my folder"], + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@my"; + const result = provider.getSuggestions([line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('@"my folder/')); + }); + + test("continues autocomplete inside quoted @ paths", () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + "my folder/other.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = '@"my folder/'; + const result = provider.getSuggestions([line], 0, line.length); + + assert.notEqual(result, null, "Should return suggestions for quoted folder path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('@"my folder/test.txt"')); + assert.ok(values?.includes('@"my folder/other.txt"')); + }); + }); + + describe("quoted path completion", () => { + let baseDir = ""; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-")); + }); + + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + test("quotes paths with spaces for direct completion", () => { + setupFolder(baseDir, { + dirs: ["my folder"], + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "my"; + const result = provider.getForceFileSuggestions([line], 0, line.length); + + 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", () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + "my folder/other.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = '"my folder/'; + const result = provider.getForceFileSuggestions([line], 0, line.length); + + assert.notEqual(result, null, "Should return suggestions for quoted folder path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('"my folder/test.txt"')); + assert.ok(values?.includes('"my folder/other.txt"')); + }); }); });