Files
Netcatty/components/terminal/autocomplete/useTerminalAutocomplete.ts
T
2026-06-26 16:52:08 +08:00

1004 lines
37 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,
type PromptDetectionResult,
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import type { Snippet } from "../../../domain/models";
import { recordCommand } from "./commandHistoryStore";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
import {
listDirectoryEntries,
normalizePathTokenForLookup,
shouldPreferRemoteShellCwd,
} from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { computeLivePreviewWrite } from "./livePreviewSequence";
import {
areSubDirPanelsEqual,
areSuggestionsEqual,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteCursorColumn,
resolveAutocompleteCwdWithSource,
} from "./terminalAutocompleteLayout";
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
export interface AutocompleteSettings {
enabled: boolean;
showGhostText: boolean;
showPopupMenu: boolean;
/** Whether popup navigation should render the highlighted candidate into the terminal input line. */
livePreview: boolean;
/** Whether accepting a candidate may clear/replace the current input line. */
allowLineReplacement: 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,
livePreview: true,
allowLineReplacement: 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,
popupAnchorViewport: { left: 0, top: 0, bottom: 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;
popupAnchorViewport: { left: number; top: number; bottom: 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>;
containerRef: RefObject<HTMLElement | 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;
/** Custom snippets to surface at the command position */
snippets?: Snippet[];
/** Accept a snippet — clears typed input then runs it (host-canonical send) */
onAcceptSnippet?: (snippet: Snippet) => void;
}
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;
showSudoHint: (text: string) => boolean;
hideSudoHint: () => void;
}
export { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
export function useTerminalAutocomplete(
options: UseTerminalAutocompleteOptions,
): TerminalAutocompleteHandle {
const { termRef, containerRef, sessionId, hostId, hostOs, settings: userSettings, onAcceptText, protocol, getCwd, snippets, onAcceptSnippet } = 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 snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
const onAcceptSnippetRef = useRef(onAcceptSnippet);
onAcceptSnippetRef.current = onAcceptSnippet;
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(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
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,
);
}, []);
const repositionPopup = useCallback(() => {
const term = termRef.current;
if (!term) return;
setState((prev) => {
if (!prev.popupVisible || prev.suggestions.length === 0) return prev;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
const cursorColumn = prompt.isAtPrompt
? resolveAutocompleteCursorColumn(term, prompt)
: term.buffer.active.cursorX;
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
prev.suggestions.length,
cursorColumn,
);
// 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,
popupAnchorViewport: {
left: anchor.anchorLeft,
top: anchor.anchorTop,
bottom: anchor.anchorBottom,
},
expandUpward: anchor.expandUpward,
};
});
}, [containerRef, termRef]);
/** 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 cwdResolution = resolveAutocompleteCwdWithSource(
activePrompt?.promptText ?? "",
activeWord,
getCwdRef.current?.(),
hostOsRef.current,
);
const dirPath = normalizePathTokenForLookup(parseCommandLine(item.text).currentWord, cwdResolution.cwd, {
preferRelativeCwd: shouldPreferRemoteShellCwd(
protocolRef.current,
sessionIdRef.current,
hostOsRef.current,
cwdResolution.cwd,
cwdResolution.source,
),
});
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,
};
});
});
// Adding/removing sub-dir panels changes the popup's total width, which
// can push it off-screen. Recompute placement after state settles.
requestAnimationFrame(() => {
repositionPopup();
});
});
}, [fetchDirEntries, repositionPopup, 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>(() => {});
/**
* 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 cwdResolution = resolveAutocompleteCwdWithSource(
prompt.promptText,
parsedInput.currentWord,
getCwdRef.current?.(),
hostOsRef.current,
);
// Single query for both ghost text and popup
let completions = await getCompletions(input, {
hostId: hostIdRef.current,
os: hostOsRef.current,
maxResults: settingsRef.current.maxSuggestions,
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
cwd: cwdResolution.cwd,
cwdSource: cwdResolution.source,
snippets: snippetsRef.current,
});
if (!settingsRef.current.allowLineReplacement) {
completions = completions.filter((completion) =>
completion.source !== "snippet" && completion.text.startsWith(input),
);
}
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;
// Snippets are popup-only — never used as inline ghost text.
const nextSuggestion = completions.find((c) => c.source !== "snippet")?.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 cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
completions.length,
cursorColumn,
);
startTransition(() => {
setState((prev) => {
if (version !== fetchVersionRef.current) return prev;
const nextState: AutocompleteState = {
suggestions: completions,
selectedIndex: -1,
popupVisible: true,
popupAnchorViewport: {
left: anchor.anchorLeft,
top: anchor.anchorTop,
bottom: anchor.anchorBottom,
},
expandUpward: anchor.expandUpward,
subDirPanels: [],
subDirFocusLevel: -1,
};
if (
prev.popupVisible &&
prev.selectedIndex === nextState.selectedIndex &&
prev.expandUpward === nextState.expandUpward &&
prev.popupAnchorViewport.left === nextState.popupAnchorViewport.left &&
prev.popupAnchorViewport.top === nextState.popupAnchorViewport.top &&
prev.popupAnchorViewport.bottom === nextState.popupAnchorViewport.bottom &&
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, containerRef]);
// 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) => {
handleTerminalAutocompleteInput(data, {
settingsRef,
lastKeystrokeRef,
suppressNextEnterRecordRef,
lastAcceptedCommandRef,
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
termRef,
hostIdRef,
hostOsRef,
ghostAddonRef,
debounceTimerRef,
clearState,
fetchSuggestions,
});
},
[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 => handleTerminalAutocompleteKeyEvent(e, {
settingsRef,
stateRef,
ghostAddonRef,
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps -- handler uses refs and callbacks initialized below.
[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) => {
if (!settingsRef.current.livePreview) return;
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const baseline = previewBaselineRef.current;
const selected = index >= 0 ? s.suggestions[index] : null;
// Snippets aren't literal completions — keep the user's typed text in the
// line (the popup detail panel shows the full command instead).
const candidate =
selected && selected.source !== "snippet" ? selected.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;
// Live-preview can move/wrap the cursor. Recompute the anchor after xterm
// has processed the write so the popup doesn't drift or flip into a stale
// position (fixes #1710).
requestAnimationFrame(() => {
repositionPopup();
});
}, [termRef, writeToTerminal, repositionPopup]);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
const term = termRef.current;
if (term) {
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (prompt.isAtPrompt && prompt.userInput.length > 0) {
if (!settingsRef.current.allowLineReplacement) return false;
const clearSequence = hostOsRef.current === "windows"
? "\b".repeat(prompt.userInput.length)
: "\x15"; // Ctrl+U (readline kill-line)
writeToTerminal(clearSequence);
}
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
onAcceptSnippetRef.current?.(snippet);
clearState();
return true;
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
}, [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 false;
// 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 false;
// 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 {
if (!settingsRef.current.allowLineReplacement) return false;
// 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();
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearState is stable
[termRef, writeToTerminal],
);
const acceptPreviewlessSelection = useCallback((index: number): boolean => {
const suggestion = stateRef.current.suggestions[index];
if (!suggestion) return false;
if (suggestion.source === "snippet" && suggestion.snippet) {
return acceptSnippet(suggestion.snippet);
}
return insertSuggestion(suggestion, true);
}, [acceptSnippet, insertSuggestion]);
/**
* Select a suggestion from the popup (Tab / mouse click — insert only, no execute).
*/
const selectSuggestion = useCallback(
(suggestion: CompletionSuggestion) => {
if (suggestion.source === "snippet" && suggestion.snippet) {
acceptSnippet(suggestion.snippet);
return;
}
insertSuggestion(suggestion, false);
},
[insertSuggestion, acceptSnippet],
);
const closePopup = useCallback(() => {
clearState();
}, [clearState]);
const dispose = useCallback(() => {
disposedRef.current = true;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
ghostAddonRef.current?.dispose();
ghostAddonRef.current = null;
}, []);
const showSudoHint = useCallback((text: string): boolean => {
const addon = ghostAddonRef.current;
if (!addon) return false;
addon.showHint(text);
return addon.isHintActive();
}, []);
const hideSudoHint = useCallback(() => {
ghostAddonRef.current?.hideHint();
}, []);
useEffect(() => {
return () => { dispose(); };
}, [dispose]);
return {
state,
ghostTextAddon: ghostAddonRef.current,
handleInput,
handleKeyEvent,
selectSuggestion,
repositionPopup,
closePopup,
dispose,
showSudoHint,
hideSudoHint,
};
}