Files
NetMesh/components/terminal/autocomplete/terminalAutocompleteKeyEvent.ts

388 lines
15 KiB
TypeScript
Raw Permalink Normal View History

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<AutocompleteSettings>;
stateRef: MutableRefObject<AutocompleteState>;
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
setState: Dispatch<SetStateAction<AutocompleteState>>;
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<number>;
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;
}