Files
Netcatty/components/terminal/autocomplete/useTerminalAutocomplete.ts
T
陈大猫 a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00

1571 lines
60 KiB
TypeScript

/**
* React hook for terminal autocomplete.
* Orchestrates:
* - Prompt detection
* - Ghost text addon
* - Popup menu state
* - Keyboard interaction (→ accept, Tab toggle popup, ↑↓ navigate, Esc close)
* - Input debouncing
*/
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import { GhostTextAddon } from "./GhostTextAddon";
import {
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import { recordCommand } from "./commandHistoryStore";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
import { getXTermCellDimensions } from "./xtermUtils";
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { computeLivePreviewWrite } from "./livePreviewSequence";
export interface AutocompleteSettings {
enabled: boolean;
showGhostText: boolean;
showPopupMenu: boolean;
debounceMs: number;
minChars: number;
maxSuggestions: number;
/** Typing speed threshold — suppress suggestions when typing faster than this (ms between keystrokes) */
fastTypingThresholdMs: number;
}
export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
enabled: true,
showGhostText: true,
showPopupMenu: true,
debounceMs: 100,
minChars: 1,
maxSuggestions: 8,
fastTypingThresholdMs: 40,
};
/**
* Whether completion work is worth doing — i.e. whether anything would
* actually be rendered. With both the popup and ghost text disabled, querying
* completions only to discard the result is pure main-thread waste, so callers
* skip it entirely.
*/
export function shouldQueryCompletions(
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
): boolean {
return settings.showPopupMenu || settings.showGhostText;
}
/** Shared empty state to avoid creating new objects on every reset */
const EMPTY_STATE: AutocompleteState = Object.freeze({
suggestions: [],
selectedIndex: -1,
popupVisible: false,
popupPosition: { x: 0, y: 0 },
popupCursorLineTop: 0,
popupCursorLineBottom: 0,
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
});
export interface SubDirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
export interface SubDirPanel {
entries: SubDirEntry[];
selectedIndex: number;
/** The absolute directory path this panel lists */
dirPath: string;
}
export interface AutocompleteState {
suggestions: CompletionSuggestion[];
selectedIndex: number;
popupVisible: boolean;
popupPosition: { x: number; y: number };
popupCursorLineTop: number;
popupCursorLineBottom: number;
expandUpward: boolean;
/** Stack of sub-directory panels (cascading: panel 0 → panel 1 → ...) */
subDirPanels: SubDirPanel[];
/** Which level has focus: -1 = main panel, 0+ = sub-dir panel index */
subDirFocusLevel: number;
}
interface UseTerminalAutocompleteOptions {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
/** Callback to write text to the terminal session — replaces CustomEvent */
onAcceptText?: (text: string) => void;
/** Connection protocol for path completion routing */
protocol?: string;
/** Get current working directory (from OSC 7 or other source) */
getCwd?: () => string | undefined;
}
export interface TerminalAutocompleteHandle {
state: AutocompleteState;
ghostTextAddon: GhostTextAddon | null;
handleInput: (data: string) => void;
handleKeyEvent: (e: KeyboardEvent) => boolean;
selectSuggestion: (suggestion: CompletionSuggestion) => void;
repositionPopup: () => void;
closePopup: () => void;
dispose: () => void;
}
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
function hasStandardShellPromptTerminator(promptText: string): boolean {
return /[$#%>]$/.test(promptText.trimEnd());
}
function isSingleThemedPromptTerminator(promptText: string): boolean {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
}
function isThemedPromptPathToken(token: string): boolean {
return (
token === "~" ||
token.startsWith("~/") ||
token.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(token) ||
token.includes("\\")
);
}
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
const hasThemedPromptMarker =
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
Array.from(prompt.promptText).some((ch) => {
const code = ch.charCodeAt(0);
return code >= 0xE000 && code <= 0xF8FF;
});
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
return false;
}
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
return (
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
/\S+\s+\S/.test(prompt.userInput)
);
}
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
}
export function getCommandToRecordOnEnter(
livePrompt: PromptDetectionResult,
alignedTyped: string | null,
typedBuffer: string,
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
if (reliableTypedCommand) {
const reconciledPrompt = reconcilePromptWithExternalCommand(
livePrompt,
reliableTypedCommand,
);
if (reconciledPrompt) return reliableTypedCommand;
}
const liveCommand = livePrompt.userInput.trim();
if (!liveCommand && reliableTypedCommand) {
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
? null
: reliableTypedCommand;
}
if (!liveCommand) return null;
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
const liveInputMayIncludePromptDecoration =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
liveCommand !== typedBuffer.trim() &&
liveCommand.endsWith(typedBuffer.trim());
if (liveInputMayIncludePromptDecoration) return null;
const liveInputMayBeLagging =
typedBufferReliable &&
typedBuffer.trim().length > 0 &&
typedBuffer.length > livePrompt.userInput.length &&
typedBuffer.startsWith(livePrompt.userInput);
if (liveInputMayBeLagging) return null;
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
return liveCommand;
}
export function useTerminalAutocomplete(
options: UseTerminalAutocompleteOptions,
): TerminalAutocompleteHandle {
const { termRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd } = options;
const rawSettings: AutocompleteSettings = {
...DEFAULT_AUTOCOMPLETE_SETTINGS,
...userSettings,
};
// Mutual-exclusivity guard matching the repo-wide contract:
// - SettingsTerminalTab toggles one off when the other is enabled.
// - domain/models.ts normalizes stored settings so popup wins.
// Keep the guard here too so callers that pass DEFAULT_AUTOCOMPLETE_SETTINGS
// directly (e.g. tests or future embedders) don't end up rendering both
// systems at once. In the normal Terminal.tsx → store path only one of
// the two arrives as true, so this is defensive, not load-bearing.
const settings: AutocompleteSettings = {
...rawSettings,
showGhostText: rawSettings.showPopupMenu ? false : rawSettings.showGhostText,
};
// Use refs for values accessed in callbacks to avoid stale closures
const settingsRef = useRef(settings);
settingsRef.current = settings;
const onAcceptTextRef = useRef(onAcceptText);
onAcceptTextRef.current = onAcceptText;
const hostIdRef = useRef(hostId);
hostIdRef.current = hostId;
const hostOsRef = useRef(hostOs);
hostOsRef.current = hostOs;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const protocolRef = useRef(protocol);
protocolRef.current = protocol;
const getCwdRef = useRef(getCwd);
getCwdRef.current = getCwd;
const [state, setState] = useState<AutocompleteState>(EMPTY_STATE);
const ghostAddonRef = useRef<GhostTextAddon | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastKeystrokeRef = useRef<number>(0);
const lastPromptRef = useRef<PromptDetectionResult | null>(null);
const disposedRef = useRef(false);
const stateRef = useRef(state);
stateRef.current = state;
/** Flag to suppress handleInput's Enter recording when selectAndExecute already did it */
const suppressNextEnterRecordRef = useRef(false);
/** Monotonic counter to invalidate stale async completion results */
const fetchVersionRef = useRef(0);
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
const lastAcceptedCommandRef = useRef<string | null>(null);
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
const previewBaselineRef = useRef<string>("");
/** Whether a popup candidate is currently rendered into the command line (#1005). */
const previewActiveRef = useRef(false);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
* Keystroke buffer mirroring what the user has typed since the last
* prompt boundary (Enter / Ctrl-C / Ctrl-U / cursor movement).
*
* detectPrompt parses the xterm buffer and can misattribute theme
* content — e.g. oh-my-zsh robbyrussell's "➜ ~ " — as user input.
* Keeping an independent keystroke log lets getAlignedPrompt snap the
* detected userInput back to what was actually typed (and only when
* the buffer matches the live line's tail), which in turn keeps
* history recording and Tab insertion honest (#806).
*/
const typedInputBufferRef = useRef<string>("");
/**
* Whether typedInputBufferRef can be trusted as the full tail of the
* current command line. Cleared after any event this append-only buffer
* can't follow (history recall via ↑/Ctrl-P, cursor moves, reverse
* search, etc.). Reset to true on clean line boundaries — Enter,
* Ctrl-C, Ctrl-U — and after we explicitly re-align via
* insertSuggestion or a ghost-text accept.
*
* Without this flag, an Up-arrow-recall workflow would leave the buffer
* holding only the post-navigation suffix, and Enter would record that
* suffix as a command (pollutes history, misleads future completions).
*/
const typedBufferReliableRef = useRef<boolean>(true);
// Preload common specs on first mount (only if enabled)
useEffect(() => {
if (settings.enabled) {
preloadCommonSpecs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Initialize ghost text addon — poll for termRef since it's set after xterm runtime creation
// Also clears popup/ghost when autocomplete is disabled at runtime
useEffect(() => {
if (!settings.enabled) {
// Clear any visible popup/ghost when disabled
clearState();
return;
}
let addon: GhostTextAddon | null = null;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const tryActivate = () => {
const term = termRef.current;
if (!term || cancelled) return;
addon = new GhostTextAddon();
addon.activate(term);
ghostAddonRef.current = addon;
};
// termRef may not be set yet on first render — poll briefly
if (termRef.current) {
tryActivate();
} else {
const poll = () => {
if (cancelled) return;
if (termRef.current) {
tryActivate();
} else {
pollTimer = setTimeout(poll, 50);
}
};
pollTimer = setTimeout(poll, 50);
}
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
addon?.dispose();
ghostAddonRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, settings.enabled]);
// Hide any active ghost when the user turns showGhostText off mid-
// session. The fetchSuggestions branch (~L531) already gates new
// shows on the flag, but a ghost that was already on screen at toggle
// time would otherwise keep sliding around under a disabled setting
// until something unrelated called clearState (Codex #815 P2).
useEffect(() => {
if (!settings.showGhostText) {
ghostAddonRef.current?.hide();
}
}, [settings.showGhostText]);
/**
* Write accepted text to the terminal via callback (no CustomEvent).
*/
const writeToTerminal = useCallback((text: string) => {
onAcceptTextRef.current?.(text);
}, []);
/**
* Clear popup/ghost state. Skips re-render if already empty.
*/
const clearState = useCallback(() => {
ghostAddonRef.current?.hide();
// Bump version to invalidate any in-flight async completions
fetchVersionRef.current++;
subDirFetchVersionRef.current++;
setState((prev) =>
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
);
}, []);
/** Fetch directory listing via IPC. */
const fetchDirEntries = useCallback(async (dirPath: string): Promise<SubDirEntry[]> => {
return listDirectoryEntries(dirPath, {
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
os: hostOsRef.current,
foldersOnly: false,
limit: 50,
});
}, []);
/** Fetch sub-dir entries for the main panel's selected item (level 0). */
const fetchSubDirForIndex = useCallback((index: number) => {
const s = stateRef.current;
if (index < 0 || index >= s.suggestions.length) return;
const item = s.suggestions[index];
if (item.source !== "path" || item.fileType !== "directory") {
subDirFetchVersionRef.current++;
setState((prev) => prev.subDirPanels.length > 0
? { ...prev, subDirPanels: [], subDirFocusLevel: -1 }
: prev);
return;
}
const term = termRef.current;
const { prompt: livePrompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
const activePrompt = livePrompt.isAtPrompt ? livePrompt : lastPromptRef.current;
const activeWord = activePrompt?.isAtPrompt
? parseCommandLine(activePrompt.userInput).currentWord
: parseCommandLine(item.text).currentWord;
const cwd = resolveAutocompleteCwd(
activePrompt?.promptText ?? "",
activeWord,
getCwdRef.current?.(),
hostOsRef.current,
);
const dirPath = normalizePathTokenForLookup(parseCommandLine(item.text).currentWord, cwd, {
preferRelativeCwd: Boolean(
sessionIdRef.current && protocolRef.current !== "local" && hostOsRef.current === "linux",
),
});
if (!dirPath) return;
const requestVersion = ++subDirFetchVersionRef.current;
fetchDirEntries(dirPath).then((entries) => {
if (requestVersion !== subDirFetchVersionRef.current) return;
startTransition(() => {
setState((prev) => {
if (prev.selectedIndex !== index) return prev;
const nextPanels = entries.length > 0 ? [{ entries, selectedIndex: -1, dirPath }] : [];
if (
prev.subDirFocusLevel === -1 &&
prev.subDirPanels.length === nextPanels.length &&
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
) {
return prev;
}
return {
...prev,
subDirPanels: nextPanels,
subDirFocusLevel: -1,
};
});
});
});
}, [fetchDirEntries, termRef]);
/** Expand a directory at the given panel level → fetch contents and push new panel.
* Does NOT change focus level — use moveFocus param to override. */
const expandSubDir = useCallback((level: number, entry: SubDirEntry, moveFocus = false) => {
const s = stateRef.current;
const panel = s.subDirPanels[level];
if (!panel || entry.type !== "directory") return;
const parentPath = panel.dirPath.endsWith("/") ? panel.dirPath : panel.dirPath + "/";
const childPath = parentPath + entry.name + "/";
const requestVersion = ++subDirFetchVersionRef.current;
fetchDirEntries(childPath).then((entries) => {
if (requestVersion !== subDirFetchVersionRef.current || entries.length === 0) return;
startTransition(() => {
setState((prev) => {
const currentPanel = prev.subDirPanels[level];
if (!currentPanel || currentPanel.dirPath !== panel.dirPath) return prev;
const nextPanels = prev.subDirPanels.slice(0, level + 1);
nextPanels.push({ entries, selectedIndex: moveFocus ? 0 : -1, dirPath: childPath });
const nextFocusLevel = moveFocus ? level + 1 : prev.subDirFocusLevel;
if (
prev.subDirFocusLevel === nextFocusLevel &&
prev.subDirPanels.length === nextPanels.length &&
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
) {
return prev;
}
return {
...prev,
subDirPanels: nextPanels,
subDirFocusLevel: nextFocusLevel,
};
});
});
// When moving focus into a newly opened panel, the first item is auto-selected.
// If that first item is itself a directory, eagerly show its next level so the
// user doesn't need to move ↓↑ just to trigger the usual auto-expand behavior.
const firstEntry = moveFocus ? entries[0] : null;
if (firstEntry?.type !== "directory") return;
const nestedChildPath = `${childPath}${firstEntry.name}/`;
fetchDirEntries(nestedChildPath).then((nestedEntries) => {
if (requestVersion !== subDirFetchVersionRef.current || nestedEntries.length === 0) return;
startTransition(() => {
setState((prev) => {
const currentChildPanel = prev.subDirPanels[level + 1];
if (
!currentChildPanel ||
currentChildPanel.dirPath !== childPath ||
currentChildPanel.selectedIndex !== 0
) {
return prev;
}
const nextPanels = prev.subDirPanels.slice(0, level + 2);
nextPanels.push({ entries: nestedEntries, selectedIndex: -1, dirPath: nestedChildPath });
if (
prev.subDirPanels.length === nextPanels.length &&
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
) {
return prev;
}
return {
...prev,
subDirPanels: nextPanels,
};
});
});
});
});
}, [fetchDirEntries]);
// Ref to fetchSuggestions (avoids circular dep — defined after fetchSuggestions)
const fetchSuggestionsRef = useRef<() => void>(() => {});
const repositionPopup = useCallback(() => {
const term = termRef.current;
if (!term) return;
setState((prev) => {
if (!prev.popupVisible || prev.suggestions.length === 0) return prev;
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, prev.suggestions.length);
// Force a re-render even when the relative cursor cell hasn't changed.
// The terminal container may have moved in the viewport after a fit/resize.
return {
...prev,
popupPosition: position,
popupCursorLineTop: cursorLineTop,
popupCursorLineBottom: cursorLineBottom,
expandUpward,
};
});
}, [termRef]);
/**
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
*/
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const panel = s.subDirPanels[level];
if (!panel) return;
const { prompt } = getAlignedPrompt(
term, typedInputBufferRef.current, typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const parsed = parseCommandLine(prompt.userInput);
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
+ (parsed.wordIndex > 0 ? " " : "");
const currentToken = parsed.currentWord;
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
? currentToken[0] : "";
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
const suffix = entry.type === "directory" ? "/" : "";
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name : shellEscape(entry.name);
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
previewActiveRef.current = true;
lastAcceptedCommandRef.current = newCommand;
}, [termRef, writeToTerminal]);
/** Handle selecting a file/directory from any sub-dir panel.
* Builds the full path from the panel stack and replaces the current input. */
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
// Build the full path: panel's dirPath + entry name
const panel = s.subDirPanels[level];
if (!panel) return;
// Get current prompt to know what command prefix to keep (e.g., "cd ").
// getAlignedPrompt handles robbyrussell-style themes by trimming the
// cwd marker out of userInput when the typed buffer is aligned (#806).
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!prompt.isAtPrompt) return;
// Find the command part (everything before the path argument)
// e.g., userInput = "cd /usr/" → command prefix = "cd ", we replace the whole path
const parsedPrompt = parseCommandLine(prompt.userInput);
const cmdPrefix = parsedPrompt.tokens
.slice(0, parsedPrompt.wordIndex)
.join(" ") + (parsedPrompt.wordIndex > 0 ? " " : "");
const currentToken = parsedPrompt.currentWord;
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
? currentToken[0]
: "";
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
const suffix = entry.type === "directory" ? "/" : "";
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: shellEscape(entry.name);
const fullPath = panel.dirPath + entryName + suffix;
const replacementPath = `${quotePrefix}${fullPath}${quoteSuffix}`;
// Clear current input and write: cmdPrefix + fullPath
const isWindows = hostOsRef.current === "windows";
const clearSeq = isWindows
? "\b".repeat(prompt.userInput.length)
: "\x15";
const newCommand = cmdPrefix + replacementPath;
writeToTerminal(clearSeq + newCommand);
// Sub-dir selection rewrote the whole command line; re-align the
// keystroke buffer so the next Enter records the executed command
// instead of whatever partial input we had before (P2 from #814).
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
clearState();
if (entry.type === "directory") {
setTimeout(() => fetchSuggestionsRef.current(), 50);
}
}, [writeToTerminal, clearState, termRef]);
/**
* Fetch and display suggestions based on current input.
* Single query path for both ghost text and popup (no duplicate queries).
*/
const fetchSuggestions = useCallback(async () => {
const term = termRef.current;
if (!term || disposedRef.current || !settingsRef.current.enabled) {
return;
}
// Nothing will be rendered when both the popup and ghost text are off, so
// don't run the (potentially expensive) completion query just to throw the
// result away. Clear any stale state and bail before touching history,
// fig specs, or remote path lookups.
if (!shouldQueryCompletions(settingsRef.current)) {
clearState();
return;
}
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
lastPromptRef.current = prompt;
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
clearState();
return;
}
// Suppress autocomplete when cursor is not at end of input —
// inserting text mid-line would corrupt the command (e.g., "git st|tus" → "git statustus")
const buffer = term.buffer.active;
const lineAfterCursor = buffer.getLine(buffer.cursorY + buffer.baseY)
?.translateToString(false).substring(buffer.cursorX).trimEnd();
if (lineAfterCursor && lineAfterCursor.length > 0) {
clearState();
return;
}
const input = prompt.userInput;
const parsedInput = parseCommandLine(input);
const cwd = resolveAutocompleteCwd(
prompt.promptText,
parsedInput.currentWord,
getCwdRef.current?.(),
hostOsRef.current,
);
// Single query for both ghost text and popup
const completions = await getCompletions(input, {
hostId: hostIdRef.current,
os: hostOsRef.current,
maxResults: settingsRef.current.maxSuggestions,
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
cwd,
});
if (disposedRef.current || version !== fetchVersionRef.current) return;
// Discard stale results: if the user kept typing while getCompletions was running,
// the current prompt input will have changed. Re-detect and compare.
const { prompt: currentPrompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!currentPrompt.isAtPrompt || currentPrompt.userInput !== input) {
return; // Input changed — these completions are stale
}
// Ghost text: keep the active prediction stable while the user's
// input still fits within it. Only swap to a fresh prediction once
// the current one no longer matches the typed prefix.
if (settingsRef.current.showGhostText) {
const ghost = ghostAddonRef.current;
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
if (ghostDecision.type === "show") {
ghost?.show(ghostDecision.suggestion, input);
} else if (ghostDecision.type === "hide") {
ghost?.hide();
}
}
// Popup
if (settingsRef.current.showPopupMenu && completions.length > 0) {
// Live-preview baseline: the typed input these suggestions completed.
previewBaselineRef.current = input;
previewActiveRef.current = false;
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
startTransition(() => {
setState((prev) => {
const nextState: AutocompleteState = {
suggestions: completions,
selectedIndex: -1,
popupVisible: true,
popupPosition: position,
popupCursorLineTop: cursorLineTop,
popupCursorLineBottom: cursorLineBottom,
expandUpward,
subDirPanels: [],
subDirFocusLevel: -1,
};
if (
prev.popupVisible &&
prev.selectedIndex === nextState.selectedIndex &&
prev.expandUpward === nextState.expandUpward &&
prev.popupPosition.x === nextState.popupPosition.x &&
prev.popupPosition.y === nextState.popupPosition.y &&
prev.popupCursorLineTop === nextState.popupCursorLineTop &&
prev.popupCursorLineBottom === nextState.popupCursorLineBottom &&
prev.subDirFocusLevel === -1 &&
prev.subDirPanels.length === 0 &&
areSuggestionsEqual(prev.suggestions, nextState.suggestions)
) {
return prev;
}
return nextState;
});
});
} else {
startTransition(() => {
setState((prev) =>
prev.popupVisible || prev.suggestions.length > 0
? { ...EMPTY_STATE }
: prev,
);
});
}
}, [termRef, clearState]);
// Keep ref in sync so handleSubDirSelect can call it
fetchSuggestionsRef.current = fetchSuggestions;
/**
* Handle terminal input data. Called on every character.
*/
const handleInput = useCallback(
(data: string) => {
if (!settingsRef.current.enabled) return;
const now = Date.now();
const timeSinceLastKeystroke = now - lastKeystrokeRef.current;
lastKeystrokeRef.current = now;
// Command recording: Enter key
if (data === "\r" || data === "\n") {
// Skip recording if selectAndExecute already recorded this command
if (suppressNextEnterRecordRef.current) {
suppressNextEnterRecordRef.current = false;
} else {
// If user accepted a completion (Tab/→) and immediately pressed Enter,
// the buffer may not reflect the accepted text yet. Use the tracked value.
if (lastAcceptedCommandRef.current) {
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
} else {
// Require a live prompt before trusting either keystroke buffer
// or buffer-based detection — otherwise sudo password Enter
// would record the typed password as a command.
const typedBuffer = typedInputBufferRef.current;
const typedBufferReliable = typedBufferReliableRef.current;
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
termRef.current,
typedBuffer,
typedBufferReliable,
);
const commandToRecord = getCommandToRecordOnEnter(
livePrompt,
alignedTyped,
typedBuffer,
typedBufferReliable,
);
if (commandToRecord) {
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
}
}
lastAcceptedCommandRef.current = null;
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
clearState();
return;
}
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
// buffer is once again a true reflection of the (empty) line.
if (data === "\x03" || data === "\x15") {
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
// Same rationale as the ctrl/escape early returns below: any
// previously-accepted suggestion is gone from the line too, so
// accept → Ctrl-C → type "foo" → Enter must not log the stale
// accepted command via the Enter fast path.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Backspace / DEL: drop the last typed char so the buffer stays aligned
// with what the shell actually holds.
if (data === "\x7f" || data === "\b") {
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
} else if (data === "\x17") {
// Ctrl+W: word-erase — kill the trailing whitespace + word.
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
} else if (data.startsWith("\x1b[200~")) {
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
// literal input, so newlines stay on the zle line instead of
// executing each segment — meaning we must preserve the whole
// content in the buffer, not just the post-final-newline tail
// (Codex #814 P2).
//
// Reliability is *inherited*, not reset: if the buffer was
// already aligned with the line (reliable=true), appending this
// paste keeps it aligned; if the buffer was unreliable (e.g.
// after ↑ recalled a history command so line ≠ buffer), the
// paste only extends the tail but the head is still whatever
// the shell had, so the buffer stays unreliable. Without this,
// a paste-after-recall flow would flip reliability back on and
// Enter would record just the pasted suffix as the command
// (Codex #814 P1 follow-up).
const endIdx = data.indexOf("\x1b[201~");
const content = endIdx >= 0
? data.slice("\x1b[200~".length, endIdx)
: data.slice("\x1b[200~".length);
typedInputBufferRef.current += content;
// Paste extends the line past whatever was accepted, so the
// Enter fast-path must not record the pre-paste accepted
// command — mirrors the non-bracketed paste branch below.
lastAcceptedCommandRef.current = null;
clearState();
return;
} else if (data.startsWith("\x1b") && data !== "\x1b") {
// Cursor-movement / function keys — we lose track of where the
// cursor sits relative to our append-only buffer. Mark the
// buffer unreliable and drop it; detectPrompt takes over until
// the next Enter / Ctrl-C / Ctrl-U.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
typedInputBufferRef.current += data;
} else if (data.length > 1 && !data.startsWith("\x1b")) {
// Paste chunk. Any \r / \n inside executes the preceding text as
// a command in the shell, so keeping the pre-newline portion in
// our buffer would leave stale content that a later Enter could
// record (Codex #814 P2). Drop everything up to and including
// the last terminator and keep only the tail as new content.
// Intermediate executed lines aren't synthesized back into
// recordCommand here — the onCommandExecuted path in
// createXTermRuntime still captures them independently.
const lastCR = data.lastIndexOf("\r");
const lastLF = data.lastIndexOf("\n");
const nlIdx = Math.max(lastCR, lastLF);
if (nlIdx >= 0) {
typedInputBufferRef.current = data.slice(nlIdx + 1);
typedBufferReliableRef.current = true;
// The embedded newline flushed any previously-accepted
// suggestion too — clearing the cache here prevents the next
// Enter from falling into the lastAcceptedCommandRef fast path
// and recording that stale command.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
typedInputBufferRef.current += data;
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
// line in ways this append-only buffer can't follow. Same story
// as escape sequences above — and hide the ghost too, so the
// unreliable-accept fallback doesn't pull a stale tail onto a
// recalled line (Codex #815 follow-up).
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
// should not let an old accepted command sneak back in via the
// Enter fast path after reverse-search picks a different one.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Escape sequences (arrow keys, Home, End, etc.): clear stale suggestions
// since cursor position may have changed, making current suggestions invalid.
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
if (data.startsWith("\x1b") && data !== "\x1b") {
// Same fast-path reset as the single-byte ctrl-char branch above —
// accept-then-↑/↓ must not record the stale accepted command if
// the user then presses Enter on a different recalled line.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// User is typing more — invalidate accepted command fallback since the
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// The previewed candidate is now edited, so the line is the user's own
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
// show() time; a fast "type + press →" sequence then pastes the
// pre-update tail on top of the new input ("doc" + "cker ls" →
// "doccker ls"). Skip when the user has turned showGhostText off
// mid-session: otherwise a ghost that was active before the toggle
// would keep moving around under a setting the user just said to
// disable (Codex #815 P2).
//
// Reliable buffer: feed adjustToInput the full post-mutation buffer
// so multi-char pastes refresh the ghost as one batch. Unreliable
// buffer (post Tab / cursor-move / history recall): the buffer
// is just the suffix typed since unreliability began, so feeding
// it to adjustToInput would fail the prefix invariant and hide
// the ghost. Instead let the addon evolve its own currentInput
// off the keystroke directly (issue #906) — that input was seeded
// by the last show() with the live xterm reading, which is the
// only post-Tab source-of-truth we have.
if (settingsRef.current.showGhostText) {
if (typedBufferReliableRef.current) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
} else {
ghostAddonRef.current?.applyKeystroke(data);
}
}
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
// Debounced suggestion fetch
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (isFastTyping) {
// Still debounce, but with a longer delay to wait for typing to pause
debounceTimerRef.current = setTimeout(() => {
fetchSuggestions();
}, settingsRef.current.debounceMs * 3);
} else {
debounceTimerRef.current = setTimeout(() => {
fetchSuggestions();
}, settingsRef.current.debounceMs);
}
},
[fetchSuggestions, termRef, clearState],
);
/**
* Handle keyboard events for autocomplete interaction.
* Returns false if the event was consumed (should not propagate to terminal).
*/
const handleKeyEvent = useCallback(
(e: KeyboardEvent): boolean => {
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
const s = stateRef.current;
const ghost = ghostAddonRef.current;
// Right arrow: if popup has selected directory with sub-dir panel, enter it
// Skip this handler entirely when sub-dir panels are focused — let the
// sub-panel navigation block handle → for deeper expansion.
if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.selectedIndex >= 0 && s.subDirPanels.length > 0) {
const selected = s.suggestions[s.selectedIndex];
if (selected?.fileType === "directory") {
e.preventDefault();
const firstEntry = s.subDirPanels[0]?.entries[0];
setState((prev) => {
const panels = [...prev.subDirPanels];
if (panels[0]) panels[0] = { ...panels[0], selectedIndex: 0 };
return { ...prev, subDirPanels: panels, subDirFocusLevel: 0 };
});
if (firstEntry?.type === "directory") {
expandSubDir(0, firstEntry, false);
}
return false;
}
}
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
// so a fast "type + →" that lands in the hide-until-render gap
// still hits this branch and accepts the pending ghost.
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
// When the keystroke buffer is reliable, recompute the tail
// against the *live* buffer so a fast "type + →" in the
// hide-until-render gap still writes the correct tail. When
// it's not reliable (post history-recall / Ctrl-R), we can't
// treat empty buffer as "nothing typed" — the line actually
// has content we're not tracking — so fall back to the
// ghost's own cached tail instead of writing the entire
// suggestion onto an already-populated line.
let ghostText: string;
let newBuffer: string | null;
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion && fullSuggestion.startsWith(live)) {
ghostText = fullSuggestion.substring(live.length);
newBuffer = fullSuggestion;
} else {
ghostText = "";
newBuffer = null;
}
} else {
ghostText = ghost.getGhostText();
newBuffer = null; // buffer is unreliable; don't flip it back on
}
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = fullSuggestion;
if (newBuffer !== null) {
typedInputBufferRef.current = newBuffer;
typedBufferReliableRef.current = true;
}
ghost.hide();
clearState();
} else {
ghost.hide();
}
return false;
}
}
// Ctrl+Right / Alt+Right (Mac): accept next word
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
if (!fullSuggestion) {
ghost.hide();
return false;
}
// Determine the baseline the next word should extend. Reliable
// buffer: resync the ghost to the live buffer so getNextWord
// operates on the up-to-date tail. Unreliable buffer (post
// history-recall / Ctrl-R): don't reanchor to "" — that would
// make getNextWord hand back the very first word and the shell
// would duplicate leading tokens on top of the recalled line.
// Fall back to the ghost's existing cached input instead.
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion.startsWith(live)) {
ghost.show(fullSuggestion, live);
} else {
ghost.hide();
return false;
}
}
const base = ghost.getGhostText().length > 0
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
: fullSuggestion;
const nextWord = ghost.getNextWord();
if (nextWord) {
writeToTerminal(nextWord);
// Only extend the buffer if it was already aligned with the
// line — otherwise we'd end up with just the appended word,
// which the next Enter would then record as the command.
if (typedBufferReliableRef.current) {
typedInputBufferRef.current += nextWord;
}
// Shrink the ghost to reflect what's left after the accept.
const newInput = base + nextWord;
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
ghost.show(fullSuggestion, newInput);
} else {
ghost.hide();
}
}
return false;
}
}
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
// #1005: don't intercept Tab. Keep whatever is currently rendered on
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isActive()) {
ghost.hide();
}
}
// Up/Down/Left/Right: navigate popup + sub-dir panel
if (s.popupVisible && s.suggestions.length > 0) {
const focusLevel = s.subDirFocusLevel;
const focusedPanel = focusLevel >= 0 ? s.subDirPanels[focusLevel] : null;
// Sub-dir panel focused: ↑↓ navigate, ← go back, → go deeper
if (focusLevel >= 0 && focusedPanel) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const newIdx = e.key === "ArrowUp"
? (focusedPanel.selectedIndex <= 0 ? focusedPanel.entries.length - 1 : focusedPanel.selectedIndex - 1)
: (focusedPanel.selectedIndex >= focusedPanel.entries.length - 1 ? 0 : focusedPanel.selectedIndex + 1);
setState((prev) => {
const panels = [...prev.subDirPanels];
const p = panels[focusLevel];
if (!p) return prev;
panels[focusLevel] = { ...p, selectedIndex: newIdx };
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
});
// Live-render the highlighted entry's full path into the line (#1005).
const newEntry = focusedPanel.entries[newIdx];
if (newEntry) renderSubDirPath(focusLevel, newEntry);
// Auto-expand next level if the newly selected item is a directory
if (newEntry?.type === "directory") {
expandSubDir(focusLevel, newEntry);
}
return false;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel + 1),
subDirFocusLevel: focusLevel - 1,
}));
return false;
}
if (e.key === "ArrowRight") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry?.type === "directory") {
e.preventDefault();
expandSubDir(focusLevel, entry, true); // moveFocus = true
return false;
}
}
if (e.key === "Enter" || e.key === "Tab") {
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
if (entry && focusedPanel.selectedIndex >= 0) {
e.preventDefault();
handleSubDirSelect(focusLevel, entry);
return false;
}
}
if (e.key === "Escape") {
e.preventDefault();
if (focusLevel > 0) {
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
subDirFocusLevel: focusLevel - 1,
}));
} else {
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
}
return false;
}
if (
e.key.length === 1 ||
e.key === "Backspace" ||
e.key === "Delete" ||
e.key === "Home" ||
e.key === "End"
) {
clearState();
}
return true;
}
// Main panel navigation. The cycle includes a -1 "no selection" slot so
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
// the selection live-renders the candidate into the command line (#1005).
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const n = s.suggestions.length;
const cur = s.selectedIndex;
const next =
e.key === "ArrowDown"
? (cur >= n - 1 ? -1 : cur + 1)
: (cur <= -1 ? n - 1 : cur - 1);
setState((prev) => ({
...prev,
selectedIndex: next,
subDirPanels: [], subDirFocusLevel: -1,
}));
renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
return false;
}
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (e.key === "Enter") {
clearState();
previewActiveRef.current = false;
return true;
}
}
// Escape: close popup and hide ghost text
// Only consume Escape if popup is visible; don't block Escape for vi-mode shells
// when only ghost text is showing (ghost text is passive/non-intrusive)
if (e.key === "Escape" && s.popupVisible) {
e.preventDefault();
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
ghost?.hide();
clearState();
previewActiveRef.current = false;
return false;
}
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- insertSuggestion uses refs, stable identity
[writeToTerminal],
);
/**
* Render the suggestion at `index` straight into the command line (Termius
* live-preview, #1005). `index < 0` restores the user's typed baseline.
*/
const renderPreviewSelection = useCallback((index: number) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const baseline = previewBaselineRef.current;
const candidate =
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
const { prompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput,
candidate,
os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = candidate;
typedBufferReliableRef.current = true;
const isPreview = index >= 0 && candidate !== baseline;
previewActiveRef.current = isPreview;
lastAcceptedCommandRef.current = isPreview ? candidate : null;
}, [termRef, writeToTerminal]);
/**
* Insert a suggestion into the terminal.
* @param execute If true, also sends \r to execute the command.
*/
const insertSuggestion = useCallback(
(suggestion: CompletionSuggestion, execute: boolean) => {
const term = termRef.current;
if (!term) return;
// Always use real-time prompt detection — lastPromptRef may be stale
// if the user typed more characters after suggestions were fetched.
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!prompt.isAtPrompt) return;
// If suggestion starts with the current input, insert only the remaining part.
// Otherwise (fuzzy match), clear the line first and write the full suggestion.
let payload: string;
if (suggestion.text.startsWith(prompt.userInput)) {
const textToInsert = suggestion.text.substring(prompt.userInput.length);
payload = execute ? textToInsert + "\r" : textToInsert;
} else {
// Fuzzy match: clear current input, then write full command.
// Ctrl+U works on POSIX shells (bash/zsh/fish).
// On Windows (cmd.exe/PowerShell), use backspaces to erase instead.
const isWindows = hostOsRef.current === "windows";
const clearSequence = isWindows
? "\b".repeat(prompt.userInput.length) // Backspace to erase
: "\x15"; // Ctrl+U (readline kill-line)
payload = clearSequence + suggestion.text + (execute ? "\r" : "");
}
if (payload) {
writeToTerminal(payload);
}
// Keystroke buffer now reflects the accepted text (either extended by
// the insertion suffix, or wholesale replaced by the fuzzy-match path
// that emits Ctrl-U first). Re-aligning it here keeps the subsequent
// Enter-record honest, and flips reliability back on since we know
// the line content exactly.
if (execute) {
typedInputBufferRef.current = "";
} else {
typedInputBufferRef.current = suggestion.text;
}
typedBufferReliableRef.current = true;
// Track accepted command for accurate history recording on fast Enter
if (!execute) {
lastAcceptedCommandRef.current = suggestion.text;
}
// When executing, record command here and suppress the handleInput Enter recording
if (execute) {
recordCommand(suggestion.text, hostIdRef.current, hostOsRef.current);
suppressNextEnterRecordRef.current = true;
// Safety timeout: clear the flag if handleInput's Enter doesn't consume it
// (e.g., if xterm doesn't fire onData because handleKeyEvent returned false)
setTimeout(() => { suppressNextEnterRecordRef.current = false; }, 100);
}
clearState();
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
[termRef, writeToTerminal],
);
/**
* Select a suggestion from the popup (Tab / mouse click — insert only, no execute).
*/
const selectSuggestion = useCallback(
(suggestion: CompletionSuggestion) => {
insertSuggestion(suggestion, false);
},
[insertSuggestion],
);
const closePopup = useCallback(() => {
clearState();
}, [clearState]);
const dispose = useCallback(() => {
disposedRef.current = true;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
ghostAddonRef.current?.dispose();
ghostAddonRef.current = null;
}, []);
useEffect(() => {
return () => { dispose(); };
}, [dispose]);
return {
state,
ghostTextAddon: ghostAddonRef.current,
handleInput,
handleKeyEvent,
selectSuggestion,
repositionPopup,
closePopup,
dispose,
};
}
function resolveAutocompleteCwd(
promptText: string,
currentWord: string,
fallbackCwd: string | undefined,
os: "linux" | "windows" | "macos",
): string | undefined {
if (os === "windows") return fallbackCwd;
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
// Absolute or home-relative paths don't depend on cwd
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
return fallbackCwd;
}
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
// extraction which reflects the current visible prompt — more up-to-date
// than fallbackCwd when OSC 7 is not supported.
const promptCwd = extractPosixCwdFromPrompt(promptText);
return chooseAutocompleteCwd(promptCwd, fallbackCwd);
}
function chooseAutocompleteCwd(
promptCwd: string | undefined,
fallbackCwd: string | undefined,
): string | undefined {
if (!promptCwd) return fallbackCwd;
if (!fallbackCwd) return promptCwd;
// Prompt cwd is extracted from the currently visible prompt, so it tracks
// directory changes even when OSC 7 is not supported. Prefer it over
// fallbackCwd (which may be stale from initial connection) whenever it
// looks like a usable path.
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
return promptCwd;
}
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
return fallbackCwd;
}
function extractPosixCwdFromPrompt(promptText: string): string | undefined {
const trimmed = promptText.trimEnd().replace(/[#$%>]\s*$/, "");
if (!trimmed) return undefined;
const patterns = [
/:(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
/\s(\/[^\s\]]*|~(?:\/[^\s\]]*)?)\]$/,
/(^|[\s:])(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
];
for (const pattern of patterns) {
const match = trimmed.match(pattern);
if (!match) continue;
const candidate = match[match.length - 1];
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
return candidate;
}
}
const fallbackTokens = trimmed
.split(/\s+/)
.map((token) => token.replace(/^[([{:]+/, "").replace(/[\])}:]+$/, ""));
for (let index = fallbackTokens.length - 1; index >= 0; index--) {
const candidate = fallbackTokens[index];
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
return candidate;
}
}
return undefined;
}
function areSuggestionsEqual(
left: CompletionSuggestion[],
right: CompletionSuggestion[],
): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
const a = left[i];
const b = right[i];
if (
a.text !== b.text ||
a.displayText !== b.displayText ||
a.description !== b.description ||
a.source !== b.source ||
a.score !== b.score ||
a.frequency !== b.frequency ||
a.fileType !== b.fileType
) {
return false;
}
}
return true;
}
function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
const a = left[i];
const b = right[i];
if (a.dirPath !== b.dirPath || a.selectedIndex !== b.selectedIndex) return false;
if (a.entries.length !== b.entries.length) return false;
for (let j = 0; j < a.entries.length; j++) {
if (a.entries[j].name !== b.entries[j].name || a.entries[j].type !== b.entries[j].type) {
return false;
}
}
}
return true;
}
/**
* Calculate popup position based on terminal cursor.
*/
function calculatePopupPosition(
term: XTerm,
itemCount: number,
): {
position: { x: number; y: number };
cursorLineTop: number;
cursorLineBottom: number;
expandUpward: boolean;
} {
const termElement = term.element;
if (!termElement) {
return {
position: { x: 0, y: 0 },
cursorLineTop: 0,
cursorLineBottom: 0,
expandUpward: false,
};
}
const dims = getXTermCellDimensions(term);
const buffer = term.buffer.active;
const cursorX = buffer.cursorX;
const cursorY = buffer.cursorY;
const cursorLineTop = cursorY * dims.height;
const cursorLineBottom = (cursorY + 1) * dims.height;
const estimatedPopupHeight = itemCount * 28 + 8;
const totalRows = term.rows;
const spaceBelow = (totalRows - cursorY - 1) * dims.height;
const expandUpward = spaceBelow < estimatedPopupHeight && cursorY > 2;
if (expandUpward) {
return {
position: { x: cursorX * dims.width, y: cursorY * dims.height },
cursorLineTop,
cursorLineBottom,
expandUpward: true,
};
}
return {
position: { x: cursorX * dims.width, y: (cursorY + 1) * dims.height + 4 },
cursorLineTop,
cursorLineBottom,
expandUpward: false,
};
}