[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
387
components/terminal/autocomplete/terminalAutocompleteKeyEvent.ts
Normal file
387
components/terminal/autocomplete/terminalAutocompleteKeyEvent.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user