feat(tui): More flexile autocomplete for paths

This commit is contained in:
Armin Ronacher
2026-05-05 12:40:31 +02:00
Unverified
parent 3c9c54d51b
commit 9a34bf1c95
4 changed files with 145 additions and 13 deletions
+42 -6
View File
@@ -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) {
+24 -7
View File
@@ -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(
+43
View File
@@ -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", () => {
+36
View File
@@ -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;