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
1471 lines
55 KiB
TypeScript
1471 lines
55 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 { seedLocalShellHistoryFromHistfiles } from "./localShellHistorySeed";
|
|
import { shellEscape } from "./completionEngine";
|
|
import { preloadCommonSpecs } from "./figSpecLoader";
|
|
import {
|
|
listDirectoryEntries,
|
|
normalizePathTokenForLookup,
|
|
shouldPreferRemoteShellCwd,
|
|
} from "./remotePathCompleter";
|
|
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
|
import { isWindowsShellLineInput, shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
|
import {
|
|
areSubDirPanelsEqual,
|
|
areSuggestionsEqual,
|
|
nextAutocompletePopupAnchorViewport,
|
|
resolveAutocompleteCursorCell,
|
|
resolveAutocompleteCwdWithSource,
|
|
resolveAutocompletePopupAnchorInViewport,
|
|
resolvePreservedSuggestionIndex,
|
|
} from "./terminalAutocompleteLayout";
|
|
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
|
|
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
|
|
import {
|
|
computeAutocompleteAcceptWrite,
|
|
isSameAutocompleteQuery,
|
|
resolveAutocompleteQueryInput,
|
|
shouldBlockAutocompleteForSensitivePrompt,
|
|
} from "./terminalAutocompletePrompt";
|
|
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
|
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
|
|
|
|
|
|
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;
|
|
/** Whether Shift+Enter is reserved for the terminal's configured send text. */
|
|
shiftEnterNewlineEnabled: boolean;
|
|
/** Which history pool suggestions draw from (current host vs all hosts). */
|
|
historyScope: "host" | "global";
|
|
}
|
|
|
|
export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
|
|
enabled: true,
|
|
showGhostText: true,
|
|
showPopupMenu: true,
|
|
livePreview: true,
|
|
allowLineReplacement: true,
|
|
debounceMs: 100,
|
|
minChars: 1,
|
|
maxSuggestions: 50,
|
|
fastTypingThresholdMs: 40,
|
|
shiftEnterNewlineEnabled: true,
|
|
historyScope: "host",
|
|
};
|
|
|
|
/** Max time to poll for shell echo after a pre-echo debounce cycle (#2813). */
|
|
const ECHO_VALIDATION_MAX_WAIT_MS = 3000;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
function isSubsequence(query: string, target: string): boolean {
|
|
let queryIndex = 0;
|
|
for (let targetIndex = 0; targetIndex < target.length && queryIndex < query.length; targetIndex++) {
|
|
if (target[targetIndex] === query[queryIndex]) queryIndex++;
|
|
}
|
|
return queryIndex === query.length;
|
|
}
|
|
|
|
/**
|
|
* Remove history rows that no longer match the edited input while a debounced
|
|
* provider refresh is pending. Other sources keep their provider-specific
|
|
* matching semantics (snippet search, plugin replacement, etc.).
|
|
*/
|
|
export function reconcileAutocompletePopupState(
|
|
prev: AutocompleteState,
|
|
input: string | null,
|
|
): AutocompleteState {
|
|
if (!prev.popupVisible || prev.suggestions.length === 0) return prev;
|
|
if (input === null) return { ...EMPTY_STATE };
|
|
|
|
const normalizedInput = input.toLowerCase();
|
|
const inputContext = parseCommandLine(input);
|
|
const allowFuzzyHistory = input.length >= 2 && inputContext.wordIndex === 0;
|
|
const suggestions = prev.suggestions.filter((suggestion) => {
|
|
if (suggestion.source !== "history") return true;
|
|
const text = suggestion.text.toLowerCase();
|
|
if (suggestion.historyMatch === "path-argument") {
|
|
const suggestionContext = parseCommandLine(suggestion.text);
|
|
const currentArgument = inputContext.currentWord
|
|
.replace(/^['"]/, "")
|
|
.replace(/['"]$/, "")
|
|
.replace(/\\ /g, " ")
|
|
.toLowerCase();
|
|
const suggestionArgument = suggestionContext.currentWord
|
|
.replace(/^['"]/, "")
|
|
.replace(/['"]$/, "")
|
|
.replace(/\\ /g, " ")
|
|
.toLowerCase();
|
|
return suggestionContext.commandName.toLowerCase() === inputContext.commandName.toLowerCase()
|
|
&& suggestionArgument.startsWith(currentArgument);
|
|
}
|
|
if (text.length <= normalizedInput.length) return false;
|
|
return text.startsWith(normalizedInput)
|
|
|| (allowFuzzyHistory && isSubsequence(normalizedInput, text));
|
|
});
|
|
|
|
if (suggestions.length === 0) return { ...EMPTY_STATE };
|
|
if (
|
|
prev.selectedIndex === -1
|
|
&& prev.subDirFocusLevel === -1
|
|
&& prev.subDirPanels.length === 0
|
|
&& areSuggestionsEqual(prev.suggestions, suggestions)
|
|
) {
|
|
return prev;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
suggestions,
|
|
selectedIndex: -1,
|
|
subDirPanels: [],
|
|
subDirFocusLevel: -1,
|
|
};
|
|
}
|
|
|
|
type HostCompletionProviderOptions = Parameters<typeof getCompletions>[1] & {
|
|
/** Host-owned prompt identity used to gate third-party Provider access. */
|
|
promptText: string;
|
|
/**
|
|
* False when input was synthesized from the pre-echo keystroke buffer.
|
|
* External Providers must stay disabled until the live line validates input.
|
|
*/
|
|
allowExternalProviders?: boolean;
|
|
/** Aborted whenever the prompt/session security state invalidates this query. */
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
interface UseTerminalAutocompleteOptions {
|
|
termRef: RefObject<XTerm | null>;
|
|
containerRef: RefObject<HTMLElement | null>;
|
|
sessionId: string;
|
|
hostId: string;
|
|
hostGroup?: 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;
|
|
/**
|
|
* Host-owned password/auth prompt latch. When true, suppress autocomplete
|
|
* even if the PS1 still looks like a normal shell (e.g. `read -s -p '$ '`).
|
|
*/
|
|
sensitiveInputActiveRef?: RefObject<boolean>;
|
|
/** Host-owned completion Provider adapter; defaults to Netcatty's built-in Provider. */
|
|
provideCompletions?: (
|
|
input: string,
|
|
options: HostCompletionProviderOptions,
|
|
) => Promise<CompletionSuggestion[]>;
|
|
/**
|
|
* Vendor bastion / network-device CLI. Live-preview PTY rewrites (Ctrl-U)
|
|
* disconnect these sessions, so the popup stays and preview writes no-op (#1193).
|
|
*/
|
|
isNetworkDevice?: boolean;
|
|
}
|
|
|
|
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 {
|
|
computeAutocompleteAcceptWrite,
|
|
getCommandToRecordOnEnter,
|
|
resolveAutocompleteQueryInput,
|
|
} from "./terminalAutocompletePrompt";
|
|
|
|
export function useTerminalAutocomplete(
|
|
options: UseTerminalAutocompleteOptions,
|
|
): TerminalAutocompleteHandle {
|
|
const {
|
|
termRef,
|
|
containerRef,
|
|
sessionId,
|
|
hostId,
|
|
hostGroup,
|
|
hostOs,
|
|
settings: userSettings,
|
|
onAcceptText,
|
|
protocol,
|
|
getCwd,
|
|
snippets,
|
|
onAcceptSnippet,
|
|
sensitiveInputActiveRef,
|
|
provideCompletions,
|
|
isNetworkDevice = false,
|
|
} = 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,
|
|
livePreview: shouldWriteAutocompleteLivePreview(rawSettings.livePreview, isNetworkDevice),
|
|
};
|
|
|
|
// 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 hostGroupRef = useRef(hostGroup);
|
|
hostGroupRef.current = hostGroup;
|
|
const hostOsRef = useRef(hostOs);
|
|
hostOsRef.current = hostOs;
|
|
const sessionIdRef = useRef(sessionId);
|
|
sessionIdRef.current = sessionId;
|
|
const protocolRef = useRef(protocol);
|
|
protocolRef.current = protocol;
|
|
const isNetworkDeviceRef = useRef(isNetworkDevice);
|
|
isNetworkDeviceRef.current = isNetworkDevice;
|
|
const getCwdRef = useRef(getCwd);
|
|
getCwdRef.current = getCwd;
|
|
const provideCompletionsRef = useRef(provideCompletions ?? getCompletions);
|
|
provideCompletionsRef.current = provideCompletions ?? getCompletions;
|
|
|
|
const [state, setState] = useState<AutocompleteState>(EMPTY_STATE);
|
|
|
|
const ghostAddonRef = useRef<GhostTextAddon | null>(null);
|
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
/**
|
|
* Pre-echo debounce cycles must poll until the live line validates the
|
|
* keystroke buffer (or we give up). PTY echo updates xterm only and does
|
|
* not schedule another fetchSuggestions — without this, whole-word IME /
|
|
* high-latency SSH commits never show completions until the next key.
|
|
*/
|
|
const echoValidationTypedRef = useRef<string | null>(null);
|
|
const echoValidationStartedAtRef = useRef<number | 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);
|
|
const completionAbortRef = useRef<AbortController | null>(null);
|
|
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
|
const lastAcceptedCommandRef = useRef<string | null>(null);
|
|
/** Deadline for treating the next `.` / `_` as readline Meta after Esc dismissed the popup. */
|
|
const escMetaPrefixUntilRef = useRef(0);
|
|
/** 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
|
|
}, []);
|
|
|
|
// Local Terminal: seed autocomplete history from ~/.zsh_history (etc.) once
|
|
// per stable hostId so prefix suggestions match Ghostty-style histfile recall
|
|
// (#2037). Remote sessions keep reading history via the side panel instead.
|
|
useEffect(() => {
|
|
if (!settings.enabled) return;
|
|
if (protocol !== "local") return;
|
|
if (!hostId) return;
|
|
void seedLocalShellHistoryFromHistfiles(hostId, hostOs);
|
|
}, [settings.enabled, protocol, hostId, hostOs]);
|
|
|
|
// 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;
|
|
}
|
|
echoValidationTypedRef.current = null;
|
|
echoValidationStartedAtRef.current = null;
|
|
ghostAddonRef.current?.hide();
|
|
completionAbortRef.current?.abort();
|
|
completionAbortRef.current = null;
|
|
// Bump version to invalidate any in-flight async completions
|
|
fetchVersionRef.current++;
|
|
subDirFetchVersionRef.current++;
|
|
setState((prev) =>
|
|
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
|
|
);
|
|
}, []);
|
|
|
|
/**
|
|
* Reconcile the visible popup with the newly edited input synchronously.
|
|
* The full provider refresh remains debounced, but rows from the previous
|
|
* query must not remain actionable while that refresh is pending.
|
|
*/
|
|
const syncPopupToInput = useCallback((input: string | null) => {
|
|
completionAbortRef.current?.abort();
|
|
completionAbortRef.current = null;
|
|
fetchVersionRef.current++;
|
|
subDirFetchVersionRef.current++;
|
|
|
|
setState((prev) => reconcileAutocompletePopupState(prev, input));
|
|
}, []);
|
|
|
|
const repositionPopup = useCallback(() => {
|
|
// Skip the React setter entirely while the popup is closed so a line-feed
|
|
// burst cannot stall the renderer (#3204).
|
|
if (!stateRef.current.popupVisible || stateRef.current.suggestions.length === 0) return;
|
|
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 queryInput = resolveAutocompleteQueryInput(
|
|
prompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
// Prefer resolved query input for anchoring so lagging remote echo does
|
|
// not leave the popup behind the typed command (main CursorCell helper).
|
|
const cursorCell = prompt.isAtPrompt && queryInput !== null
|
|
? resolveAutocompleteCursorCell(term, {
|
|
promptText: prompt.promptText,
|
|
userInput: queryInput,
|
|
})
|
|
: {
|
|
column: term.buffer.active.cursorX,
|
|
row: term.buffer.active.cursorY,
|
|
};
|
|
// Pin to the wrapped command start after wrap/scroll so the popup
|
|
// follows the first physical line instead of covering it (#3061).
|
|
const anchor = resolveAutocompletePopupAnchorInViewport(
|
|
term,
|
|
containerRef.current,
|
|
prev.suggestions.length,
|
|
cursorCell.column,
|
|
cursorCell.row,
|
|
);
|
|
const nextAnchor = nextAutocompletePopupAnchorViewport(
|
|
prev.popupAnchorViewport,
|
|
prev.expandUpward,
|
|
anchor,
|
|
);
|
|
if (!nextAnchor) return prev;
|
|
|
|
return {
|
|
...prev,
|
|
popupAnchorViewport: nextAnchor.viewport,
|
|
expandUpward: nextAnchor.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 activeLine = activePrompt
|
|
? resolveAutocompleteQueryInput(
|
|
activePrompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
)
|
|
: null;
|
|
const activeWord = activeLine !== null
|
|
? parseCommandLine(activeLine).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) => {
|
|
if (!shouldWriteAutocompleteLivePreview(
|
|
settingsRef.current.livePreview,
|
|
isNetworkDeviceRef.current,
|
|
)) return;
|
|
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,
|
|
);
|
|
const line = resolveAutocompleteQueryInput(
|
|
prompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
if (line === null) return;
|
|
const parsed = parseCommandLine(line);
|
|
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 = computeAutocompleteAcceptWrite({
|
|
prompt,
|
|
typedBuffer: typedInputBufferRef.current,
|
|
typedBufferReliable: typedBufferReliableRef.current,
|
|
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;
|
|
|
|
// getAlignedPrompt handles robbyrussell-style themes by trimming the
|
|
// cwd marker out of userInput when the typed buffer is aligned (#806).
|
|
// Mutation baseline still prefers the reliable typed buffer when echo lags
|
|
// (#2830), matching suggestion matching / live-preview.
|
|
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
|
const line = resolveAutocompleteQueryInput(
|
|
prompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
if (line === null) 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(line);
|
|
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}`;
|
|
const newCommand = cmdPrefix + replacementPath;
|
|
const payload = computeAutocompleteAcceptWrite({
|
|
prompt,
|
|
typedBuffer: typedInputBufferRef.current,
|
|
typedBufferReliable: typedBufferReliableRef.current,
|
|
candidate: newCommand,
|
|
os: hostOsRef.current,
|
|
});
|
|
if (payload === null) return;
|
|
if (payload) writeToTerminal(payload);
|
|
// 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;
|
|
}
|
|
|
|
// Suppress autocomplete for the entire alternate screen buffer (codex CLI,
|
|
// vim, htop, less, …). Full-screen apps own their own input UI there;
|
|
// Netcatty's popup/ghost text would clash — e.g. codex's "/" slash-command
|
|
// menu gets covered because the composer line also matches the shell-prompt
|
|
// heuristic. This is intentionally unconditional: multiplexers (tmux/screen)
|
|
// also keep the outer xterm on the alternate buffer for the whole session,
|
|
// so Netcatty autocomplete is off while attached — same simple tradeoff as
|
|
// other terminal hosts, rather than chasing TUI-vs-shell heuristics. #2530
|
|
if (isTerminalAlternateScreenActive(term)) {
|
|
clearState();
|
|
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;
|
|
}
|
|
|
|
const { prompt, allowExternalProviders = true } = getAlignedPrompt(
|
|
term,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
lastPromptRef.current = prompt;
|
|
|
|
// Explicit password / auth-challenge prompts (and host-latched sensitive
|
|
// input) stay fail-closed even when echo has already validated the line.
|
|
if (
|
|
shouldBlockAutocompleteForSensitivePrompt({
|
|
sensitiveInputActive: sensitiveInputActiveRef?.current === true,
|
|
promptText: prompt.promptText,
|
|
})
|
|
) {
|
|
clearState();
|
|
return;
|
|
}
|
|
|
|
// Pre-echo keystroke buffer can look identical to an echo-disabled
|
|
// password prompt (`read -s -p '$ '`). Do not render or accept
|
|
// built-in history/snippet suggestions until the shell echoes input.
|
|
// Incoming PTY echo does not re-schedule fetches, so keep polling this
|
|
// debounce cycle until the live line validates — or until max wait
|
|
// (silent prompts never echo). clearState cancels timers and echo-wait
|
|
// refs; re-arm the wait afterward when still within the window.
|
|
// Partial echo still reaches resolveAutocompleteQueryInput below (#2830).
|
|
if (allowExternalProviders === false) {
|
|
const typed = typedInputBufferRef.current;
|
|
const startedAt =
|
|
echoValidationTypedRef.current === typed &&
|
|
echoValidationStartedAtRef.current != null
|
|
? echoValidationStartedAtRef.current
|
|
: Date.now();
|
|
clearState();
|
|
const withinWait =
|
|
typed.length > 0 &&
|
|
!disposedRef.current &&
|
|
Date.now() - startedAt < ECHO_VALIDATION_MAX_WAIT_MS;
|
|
if (withinWait) {
|
|
echoValidationTypedRef.current = typed;
|
|
echoValidationStartedAtRef.current = startedAt;
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
debounceTimerRef.current = null;
|
|
void fetchSuggestionsRef.current();
|
|
}, settingsRef.current.debounceMs);
|
|
}
|
|
return;
|
|
}
|
|
|
|
echoValidationTypedRef.current = null;
|
|
echoValidationStartedAtRef.current = null;
|
|
|
|
// Capture version at start — if it changes during async work, discard results
|
|
const version = ++fetchVersionRef.current;
|
|
|
|
// Prefer the reliable keystroke buffer when remote echo lags (#2830).
|
|
// getAlignedPrompt intentionally stays stricter for Enter recording.
|
|
// `prompt` was already resolved above for the echo-validation gate.
|
|
const input = resolveAutocompleteQueryInput(
|
|
prompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
if (!prompt.isAtPrompt || input === null || input.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 cursorLine = buffer.getLine(buffer.cursorY + buffer.baseY);
|
|
const lineAfterCursor = cursorLine
|
|
? sliceStringByCellColumns(
|
|
cursorLine.translateToString(false),
|
|
Math.max(0, buffer.cursorX),
|
|
undefined,
|
|
term,
|
|
).trimEnd()
|
|
: "";
|
|
if (lineAfterCursor && lineAfterCursor.length > 0) {
|
|
clearState();
|
|
return;
|
|
}
|
|
const parsedInput = parseCommandLine(input);
|
|
const cwdResolution = resolveAutocompleteCwdWithSource(
|
|
prompt.promptText,
|
|
parsedInput.currentWord,
|
|
getCwdRef.current?.(),
|
|
hostOsRef.current,
|
|
);
|
|
|
|
// Single query for both ghost text and popup
|
|
completionAbortRef.current?.abort();
|
|
const completionController = new AbortController();
|
|
completionAbortRef.current = completionController;
|
|
// Retain the first (budgeted) result so a late path listing for cache-bypassed
|
|
// relative SSH cwd can merge path suggestions without another IPC round-trip.
|
|
let settledCompletions: CompletionSuggestion[] | null = null;
|
|
let pendingLatePathSuggestions: CompletionSuggestion[] | null = null;
|
|
|
|
const mergeLatePathSuggestions = (
|
|
base: CompletionSuggestion[],
|
|
latePathSuggestions: CompletionSuggestion[],
|
|
): CompletionSuggestion[] => {
|
|
const withoutPaths = base.filter((entry) => entry.source !== "path");
|
|
const indexByText = new Map<string, number>();
|
|
const merged = [...withoutPaths];
|
|
for (let index = 0; index < withoutPaths.length; index++) {
|
|
indexByText.set(withoutPaths[index].text, index);
|
|
}
|
|
for (const suggestion of latePathSuggestions) {
|
|
const existingIndex = indexByText.get(suggestion.text);
|
|
if (existingIndex !== undefined) {
|
|
// Prefer late path over duplicate history/plugin text so directory
|
|
// candidates keep fileType for cascading path panels (matches
|
|
// in-budget score-sort + dedup where path at 750 beats recent history).
|
|
merged[existingIndex] = suggestion;
|
|
continue;
|
|
}
|
|
indexByText.set(suggestion.text, merged.length);
|
|
merged.push(suggestion);
|
|
}
|
|
merged.sort((left, right) => right.score - left.score);
|
|
// Cap to the configured popup limit. provideTerminalCompletions already
|
|
// slices built-in+plugin results to request.maximum; late path merges
|
|
// bypass that path and must honor the same user setting (including when
|
|
// it is below getCompletions' internal path-active floor of 24).
|
|
const limit = Math.max(1, settingsRef.current.maxSuggestions);
|
|
const limited = merged.slice(0, limit);
|
|
if (!settingsRef.current.allowLineReplacement) {
|
|
return limited.filter((completion) =>
|
|
completion.source !== "snippet" && completion.text.startsWith(input),
|
|
);
|
|
}
|
|
return limited;
|
|
};
|
|
|
|
const applyCompletions = (
|
|
completions: CompletionSuggestion[],
|
|
currentPrompt: ReturnType<typeof getAlignedPrompt>["prompt"],
|
|
options?: { preserveSelection?: boolean },
|
|
) => {
|
|
// Echo-lag-aware logical caret: resolved query `input` can be ahead of
|
|
// the live xterm cursor / currentPrompt.userInput. When a late path
|
|
// refresh lands while live-preview has already rewritten the shell line,
|
|
// anchor from that resolved command line instead — keep `input` only for
|
|
// query-staleness checks above.
|
|
const caretUserInput =
|
|
options?.preserveSelection && previewActiveRef.current
|
|
? (resolveAutocompleteQueryInput(
|
|
currentPrompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
) ?? typedInputBufferRef.current)
|
|
: input;
|
|
const cursorCell = resolveAutocompleteCursorCell(term, {
|
|
promptText: currentPrompt.promptText,
|
|
userInput: caretUserInput,
|
|
});
|
|
|
|
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,
|
|
caretUserInput,
|
|
nextSuggestion,
|
|
);
|
|
if (ghostDecision.type === "show") {
|
|
// Pre-echo probe in GhostTextAddon handles lagging SSH echo.
|
|
ghost?.show(ghostDecision.suggestion, caretUserInput);
|
|
} 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;
|
|
// Ordinary new queries clear preview; same-query late-path refreshes
|
|
// keep it so Escape can still restore the typed baseline after a
|
|
// highlight that remains written into the shell line.
|
|
if (!options?.preserveSelection) {
|
|
previewActiveRef.current = false;
|
|
}
|
|
const anchor = resolveAutocompletePopupAnchorInViewport(
|
|
term,
|
|
containerRef.current,
|
|
completions.length,
|
|
cursorCell.column,
|
|
cursorCell.row,
|
|
);
|
|
startTransition(() => {
|
|
setState((prev) => {
|
|
if (version !== fetchVersionRef.current) return prev;
|
|
|
|
// Only late-path refreshes for *this* query preserve highlight.
|
|
// Ordinary keystroke-driven queries must reset selection; otherwise
|
|
// preview-off Enter can accept a stale row that still matches.
|
|
const selectedIndex = options?.preserveSelection && prev.popupVisible
|
|
? resolvePreservedSuggestionIndex(
|
|
prev.suggestions,
|
|
prev.selectedIndex,
|
|
completions,
|
|
)
|
|
: -1;
|
|
|
|
const nextState: AutocompleteState = {
|
|
suggestions: completions,
|
|
selectedIndex,
|
|
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,
|
|
);
|
|
});
|
|
}
|
|
};
|
|
|
|
const isCurrentQueryStillActive = (): ReturnType<typeof getAlignedPrompt>["prompt"] | null => {
|
|
if (disposedRef.current || version !== fetchVersionRef.current) return null;
|
|
if (isTerminalAlternateScreenActive(term)) {
|
|
clearState();
|
|
return null;
|
|
}
|
|
// Discard stale results: if the user kept typing while getCompletions was running,
|
|
// the current prompt input will have changed. Re-detect and compare. Use the same
|
|
// echo-lag-aware resolver as the query so catching-up remote echo alone does not
|
|
// drop local matches (#2830).
|
|
const { prompt: currentPrompt } = getAlignedPrompt(
|
|
term,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
const currentInput = resolveAutocompleteQueryInput(
|
|
currentPrompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
if (
|
|
!currentPrompt.isAtPrompt
|
|
|| !isSameAutocompleteQuery({
|
|
queryInput: input,
|
|
currentInput,
|
|
previewActive: previewActiveRef.current,
|
|
previewBaseline: previewBaselineRef.current,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
return currentPrompt;
|
|
};
|
|
|
|
let completions: CompletionSuggestion[];
|
|
try {
|
|
completions = await provideCompletionsRef.current(input, {
|
|
hostId: hostIdRef.current,
|
|
hostGroup: hostGroupRef.current,
|
|
os: hostOsRef.current,
|
|
maxResults: settingsRef.current.maxSuggestions,
|
|
historyScope: settingsRef.current.historyScope,
|
|
sessionId: sessionIdRef.current,
|
|
protocol: protocolRef.current,
|
|
cwd: cwdResolution.cwd,
|
|
cwdSource: cwdResolution.source,
|
|
snippets: snippetsRef.current,
|
|
promptText: prompt.promptText,
|
|
allowExternalProviders,
|
|
signal: completionController.signal,
|
|
onLatePathSuggestions: (latePathSuggestions) => {
|
|
if (completionController.signal.aborted) return;
|
|
const currentPrompt = isCurrentQueryStillActive();
|
|
if (!currentPrompt) return;
|
|
// Plugin merge may still be in flight after built-in getCompletions
|
|
// returns; hold late paths until the first settled result arrives.
|
|
if (settledCompletions === null) {
|
|
pendingLatePathSuggestions = latePathSuggestions;
|
|
return;
|
|
}
|
|
const next = mergeLatePathSuggestions(settledCompletions, latePathSuggestions);
|
|
settledCompletions = next;
|
|
applyCompletions(next, currentPrompt, { preserveSelection: true });
|
|
},
|
|
});
|
|
} finally {
|
|
if (completionAbortRef.current === completionController) completionAbortRef.current = null;
|
|
}
|
|
if (completionController.signal.aborted) return;
|
|
if (!settingsRef.current.allowLineReplacement) {
|
|
completions = completions.filter((completion) =>
|
|
completion.source !== "snippet" && completion.text.startsWith(input),
|
|
);
|
|
}
|
|
|
|
// Recheck after the async lookup: the terminal may have entered the alternate
|
|
// screen while completions were pending (e.g. launching codex/vim). Drop any
|
|
// result that would paint popup/ghost over a TUI. Same unconditional policy. #2530
|
|
const currentPrompt = isCurrentQueryStillActive();
|
|
if (!currentPrompt) {
|
|
return;
|
|
}
|
|
|
|
if (pendingLatePathSuggestions) {
|
|
completions = mergeLatePathSuggestions(completions, pendingLatePathSuggestions);
|
|
pendingLatePathSuggestions = null;
|
|
}
|
|
settledCompletions = completions;
|
|
applyCompletions(completions, currentPrompt);
|
|
}, [termRef, clearState, containerRef, sensitiveInputActiveRef]);
|
|
|
|
// 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,
|
|
previewBaselineRef,
|
|
previewActiveRef,
|
|
termRef,
|
|
hostIdRef,
|
|
hostOsRef,
|
|
ghostAddonRef,
|
|
debounceTimerRef,
|
|
clearState,
|
|
syncPopupToInput,
|
|
fetchSuggestions,
|
|
});
|
|
},
|
|
[fetchSuggestions, termRef, clearState, syncPopupToInput],
|
|
);
|
|
|
|
/**
|
|
* 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,
|
|
escMetaPrefixUntilRef,
|
|
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 (!shouldWriteAutocompleteLivePreview(
|
|
settingsRef.current.livePreview,
|
|
isNetworkDeviceRef.current,
|
|
)) 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,
|
|
);
|
|
const seq = computeAutocompleteAcceptWrite({
|
|
prompt,
|
|
typedBuffer: typedInputBufferRef.current,
|
|
typedBufferReliable: typedBufferReliableRef.current,
|
|
candidate,
|
|
os: hostOsRef.current,
|
|
});
|
|
if (seq === null) return;
|
|
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);
|
|
const line = resolveAutocompleteQueryInput(
|
|
prompt,
|
|
typedInputBufferRef.current,
|
|
typedBufferReliableRef.current,
|
|
);
|
|
if (line !== null && line.length > 0) {
|
|
if (!settingsRef.current.allowLineReplacement) return false;
|
|
const clearSequence = isWindowsShellLineInput(hostOsRef.current, prompt.promptText)
|
|
? "\b".repeat(line.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.
|
|
// Accept writes must use the echo-lag-aware line baseline (#2830).
|
|
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
|
|
const payload = computeAutocompleteAcceptWrite({
|
|
prompt,
|
|
typedBuffer: typedInputBufferRef.current,
|
|
typedBufferReliable: typedBufferReliableRef.current,
|
|
candidate: suggestion.text,
|
|
os: hostOsRef.current,
|
|
execute,
|
|
allowLineReplacement: settingsRef.current.allowLineReplacement,
|
|
});
|
|
if (payload === null) return false;
|
|
|
|
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;
|
|
completionAbortRef.current?.abort();
|
|
completionAbortRef.current = null;
|
|
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(() => {
|
|
// Fast Refresh preserves refs across effect teardown/re-run. dispose() sets
|
|
// disposedRef=true on cleanup; without resetting here, every HMR of this
|
|
// module (or TerminalAutocomplete) permanently kills fetchSuggestions while
|
|
// handleInput keeps scheduling — matches "fetch-scheduled but no popup".
|
|
disposedRef.current = false;
|
|
return () => { dispose(); };
|
|
}, [dispose]);
|
|
|
|
return {
|
|
state,
|
|
ghostTextAddon: ghostAddonRef.current,
|
|
handleInput,
|
|
handleKeyEvent,
|
|
selectSuggestion,
|
|
repositionPopup,
|
|
closePopup,
|
|
dispose,
|
|
showSudoHint,
|
|
hideSudoHint,
|
|
};
|
|
}
|