fix(tui): keep @ autocomplete responsive in large trees closes #1278
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix
|
- Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix
|
||||||
- Fixed slash-command Tab completion from immediately chaining into argument autocomplete after completing the command name, restoring flows like `/model` that submit into a selector dialog ([#2577](https://github.com/badlogic/pi-mono/issues/2577))
|
- Fixed slash-command Tab completion from immediately chaining into argument autocomplete after completing the command name, restoring flows like `/model` that submit into a selector dialog ([#2577](https://github.com/badlogic/pi-mono/issues/2577))
|
||||||
- Fixed stale content and incorrect viewport tracking after TUI content shrinks or transient components inflate the working area ([#2126](https://github.com/badlogic/pi-mono/pull/2126) by [@Perlence](https://github.com/Perlence))
|
- Fixed stale content and incorrect viewport tracking after TUI content shrinks or transient components inflate the working area ([#2126](https://github.com/badlogic/pi-mono/pull/2126) by [@Perlence](https://github.com/Perlence))
|
||||||
|
- Fixed `@` autocomplete to debounce editor-triggered searches, cancel in-flight `fd` lookups cleanly, and keep suggestions visible while results refresh ([#1278](https://github.com/badlogic/pi-mono/issues/1278))
|
||||||
|
|
||||||
|
|
||||||
## [0.62.0] - 2026-03-23
|
## [0.62.0] - 2026-03-23
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawnSync } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { readdirSync, statSync } from "fs";
|
import { readdirSync, statSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { basename, dirname, join } from "path";
|
import { basename, dirname, join } from "path";
|
||||||
@@ -121,12 +121,13 @@ function buildCompletionValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use fd to walk directory tree (fast, respects .gitignore)
|
// Use fd to walk directory tree (fast, respects .gitignore)
|
||||||
function walkDirectoryWithFd(
|
async function walkDirectoryWithFd(
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
fdPath: string,
|
fdPath: string,
|
||||||
query: string,
|
query: string,
|
||||||
maxResults: number,
|
maxResults: number,
|
||||||
): Array<{ path: string; isDirectory: boolean }> {
|
signal: AbortSignal,
|
||||||
|
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||||
const args = [
|
const args = [
|
||||||
"--base-directory",
|
"--base-directory",
|
||||||
baseDir,
|
baseDir,
|
||||||
@@ -146,41 +147,69 @@ function walkDirectoryWithFd(
|
|||||||
".git/**",
|
".git/**",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add query as pattern if provided
|
|
||||||
if (query) {
|
if (query) {
|
||||||
args.push(buildFdPathQuery(query));
|
args.push(buildFdPathQuery(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = spawnSync(fdPath, args, {
|
return await new Promise((resolve) => {
|
||||||
encoding: "utf-8",
|
if (signal.aborted) {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
resolve([]);
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
return;
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status !== 0 || !result.stdout) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
||||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const displayLine = toDisplayPath(line);
|
|
||||||
const hasTrailingSeparator = displayLine.endsWith("/");
|
|
||||||
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
|
||||||
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fd outputs directories with trailing /
|
const child = spawn(fdPath, args, {
|
||||||
const isDirectory = hasTrailingSeparator;
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
results.push({
|
|
||||||
path: displayLine,
|
|
||||||
isDirectory,
|
|
||||||
});
|
});
|
||||||
}
|
let stdout = "";
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
return results;
|
const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(results);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
child.stdout.setEncoding("utf-8");
|
||||||
|
child.stdout.on("data", (chunk: string) => {
|
||||||
|
stdout += chunk;
|
||||||
|
});
|
||||||
|
child.on("error", () => {
|
||||||
|
finish([]);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (signal.aborted || code !== 0 || !stdout) {
|
||||||
|
finish([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||||
|
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const displayLine = toDisplayPath(line);
|
||||||
|
const hasTrailingSeparator = displayLine.endsWith("/");
|
||||||
|
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
||||||
|
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
path: displayLine,
|
||||||
|
isDirectory: hasTrailingSeparator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(results);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutocompleteItem {
|
export interface AutocompleteItem {
|
||||||
@@ -197,6 +226,11 @@ export interface SlashCommand {
|
|||||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteSuggestions {
|
||||||
|
items: AutocompleteItem[];
|
||||||
|
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutocompleteProvider {
|
export interface AutocompleteProvider {
|
||||||
// Get autocomplete suggestions for current text/cursor position
|
// Get autocomplete suggestions for current text/cursor position
|
||||||
// Returns null if no suggestions available
|
// Returns null if no suggestions available
|
||||||
@@ -204,10 +238,8 @@ export interface AutocompleteProvider {
|
|||||||
lines: string[],
|
lines: string[],
|
||||||
cursorLine: number,
|
cursorLine: number,
|
||||||
cursorCol: number,
|
cursorCol: number,
|
||||||
): {
|
options: { signal: AbortSignal; force?: boolean },
|
||||||
items: AutocompleteItem[];
|
): Promise<AutocompleteSuggestions | null>;
|
||||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
// Apply the selected item
|
// Apply the selected item
|
||||||
// Returns the new text and cursor position
|
// Returns the new text and cursor position
|
||||||
@@ -240,19 +272,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
this.fdPath = fdPath;
|
this.fdPath = fdPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestions(
|
async getSuggestions(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
cursorLine: number,
|
cursorLine: number,
|
||||||
cursorCol: number,
|
cursorCol: number,
|
||||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
options: { signal: AbortSignal; force?: boolean },
|
||||||
|
): Promise<AutocompleteSuggestions | null> {
|
||||||
const currentLine = lines[cursorLine] || "";
|
const currentLine = lines[cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||||
|
|
||||||
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
|
|
||||||
const atPrefix = this.extractAtPrefix(textBeforeCursor);
|
const atPrefix = this.extractAtPrefix(textBeforeCursor);
|
||||||
if (atPrefix) {
|
if (atPrefix) {
|
||||||
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
||||||
const suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix });
|
const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
|
||||||
|
isQuotedPrefix,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
if (suggestions.length === 0) return null;
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -261,13 +296,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for slash commands
|
if (!options.force && textBeforeCursor.startsWith("/")) {
|
||||||
if (textBeforeCursor.startsWith("/")) {
|
|
||||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||||
|
|
||||||
if (spaceIndex === -1) {
|
if (spaceIndex === -1) {
|
||||||
// No space yet - complete command names with fuzzy matching
|
const prefix = textBeforeCursor.slice(1);
|
||||||
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
|
||||||
const commandItems = this.commands.map((cmd) => ({
|
const commandItems = this.commands.map((cmd) => ({
|
||||||
name: "name" in cmd ? cmd.name : cmd.value,
|
name: "name" in cmd ? cmd.name : cmd.value,
|
||||||
label: "name" in cmd ? cmd.name : cmd.label,
|
label: "name" in cmd ? cmd.name : cmd.label,
|
||||||
@@ -286,57 +319,42 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
items: filtered,
|
items: filtered,
|
||||||
prefix: textBeforeCursor,
|
prefix: textBeforeCursor,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
// Space found - complete command arguments
|
|
||||||
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
|
||||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
|
||||||
|
|
||||||
const command = this.commands.find((cmd) => {
|
|
||||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
||||||
return name === commandName;
|
|
||||||
});
|
|
||||||
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
|
||||||
return null; // No argument completion for this command
|
|
||||||
}
|
|
||||||
|
|
||||||
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
|
||||||
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: argumentSuggestions,
|
|
||||||
prefix: argumentText,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check for file paths - triggered by Tab or if we detect a path pattern
|
const commandName = textBeforeCursor.slice(1, spaceIndex);
|
||||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
|
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
|
||||||
|
|
||||||
if (pathMatch !== null) {
|
const command = this.commands.find((cmd) => {
|
||||||
const suggestions = this.getFileSuggestions(pathMatch);
|
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||||
if (suggestions.length === 0) return null;
|
return name === commandName;
|
||||||
|
});
|
||||||
|
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we have an exact match that is a directory
|
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
||||||
// In that case, we might want to return suggestions for the directory content instead
|
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
||||||
// But only if the prefix ends with /
|
return null;
|
||||||
if (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith("/")) {
|
|
||||||
// Exact match found (e.g. user typed "src" and "src/" is the only match)
|
|
||||||
// We still return it so user can select it and add /
|
|
||||||
return {
|
|
||||||
items: suggestions,
|
|
||||||
prefix: pathMatch,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: suggestions,
|
items: argumentSuggestions,
|
||||||
prefix: pathMatch,
|
prefix: argumentText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
|
||||||
|
if (pathMatch === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = this.getFileSuggestions(pathMatch);
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: suggestions,
|
||||||
|
prefix: pathMatch,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCompletion(
|
applyCompletion(
|
||||||
@@ -684,9 +702,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy file search using fd (fast, respects .gitignore)
|
// Fuzzy file search using fd (fast, respects .gitignore)
|
||||||
private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {
|
private async getFuzzyFileSuggestions(
|
||||||
if (!this.fdPath) {
|
query: string,
|
||||||
// fd not available, return empty results
|
options: { isQuotedPrefix: boolean; signal: AbortSignal },
|
||||||
|
): Promise<AutocompleteItem[]> {
|
||||||
|
if (!this.fdPath || options.signal.aborted) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,9 +714,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
||||||
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
||||||
const fdQuery = scopedQuery?.query ?? query;
|
const fdQuery = scopedQuery?.query ?? query;
|
||||||
const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);
|
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Score entries
|
|
||||||
const scoredEntries = entries
|
const scoredEntries = entries
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -704,14 +726,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
}))
|
}))
|
||||||
.filter((entry) => entry.score > 0);
|
.filter((entry) => entry.score > 0);
|
||||||
|
|
||||||
// Sort by score (descending) and take top 20
|
|
||||||
scoredEntries.sort((a, b) => b.score - a.score);
|
scoredEntries.sort((a, b) => b.score - a.score);
|
||||||
const topEntries = scoredEntries.slice(0, 20);
|
const topEntries = scoredEntries.slice(0, 20);
|
||||||
|
|
||||||
// Build suggestions
|
|
||||||
const suggestions: AutocompleteItem[] = [];
|
const suggestions: AutocompleteItem[] = [];
|
||||||
for (const { path: entryPath, isDirectory } of topEntries) {
|
for (const { path: entryPath, isDirectory } of topEntries) {
|
||||||
// fd already includes trailing / for directories
|
|
||||||
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
||||||
const displayPath = scopedQuery
|
const displayPath = scopedQuery
|
||||||
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
|
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
|
||||||
@@ -737,35 +756,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force file completion (called on Tab key) - always returns suggestions
|
|
||||||
getForceFileSuggestions(
|
|
||||||
lines: string[],
|
|
||||||
cursorLine: number,
|
|
||||||
cursorCol: number,
|
|
||||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
|
||||||
const currentLine = lines[cursorLine] || "";
|
|
||||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
||||||
|
|
||||||
// Don't trigger if we're typing a slash command at the start of the line
|
|
||||||
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force extract path prefix - this will always return something
|
|
||||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
|
|
||||||
if (pathMatch !== null) {
|
|
||||||
const suggestions = this.getFileSuggestions(pathMatch);
|
|
||||||
if (suggestions.length === 0) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: suggestions,
|
|
||||||
prefix: pathMatch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should trigger file completion (called on Tab key)
|
// Check if we should trigger file completion (called on Tab key)
|
||||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||||
const currentLine = lines[cursorLine] || "";
|
const currentLine = lines[cursorLine] || "";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
import type { AutocompleteProvider, AutocompleteSuggestions, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||||
import { getKeybindings } from "../keybindings.js";
|
import { getKeybindings } from "../keybindings.js";
|
||||||
import { decodeKittyPrintable, matchesKey } from "../keys.js";
|
import { decodeKittyPrintable, matchesKey } from "../keys.js";
|
||||||
import { KillRing } from "../kill-ring.js";
|
import { KillRing } from "../kill-ring.js";
|
||||||
@@ -212,6 +212,8 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|||||||
maxPrimaryColumnWidth: 32,
|
maxPrimaryColumnWidth: 32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
||||||
|
|
||||||
export class Editor implements Component, Focusable {
|
export class Editor implements Component, Focusable {
|
||||||
private state: EditorState = {
|
private state: EditorState = {
|
||||||
lines: [""],
|
lines: [""],
|
||||||
@@ -241,6 +243,11 @@ export class Editor implements Component, Focusable {
|
|||||||
private autocompleteState: "regular" | "force" | null = null;
|
private autocompleteState: "regular" | "force" | null = null;
|
||||||
private autocompletePrefix: string = "";
|
private autocompletePrefix: string = "";
|
||||||
private autocompleteMaxVisible: number = 5;
|
private autocompleteMaxVisible: number = 5;
|
||||||
|
private autocompleteAbort?: AbortController;
|
||||||
|
private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private autocompleteRequestTask: Promise<void> = Promise.resolve();
|
||||||
|
private autocompleteStartToken: number = 0;
|
||||||
|
private autocompleteRequestId: number = 0;
|
||||||
|
|
||||||
// Paste tracking for large pastes
|
// Paste tracking for large pastes
|
||||||
private pastes: Map<number, string> = new Map();
|
private pastes: Map<number, string> = new Map();
|
||||||
@@ -316,6 +323,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
||||||
|
this.cancelAutocomplete();
|
||||||
this.autocompleteProvider = provider;
|
this.autocompleteProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,6 +930,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
|
this.cancelAutocomplete();
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
const normalized = this.normalizeText(text);
|
const normalized = this.normalizeText(text);
|
||||||
@@ -939,6 +948,7 @@ export class Editor implements Component, Focusable {
|
|||||||
*/
|
*/
|
||||||
insertTextAtCursor(text: string): void {
|
insertTextAtCursor(text: string): void {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
this.cancelAutocomplete();
|
||||||
this.pushUndoSnapshot();
|
this.pushUndoSnapshot();
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
this.historyIndex = -1;
|
this.historyIndex = -1;
|
||||||
@@ -1065,6 +1075,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handlePaste(pastedText: string): void {
|
private handlePaste(pastedText: string): void {
|
||||||
|
this.cancelAutocomplete();
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
||||||
@@ -1120,6 +1131,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private addNewLine(): void {
|
private addNewLine(): void {
|
||||||
|
this.cancelAutocomplete();
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
||||||
@@ -1155,6 +1167,7 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private submitValue(): void {
|
private submitValue(): void {
|
||||||
|
this.cancelAutocomplete();
|
||||||
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
||||||
|
|
||||||
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
||||||
@@ -2019,10 +2032,34 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
||||||
|
this.requestAutocomplete({ force: false, explicitTab });
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTabCompletion(): void {
|
||||||
if (!this.autocompleteProvider) return;
|
if (!this.autocompleteProvider) return;
|
||||||
|
|
||||||
// Check if we should trigger file completion on Tab
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
if (explicitTab) {
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
|
|
||||||
|
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
||||||
|
this.handleSlashCommandCompletion();
|
||||||
|
} else {
|
||||||
|
this.forceFileAutocomplete(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSlashCommandCompletion(): void {
|
||||||
|
this.requestAutocomplete({ force: false, explicitTab: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private forceFileAutocomplete(explicitTab: boolean = false): void {
|
||||||
|
this.requestAutocomplete({ force: true, explicitTab });
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
|
||||||
|
if (!this.autocompleteProvider) return;
|
||||||
|
|
||||||
|
if (options.force) {
|
||||||
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
||||||
const shouldTrigger =
|
const shouldTrigger =
|
||||||
!provider.shouldTriggerFileCompletion ||
|
!provider.shouldTriggerFileCompletion ||
|
||||||
@@ -2032,139 +2069,162 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
this.cancelAutocompleteRequest();
|
||||||
this.state.lines,
|
const startToken = ++this.autocompleteStartToken;
|
||||||
this.state.cursorLine,
|
|
||||||
this.state.cursorCol,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
const debounceMs = this.getAutocompleteDebounceMs(options);
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
if (debounceMs > 0) {
|
||||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
this.autocompleteDebounceTimer = setTimeout(() => {
|
||||||
|
this.autocompleteDebounceTimer = undefined;
|
||||||
// If typed prefix exactly matches one of the suggestions, select that item
|
void this.startAutocompleteRequest(startToken, options);
|
||||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
}, debounceMs);
|
||||||
if (bestMatchIndex >= 0) {
|
|
||||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.autocompleteState = "regular";
|
|
||||||
} else {
|
|
||||||
this.cancelAutocomplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTabCompletion(): void {
|
|
||||||
if (!this.autocompleteProvider) return;
|
|
||||||
|
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
||||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
||||||
|
|
||||||
// Check if we're in a slash command context
|
|
||||||
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
|
||||||
this.handleSlashCommandCompletion();
|
|
||||||
} else {
|
|
||||||
this.forceFileAutocomplete(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSlashCommandCompletion(): void {
|
|
||||||
this.tryTriggerAutocomplete(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
|
||||||
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
||||||
536643416/job/55932288317 havea look at .gi
|
|
||||||
*/
|
|
||||||
private forceFileAutocomplete(explicitTab: boolean = false): void {
|
|
||||||
if (!this.autocompleteProvider) return;
|
|
||||||
|
|
||||||
// Check if provider supports force file suggestions via runtime check
|
|
||||||
const provider = this.autocompleteProvider as {
|
|
||||||
getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
|
|
||||||
};
|
|
||||||
if (typeof provider.getForceFileSuggestions !== "function") {
|
|
||||||
this.tryTriggerAutocomplete(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = provider.getForceFileSuggestions(
|
void this.startAutocompleteRequest(startToken, options);
|
||||||
this.state.lines,
|
}
|
||||||
this.state.cursorLine,
|
|
||||||
this.state.cursorCol,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
private async startAutocompleteRequest(
|
||||||
// If there's exactly one suggestion, apply it immediately
|
startToken: number,
|
||||||
if (explicitTab && suggestions.items.length === 1) {
|
options: { force: boolean; explicitTab: boolean },
|
||||||
const item = suggestions.items[0]!;
|
): Promise<void> {
|
||||||
this.pushUndoSnapshot();
|
const previousTask = this.autocompleteRequestTask;
|
||||||
this.lastAction = null;
|
this.autocompleteRequestTask = (async () => {
|
||||||
const result = this.autocompleteProvider.applyCompletion(
|
await previousTask;
|
||||||
this.state.lines,
|
if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
|
||||||
this.state.cursorLine,
|
|
||||||
this.state.cursorCol,
|
|
||||||
item,
|
|
||||||
suggestions.prefix,
|
|
||||||
);
|
|
||||||
this.state.lines = result.lines;
|
|
||||||
this.state.cursorLine = result.cursorLine;
|
|
||||||
this.setCursorCol(result.cursorCol);
|
|
||||||
if (this.onChange) this.onChange(this.getText());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
const controller = new AbortController();
|
||||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
this.autocompleteAbort = controller;
|
||||||
|
const requestId = ++this.autocompleteRequestId;
|
||||||
|
const snapshotText = this.getText();
|
||||||
|
const snapshotLine = this.state.cursorLine;
|
||||||
|
const snapshotCol = this.state.cursorCol;
|
||||||
|
|
||||||
// If typed prefix exactly matches one of the suggestions, select that item
|
await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
|
||||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
})();
|
||||||
if (bestMatchIndex >= 0) {
|
await this.autocompleteRequestTask;
|
||||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.autocompleteState = "force";
|
|
||||||
} else {
|
|
||||||
this.cancelAutocomplete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelAutocomplete(): void {
|
private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
|
||||||
|
if (options.explicitTab || options.force) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||||
|
const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
|
||||||
|
return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runAutocompleteRequest(
|
||||||
|
requestId: number,
|
||||||
|
controller: AbortController,
|
||||||
|
snapshotText: string,
|
||||||
|
snapshotLine: number,
|
||||||
|
snapshotCol: number,
|
||||||
|
options: { force: boolean; explicitTab: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.autocompleteProvider) return;
|
||||||
|
|
||||||
|
const suggestions = await this.autocompleteProvider.getSuggestions(
|
||||||
|
this.state.lines,
|
||||||
|
this.state.cursorLine,
|
||||||
|
this.state.cursorCol,
|
||||||
|
{ signal: controller.signal, force: options.force },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autocompleteAbort = undefined;
|
||||||
|
|
||||||
|
if (!suggestions || suggestions.items.length === 0) {
|
||||||
|
this.cancelAutocomplete();
|
||||||
|
this.tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.force && options.explicitTab && suggestions.items.length === 1) {
|
||||||
|
const item = suggestions.items[0]!;
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
this.lastAction = null;
|
||||||
|
const result = this.autocompleteProvider.applyCompletion(
|
||||||
|
this.state.lines,
|
||||||
|
this.state.cursorLine,
|
||||||
|
this.state.cursorCol,
|
||||||
|
item,
|
||||||
|
suggestions.prefix,
|
||||||
|
);
|
||||||
|
this.state.lines = result.lines;
|
||||||
|
this.state.cursorLine = result.cursorLine;
|
||||||
|
this.setCursorCol(result.cursorCol);
|
||||||
|
if (this.onChange) this.onChange(this.getText());
|
||||||
|
this.tui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
|
||||||
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAutocompleteRequestCurrent(
|
||||||
|
requestId: number,
|
||||||
|
controller: AbortController,
|
||||||
|
snapshotText: string,
|
||||||
|
snapshotLine: number,
|
||||||
|
snapshotCol: number,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
requestId === this.autocompleteRequestId &&
|
||||||
|
this.getText() === snapshotText &&
|
||||||
|
this.state.cursorLine === snapshotLine &&
|
||||||
|
this.state.cursorCol === snapshotCol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void {
|
||||||
|
this.autocompletePrefix = suggestions.prefix;
|
||||||
|
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||||
|
|
||||||
|
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
||||||
|
if (bestMatchIndex >= 0) {
|
||||||
|
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autocompleteState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelAutocompleteRequest(): void {
|
||||||
|
this.autocompleteStartToken += 1;
|
||||||
|
if (this.autocompleteDebounceTimer) {
|
||||||
|
clearTimeout(this.autocompleteDebounceTimer);
|
||||||
|
this.autocompleteDebounceTimer = undefined;
|
||||||
|
}
|
||||||
|
this.autocompleteAbort?.abort();
|
||||||
|
this.autocompleteAbort = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAutocompleteUi(): void {
|
||||||
this.autocompleteState = null;
|
this.autocompleteState = null;
|
||||||
this.autocompleteList = undefined;
|
this.autocompleteList = undefined;
|
||||||
this.autocompletePrefix = "";
|
this.autocompletePrefix = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cancelAutocomplete(): void {
|
||||||
|
this.cancelAutocompleteRequest();
|
||||||
|
this.clearAutocompleteUi();
|
||||||
|
}
|
||||||
|
|
||||||
public isShowingAutocomplete(): boolean {
|
public isShowingAutocomplete(): boolean {
|
||||||
return this.autocompleteState !== null;
|
return this.autocompleteState !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAutocomplete(): void {
|
private updateAutocomplete(): void {
|
||||||
if (!this.autocompleteState || !this.autocompleteProvider) return;
|
if (!this.autocompleteState || !this.autocompleteProvider) return;
|
||||||
|
this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
|
||||||
if (this.autocompleteState === "force") {
|
|
||||||
this.forceFileAutocomplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
|
||||||
this.state.lines,
|
|
||||||
this.state.cursorLine,
|
|
||||||
this.state.cursorCol,
|
|
||||||
);
|
|
||||||
if (suggestions && suggestions.items.length > 0) {
|
|
||||||
this.autocompletePrefix = suggestions.prefix;
|
|
||||||
// Always create new SelectList to ensure update
|
|
||||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
||||||
|
|
||||||
// If typed prefix exactly matches one of the suggestions, select that item
|
|
||||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
|
||||||
if (bestMatchIndex >= 0) {
|
|
||||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.cancelAutocomplete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
export {
|
export {
|
||||||
type AutocompleteItem,
|
type AutocompleteItem,
|
||||||
type AutocompleteProvider,
|
type AutocompleteProvider,
|
||||||
|
type AutocompleteSuggestions,
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "./autocomplete.js";
|
} from "./autocomplete.js";
|
||||||
|
|||||||
@@ -46,15 +46,23 @@ const requireFdPath = (): string => {
|
|||||||
return fdPath;
|
return fdPath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSuggestions = (
|
||||||
|
provider: CombinedAutocompleteProvider,
|
||||||
|
lines: string[],
|
||||||
|
cursorLine: number,
|
||||||
|
cursorCol: number,
|
||||||
|
force: boolean = false,
|
||||||
|
) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force });
|
||||||
|
|
||||||
describe("CombinedAutocompleteProvider", () => {
|
describe("CombinedAutocompleteProvider", () => {
|
||||||
describe("extractPathPrefix", () => {
|
describe("extractPathPrefix", () => {
|
||||||
it("extracts / from 'hey /' when forced", () => {
|
it("extracts / from 'hey /' when forced", async () => {
|
||||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||||
const lines = ["hey /"];
|
const lines = ["hey /"];
|
||||||
const cursorLine = 0;
|
const cursorLine = 0;
|
||||||
const cursorCol = 5; // After the "/"
|
const cursorCol = 5; // After the "/"
|
||||||
|
|
||||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for root directory");
|
assert.notEqual(result, null, "Should return suggestions for root directory");
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -62,13 +70,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /A from '/A' when forced", () => {
|
it("extracts /A from '/A' when forced", async () => {
|
||||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||||
const lines = ["/A"];
|
const lines = ["/A"];
|
||||||
const cursorLine = 0;
|
const cursorLine = 0;
|
||||||
const cursorCol = 2; // After the "A"
|
const cursorCol = 2; // After the "A"
|
||||||
|
|
||||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||||
|
|
||||||
console.log("Result:", result);
|
console.log("Result:", result);
|
||||||
// This might return null if /A doesn't match anything, which is fine
|
// This might return null if /A doesn't match anything, which is fine
|
||||||
@@ -78,25 +86,25 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not trigger for slash commands", () => {
|
it("does not trigger for slash commands", async () => {
|
||||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||||
const lines = ["/model"];
|
const lines = ["/model"];
|
||||||
const cursorLine = 0;
|
const cursorLine = 0;
|
||||||
const cursorCol = 6; // After "model"
|
const cursorCol = 6; // After "model"
|
||||||
|
|
||||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||||
|
|
||||||
console.log("Result:", result);
|
console.log("Result:", result);
|
||||||
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("triggers for absolute paths after slash command argument", () => {
|
it("triggers for absolute paths after slash command argument", async () => {
|
||||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||||
const lines = ["/command /"];
|
const lines = ["/command /"];
|
||||||
const cursorLine = 0;
|
const cursorLine = 0;
|
||||||
const cursorCol = 10; // After the second "/"
|
const cursorCol = 10; // After the second "/"
|
||||||
|
|
||||||
const result = provider.getForceFileSuggestions(lines, cursorLine, cursorCol);
|
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||||
|
|
||||||
console.log("Result:", result);
|
console.log("Result:", result);
|
||||||
assert.notEqual(result, null, "Should trigger for absolute paths in command arguments");
|
assert.notEqual(result, null, "Should trigger for absolute paths in command arguments");
|
||||||
@@ -123,7 +131,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
rmSync(rootDir, { recursive: true, force: true });
|
rmSync(rootDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns all files and folders for empty @ query", () => {
|
test("returns all files and folders for empty @ query", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["src"],
|
dirs: ["src"],
|
||||||
files: {
|
files: {
|
||||||
@@ -133,13 +141,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@";
|
const line = "@";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value).sort();
|
const values = result?.items.map((item) => item.value).sort();
|
||||||
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
|
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches file with extension in query", () => {
|
test("matches file with extension in query", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"file.txt": "content",
|
"file.txt": "content",
|
||||||
@@ -148,13 +156,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@file.txt";
|
const line = "@file.txt";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("@file.txt"));
|
assert.ok(values?.includes("@file.txt"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filters are case insensitive", () => {
|
test("filters are case insensitive", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["src"],
|
dirs: ["src"],
|
||||||
files: {
|
files: {
|
||||||
@@ -164,13 +172,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@re";
|
const line = "@re";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value).sort();
|
const values = result?.items.map((item) => item.value).sort();
|
||||||
assert.deepStrictEqual(values, ["@README.md"]);
|
assert.deepStrictEqual(values, ["@README.md"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ranks directories before files", () => {
|
test("ranks directories before files", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["src"],
|
dirs: ["src"],
|
||||||
files: {
|
files: {
|
||||||
@@ -180,7 +188,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@src";
|
const line = "@src";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const firstValue = result?.items[0]?.value;
|
const firstValue = result?.items[0]?.value;
|
||||||
const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt");
|
const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt");
|
||||||
@@ -188,7 +196,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
assert.ok(hasSrcFile);
|
assert.ok(hasSrcFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns nested file paths", () => {
|
test("returns nested file paths", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"src/index.ts": "export {};\n",
|
"src/index.ts": "export {};\n",
|
||||||
@@ -197,13 +205,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@index";
|
const line = "@index";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("@src/index.ts"));
|
assert.ok(values?.includes("@src/index.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches deeply nested paths", () => {
|
test("matches deeply nested paths", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"packages/tui/src/autocomplete.ts": "export {};",
|
"packages/tui/src/autocomplete.ts": "export {};",
|
||||||
@@ -213,14 +221,14 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@tui/src/auto";
|
const line = "@tui/src/auto";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
|
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
|
||||||
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
|
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches directory in middle of path with --full-path", () => {
|
test("matches directory in middle of path with --full-path", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"src/components/Button.tsx": "export {};",
|
"src/components/Button.tsx": "export {};",
|
||||||
@@ -230,14 +238,14 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@components/";
|
const line = "@components/";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("@src/components/Button.tsx"));
|
assert.ok(values?.includes("@src/components/Button.tsx"));
|
||||||
assert.ok(!values?.includes("@src/utils/helpers.ts"));
|
assert.ok(!values?.includes("@src/utils/helpers.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scopes fuzzy search to relative directories and searches recursively", () => {
|
test("scopes fuzzy search to relative directories and searches recursively", async () => {
|
||||||
setupFolder(outsideDir, {
|
setupFolder(outsideDir, {
|
||||||
files: {
|
files: {
|
||||||
"nested/alpha.ts": "export {};",
|
"nested/alpha.ts": "export {};",
|
||||||
@@ -248,7 +256,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@../outside/a";
|
const line = "@../outside/a";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
|
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
|
||||||
@@ -256,7 +264,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
|
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quotes paths with spaces for @ suggestions", () => {
|
test("quotes paths with spaces for @ suggestions", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["my folder"],
|
dirs: ["my folder"],
|
||||||
files: {
|
files: {
|
||||||
@@ -266,13 +274,13 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@my";
|
const line = "@my";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes('@"my folder/"'));
|
assert.ok(values?.includes('@"my folder/"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes hidden paths but excludes .git", () => {
|
test("includes hidden paths but excludes .git", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: [".pi", ".github", ".git"],
|
dirs: [".pi", ".github", ".git"],
|
||||||
files: {
|
files: {
|
||||||
@@ -284,7 +292,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = "@";
|
const line = "@";
|
||||||
const result = provider.getSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||||
|
|
||||||
const values = result?.items.map((item) => item.value) ?? [];
|
const values = result?.items.map((item) => item.value) ?? [];
|
||||||
assert.ok(values.includes("@.pi/"));
|
assert.ok(values.includes("@.pi/"));
|
||||||
@@ -292,7 +300,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/")));
|
assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("continues autocomplete inside quoted @ paths", () => {
|
test("continues autocomplete inside quoted @ paths", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"my folder/test.txt": "content",
|
"my folder/test.txt": "content",
|
||||||
@@ -302,7 +310,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = '@"my folder/"';
|
const line = '@"my folder/"';
|
||||||
const result = provider.getSuggestions([line], 0, line.length - 1);
|
const result = await getSuggestions(provider, [line], 0, line.length - 1);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
@@ -310,7 +318,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
assert.ok(values?.includes('@"my folder/other.txt"'));
|
assert.ok(values?.includes('@"my folder/other.txt"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applies quoted @ completion without duplicating closing quote", () => {
|
test("applies quoted @ completion without duplicating closing quote", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"my folder/test.txt": "content",
|
"my folder/test.txt": "content",
|
||||||
@@ -320,7 +328,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||||
const line = '@"my folder/te"';
|
const line = '@"my folder/te"';
|
||||||
const cursorCol = line.length - 1;
|
const cursorCol = line.length - 1;
|
||||||
const result = provider.getSuggestions([line], 0, cursorCol);
|
const result = await getSuggestions(provider, [line], 0, cursorCol);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for quoted @ path");
|
assert.notEqual(result, null, "Should return suggestions for quoted @ path");
|
||||||
const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"');
|
const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"');
|
||||||
@@ -342,7 +350,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
rmSync(baseDir, { recursive: true, force: true });
|
rmSync(baseDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves ./ prefix when completing paths", () => {
|
test("preserves ./ prefix when completing paths", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"update.sh": "#!/bin/bash",
|
"update.sh": "#!/bin/bash",
|
||||||
@@ -352,14 +360,14 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||||
const line = "./up";
|
const line = "./up";
|
||||||
const result = provider.getForceFileSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for ./ path");
|
assert.notEqual(result, null, "Should return suggestions for ./ path");
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`);
|
assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves ./ prefix for directory completions", () => {
|
test("preserves ./ prefix for directory completions", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["src"],
|
dirs: ["src"],
|
||||||
files: {
|
files: {
|
||||||
@@ -369,7 +377,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||||
const line = "./sr";
|
const line = "./sr";
|
||||||
const result = provider.getForceFileSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for ./ directory path");
|
assert.notEqual(result, null, "Should return suggestions for ./ directory path");
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
@@ -388,7 +396,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
rmSync(baseDir, { recursive: true, force: true });
|
rmSync(baseDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quotes paths with spaces for direct completion", () => {
|
test("quotes paths with spaces for direct completion", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
dirs: ["my folder"],
|
dirs: ["my folder"],
|
||||||
files: {
|
files: {
|
||||||
@@ -398,14 +406,14 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||||
const line = "my";
|
const line = "my";
|
||||||
const result = provider.getForceFileSuggestions([line], 0, line.length);
|
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for path completion");
|
assert.notEqual(result, null, "Should return suggestions for path completion");
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
assert.ok(values?.includes('"my folder/"'));
|
assert.ok(values?.includes('"my folder/"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("continues completion inside quoted paths", () => {
|
test("continues completion inside quoted paths", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"my folder/test.txt": "content",
|
"my folder/test.txt": "content",
|
||||||
@@ -415,7 +423,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
|
|
||||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||||
const line = '"my folder/"';
|
const line = '"my folder/"';
|
||||||
const result = provider.getForceFileSuggestions([line], 0, line.length - 1);
|
const result = await getSuggestions(provider, [line], 0, line.length - 1, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
|
||||||
const values = result?.items.map((item) => item.value);
|
const values = result?.items.map((item) => item.value);
|
||||||
@@ -423,7 +431,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
assert.ok(values?.includes('"my folder/other.txt"'));
|
assert.ok(values?.includes('"my folder/other.txt"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applies quoted completion without duplicating closing quote", () => {
|
test("applies quoted completion without duplicating closing quote", async () => {
|
||||||
setupFolder(baseDir, {
|
setupFolder(baseDir, {
|
||||||
files: {
|
files: {
|
||||||
"my folder/test.txt": "content",
|
"my folder/test.txt": "content",
|
||||||
@@ -433,7 +441,7 @@ describe("CombinedAutocompleteProvider", () => {
|
|||||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||||
const line = '"my folder/te"';
|
const line = '"my folder/te"';
|
||||||
const cursorCol = line.length - 1;
|
const cursorCol = line.length - 1;
|
||||||
const result = provider.getForceFileSuggestions([line], 0, cursorCol);
|
const result = await getSuggestions(provider, [line], 0, cursorCol, true);
|
||||||
|
|
||||||
assert.notEqual(result, null, "Should return suggestions for quoted path");
|
assert.notEqual(result, null, "Should return suggestions for quoted path");
|
||||||
const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"');
|
const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"');
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ function applyCompletion(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushAutocomplete(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
describe("Editor component", () => {
|
describe("Editor component", () => {
|
||||||
describe("Prompt history navigation", () => {
|
describe("Prompt history navigation", () => {
|
||||||
it("does nothing on Up arrow when history is empty", () => {
|
it("does nothing on Up arrow when history is empty", () => {
|
||||||
@@ -1641,7 +1646,7 @@ describe("Editor component", () => {
|
|||||||
let suggestionCalls = 0;
|
let suggestionCalls = 0;
|
||||||
|
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: () => {
|
getSuggestions: async () => {
|
||||||
suggestionCalls += 1;
|
suggestionCalls += 1;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -1902,12 +1907,12 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "hello");
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("undoes autocomplete", () => {
|
it("undoes autocomplete", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Create a mock autocomplete provider
|
// Create a mock autocomplete provider
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const prefix = text.slice(0, cursorCol);
|
const prefix = text.slice(0, cursorCol);
|
||||||
if (prefix === "di") {
|
if (prefix === "di") {
|
||||||
@@ -1930,11 +1935,7 @@ describe("Editor component", () => {
|
|||||||
|
|
||||||
// Press Tab to trigger autocomplete
|
// Press Tab to trigger autocomplete
|
||||||
editor.handleInput("\t");
|
editor.handleInput("\t");
|
||||||
// Autocomplete should be showing with "dist/" suggestion
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
|
||||||
|
|
||||||
// Press Tab again to accept the suggestion
|
|
||||||
editor.handleInput("\t");
|
|
||||||
assert.strictEqual(editor.getText(), "dist/");
|
assert.strictEqual(editor.getText(), "dist/");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
|
||||||
@@ -1945,15 +1946,14 @@ describe("Editor component", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Autocomplete", () => {
|
describe("Autocomplete", () => {
|
||||||
it("auto-applies single force-file suggestion without showing menu", () => {
|
it("auto-applies single force-file suggestion without showing menu", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Create a mock provider with getForceFileSuggestions that returns single item
|
const mockProvider: AutocompleteProvider = {
|
||||||
const mockProvider: AutocompleteProvider & {
|
getSuggestions: async (lines, _cursorLine, cursorCol, options) => {
|
||||||
getForceFileSuggestions: AutocompleteProvider["getSuggestions"];
|
if (!options.force) {
|
||||||
} = {
|
return null;
|
||||||
getSuggestions: () => null,
|
}
|
||||||
getForceFileSuggestions: (lines, _cursorLine, cursorCol) => {
|
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const prefix = text.slice(0, cursorCol);
|
const prefix = text.slice(0, cursorCol);
|
||||||
if (prefix === "Work") {
|
if (prefix === "Work") {
|
||||||
@@ -1978,6 +1978,7 @@ describe("Editor component", () => {
|
|||||||
|
|
||||||
// Press Tab - should auto-apply without showing menu
|
// Press Tab - should auto-apply without showing menu
|
||||||
editor.handleInput("\t");
|
editor.handleInput("\t");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.getText(), "Workspace/");
|
assert.strictEqual(editor.getText(), "Workspace/");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
|
||||||
@@ -1986,15 +1987,14 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "Work");
|
assert.strictEqual(editor.getText(), "Work");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows menu when force-file has multiple suggestions", () => {
|
it("shows menu when force-file has multiple suggestions", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Create a mock provider with getForceFileSuggestions that returns multiple items
|
const mockProvider: AutocompleteProvider = {
|
||||||
const mockProvider: AutocompleteProvider & {
|
getSuggestions: async (lines, _cursorLine, cursorCol, options) => {
|
||||||
getForceFileSuggestions: AutocompleteProvider["getSuggestions"];
|
if (!options.force) {
|
||||||
} = {
|
return null;
|
||||||
getSuggestions: () => null,
|
}
|
||||||
getForceFileSuggestions: (lines, _cursorLine, cursorCol) => {
|
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const prefix = text.slice(0, cursorCol);
|
const prefix = text.slice(0, cursorCol);
|
||||||
if (prefix === "src") {
|
if (prefix === "src") {
|
||||||
@@ -2021,7 +2021,8 @@ describe("Editor component", () => {
|
|||||||
|
|
||||||
// Press Tab - should show menu because there are multiple suggestions
|
// Press Tab - should show menu because there are multiple suggestions
|
||||||
editor.handleInput("\t");
|
editor.handleInput("\t");
|
||||||
assert.strictEqual(editor.getText(), "src"); // Text unchanged
|
await flushAutocomplete();
|
||||||
|
assert.strictEqual(editor.getText(), "src");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Tab again to accept first suggestion
|
// Press Tab again to accept first suggestion
|
||||||
@@ -2030,12 +2031,9 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps suggestions open when typing in force mode (Tab-triggered)", () => {
|
it("keeps suggestions open when typing in force mode (Tab-triggered)", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider with both getSuggestions and getForceFileSuggestions
|
|
||||||
// getSuggestions only returns results for path-like patterns
|
|
||||||
// getForceFileSuggestions always extracts prefix and filters
|
|
||||||
const allFiles = [
|
const allFiles = [
|
||||||
{ value: "readme.md", label: "readme.md" },
|
{ value: "readme.md", label: "readme.md" },
|
||||||
{ value: "package.json", label: "package.json" },
|
{ value: "package.json", label: "package.json" },
|
||||||
@@ -2043,29 +2041,14 @@ describe("Editor component", () => {
|
|||||||
{ value: "dist/", label: "dist/" },
|
{ value: "dist/", label: "dist/" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockProvider: AutocompleteProvider & {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getForceFileSuggestions: (
|
getSuggestions: async (lines, _cursorLine, cursorCol, options) => {
|
||||||
lines: string[],
|
|
||||||
cursorLine: number,
|
|
||||||
cursorCol: number,
|
|
||||||
) => { items: { value: string; label: string }[]; prefix: string } | null;
|
|
||||||
} = {
|
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const prefix = text.slice(0, cursorCol);
|
const prefix = text.slice(0, cursorCol);
|
||||||
// Only return suggestions for path-like patterns (contains / or starts with .)
|
const shouldMatch = options.force || prefix.includes("/") || prefix.startsWith(".");
|
||||||
if (prefix.includes("/") || prefix.startsWith(".")) {
|
if (!shouldMatch) {
|
||||||
const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
return null;
|
||||||
if (filtered.length > 0) {
|
|
||||||
return { items: filtered, prefix };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
|
||||||
getForceFileSuggestions: (lines, _cursorLine, cursorCol) => {
|
|
||||||
const text = lines[0] || "";
|
|
||||||
const prefix = text.slice(0, cursorCol);
|
|
||||||
// Always filter files by prefix
|
|
||||||
const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||||
if (filtered.length > 0) {
|
if (filtered.length > 0) {
|
||||||
return { items: filtered, prefix };
|
return { items: filtered, prefix };
|
||||||
@@ -2079,15 +2062,18 @@ describe("Editor component", () => {
|
|||||||
|
|
||||||
// Press Tab on empty prompt - should show all files (force mode)
|
// Press Tab on empty prompt - should show all files (force mode)
|
||||||
editor.handleInput("\t");
|
editor.handleInput("\t");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Type "r" - should narrow to "readme.md" (force mode keeps suggestions open)
|
// Type "r" - should narrow to "readme.md" (force mode keeps suggestions open)
|
||||||
editor.handleInput("r");
|
editor.handleInput("r");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.getText(), "r");
|
assert.strictEqual(editor.getText(), "r");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Type "e" - should still show "readme.md"
|
// Type "e" - should still show "readme.md"
|
||||||
editor.handleInput("e");
|
editor.handleInput("e");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.getText(), "re");
|
assert.strictEqual(editor.getText(), "re");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
@@ -2097,12 +2083,85 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides autocomplete when backspacing slash command to empty", () => {
|
it("debounces @ autocomplete while typing", async () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let suggestionCalls = 0;
|
||||||
|
|
||||||
|
const mockProvider: AutocompleteProvider = {
|
||||||
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
|
suggestionCalls += 1;
|
||||||
|
const text = (lines[0] || "").slice(0, cursorCol);
|
||||||
|
return {
|
||||||
|
items: [{ value: "@main.ts", label: "main.ts" }],
|
||||||
|
prefix: text,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
||||||
|
editor.handleInput("@");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
editor.handleInput("m");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
editor.handleInput("a");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
editor.handleInput("i");
|
||||||
|
|
||||||
|
assert.strictEqual(suggestionCalls, 0);
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
await flushAutocomplete();
|
||||||
|
|
||||||
|
assert.strictEqual(suggestionCalls, 1);
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts active @ autocomplete when typing continues", async () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let aborts = 0;
|
||||||
|
|
||||||
|
const mockProvider: AutocompleteProvider = {
|
||||||
|
getSuggestions: async (_lines, _cursorLine, _cursorCol, options) => {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve({ items: [{ value: "@main.ts", label: "main.ts" }], prefix: "@main" });
|
||||||
|
}, 500);
|
||||||
|
options.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
aborts += 1;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyCompletion,
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
||||||
|
editor.handleInput("@");
|
||||||
|
editor.handleInput("m");
|
||||||
|
editor.handleInput("a");
|
||||||
|
editor.handleInput("i");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
editor.handleInput("n");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
assert.strictEqual(aborts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides autocomplete when backspacing slash command to empty", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider with slash commands
|
// Mock provider with slash commands
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const prefix = text.slice(0, cursorCol);
|
const prefix = text.slice(0, cursorCol);
|
||||||
// Only return slash command suggestions when line starts with /
|
// Only return slash command suggestions when line starts with /
|
||||||
@@ -2126,21 +2185,23 @@ describe("Editor component", () => {
|
|||||||
|
|
||||||
// Type "/" - should show slash command suggestions
|
// Type "/" - should show slash command suggestions
|
||||||
editor.handleInput("/");
|
editor.handleInput("/");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.getText(), "/");
|
assert.strictEqual(editor.getText(), "/");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Backspace to delete "/" - should hide autocomplete completely
|
// Backspace to delete "/" - should hide autocomplete completely
|
||||||
editor.handleInput("\x7f"); // Backspace
|
editor.handleInput("\x7f"); // Backspace
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.getText(), "");
|
assert.strictEqual(editor.getText(), "");
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies exact typed slash-argument value on Enter even when first item is highlighted", () => {
|
it("applies exact typed slash-argument value on Enter even when first item is highlighted", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider for /argtest command with argument completions
|
// Mock provider for /argtest command with argument completions
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const beforeCursor = text.slice(0, cursorCol);
|
const beforeCursor = text.slice(0, cursorCol);
|
||||||
|
|
||||||
@@ -2181,6 +2242,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput("o");
|
editor.handleInput("o");
|
||||||
|
|
||||||
assert.strictEqual(editor.getText(), "/argtest two");
|
assert.strictEqual(editor.getText(), "/argtest two");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Enter - should apply the exact typed value "two", not the first item
|
// Press Enter - should apply the exact typed value "two", not the first item
|
||||||
@@ -2190,12 +2252,12 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "/argtest two");
|
assert.strictEqual(editor.getText(), "/argtest two");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects first prefix match on Enter when typed arg is not exact match", () => {
|
it("selects first prefix match on Enter when typed arg is not exact match", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider for /argtest command with argument completions
|
// Mock provider for /argtest command with argument completions
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const beforeCursor = text.slice(0, cursorCol);
|
const beforeCursor = text.slice(0, cursorCol);
|
||||||
|
|
||||||
@@ -2233,6 +2295,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput(" ");
|
editor.handleInput(" ");
|
||||||
editor.handleInput("t");
|
editor.handleInput("t");
|
||||||
|
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Enter - "t" prefix matches "two" (first in list), so "two" is applied
|
// Press Enter - "t" prefix matches "two" (first in list), so "two" is applied
|
||||||
@@ -2240,12 +2303,12 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "/argtest two");
|
assert.strictEqual(editor.getText(), "/argtest two");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights unique prefix match as user types (before full exact match)", () => {
|
it("highlights unique prefix match as user types (before full exact match)", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider that returns all items unfiltered (like real extensions do)
|
// Mock provider that returns all items unfiltered (like real extensions do)
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const beforeCursor = text.slice(0, cursorCol);
|
const beforeCursor = text.slice(0, cursorCol);
|
||||||
|
|
||||||
@@ -2281,6 +2344,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput("w");
|
editor.handleInput("w");
|
||||||
|
|
||||||
assert.strictEqual(editor.getText(), "/argtest tw");
|
assert.strictEqual(editor.getText(), "/argtest tw");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Enter - "tw" uniquely matches "two", so "two" should be applied
|
// Press Enter - "tw" uniquely matches "two", so "two" should be applied
|
||||||
@@ -2288,12 +2352,12 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "/argtest two");
|
assert.strictEqual(editor.getText(), "/argtest two");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects first prefix match when multiple items match", () => {
|
it("selects first prefix match when multiple items match", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider that returns all items unfiltered
|
// Mock provider that returns all items unfiltered
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const beforeCursor = text.slice(0, cursorCol);
|
const beforeCursor = text.slice(0, cursorCol);
|
||||||
|
|
||||||
@@ -2326,6 +2390,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput(" ");
|
editor.handleInput(" ");
|
||||||
editor.handleInput("t");
|
editor.handleInput("t");
|
||||||
|
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Enter - "t" matches "two" first, so "two" is selected
|
// Press Enter - "t" matches "two" first, so "two" is selected
|
||||||
@@ -2333,12 +2398,12 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "/argtest two");
|
assert.strictEqual(editor.getText(), "/argtest two");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("works for built-in-style command argument completion path (model-like)", () => {
|
it("works for built-in-style command argument completion path (model-like)", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
// Mock provider for /model command with model completions
|
// Mock provider for /model command with model completions
|
||||||
const mockProvider: AutocompleteProvider = {
|
const mockProvider: AutocompleteProvider = {
|
||||||
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||||
const text = lines[0] || "";
|
const text = lines[0] || "";
|
||||||
const beforeCursor = text.slice(0, cursorCol);
|
const beforeCursor = text.slice(0, cursorCol);
|
||||||
|
|
||||||
@@ -2386,6 +2451,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput("i");
|
editor.handleInput("i");
|
||||||
|
|
||||||
assert.strictEqual(editor.getText(), "/model gpt-4o-mini");
|
assert.strictEqual(editor.getText(), "/model gpt-4o-mini");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
// Press Enter - should retain exact typed value, not apply first highlighted item
|
// Press Enter - should retain exact typed value, not apply first highlighted item
|
||||||
@@ -2395,7 +2461,7 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), "/model gpt-4o-mini");
|
assert.strictEqual(editor.getText(), "/model gpt-4o-mini");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show argument completions when command has no argument completer", () => {
|
it("does not show argument completions when command has no argument completer", async () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
const provider = new CombinedAutocompleteProvider([
|
const provider = new CombinedAutocompleteProvider([
|
||||||
{ name: "help", description: "Show help" },
|
{ name: "help", description: "Show help" },
|
||||||
@@ -2410,6 +2476,7 @@ describe("Editor component", () => {
|
|||||||
editor.handleInput("/");
|
editor.handleInput("/");
|
||||||
editor.handleInput("h");
|
editor.handleInput("h");
|
||||||
editor.handleInput("e");
|
editor.handleInput("e");
|
||||||
|
await flushAutocomplete();
|
||||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
editor.handleInput("\t");
|
editor.handleInput("\t");
|
||||||
|
|||||||
Reference in New Issue
Block a user