Files
NetMesh/components/terminal/autocomplete/completionEngine.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

762 lines
24 KiB
TypeScript

/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Context-aware path completions and @withfig/autocomplete specs
* 2. Command history
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
* option, or argument position) and provides appropriate suggestions.
*/
import {
queryHistory,
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
getAvailableSpecs,
normalizeCommandName,
resolveNames,
type FigSpec,
type FigSubcommand,
type FigOption,
} from "./figSpecLoader";
import {
shouldDoPathCompletion,
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
import { getSnippetSuggestions } from "./snippetCompleter";
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet" | "plugin";
export interface CompletionSuggestion {
/** The text to insert */
text: string;
/** Display text (may differ from insert text) */
displayText: string;
/** Optional description */
description?: string;
/** Source of this suggestion */
source: SuggestionSource;
/** Relevance score (higher = more relevant) */
score: number;
/** For history entries: execution frequency */
frequency?: number;
/** Matching rule used by recent history surfaced during path completion. */
historyMatch?: "path-argument";
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
/** For snippet suggestions: the source snippet (used by the accept path). */
snippet?: Snippet;
/** For plugin suggestions: the owning Provider contribution. */
providerId?: string;
}
export interface CompletionContext {
/** Full command line text */
commandLine: string;
/** Current word being typed */
currentWord: string;
/** Index of the current word in the parsed tokens */
wordIndex: number;
/** Parsed command tokens */
tokens: string[];
/** The base command name (first token) */
commandName: string;
/** Whether the current position is after a recognized option that expects an argument */
isOptionArg: boolean;
}
/**
* Soft wait for remote/local path listings. History, fig specs, and snippets are
* local and should paint without waiting on high-latency SSH exec (#2830).
* Timed-out listings still finish in the background: cacheable paths warm the
* shared cache, and cache-bypassed relative SSH paths notify via onLateResult
* so the UI can merge path suggestions when the listing finally resolves.
*/
export const PATH_COMPLETION_BUDGET_MS = 150;
type PathSuggestionEntry = { name: string; type: "file" | "directory" | "symlink" };
/** @internal Exported for unit tests covering the soft path-listing budget. */
export async function getPathSuggestionsWithinBudget(
pathPromise: Promise<PathSuggestionEntry[]>,
budgetMs: number,
onLateResult?: (entries: PathSuggestionEntry[]) => void,
): Promise<PathSuggestionEntry[]> {
if (!Number.isFinite(budgetMs) || budgetMs < 0) {
return pathPromise;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const raced = await Promise.race([
// Settle rejections here so a late failure after timeout cannot surface
// as an unhandled rejection from the losing Promise.race branch.
pathPromise.then(
(entries) => ({ kind: "entries" as const, entries }),
() => ({ kind: "entries" as const, entries: [] as PathSuggestionEntry[] }),
),
new Promise<{ kind: "timeout" }>((resolve) => {
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), budgetMs);
}),
]);
if (raced.kind === "entries") return raced.entries;
// Keep the listing in flight. Cacheable paths warm the shared cache for a
// later keystroke; bypassed relative SSH paths have no cache, so surface
// the late result to the caller instead of discarding it.
void pathPromise.then(
(entries) => {
if (entries.length > 0) onLateResult?.(entries);
},
() => {},
);
return [];
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
}
function buildPathCompletionSuggestions(
ctx: CompletionContext,
pathEntries: PathSuggestionEntry[],
cwd: string | undefined,
): CompletionSuggestion[] {
if (pathEntries.length === 0) return [];
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
const suggestions: CompletionSuggestion[] = [];
for (const entry of pathEntries) {
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: shellEscape(entry.name);
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
displayText: entry.name + suffix,
source: "path",
score: 750,
fileType: entry.type,
});
}
return suggestions;
}
interface SpecSuggestionResult {
suggestions: CompletionSuggestion[];
pathArgs?: FigSubcommand["args"];
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
return `'${name.replace(/'/g, "'\\''")}'`;
}
return name;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
// Always include the last token (even if empty, to indicate trailing space)
tokens.push(current);
return tokens;
}
/**
* Parse the current command line into a CompletionContext.
*/
export function parseCommandLine(input: string): CompletionContext {
const tokens = tokenize(input);
const wordIndex = tokens.length - 1;
const currentWord = tokens[wordIndex] || "";
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
return {
commandLine: input,
currentWord,
wordIndex,
tokens,
commandName,
isOptionArg: false,
};
}
/**
* Main completion function. Returns sorted suggestions from all sources.
* Ghost text should use completions[0].text instead of a separate query.
*/
export async function getCompletions(
input: string,
options: {
hostId?: string;
hostGroup?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
sessionId?: string;
/** Connection protocol (ssh, local, telnet, serial) */
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
cwdSource?: AutocompleteCwdSource;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Which history pool to query (default: current host only). */
historyScope?: AutocompleteHistoryScope;
/**
* Soft budget for path listings (ms). Local suggestions return when this
* elapses even if remote `find` is still running. Use `Infinity` in tests
* that need the full remote listing.
*/
pathBudgetMs?: number;
/**
* Invoked when a path listing finishes after the soft budget elapsed.
* Needed for cache-bypassed relative SSH cwd lookups, which cannot warm
* the shared directory cache for a later keystroke.
*/
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15, historyScope = "host" } = options;
const pathBudgetMs = options.pathBudgetMs ?? PATH_COMPLETION_BUDGET_MS;
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
? await getSpecSuggestions(ctx)
: { suggestions: [] };
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, specResult.pathArgs)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
// History queries honor historyScope; snippets still stay host-scoped.
const historyHostId = historyScope === "global" ? undefined : hostId;
// 1. History suggestions (full command line prefix match)
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId: historyHostId,
limit: preferPathSuggestions ? 0 : 5,
};
const historyMatches = queryHistory(input, historyOpts);
for (const entry of historyMatches) {
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 1000 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (preferPathSuggestions && ctx.commandName) {
// When path completion is active (file-related commands like cat, vim, cd),
// recent history is still useful but should rank below actual path matches
// from the current directory.
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId: historyHostId,
limit: 5,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 720 - index,
frequency: entry.frequency,
historyMatch: "path-argument",
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const pathEntries = canQueryPaths && pathCheck.shouldComplete
? await getPathSuggestionsWithinBudget(
getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
os: options.os,
cwd: options.cwd,
cwdSource: options.cwdSource,
foldersOnly: pathCheck.foldersOnly,
}),
pathBudgetMs,
(lateEntries) => {
if (!options.onLatePathSuggestions) return;
const latePathSuggestions = buildPathCompletionSuggestions(
ctx,
lateEntries,
options.cwd,
);
if (latePathSuggestions.length > 0) {
options.onLatePathSuggestions(latePathSuggestions);
}
},
)
: [];
for (const suggestion of specResult.suggestions) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
for (const suggestion of buildPathCompletionSuggestions(ctx, pathEntries, options.cwd)) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
// 3. Fuzzy history fallback while typing the command name. Once arguments
// are present, history completion is prefix-only: fuzzy matching the whole
// line can borrow characters from later paths and keep an incompatible
// middle argument visible (issue #3088).
if (
ctx.wordIndex === 0 &&
!preferPathSuggestions &&
suggestions.length < 3 &&
input.length >= 2
) {
const fuzzyMatches = fuzzyQueryHistory(input, {
...historyOpts,
limit: 5,
});
for (const entry of fuzzyMatches) {
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 500 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// Snippets: only at the command position (typing the command name).
// Push without the early seen-text skip: snippets score above history, so if
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);
// Deduplicate
const seen = new Set<string>();
const unique: CompletionSuggestion[] = [];
for (const s of suggestions) {
if (seen.has(s.text)) continue;
seen.add(s.text);
unique.push(s);
if (unique.length >= resultLimit) break;
}
return unique;
}
function normalizeHistoryPathPrefix(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ");
}
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
}
return { suggestions };
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return { suggestions };
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return { suggestions };
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
for (const sub of spec.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.currentWord + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
return { suggestions };
}
// Navigate the spec tree based on typed tokens
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
// and show its children as preview (e.g., "git commit" shows commit's options)
if (currentToken && resolved.subcommands) {
const exactMatch = resolved.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(currentToken);
});
if (exactMatch) {
// Navigate into the matched subcommand and show its children
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
// Show child subcommands
if (childResolved.subcommands) {
for (const sub of childResolved.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.commandLine + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
// Show child options
appendOptionPreviewSuggestions(
suggestions,
ctx.commandLine,
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return { suggestions };
}
}
// Suggest subcommands (prefix match, excluding exact matches)
if (resolved.subcommands) {
for (const sub of resolved.subcommands) {
const names = resolveNames(sub.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: sub.description,
source: "subcommand",
score: 800,
});
}
}
}
}
// Suggest options
const hasDirectOptionSuggestions = appendOptionSuggestions(
suggestions,
ctx,
currentToken,
resolved.options,
);
if (!hasDirectOptionSuggestions) {
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
}
// Suggest argument values from suggestions in the spec
if (resolved.args) {
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
for (const arg of args) {
if (arg.suggestions) {
for (const sug of arg.suggestions) {
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
const sugDesc = typeof sug === "string" ? undefined : sug.description;
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
displayText: sugName,
description: sugDesc,
source: "arg",
score: 600,
});
}
}
}
}
}
return {
suggestions,
pathArgs: resolved.args,
};
}
/**
* Get command name suggestions by matching against available specs.
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
*/
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
const specs = await getAvailableSpecs();
const lower = prefix.toLowerCase();
const suggestions: CompletionSuggestion[] = [];
for (const name of specs) {
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
if (name.includes("/")) continue;
if (name.startsWith(lower) && name !== lower) {
suggestions.push({
text: name,
displayText: name,
source: "command",
score: 600,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
interface ResolvedContext {
subcommands?: FigSubcommand[];
options?: FigOption[];
fallbackOptions?: FigOption[];
args?: FigSubcommand["args"];
}
/**
* Walk the spec tree following the typed tokens to find the current context.
* Handles options with arguments (e.g., --name value) by skipping the value token.
*/
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
let current: FigSubcommand = spec;
let inheritedOptions: FigOption[] = [];
let skipNext = false;
let lastOptionArgs: FigSubcommand["args"] | undefined;
for (const token of consumedTokens) {
// Skip this token if it's the argument value of a previous option
if (skipNext) {
skipNext = false;
lastOptionArgs = undefined;
continue;
}
// Handle option flags
if (token.startsWith("-")) {
// Check if this option expects an argument
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
const names = resolveNames(candidate.name);
return names.includes(token);
});
if (opt?.args) {
// This option expects an argument — the next token is its value
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
if (args.length > 0 && !args[0].isOptional) {
skipNext = true;
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
}
}
continue;
}
// Try to find a matching subcommand
if (current.subcommands) {
const sub = current.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(token);
});
if (sub) {
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
current = sub;
continue;
}
}
// If no subcommand matched, we're at the args level
break;
}
// If skipNext is still true, the currentWord is an option's arg value
// (e.g., "git archive --format |" — currentWord is the format value)
// Return the option's args instead of the subcommand's args.
if (skipNext && lastOptionArgs) {
return {
subcommands: undefined,
options: undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: lastOptionArgs,
};
}
return {
subcommands: current.subcommands,
options: current.options ? [...current.options] : undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: current.args,
};
}
function mergeOptionLists(
left: FigOption[] | undefined,
right: FigOption[] | undefined,
): FigOption[] {
const merged: FigOption[] = [];
const seen = new Set<string>();
for (const option of [...(left ?? []), ...(right ?? [])]) {
const key = resolveNames(option.name).sort().join("\0");
if (seen.has(key)) continue;
seen.add(key);
merged.push(option);
}
return merged;
}
function appendOptionSuggestions(
suggestions: CompletionSuggestion[],
ctx: CompletionContext,
currentToken: string,
options: FigOption[] | undefined,
): boolean {
if (!options || options.length === 0) return false;
let added = false;
for (const opt of options) {
const names = resolveNames(opt.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: opt.description,
source: "option",
score: 700,
});
added = true;
}
}
}
return added;
}
function appendOptionPreviewSuggestions(
suggestions: CompletionSuggestion[],
commandLine: string,
options: FigOption[] | undefined,
limit: number,
): void {
if (!options || options.length === 0 || suggestions.length >= limit) return;
for (const opt of options) {
const names = resolveNames(opt.name);
suggestions.push({
text: commandLine + " " + names[0],
displayText: names[0],
description: opt.description,
source: "option",
score: 700,
});
if (suggestions.length >= limit) break;
}
}
/**
* Rebuild the full command text with a replacement at a specific token index.
*/
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
const rebuilt = [...tokens];
rebuilt[replaceIndex] = replacement;
return rebuilt.join(" ");
}