import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fuzzyFind } from "@oh-my-pi/pi-natives";
import { getProjectDir } from "@oh-my-pi/pi-utils";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function buildAutocompleteFuzzyDiscoveryProfile(
query: string,
basePath: string,
): {
query: string;
path: string;
maxResults: number;
hidden: boolean;
gitignore: boolean;
cache: boolean;
} {
return {
query,
path: basePath,
maxResults: 100,
hidden: true,
gitignore: true,
cache: true,
};
}
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
if (prefix.startsWith('@"')) {
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
}
if (prefix.startsWith('"')) {
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
}
if (prefix.startsWith("@")) {
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = options.isDirectory ? "" : '"';
return `${openQuote}${path}${closeQuote}`;
}
* Check if query is a subsequence of target (fuzzy match).
* "wig" matches "skill:wig" because w-i-g appear in order.
*/
function fuzzyMatch(query: string, target: string): boolean {
if (query.length === 0) return true;
if (query.length > target.length) return false;
let qi = 0;
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
if (query[qi] === target[ti]) qi++;
}
return qi === query.length;
}
* Score a fuzzy match. Higher = better match.
* Prioritizes: exact match > starts-with > contains > subsequence
*/
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 1;
if (target === query) return 100;
if (target.startsWith(query)) return 80;
if (target.includes(query)) return 60;
let qi = 0;
let gaps = 0;
let lastMatchIdx = -1;
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
if (query[qi] === target[ti]) {
if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++;
lastMatchIdx = ti;
qi++;
}
}
if (qi !== query.length) return 0;
return Math.max(1, 40 - gaps * 5);
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
hint?: string;
}
type Awaitable<T> = T | Promise<T>;
export interface SlashCommand {
name: string;
description?: string;
argumentHint?: string;
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
getInlineHint?(argumentText: string): string | null;
}
export interface AutocompleteProvider {
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): Promise<{
items: AutocompleteItem[];
prefix: string;
} | null>;
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
onApplied?: () => void;
};
getInlineHint?(lines: string[], cursorLine: number, cursorCol: number): string | null;
trySyncSlashCompletion?(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null;
* Synchronously try to expand text immediately before the cursor (no async I/O).
* Called after every single-character insert. Implementations MUST cheaply
* early-return when the trailing context cannot trigger them.
* Returns the number of characters to delete immediately before the cursor
* and the literal string to insert in their place, or null to leave the
* buffer untouched.
*/
trySyncInlineReplace?(textBeforeCursor: string): { replaceLen: number; insert: string } | null;
}
export class CombinedAutocompleteProvider implements AutocompleteProvider {
#commands: (SlashCommand | AutocompleteItem)[];
#basePath: string;
#dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
readonly #DIR_CACHE_TTL = 2000;
constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = getProjectDir()) {
this.#commands = commands;
this.#basePath = basePath;
}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const atPrefix = this.#extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
if (rawPrefix.length > 0 && this.#isOutsideCwd(rawPrefix)) {
const items = await this.#getFileSuggestions(atPrefix);
if (items.length === 0) return null;
return { items, prefix: atPrefix };
}
const suggestions =
rawPrefix.length > 0
? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
: await this.#getFileSuggestions("@");
if (suggestions.length === 0 && rawPrefix.length > 0) {
const fallback = await this.#getFileSuggestions(atPrefix);
if (fallback.length === 0) return null;
return { items: fallback, prefix: atPrefix };
}
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: atPrefix,
};
}
if (textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) {
const prefix = textBeforeCursor.slice(1);
const lowerPrefix = prefix.toLowerCase();
const matches = this.#commands
.filter(cmd => {
const name = "name" in cmd ? cmd.name : cmd.value;
if (!name) return false;
if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
const desc = cmd.description?.toLowerCase();
return desc ? fuzzyMatch(lowerPrefix, desc) : false;
})
.map(cmd => {
const name = "name" in cmd ? cmd.name : cmd.value;
const lowerName = name?.toLowerCase() ?? "";
const lowerDesc = cmd.description?.toLowerCase() ?? "";
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
value: name,
label: "name" in cmd ? cmd.name : cmd.label,
score: Math.max(nameScore, descScore),
...(fullDesc && { description: fullDesc }),
};
})
.sort((a, b) => b.score - a.score)
.map(({ score: _, ...rest }) => rest);
if (matches.length === 0) return null;
return {
items: matches,
prefix: textBeforeCursor,
};
} else {
const commandName = textBeforeCursor.slice(1, spaceIndex);
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
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;
}
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
return null;
}
return {
items: argumentSuggestions,
prefix: argumentText,
};
}
}
const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
if (pathMatch !== null) {
const suggestions = await this.#getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
if (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith("/")) {
return {
items: suggestions,
prefix: pathMatch,
};
}
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
if (isSlashCommand) {
const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 2,
};
}
if (prefix.startsWith("@")) {
const newLine = `${beforePrefix + item.value} ${afterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 1,
};
}
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length,
};
}
const newLine = beforePrefix + item.value + afterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length,
};
}
#extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
#extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
if (forceExtract) {
return pathPrefix;
}
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
return pathPrefix;
}
if (pathPrefix === "" && text.endsWith(" ")) {
return pathPrefix;
}
return null;
}
#expandHomePath(filePath: string): string {
if (filePath.startsWith("~/")) {
const expandedPath = path.join(os.homedir(), filePath.slice(2));
return filePath.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath;
} else if (filePath === "~") {
return os.homedir();
}
return filePath;
}
#isOutsideCwd(rawPrefix: string): boolean {
if (rawPrefix.length === 0) return false;
let target: string;
if (rawPrefix.startsWith("~")) {
target = this.#expandHomePath(rawPrefix);
} else if (path.isAbsolute(rawPrefix)) {
target = rawPrefix;
} else {
target = path.resolve(this.#basePath, rawPrefix);
}
const rel = path.relative(this.#basePath, target);
if (rel === "" || rel === ".") return false;
if (path.isAbsolute(rel)) return true;
const firstSep = rel.indexOf(path.sep);
const head = firstSep === -1 ? rel : rel.slice(0, firstSep);
return head === "..";
}
async #resolveScopedFuzzyQuery(
rawQuery: string,
): Promise<{ baseDir: string; query: string; displayBase: string } | null> {
const slashIndex = rawQuery.lastIndexOf("/");
if (slashIndex === -1) {
return null;
}
const displayBase = rawQuery.slice(0, slashIndex + 1);
const query = rawQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
baseDir = this.#expandHomePath(displayBase);
} else if (displayBase.startsWith("/")) {
baseDir = displayBase;
} else {
baseDir = path.join(this.#basePath, displayBase);
}
try {
if (!(await fs.promises.stat(baseDir)).isDirectory()) {
return null;
}
} catch {
return null;
}
return { baseDir, query, displayBase };
}
#scopedPathForDisplay(displayBase: string, relativePath: string): string {
if (displayBase === "/") {
return `/${relativePath}`;
}
return `${displayBase}${relativePath}`;
}
async #getCachedDirEntries(searchDir: string): Promise<fs.Dirent[]> {
const now = Date.now();
const cached = this.#dirCache.get(searchDir);
if (cached && now - cached.timestamp < this.#DIR_CACHE_TTL) {
return cached.entries;
}
const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
this.#dirCache.set(searchDir, { entries, timestamp: now });
if (this.#dirCache.size > 100) {
const sortedKeys = [...this.#dirCache.entries()]
.sort((a, b) => a[1].timestamp - b[1].timestamp)
.slice(0, 50)
.map(([key]) => key);
for (const key of sortedKeys) {
this.#dirCache.delete(key);
}
}
return entries;
}
invalidateDirCache(dir?: string): void {
if (dir) {
this.#dirCache.delete(dir);
} else {
this.#dirCache.clear();
}
}
async #getFileSuggestions(prefix: string): Promise<AutocompleteItem[]> {
try {
let searchDir: string;
let searchPrefix: string;
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.#expandHomePath(expandedPrefix);
}
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = path.join(this.#basePath, expandedPrefix);
}
searchPrefix = "";
} else if (rawPrefix.endsWith("/")) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = path.join(this.#basePath, expandedPrefix);
}
searchPrefix = "";
} else {
const dir = path.dirname(expandedPrefix);
const file = path.basename(expandedPrefix);
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = path.join(this.#basePath, dir);
}
searchPrefix = file;
}
const entries = await this.#getCachedDirEntries(searchDir);
const suggestions: AutocompleteItem[] = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
continue;
}
if (entry.name === ".git") {
continue;
}
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
try {
const fullPath = path.join(searchDir, entry.name);
isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
} catch {
continue;
}
}
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
if (displayPrefix.endsWith("/")) {
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/")) {
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2);
const dir = path.dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : path.join(dir, name)}`;
} else if (displayPrefix.startsWith("/")) {
const dir = path.dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = path.join(path.dirname(displayPrefix), name);
if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
relativePath = `./${relativePath}`;
}
}
} else {
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value,
label: name + (isDirectory ? "/" : ""),
});
}
suggestions.sort((a, b) => {
const aIsDir = a.value.endsWith("/");
const bIsDir = b.value.endsWith("/");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.label.localeCompare(b.label);
});
return suggestions;
} catch {
return [];
}
}
async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
try {
const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
const searchPath = scopedQuery?.baseDir ?? this.#basePath;
const fuzzyQuery = scopedQuery?.query ?? query;
const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
const lowerQuery = fuzzyQuery.toLowerCase();
const filteredMatches = result.matches.filter(entry => {
const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
const normalized = p.replaceAll("\\", "/");
if (/(^|\/)\.git(\/|$)/.test(normalized)) {
return false;
}
return lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.toLowerCase());
});
const topEntries = filteredMatches.slice(0, 20);
const suggestions: AutocompleteItem[] = [];
for (const { path: entryPath, isDirectory } of topEntries) {
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const displayPath = scopedQuery
? this.#scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
: pathWithoutSlash;
const entryName = path.basename(pathWithoutSlash);
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
const value = buildCompletionValue(completionPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value,
label: entryName + (isDirectory ? "/" : ""),
description: displayPath,
});
}
return suggestions;
} catch {
return [];
}
}
async getForceFileSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
return null;
}
const pathMatch = this.#extractPathPrefix(textBeforeCursor, true);
if (pathMatch !== null) {
const suggestions = await this.#getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
return null;
}
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
return false;
}
return true;
}
getInlineHint(lines: string[], cursorLine: number, cursorCol: number): string | null {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (!textBeforeCursor.startsWith("/")) return null;
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) return null;
const commandName = textBeforeCursor.slice(1, spaceIndex);
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
const command = this.#commands.find(cmd => {
const name = "name" in cmd ? cmd.name : cmd.value;
return name === commandName;
});
if (!command || !("getInlineHint" in command) || !command.getInlineHint) {
return null;
}
return command.getInlineHint(argumentText);
}
trySyncSlashCompletion(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null {
if (!textBeforeCursor.startsWith("/")) return null;
if (textBeforeCursor.length <= 1) return null;
if (textBeforeCursor.includes(" ")) return null;
const prefix = textBeforeCursor.slice(1);
const lowerPrefix = prefix.toLowerCase();
const matches = this.#commands
.filter(cmd => {
const name = "name" in cmd ? cmd.name : cmd.value;
if (!name) return false;
if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
const desc = cmd.description?.toLowerCase();
return desc ? fuzzyMatch(lowerPrefix, desc) : false;
})
.map(cmd => {
const name = "name" in cmd ? cmd.name : cmd.value;
const lowerName = name?.toLowerCase() ?? "";
const lowerDesc = cmd.description?.toLowerCase() ?? "";
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
value: name,
label: "name" in cmd ? cmd.name : cmd.label,
score: Math.max(nameScore, descScore),
...(fullDesc && { description: fullDesc }),
} as AutocompleteItem & { score: number };
})
.sort((a, b) => b.score - a.score)
.map(({ score: _, ...rest }) => rest);
if (matches.length === 0) return null;
return { items: matches, prefix: textBeforeCursor };
}
}