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
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
|
|
|
|
const suggestion = (text: string) => ({
|
|
text,
|
|
displayText: text,
|
|
source: "history" as const,
|
|
score: 1,
|
|
});
|
|
|
|
function keyEvent(key: string) {
|
|
return {
|
|
type: "keydown",
|
|
key,
|
|
ctrlKey: false,
|
|
metaKey: false,
|
|
altKey: false,
|
|
shiftKey: false,
|
|
defaultPrevented: false,
|
|
preventDefault() {
|
|
this.defaultPrevented = true;
|
|
},
|
|
} as KeyboardEvent & { defaultPrevented: boolean };
|
|
}
|
|
|
|
function shiftKeyEvent(key: string) {
|
|
return {
|
|
...keyEvent(key),
|
|
shiftKey: true,
|
|
} as KeyboardEvent & { defaultPrevented: boolean };
|
|
}
|
|
|
|
function createContext(overrides: Record<string, unknown> = {}) {
|
|
let state = {
|
|
suggestions: [suggestion("show version")],
|
|
selectedIndex: -1,
|
|
popupVisible: true,
|
|
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
|
expandUpward: false,
|
|
subDirPanels: [],
|
|
subDirFocusLevel: -1,
|
|
};
|
|
const writes: string[] = [];
|
|
const previews: number[] = [];
|
|
const accepted: number[] = [];
|
|
const clears: number[] = [];
|
|
const stateRef = { current: state };
|
|
|
|
return {
|
|
writes,
|
|
previews,
|
|
accepted,
|
|
clears,
|
|
context: {
|
|
settingsRef: {
|
|
current: {
|
|
enabled: true,
|
|
showGhostText: false,
|
|
showPopupMenu: true,
|
|
debounceMs: 100,
|
|
minChars: 1,
|
|
maxSuggestions: 8,
|
|
livePreview: false,
|
|
allowLineReplacement: false,
|
|
shiftEnterNewlineEnabled: true,
|
|
},
|
|
},
|
|
stateRef,
|
|
ghostAddonRef: { current: null },
|
|
typedInputBufferRef: { current: "sh" },
|
|
typedBufferReliableRef: { current: true },
|
|
previewActiveRef: { current: false },
|
|
lastAcceptedCommandRef: { current: null },
|
|
escMetaPrefixUntilRef: { current: 0 },
|
|
now: () => 1_000,
|
|
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
|
|
state = typeof update === "function" ? update(state) : update;
|
|
stateRef.current = state;
|
|
},
|
|
expandSubDir() {},
|
|
writeToTerminal(text: string) { writes.push(text); },
|
|
clearState() { clears.push(1); },
|
|
renderSubDirPath() {},
|
|
handleSubDirSelect() {},
|
|
fetchSubDirForIndex() {},
|
|
renderPreviewSelection(index: number) { previews.push(index); },
|
|
acceptSnippet() { return true; },
|
|
acceptPreviewlessSelection(index: number) { accepted.push(index); return true; },
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("serial-style popup navigation does not render candidates into the input line", () => {
|
|
const { context, previews } = createContext();
|
|
const event = keyEvent("ArrowDown");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.deepEqual(previews, []);
|
|
});
|
|
|
|
test("serial-style popup Enter confirms the selected candidate instead of passing Enter through", () => {
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
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("serial-style popup Enter passes through when the selected candidate is stale", () => {
|
|
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,
|
|
},
|
|
},
|
|
acceptPreviewlessSelection(index: number) {
|
|
accepted.push(index);
|
|
return false;
|
|
},
|
|
});
|
|
const event = keyEvent("Enter");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, true);
|
|
assert.equal(event.defaultPrevented, false);
|
|
assert.deepEqual(accepted, [0]);
|
|
assert.deepEqual(clears, [1]);
|
|
});
|
|
|
|
test("serial-style popup Shift+Enter is not treated as candidate confirmation", () => {
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
const event = shiftKeyEvent("Enter");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, true);
|
|
assert.equal(event.defaultPrevented, false);
|
|
assert.deepEqual(accepted, []);
|
|
assert.deepEqual(clears, []);
|
|
});
|
|
|
|
test("serial-style popup Shift+Enter confirms candidate when terminal shortcut is disabled", () => {
|
|
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.shiftEnterNewlineEnabled = false;
|
|
const event = shiftKeyEvent("Enter");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.deepEqual(accepted, [0]);
|
|
assert.deepEqual(clears, []);
|
|
});
|
|
|
|
test("Esc dismisses the popup and arms Meta prefix so Esc+. can yank-last-arg", () => {
|
|
const { context, clears } = createContext();
|
|
const event = keyEvent("Escape");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.deepEqual(clears, [1]);
|
|
assert.ok(context.escMetaPrefixUntilRef.current > 1_000);
|
|
});
|
|
|
|
test("Esc+. after dismissing the popup sends ESC+. to the shell", () => {
|
|
const { context, writes } = createContext();
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
|
|
const period = keyEvent(".");
|
|
const result = handleTerminalAutocompleteKeyEvent(period, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.equal(period.defaultPrevented, true);
|
|
assert.deepEqual(writes, ["\x1b."]);
|
|
assert.equal(context.typedInputBufferRef.current, "");
|
|
assert.equal(context.typedBufferReliableRef.current, false);
|
|
assert.equal(context.lastAcceptedCommandRef.current, null);
|
|
});
|
|
|
|
test("Esc then Shift+. does not yank (Shift+. is >)", () => {
|
|
const { context, writes } = createContext();
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
|
|
const greaterThan = shiftKeyEvent(">");
|
|
const result = handleTerminalAutocompleteKeyEvent(greaterThan, context);
|
|
|
|
assert.equal(result, true);
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("Esc+_ after dismissing the popup sends ESC+_ to the shell", () => {
|
|
const { context, writes } = createContext();
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
|
|
const underscore = shiftKeyEvent("_");
|
|
const result = handleTerminalAutocompleteKeyEvent(underscore, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.deepEqual(writes, ["\x1b_"]);
|
|
});
|
|
|
|
test("Esc then Shift keydown then _ still yanks (Shift is a separate event)", () => {
|
|
const { context, writes } = createContext();
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
|
|
const shiftDown = shiftKeyEvent("Shift");
|
|
assert.equal(handleTerminalAutocompleteKeyEvent(shiftDown, context), true);
|
|
assert.ok(context.escMetaPrefixUntilRef.current > 1_000);
|
|
|
|
const underscore = shiftKeyEvent("_");
|
|
const result = handleTerminalAutocompleteKeyEvent(underscore, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.deepEqual(writes, ["\x1b_"]);
|
|
});
|
|
|
|
test("Esc then a non-Meta follow-up does not inject ESC and lets the key through", () => {
|
|
const { context, writes } = createContext();
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
|
|
const letter = keyEvent("l");
|
|
const result = handleTerminalAutocompleteKeyEvent(letter, context);
|
|
|
|
assert.equal(result, true);
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
|
});
|
|
|
|
test("Esc+. after the Meta prefix timeout does not yank", () => {
|
|
let now = 1_000;
|
|
const { context, writes } = createContext({ now: () => now });
|
|
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
|
context.stateRef.current = {
|
|
...context.stateRef.current,
|
|
popupVisible: false,
|
|
suggestions: [],
|
|
};
|
|
now = 1_000 + 501;
|
|
|
|
const period = keyEvent(".");
|
|
const result = handleTerminalAutocompleteKeyEvent(period, context);
|
|
|
|
assert.equal(result, true);
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("subdir Escape does not arm Meta prefix", () => {
|
|
const { context, clears } = createContext({
|
|
stateRef: {
|
|
current: {
|
|
suggestions: [suggestion("show version")],
|
|
selectedIndex: 0,
|
|
popupVisible: true,
|
|
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
|
expandUpward: false,
|
|
subDirPanels: [{ entries: [{ name: "src", type: "directory" }], selectedIndex: 0, dirPath: "/src" }],
|
|
subDirFocusLevel: 0,
|
|
},
|
|
},
|
|
});
|
|
const event = keyEvent("Escape");
|
|
|
|
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
|
|
|
assert.equal(result, false);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.deepEqual(clears, []);
|
|
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
|
});
|
|
|