diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 7a072d981..f90a6eda3 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -3,8 +3,10 @@ import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fuzzyFilter } from "./fuzzy.ts"; +import { autocompleteBoundaryRegex, autocompleteSeparatorRegex } from "./utils.ts"; const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); +const tokenStartRegex = new RegExp(`${autocompleteBoundaryRegex.source}$`, "u"); function toDisplayPath(value: string): string { return value.replace(/\\/g, "/"); @@ -43,12 +45,15 @@ 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] ?? "")) { - return i; + let lastDelimiter = -1; + let index = 0; + for (const character of text) { + index += character.length; + if (PATH_DELIMITERS.has(character) || autocompleteSeparatorRegex.test(character)) { + lastDelimiter = index - 1; } } - return -1; + return lastDelimiter; } function findUnclosedQuoteStart(text: string): number | null { @@ -68,7 +73,7 @@ function findUnclosedQuoteStart(text: string): number | null { } function isTokenStart(text: string, index: number): boolean { - return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); + return PATH_DELIMITERS.has(text[index - 1] ?? "") || tokenStartRegex.test(text.slice(0, index)); } function extractQuotedPrefix(text: string): string | null { @@ -108,7 +113,7 @@ function buildCompletionValue( path: string, options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean }, ): string { - const needsQuotes = options.isQuotedPrefix || path.includes(" "); + const needsQuotes = options.isQuotedPrefix || autocompleteSeparatorRegex.test(path); const prefix = options.isAtPrefix ? "@" : ""; if (!needsQuotes) { @@ -502,9 +507,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return pathPrefix; } - // Return empty string only after a space (not for completely empty text) + // Return an empty prefix after whitespace or CJK punctuation, but not for empty text. // Empty text should not trigger file suggestions - that's for forced Tab completion - if (pathPrefix === "" && text.endsWith(" ")) { + if (pathPrefix === "" && text !== "" && tokenStartRegex.test(text)) { return pathPrefix; } @@ -683,8 +688,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Sort directories first, then alphabetically suggestions.sort((a, b) => { - const aIsDir = a.value.endsWith("/"); - const bIsDir = b.value.endsWith("/"); + const aIsDir = a.label.endsWith("/"); + const bIsDir = b.label.endsWith("/"); if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.label.localeCompare(b.label); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 42b722f8c..1e9f2f57f 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -12,6 +12,8 @@ import { } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; import { + autocompleteBoundaryRegex, + autocompleteSeparatorRegex, cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, @@ -249,18 +251,26 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; +// Unquoted completions end at whitespace or CJK punctuation; quoted paths may contain either. +const unquotedAutocompleteSuffixRegex = new RegExp(`(?:(?!${autocompleteSeparatorRegex.source}).)*`, "u"); function escapeCharacterClass(value: string): string { return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); } function buildTriggerPattern(triggerCharacters: string[]): RegExp { - return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); + return new RegExp( + `${autocompleteBoundaryRegex.source}(?:@"[^"]*|[${triggerCharacters.map(escapeCharacterClass).join("")}]${unquotedAutocompleteSuffixRegex.source})$`, + "u", + ); } function buildDebouncePattern(triggerCharacters: string[]): RegExp { const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); - return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); + return new RegExp( + `${autocompleteBoundaryRegex.source}(?:@(?:"[^"]*|${unquotedAutocompleteSuffixRegex.source})|[${escapedWithoutAt.join("")}]${unquotedAutocompleteSuffixRegex.source})$`, + "u", + ); } function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string { @@ -1222,13 +1232,12 @@ export class Editor implements Component, Focusable { else if (this.autocompleteTriggerCharacters.includes(char)) { 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 (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } // Also auto-trigger when typing letters in a slash command or symbol completion context - else if (/[a-zA-Z0-9.\-_]/.test(char)) { + else if (/[a-zA-Z0-9.\-_]/.test(char) || cjkBreakRegex.test(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Check if we're in a slash command (with or without space for arguments) diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 419c52fb7..a7e381295 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -54,6 +54,14 @@ const widthCache = new Map(); export const cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; +// CJK letters remain part of words and paths; only punctuation can separate prose from completions. +export const cjkPunctuationRegex = new RegExp( + `(?:(?=\\p{Punctuation})${cjkBreakRegex.source}|[,.:;!?()[]{}“”‘’…—])`, + "u", +); +export const autocompleteSeparatorRegex = new RegExp(`(?:\\s|${cjkPunctuationRegex.source})`, "u"); +export const autocompleteBoundaryRegex = new RegExp(`(?:^|${autocompleteSeparatorRegex.source})`, "u"); + function isPrintableAscii(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index db9ac67a1..c5deff29a 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -147,6 +147,79 @@ describe("CombinedAutocompleteProvider", () => { assert.deepStrictEqual(values, ["@README.md", "@src/"].sort()); }); + test("recognizes @ after CJK punctuation without consuming the preceding text", async () => { + setupFolder(baseDir, { files: { "README.md": "readme" } }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["查看,", "\u3000", ...",.:;!?()[]{}“”‘’…—。、「」『』《》【】"]) { + for (const force of [false, true]) { + const line = `${before}@REA`; + const result = await getSuggestions(provider, [line], 0, line.length, force); + assert.ok(result, line); + assert.strictEqual(result.prefix, "@REA"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["@README.md"], + ); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}@README.md `); + assert.strictEqual(applied.cursorCol, applied.lines[0]!.length); + } + } + }); + + test("preserves CJK characters and embedded @ in attachment paths", async () => { + setupFolder(baseDir, { + files: { "文档/说明.md": "text", "文档@备份/说明.md": "backup" }, + }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["", "查看,"]) { + for (const directory of ["文档", "文档@备份"]) { + const prefix = `@${directory}/说`; + const line = before + prefix; + const result = await getSuggestions(provider, [line], 0, line.length); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`@${directory}/说明.md`], + ); + } + } + }); + + test("completes quoted CJK attachments after prose without losing path segments or quotes", async () => { + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `我的${separator}文档`; + setupFolder(baseDir, { + files: { [`${directory}/说明.md`]: "text", "文档/说明.md": "not the quoted path" }, + }); + const line = `查看:@"${directory}/说"后文`; + const cursorCol = line.indexOf('"后文'); + const result = await getSuggestions(provider, [line], 0, cursorCol); + assert.ok(result); + assert.strictEqual(result.prefix, `@"${directory}/说`); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`@"${directory}/说明.md"`], + ); + const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `查看:@"${directory}/说明.md" 后文`); + assert.strictEqual(applied.cursorCol, `查看:@"${directory}/说明.md" `.length); + } + }); + + test("does not interpret email addresses or @ after ASCII or CJK letters as attachment prefixes", async () => { + setupFolder(baseDir, { files: { "README.md": "readme", "example.com": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["user", "查看", "あ", "カ", "한", "ㄅ", "𠮷", "か\u3099", "禰\u{e0100}", "々", "A"]) { + for (const name of ["REA", "example.com"]) { + const line = `${before}@${name}`; + assert.strictEqual(await getSuggestions(provider, [line], 0, line.length), null, line); + } + } + }); + test("matches file with extension in query", async () => { setupFolder(baseDir, { files: { @@ -300,20 +373,21 @@ describe("CombinedAutocompleteProvider", () => { ); }); - test("quotes paths with spaces for @ suggestions", async () => { - setupFolder(baseDir, { - dirs: ["my folder"], - files: { - "my folder/test.txt": "content", - }, - }); - + test("quotes paths containing whitespace or CJK punctuation for @ suggestions", async () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); - const line = "@my"; - const result = await getSuggestions(provider, [line], 0, line.length); - - const values = result?.items.map((item) => item.value); - assert.ok(values?.includes('@"my folder/"')); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `my${separator}folder`; + setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } }); + const line = "@my"; + const result = await getSuggestions(provider, [line], 0, line.length); + assert.ok(result); + const item = result.items.find((entry) => entry.value === `@"${directory}/"`); + assert.ok(item, directory); + const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix); + const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol); + assert.strictEqual(continued?.prefix, `@"${directory}/`); + assert.ok(continued?.items.some((entry) => entry.value === `@"${directory}/test.txt"`)); + } }); test("includes hidden paths but excludes .git", async () => { @@ -474,6 +548,100 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); + test("completes Chinese path prefixes after whitespace or CJK punctuation on Tab", async () => { + setupFolder(baseDir, { files: { "说明.md": "file", "文档/说明.md": "nested file" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const completions = [ + { prefix: "说", value: "说明.md" }, + { prefix: "文", value: "文档/" }, + { prefix: "文档/说", value: "文档/说明.md" }, + { prefix: "./文档/说", value: "./文档/说明.md" }, + ]; + if (process.platform !== "win32") { + completions.push({ prefix: `${baseDir}/文档/说`, value: `${baseDir}/文档/说明.md` }); + } + for (const separator of " \t\u3000\u00a0,:;。!?(「《") { + for (const { prefix, value } of completions) { + const before = `查看𠮷${separator}`; + const line = `${before}${prefix} 后文`; + const cursorCol = before.length + prefix.length; + const result = await getSuggestions(provider, [line], 0, cursorCol, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [value], + ); + const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}${value} 后文`); + assert.strictEqual(applied.cursorCol, before.length + value.length); + } + } + }); + + test("treats unquoted separators as boundaries even when a matching literal path exists", async () => { + setupFolder(baseDir, { files: { "归档/说明.md": "other" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `资料${separator}归档`; + setupFolder(baseDir, { files: { [`${directory}/说明.md`]: "archive" } }); + for (const marker of ["", "@"]) { + const line = `${marker}${directory}/说`; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, "归档/说"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["归档/说明.md"], + ); + } + const quoted = `查看,"${directory}/说"后文`; + const cursorCol = quoted.indexOf('"后文'); + const result = await getSuggestions(provider, [quoted], 0, cursorCol, true); + assert.ok(result); + assert.strictEqual(result.prefix, `"${directory}/说`); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`"${directory}/说明.md"`], + ); + const applied = provider.applyCompletion([quoted], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `查看,"${directory}/说明.md"后文`); + const missing = `查看,"不存在${separator}归档/说`; + assert.strictEqual(await getSuggestions(provider, [missing], 0, missing.length, true), null); + } + }); + + test("handles an empty prefix after whitespace or CJK punctuation consistently", async () => { + setupFolder(baseDir, { files: { "说明.md": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const separator of [" ", "\t", "\u3000", ",", "。"]) { + for (const force of [false, true]) { + const line = `查看${separator}`; + const result = await getSuggestions(provider, [line], 0, line.length, force); + assert.ok(result, line); + assert.strictEqual(result.prefix, ""); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["说明.md"], + ); + } + } + assert.strictEqual(await getSuggestions(provider, [""], 0, 0), null); + }); + + test("preserves CJK characters in unprefixed Tab completions", async () => { + setupFolder(baseDir, { files: { "文档/说明.md": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "文档/说"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result); + assert.strictEqual(result.prefix, line); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["文档/说明.md"], + ); + }); + test("preserves ./ prefix when completing paths", async () => { setupFolder(baseDir, { files: { @@ -520,21 +688,35 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); - test("quotes paths with spaces for direct completion", async () => { - setupFolder(baseDir, { - dirs: ["my folder"], - files: { - "my folder/test.txt": "content", - }, - }); - + test("quotes paths containing whitespace or CJK punctuation for direct completion", async () => { const provider = new CombinedAutocompleteProvider([], baseDir); - const line = "my"; - const result = await getSuggestions(provider, [line], 0, line.length, true); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `my${separator}folder`; + setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } }); + const line = "my"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result); + const item = result.items.find((entry) => entry.value === `"${directory}/"`); + assert.ok(item, directory); + const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix); + const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol, true); + assert.strictEqual(continued?.prefix, `"${directory}/`); + assert.deepStrictEqual( + continued?.items.map((entry) => entry.value), + [`"${directory}/test.txt"`], + ); + } + }); - 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("keeps quoted directories before files", async () => { + setupFolder(baseDir, { dirs: ["z folder", "z,folder"], files: { "a.txt": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const result = await getSuggestions(provider, [""], 0, 0, true); + assert.ok(result); + assert.deepStrictEqual( + result.items.map((item) => item.label.endsWith("/")), + [true, true, false], + ); }); test("continues completion inside quoted paths", async () => { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 73fddc822..8d5004cb8 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; @@ -2129,6 +2132,209 @@ describe("Editor component", () => { }); describe("Autocomplete", () => { + it("triggers and debounces symbol completion after CJK punctuation", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const before of ["查看,", "\u3000", ...",.:;!?()[]{}“”‘’…—。、「」『』《》【】"]) { + for (const trigger of ["@", "#", "$", "-"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: string[] = []; + editor.setAutocompleteProvider({ + triggerCharacters: ["$", "-"], + getSuggestions: async (lines, cursorLine, cursorCol) => { + requests.push(lines[cursorLine]!.slice(0, cursorCol)); + return null; + }, + applyCompletion, + }); + editor.setText(before); + editor.handleInput(trigger); + t.mock.timers.tick(19); + await flushAutocomplete(); + assert.deepStrictEqual(requests, []); + t.mock.timers.tick(1); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [before + trigger]); + + editor.handleInput("r"); + editor.handleInput("e"); + t.mock.timers.tick(19); + await flushAutocomplete(); + assert.strictEqual(requests.length, 1); + t.mock.timers.tick(1); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [before + trigger, `${before}${trigger}re`]); + } + } + }); + + it("does not auto-trigger after CJK letters or for unprefixed paths", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let requests = 0; + editor.setAutocompleteProvider({ + getSuggestions: async () => { + requests++; + return null; + }, + applyCompletion, + }); + for (const text of [ + "user@example.com", + "张三@example.com", + "查看@src", + "あ@src", + "カ@src", + "한@src", + "ㄅ@src", + "𠮷@src", + "か\u3099@src", + "禰\u{e0100}@src", + "々@src", + "A@src", + "文档@备份", + "prefix#123", + "问题#123", + "查看,/path/", + "查看,./文档/", + "src/index.ts", + "./文档/说明.md", + "文档/说明.md", + "查看src/index.ts", + ]) { + editor.setText(""); + for (const char of text) editor.handleInput(char); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(requests, 0, text); + } + }); + + it("requests path completion after CJK punctuation only on Tab", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: Array<{ text: string; force: boolean | undefined }> = []; + editor.setAutocompleteProvider({ + getSuggestions: async (lines, cursorLine, cursorCol, options) => { + requests.push({ text: lines[cursorLine]!.slice(0, cursorCol), force: options.force }); + return null; + }, + applyCompletion, + }); + const text = "查看,/path/"; + for (const char of text) editor.handleInput(char); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.deepStrictEqual(requests, []); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [{ text, force: true }]); + }); + + it("completes Chinese path prefixes after whitespace or CJK punctuation with Tab", async (t) => { + const baseDir = mkdtempSync(join(tmpdir(), "pi-editor-autocomplete-")); + t.after(() => rmSync(baseDir, { recursive: true, force: true })); + mkdirSync(join(baseDir, "文档")); + writeFileSync(join(baseDir, "文档", "说明.md"), "text"); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setAutocompleteProvider(new CombinedAutocompleteProvider([], baseDir)); + for (const separator of [" ", "\t", "\u3000", "\u00a0", ",", "。"]) { + editor.setText(`查看${separator}`); + const before = editor.getText(); + editor.handleInput("文"); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `${before}文档/`); + editor.handleInput("说"); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `${before}文档/说明.md`); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length }); + } + }); + + it("ends unquoted trigger and debounce contexts at whitespace or CJK punctuation", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const separator of [" ", "\u3000", ",", "。"]) { + for (const trigger of ["@", "#", "$"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: string[] = []; + const prefix = `${trigger}src`; + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, cursorLine, cursorCol) => { + const text = lines[cursorLine]!.slice(0, cursorCol); + requests.push(text); + return text === prefix ? { prefix, items: [{ value: `${prefix}/`, label: "src/" }] } : null; + }, + applyCompletion, + }); + editor.setText(`${trigger}sr`); + editor.handleInput("c"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + editor.handleInput(separator); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [prefix, prefix + separator]); + assert.strictEqual(editor.isShowingAutocomplete(), false); + editor.handleInput("文"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [prefix, prefix + separator]); + } + } + }); + + it("re-triggers CJK path completion after accepting directories and deleting", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const provider = new CombinedAutocompleteProvider([], process.cwd()); + for (const directory of ["文档", "我的 文档", "资料,归档"]) { + const quoted = directory !== "文档"; + const initial = quoted ? `@"${directory.slice(0, 2)}` : "@文"; + const directoryValue = quoted ? `@"${directory}/"` : `@${directory}/`; + const fileValue = quoted ? `@"${directory}/说明.md"` : `@${directory}/说明.md`; + const filePrefix = quoted ? `@"${directory}/说` : `@${directory}/说`; + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setAutocompleteProvider({ + getSuggestions: async (lines, cursorLine, cursorCol) => { + const before = lines[cursorLine]!.slice(0, cursorCol); + const prefix = before.slice(before.indexOf("@")); + if (prefix === initial) { + return { prefix, items: [{ value: directoryValue, label: `${directory}/` }] }; + } + return prefix === filePrefix ? { prefix, items: [{ value: fileValue, label: "说明.md" }] } : null; + }, + applyCompletion: (...args) => provider.applyCompletion(...args), + }); + editor.setText(`查看:${initial}`); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `查看:${directoryValue}`); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + editor.handleInput("说"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + for (const deletion of ["\x7f", "\x1b[3~"]) { + editor.handleInput("错"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), false); + if (deletion === "\x1b[3~") editor.handleInput("\x1b[D"); + editor.handleInput(deletion); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + } + + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), `查看:${fileValue} `); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length }); + } + }); + it("auto-applies single force-file suggestion without showing menu", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme);