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
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
HISTORY_PREVIEW_OVERLAY_ATTR,
|
|
HISTORY_PREVIEW_WRAP_ATTR,
|
|
bufferHasPreviewScrollback,
|
|
encodeHistoryPreviewWrapFlags,
|
|
getHistoryPreviewLines,
|
|
getHistoryPreviewRows,
|
|
getHistoryPreviewSelectionText,
|
|
forcedHistoryScrollLinesForWheel,
|
|
forcedHistoryScrollPageToLines,
|
|
forcedHistoryScrollPagesForKey,
|
|
forcedHistoryScrollWheelListenerOptions,
|
|
isHistoryPreviewContextMenuTarget,
|
|
isHistoryPreviewDismissClick,
|
|
joinHistoryPreviewSelectionText,
|
|
nextHistoryPreviewTop,
|
|
selectHistoryPreviewAll,
|
|
shouldHideHistoryPreviewOnMouseDown,
|
|
shouldKeepHistoryPreviewOnKey,
|
|
} from "./terminalHistoryScrollOverride.ts";
|
|
|
|
test("select all includes every preview row and preserves soft-wrap copying", async () => {
|
|
const { JSDOM } = await import("jsdom");
|
|
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
|
|
try {
|
|
const overlay = dom.window.document.querySelector("pre")!;
|
|
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
|
|
assert.equal(selectHistoryPreviewAll(overlay), true);
|
|
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
|
|
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
|
|
overlay.textContent = "plain\ntext";
|
|
assert.equal(selectHistoryPreviewAll(overlay), true);
|
|
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
|
|
overlay.textContent = "";
|
|
assert.equal(selectHistoryPreviewAll(overlay), false);
|
|
} finally {
|
|
dom.window.close();
|
|
}
|
|
});
|
|
|
|
const wheel = (
|
|
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
|
|
) => ({
|
|
altKey: false,
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaY: -100,
|
|
metaKey: false,
|
|
shiftKey: true,
|
|
...over,
|
|
});
|
|
|
|
const key = (
|
|
over: Partial<Parameters<typeof forcedHistoryScrollPagesForKey>[0]> = {},
|
|
) => ({
|
|
altKey: false,
|
|
ctrlKey: false,
|
|
key: "PageUp",
|
|
metaKey: false,
|
|
shiftKey: true,
|
|
type: "keydown",
|
|
...over,
|
|
});
|
|
|
|
test("Shift+wheel maps to explicit history scrolling before mouse tracking can consume it", () => {
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ deltaY: -100 })), -3);
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ deltaY: 100 })), 3);
|
|
});
|
|
|
|
test("forced history wheel listener can run before xterm mouse tracking and cancel scrolling", () => {
|
|
assert.equal(forcedHistoryScrollWheelListenerOptions.capture, true);
|
|
assert.equal(forcedHistoryScrollWheelListenerOptions.passive, false);
|
|
});
|
|
|
|
test("Shift+PageUp and Shift+PageDown map to one-page history scrolling", () => {
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ key: "PageUp" })), -1);
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ key: "PageDown" })), 1);
|
|
});
|
|
|
|
test("history scroll override stays out of unmodified TUI mouse and paging input", () => {
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ shiftKey: false })), null);
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ shiftKey: false })), null);
|
|
});
|
|
|
|
test("history scroll override does not steal existing modified shortcuts", () => {
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ ctrlKey: true })), null);
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ metaKey: true })), null);
|
|
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ altKey: true })), null);
|
|
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ ctrlKey: true })), null);
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ metaKey: true })), null);
|
|
assert.equal(forcedHistoryScrollPagesForKey(key({ altKey: true })), null);
|
|
});
|
|
|
|
test("PageUp/PageDown history preview uses xterm's page size", () => {
|
|
assert.equal(forcedHistoryScrollPageToLines(-1, 24), -23);
|
|
assert.equal(forcedHistoryScrollPageToLines(1, 24), 23);
|
|
assert.equal(forcedHistoryScrollPageToLines(-1, 1), -1);
|
|
});
|
|
|
|
test("alternate-screen history preview reads normal-buffer history", () => {
|
|
const normalLines = ["old 1", "old 2", "prompt before codex", "bottom"];
|
|
const normalBuffer = {
|
|
baseY: 2,
|
|
length: normalLines.length,
|
|
type: "normal" as const,
|
|
viewportY: 2,
|
|
getLine(y: number) {
|
|
const text = normalLines[y];
|
|
if (text === undefined) return undefined;
|
|
return {
|
|
translateToString() {
|
|
return text;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const alternateBuffer = {
|
|
baseY: 0,
|
|
length: 2,
|
|
type: "alternate" as const,
|
|
viewportY: 0,
|
|
getLine(y: number) {
|
|
return {
|
|
translateToString() {
|
|
return `codex frame ${y}`;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const top = nextHistoryPreviewTop({
|
|
buffer: normalBuffer,
|
|
currentTop: null,
|
|
lines: -2,
|
|
});
|
|
|
|
assert.equal(top, 0);
|
|
assert.deepEqual(getHistoryPreviewLines({ buffer: normalBuffer, rows: 3, top }), [
|
|
"old 1",
|
|
"old 2",
|
|
"prompt before codex",
|
|
]);
|
|
assert.notDeepEqual(getHistoryPreviewLines({ buffer: normalBuffer, rows: 2, top }), [
|
|
alternateBuffer.getLine(0)?.translateToString(),
|
|
alternateBuffer.getLine(1)?.translateToString(),
|
|
]);
|
|
});
|
|
|
|
test("only a normal buffer with rows above the viewport offers preview scrollback", () => {
|
|
assert.equal(bufferHasPreviewScrollback({ baseY: 12 }), true);
|
|
// Alternate-screen hosts leave the normal buffer at a single viewport.
|
|
assert.equal(bufferHasPreviewScrollback({ baseY: 0 }), false);
|
|
});
|
|
|
|
test("history preview stays up for pointer selection and copy chords", () => {
|
|
const overlay = { contains: (node: unknown) => node === "inside" };
|
|
|
|
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", overlay), false);
|
|
assert.equal(shouldHideHistoryPreviewOnMouseDown("outside", overlay), true);
|
|
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", null), false);
|
|
|
|
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Shift" })), true);
|
|
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Meta" })), true);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "c", metaKey: true, shiftKey: false }), {
|
|
hasPreviewSelection: true,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
|
|
action: "copy",
|
|
hasPreviewSelection: true,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
|
|
action: "copy",
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
|
|
action: "selectAll",
|
|
overlayVisible: true,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
|
|
overlayVisible: true,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldKeepHistoryPreviewOnKey(key({ key: "j", shiftKey: false })),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("history preview copy uses only overlay-owned DOM selection", () => {
|
|
const overlay = {
|
|
contains(node: unknown) {
|
|
return node === "preview";
|
|
},
|
|
};
|
|
|
|
assert.equal(
|
|
getHistoryPreviewSelectionText(overlay, {
|
|
rangeCount: 1,
|
|
anchorNode: "preview",
|
|
focusNode: "preview",
|
|
toString: () => "old prompt output",
|
|
}),
|
|
"old prompt output",
|
|
);
|
|
assert.equal(
|
|
getHistoryPreviewSelectionText(overlay, {
|
|
rangeCount: 1,
|
|
anchorNode: "xterm",
|
|
focusNode: "xterm",
|
|
toString: () => "vim buffer",
|
|
}),
|
|
"",
|
|
);
|
|
assert.equal(
|
|
getHistoryPreviewSelectionText(overlay, {
|
|
rangeCount: 1,
|
|
isCollapsed: true,
|
|
anchorNode: "preview",
|
|
focusNode: "preview",
|
|
toString: () => "old prompt output",
|
|
}),
|
|
"",
|
|
);
|
|
});
|
|
|
|
test("history preview click dismisses and a drag keeps the overlay", () => {
|
|
assert.equal(
|
|
isHistoryPreviewDismissClick(
|
|
{ clientX: 10, clientY: 10 },
|
|
{ button: 0, clientX: 11, clientY: 12 },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isHistoryPreviewDismissClick(
|
|
{ clientX: 10, clientY: 10 },
|
|
{ button: 0, clientX: 40, clientY: 30 },
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isHistoryPreviewDismissClick(
|
|
{ clientX: 10, clientY: 10 },
|
|
{ button: 2, clientX: 10, clientY: 10 },
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("select-all overlay ranges still join soft-wrapped preview rows", () => {
|
|
const text = "ssh host long-command-name\n --flag";
|
|
const overlay = {
|
|
firstChild: "text",
|
|
textContent: text,
|
|
contains() {
|
|
return true;
|
|
},
|
|
getAttribute(name: string) {
|
|
return name === HISTORY_PREVIEW_WRAP_ATTR ? "01" : null;
|
|
},
|
|
};
|
|
const copied = getHistoryPreviewSelectionText(overlay, {
|
|
rangeCount: 1,
|
|
anchorNode: overlay,
|
|
focusNode: overlay,
|
|
anchorOffset: 0,
|
|
focusOffset: 1,
|
|
toString: () => text,
|
|
});
|
|
assert.equal(copied.includes("\n"), false);
|
|
assert.match(copied, /long-command-name\s*--flag/);
|
|
});
|
|
|
|
test("history preview copy keeps a selected hard line break", () => {
|
|
assert.equal(
|
|
joinHistoryPreviewSelectionText({
|
|
text: "abc\ndef",
|
|
startOffset: 0,
|
|
endOffset: 4,
|
|
wrapFlags: [false, false],
|
|
}),
|
|
"abc\n",
|
|
);
|
|
assert.equal(
|
|
joinHistoryPreviewSelectionText({
|
|
text: "abc\ndef",
|
|
startOffset: 3,
|
|
endOffset: 4,
|
|
wrapFlags: [false, false],
|
|
}),
|
|
"\n",
|
|
);
|
|
assert.equal(
|
|
joinHistoryPreviewSelectionText({
|
|
text: "abc\ndef",
|
|
startOffset: 0,
|
|
endOffset: 4,
|
|
wrapFlags: [false, true],
|
|
}),
|
|
"abc",
|
|
);
|
|
});
|
|
|
|
test("history preview copy joins soft-wrapped buffer rows", () => {
|
|
const text = "ssh user@host tail -f /var/log/very-long-name.log\n | grep error";
|
|
const joined = joinHistoryPreviewSelectionText({
|
|
text,
|
|
startOffset: 0,
|
|
endOffset: text.length,
|
|
wrapFlags: [false, true],
|
|
});
|
|
assert.equal(joined.includes("\n"), false);
|
|
assert.match(joined, /very-long-name\.log\s*\| grep error/);
|
|
|
|
const rows = getHistoryPreviewRows({
|
|
buffer: {
|
|
baseY: 1,
|
|
length: 2,
|
|
type: "normal",
|
|
viewportY: 1,
|
|
getLine(y: number) {
|
|
if (y === 0) {
|
|
return {
|
|
isWrapped: false,
|
|
translateToString() {
|
|
return "ssh user@host tail -f /var/log/very-long-name.log";
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
isWrapped: true,
|
|
translateToString() {
|
|
return " | grep error";
|
|
},
|
|
};
|
|
},
|
|
},
|
|
rows: 2,
|
|
top: 0,
|
|
});
|
|
assert.equal(encodeHistoryPreviewWrapFlags(rows), "01");
|
|
});
|
|
|
|
test("history preview right-click is recognized as an app-menu target", () => {
|
|
assert.equal(
|
|
isHistoryPreviewContextMenuTarget({
|
|
closest: (selector: string) => selector === `[${HISTORY_PREVIEW_OVERLAY_ATTR}]` ? {} as Element : null,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isHistoryPreviewContextMenuTarget({
|
|
closest: () => null,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
|
|
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
|
|
const { JSDOM } = await import("jsdom");
|
|
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
|
|
const overlay = dom.window.document.querySelector("pre")!;
|
|
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
|
|
const first = overlay.firstChild!.firstChild!;
|
|
const last = overlay.lastChild!.firstChild!;
|
|
const selection = {
|
|
rangeCount: 1, anchorNode: first, focusNode: last,
|
|
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
|
|
};
|
|
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
|
|
assert.equal(getHistoryPreviewSelectionText(overlay, {
|
|
...selection, anchorNode: overlay, focusNode: overlay,
|
|
anchorOffset: 0, focusOffset: overlay.childNodes.length,
|
|
}), "中文abcdef");
|
|
dom.window.close();
|
|
});
|