Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 4bcb98b9d6 fix: invalidate Escape-dismissed autocomplete cascade fetches
Escape at level 0 kept the main-row selection, so late directory listings
could reinstall panels after dismissal. Dismiss via a versioned cascade
helper and bind level-0 prefetch installs to suggestion identity.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:40 +00:00
netcatty-bot de17e1baf2 fix: address Codex review on PR #2824 2026-08-08 22:55:37 +00:00
netcatty-bot d8011adfbd fix: address Codex review on PR #2824 2026-08-08 22:22:24 +00:00
netcatty-bot 53df32e686 fix: address Codex review on PR #2824 2026-08-08 21:51:36 +00:00
netcatty-bot 9c120ed2fa fix: address Codex review on PR #2824 2026-08-08 21:16:34 +00:00
Cursor Agentand陈大猫 a287d303aa fix(terminal): prefetch autocomplete sub-dirs after selection commits
Calling fetchSubDirForIndex from the startTransition refresh path raced
when directory listings resolved before selectedIndex flushed, so panels
were dropped. Prefetch from a post-commit effect instead, and skip when
cascade panels are already open.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:28:36 +00:00
Cursor Agentand陈大猫 7185b38a46 fix(terminal): prefetch sub-dir panels for default autocomplete selection
Default-selecting the first suggestion skipped fetchSubDirForIndex, so
directory cascade panels and ArrowRight stayed inert until ArrowUp/Down.
Also keep open cascade panels across unchanged suggestion refreshes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:58 +00:00
netcatty-bot f06c9aace0 fix(#2821): automated Cursor CLI fix 2026-08-08 01:54:59 +00:00
6 changed files with 733 additions and 26 deletions

No files matched your search

@@ -14,6 +14,12 @@ interface TerminalAutocompleteInputContext {
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
/**
* Drop the popup highlight (and cascade panels) without hiding suggestions.
* Used when the user keeps typing so Enter cannot accept a stale default
* selection during the debounce window.
*/
clearPopupSelection: () => void;
termRef: RefObject<XTerm | null>;
hostIdRef: MutableRefObject<string>;
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
@@ -35,6 +41,7 @@ export function handleTerminalAutocompleteInput(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
clearPopupSelection,
termRef,
hostIdRef,
hostOsRef,
@@ -205,6 +212,9 @@ export function handleTerminalAutocompleteInput(
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
// Clear the default/nav highlight immediately so Enter during the
// suggestion debounce cannot accept a stale never-previewed row.
clearPopupSelection();
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
@@ -2,6 +2,7 @@ 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";
import { isPreviewlessSelectionCurrentForLine } from "./terminalAutocompleteLayout";
interface TerminalAutocompleteKeyEventContext {
settingsRef: MutableRefObject<AutocompleteSettings>;
@@ -10,14 +11,27 @@ interface TerminalAutocompleteKeyEventContext {
typedInputBufferRef: MutableRefObject<string>;
typedBufferReliableRef: MutableRefObject<boolean>;
previewActiveRef: MutableRefObject<boolean>;
/**
* True once a live preview has rewritten the line for the current popup
* cycle. Survives edits that clear `previewActiveRef` so Enter does not
* treat an edited preview as a never-previewed default selection.
*/
previewCommittedRef: MutableRefObject<boolean>;
/** Typed input that produced the current popup suggestions. */
previewBaselineRef: MutableRefObject<string>;
lastAcceptedCommandRef: MutableRefObject<string | null>;
setState: Dispatch<SetStateAction<AutocompleteState>>;
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
writeToTerminal: (text: string) => void;
clearState: () => void;
/**
* Clear cascade panels (and optionally retreat focus) while invalidating
* in-flight directory fetches so a late response cannot reinstall them.
* Omit `toFocusLevel` to dismiss the whole cascade back to the main list.
*/
dismissSubDirCascade: (toFocusLevel?: number) => 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;
@@ -45,14 +59,16 @@ export function handleTerminalAutocompleteKeyEvent(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
previewCommittedRef,
previewBaselineRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
dismissSubDirCascade,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
@@ -185,6 +201,7 @@ export function handleTerminalAutocompleteKeyEvent(
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
@@ -250,14 +267,13 @@ export function handleTerminalAutocompleteKeyEvent(
}
if (e.key === "Escape") {
e.preventDefault();
// Invalidate outstanding listings: Escape keeps the main-row
// selection, so an index-only fetch guard would otherwise reinstall
// the panels the user just dismissed.
if (focusLevel > 0) {
setState((prev) => ({
...prev,
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
subDirFocusLevel: focusLevel - 1,
}));
dismissSubDirCascade(focusLevel - 1);
} else {
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
dismissSubDirCascade();
}
return false;
}
@@ -290,40 +306,79 @@ export function handleTerminalAutocompleteKeyEvent(
subDirPanels: [], subDirFocusLevel: -1,
}));
if (settingsRef.current.livePreview) renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
// Sub-dir cascade prefetch runs from useTerminalAutocomplete after
// selectedIndex commits (also covers default-select without ArrowDown).
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.
// Enter on popup. When live-preview has already rewritten the line —
// or the user edited that preview (previewActive cleared, committed
// still set) — let Enter reach the shell. When a row is selected but
// never previewed (default first-row highlight, #2821), insert+execute
// like the previewless path. 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).
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
// Never-previewed default/nav selection: typing during the debounce
// window leaves selectedIndex on a stale row while both preview refs
// stay false. Refuse accept unless the row still matches the fetch
// baseline or prefix-completes the edited line.
if (
selected &&
!previewActiveRef.current &&
!previewCommittedRef.current &&
typedBufferReliableRef.current &&
!isPreviewlessSelectionCurrentForLine({
currentLine: typedInputBufferRef.current,
suggestionBaseline: previewBaselineRef.current,
suggestionText: selected.text,
suggestionSource: selected.source,
})
) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
if (selected?.source === "snippet" && selected.snippet) {
if (!acceptSnippet(selected.snippet)) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
e.preventDefault();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false; // consume — run the snippet, not the typed text
}
if (!settingsRef.current.livePreview && selected) {
if (
selected &&
settingsRef.current.livePreview &&
(previewActiveRef.current || previewCommittedRef.current)
) {
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
if (selected) {
if (acceptPreviewlessSelection(s.selectedIndex)) {
e.preventDefault();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false;
}
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return true;
}
}
@@ -339,6 +394,7 @@ export function handleTerminalAutocompleteKeyEvent(
ghost?.hide();
clearState();
previewActiveRef.current = false;
previewCommittedRef.current = false;
return false;
}
@@ -112,6 +112,89 @@ export function areSuggestionsEqual(
return true;
}
/**
* Whether a suggestion refresh should preserve the current popup highlight.
*
* Equal suggestion rows alone are not enough: typing clears the highlight to
* -1 for the debounce window, and a narrowed prefix often yields the same
* rows. Preserve only when the input baseline is unchanged (spurious refetch /
* ↑↓ baseline slot); otherwise restore the first-row default.
*/
export function shouldPreserveAutocompletePopupSelection(options: {
suggestionsUnchanged: boolean;
previousInputBaseline: string;
nextInputBaseline: string;
}): boolean {
return (
options.suggestionsUnchanged &&
options.previousInputBaseline === options.nextInputBaseline
);
}
/**
* Initial / refreshed popup selection (#2821).
* New suggestion lists default to the first row so Enter can confirm without
* an ArrowDown. Unchanged lists keep the user's current index (including the
* -1 "typed baseline" slot from ↑/↓ wrapping) when
* `shouldPreserveAutocompletePopupSelection` is true.
*/
export function resolveAutocompletePopupSelectedIndex(options: {
suggestionCount: number;
previousSelectedIndex: number;
keepPreviousSelection: boolean;
}): number {
if (options.suggestionCount <= 0) return -1;
if (
options.keepPreviousSelection &&
options.previousSelectedIndex >= -1 &&
options.previousSelectedIndex < options.suggestionCount
) {
return options.previousSelectedIndex;
}
return 0;
}
/**
* Whether a never-previewed popup selection is still safe to accept on Enter.
*
* Default first-row highlight (#2821) stays selected during the suggestion
* debounce window. If the user typed further, accepting would replace the
* edited line with a stale row when allowLineReplacement is on. Keep accept
* when the line still matches the fetch baseline (incl. fuzzy), or when the
* candidate still prefix-completes the edited line.
*/
export function isPreviewlessSelectionCurrentForLine(options: {
currentLine: string;
suggestionBaseline: string;
suggestionText: string;
suggestionSource: string;
}): boolean {
if (options.currentLine === options.suggestionBaseline) return true;
if (options.suggestionSource === "snippet") return false;
return options.suggestionText.startsWith(options.currentLine);
}
/**
* Whether a level-0 directory prefetch may install its panel.
*
* Index-only checks are not enough after typing replaces the first row:
* an older in-flight listing can share index 0 with a newer suggestion.
* Require matching fetch generation and suggestion identity too.
*/
export function shouldApplySubDirPrefetchResult(options: {
requestVersion: number;
currentVersion: number;
requestIndex: number;
selectedIndex: number;
requestSuggestionText: string;
selectedSuggestionText: string | undefined;
}): boolean {
if (options.requestVersion !== options.currentVersion) return false;
if (options.selectedIndex !== options.requestIndex) return false;
if (options.selectedSuggestionText === undefined) return false;
return options.selectedSuggestionText === options.requestSuggestionText;
}
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i++) {
@@ -34,6 +34,9 @@ import {
resolveAutocompleteAnchorInViewport,
resolveAutocompleteCursorColumn,
resolveAutocompleteCwdWithSource,
resolveAutocompletePopupSelectedIndex,
shouldApplySubDirPrefetchResult,
shouldPreserveAutocompletePopupSelection,
} from "./terminalAutocompleteLayout";
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
@@ -229,6 +232,12 @@ export function useTerminalAutocomplete(
const previewBaselineRef = useRef<string>("");
/** Whether a popup candidate is currently rendered into the command line (#1005). */
const previewActiveRef = useRef(false);
/**
* Whether a live preview has rewritten the line for the current popup cycle.
* Stays true after the user edits the previewed text (which clears
* previewActiveRef) so Enter does not accept the stale selected row.
*/
const previewCommittedRef = useRef(false);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
@@ -352,11 +361,64 @@ export function useTerminalAutocomplete(
// Bump version to invalidate any in-flight async completions
fetchVersionRef.current++;
subDirFetchVersionRef.current++;
previewActiveRef.current = false;
previewCommittedRef.current = false;
setState((prev) =>
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
);
}, []);
/**
* Clear the popup highlight (and cascade) while keeping suggestions visible.
* Syncs stateRef immediately so a same-tick Enter cannot accept a stale row.
*/
const clearPopupSelection = useCallback(() => {
const prev = stateRef.current;
if (
!prev.popupVisible ||
(prev.selectedIndex < 0 && prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0)
) {
return;
}
// Drop in-flight level-0 fetches so a stale directory cannot install after
// the next refresh defaults back to index 0 for a different first row.
subDirFetchVersionRef.current++;
const next = {
...prev,
selectedIndex: -1,
subDirPanels: [] as typeof prev.subDirPanels,
subDirFocusLevel: -1,
};
stateRef.current = next;
setState(next);
}, []);
/**
* Dismiss cascade panels (Escape) while keeping the main popup selection.
* Always bumps the fetch generation so a late listing cannot reopen panels.
*/
const dismissSubDirCascade = useCallback((toFocusLevel?: number) => {
subDirFetchVersionRef.current++;
setState((prev) => {
if (toFocusLevel !== undefined && toFocusLevel >= 0) {
const nextPanels = prev.subDirPanels.slice(0, toFocusLevel + 1);
if (
prev.subDirFocusLevel === toFocusLevel &&
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
) {
return prev;
}
return {
...prev,
subDirPanels: nextPanels,
subDirFocusLevel: toFocusLevel,
};
}
if (prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0) return prev;
return { ...prev, subDirPanels: [], subDirFocusLevel: -1 };
});
}, []);
const repositionPopup = useCallback(() => {
const term = termRef.current;
if (!term) return;
@@ -439,11 +501,23 @@ export function useTerminalAutocomplete(
if (!dirPath) return;
const requestVersion = ++subDirFetchVersionRef.current;
const requestSuggestionText = item.text;
fetchDirEntries(dirPath).then((entries) => {
if (requestVersion !== subDirFetchVersionRef.current) return;
startTransition(() => {
setState((prev) => {
if (prev.selectedIndex !== index) return prev;
if (
!shouldApplySubDirPrefetchResult({
requestVersion,
currentVersion: subDirFetchVersionRef.current,
requestIndex: index,
selectedIndex: prev.selectedIndex,
requestSuggestionText,
selectedSuggestionText: prev.suggestions[index]?.text,
})
) {
return prev;
}
const nextPanels = entries.length > 0 ? [{ entries, selectedIndex: -1, dirPath }] : [];
if (
prev.subDirFocusLevel === -1 &&
@@ -580,6 +654,7 @@ export function useTerminalAutocomplete(
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
previewActiveRef.current = true;
previewCommittedRef.current = true;
lastAcceptedCommandRef.current = newCommand;
}, [termRef, writeToTerminal]);
@@ -764,8 +839,12 @@ export function useTerminalAutocomplete(
// Popup
if (settingsRef.current.showPopupMenu && completions.length > 0) {
// Live-preview baseline: the typed input these suggestions completed.
// Capture the prior baseline first — equal suggestion rows after typing
// must not preserve the debounce clear's programmatic -1.
const previousInputBaseline = previewBaselineRef.current;
previewBaselineRef.current = input;
previewActiveRef.current = false;
previewCommittedRef.current = false;
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
const anchor = resolveAutocompleteAnchorInViewport(
term,
@@ -777,9 +856,23 @@ export function useTerminalAutocomplete(
setState((prev) => {
if (version !== fetchVersionRef.current) return prev;
const suggestionsUnchanged =
prev.popupVisible && areSuggestionsEqual(prev.suggestions, completions);
const keepPreviousSelection = shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged,
previousInputBaseline,
nextInputBaseline: input,
});
const selectedIndex = resolveAutocompletePopupSelectedIndex({
suggestionCount: completions.length,
previousSelectedIndex: prev.selectedIndex,
keepPreviousSelection,
});
const keepSubDirPanels =
suggestionsUnchanged && selectedIndex === prev.selectedIndex;
const nextState: AutocompleteState = {
suggestions: completions,
selectedIndex: -1,
selectedIndex,
popupVisible: true,
popupAnchorViewport: {
left: anchor.anchorLeft,
@@ -787,8 +880,11 @@ export function useTerminalAutocomplete(
bottom: anchor.anchorBottom,
},
expandUpward: anchor.expandUpward,
subDirPanels: [],
subDirFocusLevel: -1,
// Fresh lists (or a selection change) drop cascade panels.
// Unchanged selection keeps them so a spurious refetch does not
// collapse an open directory tree.
subDirPanels: keepSubDirPanels ? prev.subDirPanels : [],
subDirFocusLevel: keepSubDirPanels ? prev.subDirFocusLevel : -1,
};
if (
@@ -798,9 +894,9 @@ export function useTerminalAutocomplete(
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)
prev.subDirFocusLevel === nextState.subDirFocusLevel &&
areSubDirPanelsEqual(prev.subDirPanels, nextState.subDirPanels) &&
suggestionsUnchanged
) {
return prev;
}
@@ -835,6 +931,7 @@ export function useTerminalAutocomplete(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
clearPopupSelection,
termRef,
hostIdRef,
hostOsRef,
@@ -844,7 +941,7 @@ export function useTerminalAutocomplete(
fetchSuggestions,
});
},
[fetchSuggestions, termRef, clearState],
[fetchSuggestions, termRef, clearState, clearPopupSelection],
);
/**
@@ -859,14 +956,16 @@ export function useTerminalAutocomplete(
typedInputBufferRef,
typedBufferReliableRef,
previewActiveRef,
previewCommittedRef,
previewBaselineRef,
lastAcceptedCommandRef,
setState,
expandSubDir,
writeToTerminal,
clearState,
dismissSubDirCascade,
renderSubDirPath,
handleSubDirSelect,
fetchSubDirForIndex,
renderPreviewSelection,
acceptPreviewlessSelection,
acceptSnippet,
@@ -875,6 +974,28 @@ export function useTerminalAutocomplete(
[writeToTerminal],
);
// Prefetch directory cascade panels after selectedIndex commits. Covers both
// default-select (#2821) and ↑/↓ navigation. Must run post-commit: calling
// from the startTransition refresh path races when fetchDirEntries resolves
// before selectedIndex has flushed, and the result handler drops the panels.
// Depend only on selection/list identity — not cascade emptiness — so Escape
// dismissing level-0 panels does not immediately re-prefetch and reopen them.
useEffect(() => {
if (!state.popupVisible || state.selectedIndex < 0) return;
if (state.selectedIndex >= state.suggestions.length) return;
// Skip when cascade state already exists so an unchanged suggestion
// refresh (new array identity, same rows) does not wipe nested panels.
if (state.subDirPanels.length > 0 || state.subDirFocusLevel >= 0) return;
fetchSubDirForIndex(state.selectedIndex);
// Cascade emptiness is a skip guard only — omit from deps so Escape dismissal sticks.
// eslint-disable-next-line react-hooks/exhaustive-deps -- selection/list only
}, [
state.popupVisible,
state.selectedIndex,
state.suggestions,
fetchSubDirForIndex,
]);
/**
* Render the suggestion at `index` straight into the command line (Termius
* live-preview, #1005). `index < 0` restores the user's typed baseline.
@@ -906,6 +1027,7 @@ export function useTerminalAutocomplete(
typedBufferReliableRef.current = true;
const isPreview = index >= 0 && candidate !== baseline;
previewActiveRef.current = isPreview;
if (isPreview) previewCommittedRef.current = true;
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
@@ -46,6 +46,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
const previews: number[] = [];
const accepted: number[] = [];
const clears: number[] = [];
const dismissals: Array<number | undefined> = [];
const stateRef = { current: state };
return {
@@ -53,6 +54,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
previews,
accepted,
clears,
dismissals,
getState: () => state,
context: {
settingsRef: {
current: {
@@ -72,6 +75,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
typedInputBufferRef: { current: "sh" },
typedBufferReliableRef: { current: true },
previewActiveRef: { current: false },
previewCommittedRef: { current: false },
previewBaselineRef: { current: "sh" },
lastAcceptedCommandRef: { current: null },
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
state = typeof update === "function" ? update(state) : update;
@@ -80,9 +85,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
expandSubDir() {},
writeToTerminal(text: string) { writes.push(text); },
clearState() { clears.push(1); },
dismissSubDirCascade(toFocusLevel?: number) { dismissals.push(toFocusLevel); },
renderSubDirPath() {},
handleSubDirSelect() {},
fetchSubDirForIndex() {},
renderPreviewSelection(index: number) { previews.push(index); },
acceptSnippet() { return true; },
acceptPreviewlessSelection(index: number) { accepted.push(index); return true; },
@@ -202,3 +207,225 @@ test("serial-style popup Shift+Enter confirms candidate when terminal shortcut i
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter accepts the default-selected candidate when it is not previewed yet", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter passes through a stale default selection after further typing", () => {
// Default first-row highlight is never previewed. Typing during the
// debounce window leaves selectedIndex on the old row while both preview
// refs stay false — Enter must not wipe the edited line via line replacement.
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("git status")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.settingsRef.current.allowLineReplacement = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = false;
context.previewBaselineRef.current = "gst";
context.typedInputBufferRef.current = "gstx";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("live-preview Enter still accepts default selection that completes edited prefix", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = false;
context.previewBaselineRef.current = "sh";
context.typedInputBufferRef.current = "sho";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(accepted, [0]);
assert.deepEqual(clears, []);
});
test("live-preview Enter still passes through when the candidate is already on the line", () => {
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = true;
context.previewCommittedRef.current = true;
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("live-preview Enter passes through an edited preview instead of accepting the old row", () => {
// Arrow navigation live-previewed a row, then the user typed (clears
// previewActive) before debounce refresh. Enter must keep the edited
// line — not wipe it via acceptPreviewlessSelection.
const { context, accepted, clears } = createContext({
stateRef: {
current: {
suggestions: [suggestion("show version")],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [],
subDirFocusLevel: -1,
},
},
});
context.settingsRef.current.livePreview = true;
context.previewActiveRef.current = false;
context.previewCommittedRef.current = true;
context.typedInputBufferRef.current = "show version --short";
const event = keyEvent("Enter");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, true);
assert.equal(event.defaultPrevented, false);
assert.deepEqual(accepted, []);
assert.deepEqual(clears, [1]);
});
test("Escape at level 0 dismisses cascade via dismissSubDirCascade so in-flight fetches cannot reinstall", () => {
// Escape clears panels while keeping the main-row selection. Without
// invalidating outstanding directory fetches, a late response would
// reinstall the panel and undo the dismissal.
const { context, dismissals } = createContext({
stateRef: {
current: {
suggestions: [{
text: "cd docs/",
displayText: "docs/",
source: "path" as const,
score: 1,
fileType: "directory" as const,
}],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [{
entries: [{ name: "src", type: "directory" as const }],
selectedIndex: -1,
dirPath: "/home/docs",
}],
subDirFocusLevel: 0,
},
},
});
const event = keyEvent("Escape");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(dismissals, [undefined]);
});
test("Escape at nested level dismisses via dismissSubDirCascade with prior focus level", () => {
const { context, dismissals } = createContext({
stateRef: {
current: {
suggestions: [{
text: "cd docs/",
displayText: "docs/",
source: "path" as const,
score: 1,
fileType: "directory" as const,
}],
selectedIndex: 0,
popupVisible: true,
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
expandUpward: false,
subDirPanels: [
{
entries: [{ name: "src", type: "directory" as const }],
selectedIndex: 0,
dirPath: "/home/docs",
},
{
entries: [{ name: "main.ts", type: "file" as const }],
selectedIndex: 0,
dirPath: "/home/docs/src",
},
],
subDirFocusLevel: 1,
},
},
});
const event = keyEvent("Escape");
const result = handleTerminalAutocompleteKeyEvent(event, context);
assert.equal(result, false);
assert.equal(event.defaultPrevented, true);
assert.deepEqual(dismissals, [0]);
});
@@ -1,12 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
clampAutocompletePopupGeometry,
computeAutocompletePopupPlacement,
isPreviewlessSelectionCurrentForLine,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
resolveAutocompleteCursorColumn,
resolveAutocompletePopupSelectedIndex,
shouldApplySubDirPrefetchResult,
shouldPreserveAutocompletePopupSelection,
type PopupPlacementInput,
} from "./autocomplete/terminalAutocompleteLayout.ts";
@@ -501,3 +506,207 @@ test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
});
test("resolveAutocompletePopupSelectedIndex defaults to the first suggestion", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: -1,
keepPreviousSelection: false,
}),
0,
);
});
test("shouldPreserveAutocompletePopupSelection requires an unchanged input baseline", () => {
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: true,
previousInputBaseline: "cd do",
nextInputBaseline: "cd do",
}),
true,
);
// Typing clears selection to -1; equal rows after a narrower prefix must
// restore the first-row default instead of preserving that programmatic -1.
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: true,
previousInputBaseline: "cd do",
nextInputBaseline: "cd doc",
}),
false,
);
assert.equal(
shouldPreserveAutocompletePopupSelection({
suggestionsUnchanged: false,
previousInputBaseline: "cd do",
nextInputBaseline: "cd do",
}),
false,
);
});
test("resolveAutocompletePopupSelectedIndex keeps navigation when suggestions are unchanged", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: 2,
keepPreviousSelection: true,
}),
2,
);
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 3,
previousSelectedIndex: -1,
keepPreviousSelection: true,
}),
-1,
);
});
test("resolveAutocompletePopupSelectedIndex clamps a stale selection when the list shrinks", () => {
assert.equal(
resolveAutocompletePopupSelectedIndex({
suggestionCount: 1,
previousSelectedIndex: 4,
keepPreviousSelection: true,
}),
0,
);
});
test("isPreviewlessSelectionCurrentForLine keeps fetch-baseline fuzzy rows", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "gst",
suggestionBaseline: "gst",
suggestionText: "git status",
suggestionSource: "history",
}),
true,
);
});
test("isPreviewlessSelectionCurrentForLine rejects edits that no longer complete", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "gstx",
suggestionBaseline: "gst",
suggestionText: "git status",
suggestionSource: "history",
}),
false,
);
});
test("isPreviewlessSelectionCurrentForLine keeps prefix progress during debounce", () => {
assert.equal(
isPreviewlessSelectionCurrentForLine({
currentLine: "sho",
suggestionBaseline: "sh",
suggestionText: "show version",
suggestionSource: "history",
}),
true,
);
});
test("selected popup row prefetches sub-dir panels after selectedIndex commits", () => {
// Default-select (#2821) and ↑/↓ must prefetch via a post-commit effect.
// Prefetching inside the startTransition refresh path races: fetchDirEntries
// can resolve before selectedIndex flushes, and the result handler drops the
// panels when prev.selectedIndex still mismatches.
const source = readFileSync(
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*if \(state\.subDirPanels\.length > 0 \|\| state\.subDirFocusLevel >= 0\) return;[\s\S]*fetchSubDirForIndex\(state\.selectedIndex\);/,
);
// Escape clears subDirPanels without changing selection; if cascade emptiness
// is a dependency, the effect re-prefetches and the dismissal never sticks.
const effectMatch = source.match(
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*?\},\s*\[([\s\S]*?)\]\);/,
);
assert.ok(effectMatch, "expected sub-dir prefetch useEffect deps");
const deps = effectMatch[1] ?? "";
assert.match(deps, /state\.popupVisible/);
assert.match(deps, /state\.selectedIndex/);
assert.match(deps, /state\.suggestions/);
assert.match(deps, /fetchSubDirForIndex/);
assert.doesNotMatch(deps, /state\.subDirPanels/);
assert.doesNotMatch(deps, /state\.subDirFocusLevel/);
});
test("typing clear invalidates in-flight sub-dir fetches and restores default on baseline change", () => {
const source = readFileSync(
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/const clearPopupSelection = useCallback\(\(\) => \{[\s\S]*?subDirFetchVersionRef\.current\+\+;[\s\S]*?selectedIndex: -1,/,
);
assert.match(
source,
/shouldPreserveAutocompletePopupSelection\(\{\s*suggestionsUnchanged,\s*previousInputBaseline,\s*nextInputBaseline: input,/,
);
assert.match(
source,
/const dismissSubDirCascade = useCallback\(\([\s\S]*?subDirFetchVersionRef\.current\+\+;/,
);
assert.match(
source,
/shouldApplySubDirPrefetchResult\(\{/,
);
});
test("shouldApplySubDirPrefetchResult rejects stale version, index, or suggestion identity", () => {
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
true,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 4,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
false,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 1,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd docs/",
}),
false,
);
assert.equal(
shouldApplySubDirPrefetchResult({
requestVersion: 3,
currentVersion: 3,
requestIndex: 0,
selectedIndex: 0,
requestSuggestionText: "cd docs/",
selectedSuggestionText: "cd src/",
}),
false,
);
});