import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { GhostTextAddon } from "./GhostTextAddon"; import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete"; import type { Snippet } from "../../../domain/models"; interface TerminalAutocompleteKeyEventContext { settingsRef: MutableRefObject; stateRef: MutableRefObject; ghostAddonRef: MutableRefObject; typedInputBufferRef: MutableRefObject; typedBufferReliableRef: MutableRefObject; previewActiveRef: MutableRefObject; lastAcceptedCommandRef: MutableRefObject; setState: Dispatch>; expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void; writeToTerminal: (text: string) => void; clearState: () => void; renderSubDirPath: (level: number, entry: SubDirEntry) => void; handleSubDirSelect: (level: number, entry: SubDirEntry) => void; fetchSubDirForIndex: (index: number) => void; renderPreviewSelection: (index: number) => void; acceptPreviewlessSelection: (index: number) => boolean; acceptSnippet: (snippet: Snippet) => boolean; /** Deadline (ms) until which `.` / `_` are treated as readline Meta follow-ups. */ escMetaPrefixUntilRef: MutableRefObject; now?: () => number; } /** Readline keyseq-timeout default; Esc then . within this window is M-. */ export const AUTOCOMPLETE_ESC_META_TIMEOUT_MS = 500; export function autocompleteEscMetaFollowUpSequence(e: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; }): string | null { if (e.altKey || e.ctrlKey || e.metaKey) return null; // `_` is Shift+Minus on a standard keyboard; Shift+. is `>` and must not yank. if (e.key === "." && !e.shiftKey) return "\x1b."; if (e.key === "_") return "\x1b_"; return null; } const isAutocompleteConfirmEnter = ( e: KeyboardEvent, settings: AutocompleteSettings, ): boolean => ( e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey && (!e.shiftKey || settings.shiftEnterNewlineEnabled === false) ); export function handleTerminalAutocompleteKeyEvent( e: KeyboardEvent, context: TerminalAutocompleteKeyEventContext, ): boolean { const { settingsRef, stateRef, ghostAddonRef, typedInputBufferRef, typedBufferReliableRef, previewActiveRef, lastAcceptedCommandRef, setState, expandSubDir, writeToTerminal, clearState, renderSubDirPath, handleSubDirSelect, fetchSubDirForIndex, renderPreviewSelection, acceptPreviewlessSelection, acceptSnippet, escMetaPrefixUntilRef, now = Date.now, } = context; if (!settingsRef.current.enabled || e.type !== "keydown") return true; const metaFollowUp = autocompleteEscMetaFollowUpSequence(e); if (metaFollowUp && now() < escMetaPrefixUntilRef.current) { escMetaPrefixUntilRef.current = 0; e.preventDefault(); writeToTerminal(metaFollowUp); // Match handleTerminalAutocompleteInput's ESC-sequence path: yank-last-arg // rewrites the shell line, so the append-only typed buffer is stale. typedInputBufferRef.current = ""; typedBufferReliableRef.current = false; lastAcceptedCommandRef.current = null; return false; } if (e.key !== "Escape" && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") { escMetaPrefixUntilRef.current = 0; } 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 && settingsRef.current.livePreview) 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 (isAutocompleteConfirmEnter(e, settingsRef.current) || 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, })); if (settingsRef.current.livePreview) 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 (isAutocompleteConfirmEnter(e, settingsRef.current)) { const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null; if (selected?.source === "snippet" && selected.snippet) { if (!acceptSnippet(selected.snippet)) { clearState(); previewActiveRef.current = false; return true; } e.preventDefault(); previewActiveRef.current = false; return false; // consume — run the snippet, not the typed text } if (!settingsRef.current.livePreview && selected) { if (acceptPreviewlessSelection(s.selectedIndex)) { e.preventDefault(); previewActiveRef.current = false; return false; } clearState(); previewActiveRef.current = false; return true; } 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). // After dismissing the popup, arm a short Meta prefix so Esc+. / Esc+_ still // reach readline yank-last-arg (issue #2364) without entering vi-cmd mode. if (e.key === "Escape" && s.popupVisible) { e.preventDefault(); if (previewActiveRef.current) { renderPreviewSelection(-1); // restore the typed baseline } ghost?.hide(); clearState(); previewActiveRef.current = false; escMetaPrefixUntilRef.current = now() + AUTOCOMPLETE_ESC_META_TIMEOUT_MS; return false; } return true; }