[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,876 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clampAutocompletePopupGeometry,
computeAutocompletePopupPlacement,
nextAutocompletePopupAnchorViewport,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
resolveAutocompleteCommandStartRow,
resolveAutocompleteCursorCell,
resolveAutocompleteCursorColumn,
resolveAutocompletePopupAnchorInViewport,
resolvePreservedSuggestionIndex,
type PopupPlacementInput,
} from "./autocomplete/terminalAutocompleteLayout.ts";
const baseInput: PopupPlacementInput = {
anchorTop: 100,
anchorBottom: 120,
anchorLeft: 200,
viewportWidth: 1200,
viewportHeight: 800,
desiredHeight: 232,
totalWidth: 400,
maxHeight: 240,
anchorGap: 8,
viewportPadding: 8,
expandUpwardHint: false,
};
test("renders downward with full height when there is ample room below", () => {
const p = computeAutocompletePopupPlacement(baseInput);
assert.equal(p.renderUpward, false);
assert.equal(p.top, baseInput.anchorBottom + baseInput.anchorGap); // 128
assert.equal(p.maxHeight, 240);
// Whole popup stays within the viewport vertically.
assert.ok(p.top + p.maxHeight <= baseInput.viewportHeight - baseInput.viewportPadding);
});
test("flips upward when the cursor sits at the very bottom of the viewport", () => {
// Anchor line flush with the viewport bottom — classic "typing at the
// bottom of the terminal" case from the bug report.
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: 780,
anchorBottom: 800,
});
assert.equal(p.renderUpward, true);
// Bottom edge of the *rendered content* sits just above the input line.
// p.maxHeight is only the scroll budget; actual content is min(budget,desired).
const contentHeight = Math.min(p.maxHeight, baseInput.desiredHeight);
assert.ok(p.top + contentHeight <= 780 - baseInput.anchorGap + 0.001);
assert.ok(p.top >= baseInput.viewportPadding);
});
test("never lets a downward popup overflow the viewport bottom", () => {
// A little room below but not enough for the full desired height: the popup
// must shrink + clamp so its bottom never crosses the viewport edge.
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: 600,
anchorBottom: 620,
desiredHeight: 232,
});
const bottom = p.top + p.maxHeight;
assert.ok(
bottom <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
`popup bottom ${bottom} should stay within viewport`,
);
});
test("clamps height to the larger side when neither side fully fits", () => {
// Short viewport: cursor in the middle, not enough above or below for 232px.
const p = computeAutocompletePopupPlacement({
...baseInput,
viewportHeight: 300,
anchorTop: 140,
anchorBottom: 160,
desiredHeight: 232,
});
// More space below (300-160-16=124) than above (140-16=124 ~ tie) — either
// way the rendered height must fit the chosen side and stay on-screen.
const contentHeight = Math.min(p.maxHeight, 232);
if (p.renderUpward) {
assert.ok(p.top >= baseInput.viewportPadding);
assert.ok(p.top + contentHeight <= 160 - baseInput.anchorGap + 0.001);
} else {
assert.ok(p.top + contentHeight <= 300 - baseInput.viewportPadding + 0.001);
}
assert.ok(p.maxHeight > 0);
});
test("forceExpandUpward keeps the popup above the command-start when below fully fits", () => {
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: 100,
anchorBottom: 120,
desiredHeight: 80,
expandUpwardHint: true,
forceExpandUpward: true,
});
assert.equal(p.renderUpward, true, "must not flip below the command-start anchor");
const contentHeight = Math.min(p.maxHeight, 80);
assert.ok(p.top + contentHeight <= 100 - baseInput.anchorGap + 0.001);
});
test("honors the upward hint to break ties when neither side fits", () => {
const shared = {
...baseInput,
viewportHeight: 300,
anchorTop: 150,
anchorBottom: 170,
desiredHeight: 232,
};
const withHint = computeAutocompletePopupPlacement({ ...shared, expandUpwardHint: true });
// spaceAbove = 150-16 = 134; min(spaceBelow,80) -> spaceBelow=300-170-16=114, min=80
// 134 >= 80 so the hint flips it upward.
assert.equal(withHint.renderUpward, true);
});
test("clamps the left edge so the FULL assembly (with sub-dir panels) fits", () => {
// Cursor near the right edge, popup widened by cascading sub-dir panels.
// The whole assembly (1100px wide) must slide left to stay on-screen.
const totalWidth = 1100; // main 400 + panels + detail
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 1150,
totalWidth,
});
assert.ok(p.left >= baseInput.viewportPadding);
assert.ok(
p.left + totalWidth <= baseInput.viewportWidth - baseInput.viewportPadding + 0.001,
`right edge ${p.left + totalWidth} should stay within viewport`,
);
});
test("pins to the left padding when the assembly is wider than the viewport", () => {
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 600,
viewportWidth: 500,
totalWidth: 900,
});
// Can't fit no matter what — keep the primary list visible at the left edge.
assert.equal(p.left, baseInput.viewportPadding);
});
test("does not shift left when the popup already fits at the cursor", () => {
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 200,
totalWidth: 400,
});
assert.equal(p.left, 200);
});
test("does not shift left for detail tooltips when clampWidth excludes them", () => {
const withDetailClamp = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 750,
totalWidth: 684,
clampWidth: 400,
});
const withFullClamp = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 750,
totalWidth: 684,
});
assert.equal(withDetailClamp.left, 750);
assert.ok(withFullClamp.left < withDetailClamp.left);
});
test("clamps within a split terminal pane instead of the full window", () => {
const pane = { left: 700, top: 80, width: 680, height: 520 };
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorLeft: 1180,
totalWidth: 1100,
clampWidth: 400,
clampViewport: pane,
viewportWidth: pane.width,
viewportHeight: pane.height,
});
assert.ok(p.left >= pane.left + baseInput.viewportPadding);
assert.ok(p.left + 400 <= pane.left + pane.width - baseInput.viewportPadding + 0.001);
});
test("final popup geometry clamps the actual rendered width", () => {
const pane = { left: 700, top: 80, width: 680, height: 520 };
const geometry = clampAutocompletePopupGeometry({
left: 970,
top: 180,
width: 520,
height: 220,
clampViewport: pane,
viewportPadding: 8,
});
assert.equal(geometry.left, 852);
assert.ok(geometry.left >= pane.left + 8);
assert.ok(geometry.left + 520 <= pane.left + pane.width - 8);
});
test("final popup geometry clamps the actual rendered height", () => {
const pane = { left: 700, top: 80, width: 680, height: 520 };
const geometry = clampAutocompletePopupGeometry({
left: 760,
top: 470,
width: 360,
height: 180,
clampViewport: pane,
viewportPadding: 8,
});
assert.equal(geometry.top, 412);
assert.ok(geometry.top >= pane.top + 8);
assert.ok(geometry.top + 180 <= pane.top + pane.height - 8);
});
test("resolveAutocompleteAnchorInViewport uses the xterm screen rect in split panes", () => {
const cellWidth = 5;
const cellHeight = 200 / 24;
const screen = {
clientWidth: 400,
clientHeight: 200,
getBoundingClientRect: () => ({
left: 120,
top: 260,
right: 520,
bottom: 460,
width: 400,
height: 200,
x: 120,
y: 260,
toJSON: () => ({}),
}),
};
const container = {
querySelector: (selector: string) => (selector === ".xterm-screen" ? screen : null),
} as unknown as HTMLElement;
const term = {
element: {
querySelector: () => null,
},
cols: 80,
rows: 24,
buffer: {
active: {
cursorX: 10,
cursorY: 20,
baseY: 0,
getLine: () => ({ isWrapped: false }),
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: { width: cellWidth, height: cellHeight },
},
},
},
},
};
const anchor = resolveAutocompleteAnchorInViewport(term as never, container, 5, 10);
assert.equal(anchor.anchorLeft, 120 + cellWidth * 10);
assert.equal(anchor.anchorTop, 260 + cellHeight * 20);
assert.equal(anchor.anchorBottom, 260 + cellHeight * 21);
});
test("resolveAutocompleteCursorColumn prefers prompt-aligned column when xterm lags", () => {
const term = {
buffer: {
active: {
cursorX: 0,
cursorY: 22,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "root@host:~# d",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "root@host:~# ",
userInput: "d",
});
assert.equal(column, "root@host:~# ".length + 1);
});
test("resolveAutocompleteCursorColumn counts wide glyphs as two cells for pre-echo input", () => {
const term = {
cols: 80,
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
userInput: "部署",
});
// "$ " = 2 cells, each CJK ideograph = 2 cells → column 6 (not char-length 4).
assert.equal(column, 6);
});
test("resolveAutocompleteCursorCell wraps synthetic pre-echo columns onto the next row", () => {
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
// Four wide cells after a prompt ending at column 8 → raw column 12.
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorCell clamps a bottom-row wrap to the visible grid", () => {
// Pending wrap from the last visible row would predict row === term.rows,
// but xterm scrolls and keeps the cursor on term.rows - 1.
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 23,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 23);
});
test("resolveAutocompleteCursorCell advances past a partial wrap using unechoed userInput", () => {
// cols=10: "$ docker c" | "o|" — echo wrapped after a prefix while the
// buffered input is still the full "docker compose". Anchoring at the
// live wrapped cell (col 1) would leave the popup behind.
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "o" },
};
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 1,
cursorY: 1,
baseY: 0,
getLine: (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: () => row.text,
};
},
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "docker compose",
});
// "$ " + "docker compose" = 16 cells → col 6 on row 1.
assert.equal(cell.column, 6);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorColumn counts ZWJ emoji graphemes as one wide cluster", () => {
const term = {
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
// Technologist emoji is one grapheme (👨 + ZWJ + 💻); xterm paints it
// as two cells, not five code-point widths.
userInput: "👨‍💻",
});
assert.equal(column, 4);
});
test("short popup flips upward when the cursor is at the bottom of the screen", () => {
// Regression for issue #1710: a *short* suggestion list must flip up when
// the anchor line is the last visible row, even if there is a tiny positive
// "phantom" space below the screen rect.
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: 772,
anchorBottom: 792,
desiredHeight: 64, // 2 short items
maxHeight: 240,
});
assert.equal(p.renderUpward, true, "short popup should flip upward at bottom row");
assert.ok(p.top + Math.min(p.maxHeight, 64) <= 772 - baseInput.anchorGap + 0.001,
"rendered popup should stay above the anchor line");
assert.ok(p.top >= baseInput.viewportPadding,
"popup should not be clipped above the viewport");
});
test("clamps a downward popup back inside the terminal when the anchor is above the viewport", () => {
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: -60,
anchorBottom: -40,
desiredHeight: 96,
maxHeight: 240,
expandUpwardHint: false,
});
const contentHeight = Math.min(p.maxHeight, 96);
assert.equal(p.renderUpward, false);
assert.ok(
p.top >= baseInput.viewportPadding,
`popup top ${p.top} should stay inside the terminal viewport`,
);
assert.ok(
p.top + contentHeight <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
`popup bottom ${p.top + contentHeight} should stay inside the terminal viewport`,
);
});
test("clamps an upward popup back inside the terminal when the anchor is below the viewport", () => {
const p = computeAutocompletePopupPlacement({
...baseInput,
anchorTop: 860,
anchorBottom: 880,
desiredHeight: 96,
maxHeight: 240,
expandUpwardHint: true,
});
const contentHeight = Math.min(p.maxHeight, 96);
assert.equal(p.renderUpward, true);
assert.ok(
p.top >= baseInput.viewportPadding,
`popup top ${p.top} should stay inside the terminal viewport`,
);
assert.ok(
p.top + contentHeight <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
`popup bottom ${p.top + contentHeight} should stay inside the terminal viewport`,
);
});
test("resolveAutocompleteClampViewport clamps to the xterm screen rect", () => {
const container = {
closest: () => null,
querySelector: (selector: string) =>
selector === ".xterm-screen"
? {
getBoundingClientRect: () => ({
left: 100,
top: 50,
width: 600,
height: 400,
right: 700,
bottom: 450,
x: 100,
y: 50,
toJSON: () => ({}),
}),
}
: null,
getBoundingClientRect: () => ({
left: 100,
top: 50,
width: 600,
height: 410, // 10px taller than the screen
right: 700,
bottom: 460,
x: 100,
y: 50,
toJSON: () => ({}),
}),
} as unknown as HTMLElement;
const clamp = resolveAutocompleteClampViewport(container);
assert.equal(clamp.top, 50);
assert.equal(clamp.height, 400, "clamp height should match the screen, not the taller container");
assert.equal(clamp.width, 600);
});
test("resolveAutocompleteClampViewport prefers the visible terminal screen over the split pane", () => {
const pane = {
getAttribute: (name: string) => (name === "data-section" ? "terminal-split-pane" : null),
getBoundingClientRect: () => ({
left: 20,
top: 30,
width: 500,
height: 300,
right: 520,
bottom: 330,
x: 20,
y: 30,
toJSON: () => ({}),
}),
};
const screen = {
getBoundingClientRect: () => ({
left: 25,
top: 35,
width: 490,
height: 280,
right: 515,
bottom: 315,
x: 25,
y: 35,
toJSON: () => ({}),
}),
};
const container = {
closest: (selector: string) =>
selector === '[data-section="terminal-split-pane"]' ? (pane as never) : null,
querySelector: (selector: string) =>
selector === ".xterm-screen" ? (screen as never) : null,
getBoundingClientRect: () => ({
left: 20,
top: 30,
width: 500,
height: 310,
right: 520,
bottom: 340,
x: 20,
y: 30,
toJSON: () => ({}),
}),
} as unknown as HTMLElement;
const clamp = resolveAutocompleteClampViewport(container);
assert.equal(clamp.left, 25, "screen left should win");
assert.equal(clamp.top, 35, "screen top should win");
assert.equal(clamp.width, 490, "screen width should win");
assert.equal(clamp.height, 280, "screen height should win");
});
test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal position", () => {
const cellWidth = 9;
const cellHeight = 17;
const screen = {
clientWidth: 720,
clientHeight: 408,
getBoundingClientRect: () => ({
left: 640,
top: 180,
right: 1360,
bottom: 588,
width: 720,
height: 408,
x: 640,
y: 180,
toJSON: () => ({}),
}),
};
const textarea = {
getBoundingClientRect: () => ({
left: 640,
top: 500,
right: 1360,
bottom: 517,
width: 720,
height: 17,
x: 640,
y: 500,
toJSON: () => ({}),
}),
};
const container = {
querySelector: (selector: string) => {
if (selector === ".xterm-screen") return screen;
if (selector === "textarea.xterm-helper-textarea") return textarea;
return null;
},
} as unknown as HTMLElement;
const cursorColumn = "root@RainYun-0tWTeTRw:~# ".length + 1;
const term = {
element: {
querySelector: () => null,
},
cols: 80,
rows: 24,
buffer: {
active: {
cursorX: 0,
cursorY: 22,
baseY: 0,
getLine: () => ({ isWrapped: false }),
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: { width: cellWidth, height: cellHeight },
},
},
},
},
};
const anchor = resolveAutocompleteAnchorInViewport(
term as never,
container,
5,
cursorColumn,
);
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
});
function suggestion(partial: {
text: string;
source: "history" | "path" | "snippet" | "fig";
score?: number;
displayText?: string;
fileType?: "file" | "directory" | "symlink";
}) {
return {
displayText: partial.displayText ?? partial.text,
score: partial.score ?? 1,
...partial,
};
}
test("resolvePreservedSuggestionIndex keeps highlight after late path reorder", () => {
const previous = [
suggestion({ text: "alpha", source: "history", score: 900 }),
suggestion({ text: "bravo/", source: "history", score: 800 }),
];
const next = [
suggestion({ text: "bravo/", source: "path", score: 750, fileType: "directory" }),
suggestion({ text: "alpha", source: "history", score: 900 }),
suggestion({ text: "charlie", source: "path", score: 700, fileType: "file" }),
];
assert.equal(resolvePreservedSuggestionIndex(previous, 1, next), 0);
});
test("resolvePreservedSuggestionIndex returns -1 when selection is absent or dropped", () => {
const previous = [suggestion({ text: "gone", source: "history" })];
const next = [suggestion({ text: "kept", source: "path", fileType: "file" })];
assert.equal(resolvePreservedSuggestionIndex(previous, -1, next), -1);
assert.equal(resolvePreservedSuggestionIndex(previous, 0, next), -1);
});
function makeWrappedCommandTerm(options: {
cursorY: number;
baseY: number;
viewportY: number;
wrapped: boolean;
rows?: number;
cols?: number;
cellWidth?: number;
cellHeight?: number;
screenTop?: number;
}) {
const rows = options.rows ?? 24;
const cols = options.cols ?? 80;
const cellWidth = options.cellWidth ?? 8;
const cellHeight = options.cellHeight ?? 20;
const screenTop = options.screenTop ?? 100;
const absY = options.cursorY + options.baseY;
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
[absY]: {
isWrapped: options.wrapped,
text: options.wrapped ? "mmand that wraps" : "$ a-long-command",
},
};
if (options.wrapped) {
lines[absY - 1] = { text: "$ a-long-co" };
}
const screen = {
clientWidth: cols * cellWidth,
clientHeight: rows * cellHeight,
getBoundingClientRect: () => ({
left: 40,
top: screenTop,
right: 40 + cols * cellWidth,
bottom: screenTop + rows * cellHeight,
width: cols * cellWidth,
height: rows * cellHeight,
x: 40,
y: screenTop,
toJSON: () => ({}),
}),
};
const container = {
querySelector: (selector: string) => (selector === ".xterm-screen" ? screen : null),
} as unknown as HTMLElement;
const term = {
element: {
querySelector: () => null,
},
cols,
rows,
buffer: {
active: {
cursorX: options.wrapped ? 16 : 16,
cursorY: options.cursorY,
baseY: options.baseY,
viewportY: options.viewportY,
getLine: (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: () => row.text,
};
},
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: { width: cellWidth, height: cellHeight },
},
},
},
},
};
return { term, container, cellHeight, screenTop };
}
test("resolveAutocompleteCommandStartRow follows wrap-induced viewport scroll", () => {
const before = makeWrappedCommandTerm({
cursorY: 23,
baseY: 100,
viewportY: 100,
wrapped: false,
});
assert.equal(resolveAutocompleteCommandStartRow(before.term as never), 23);
const after = makeWrappedCommandTerm({
cursorY: 23,
baseY: 101,
viewportY: 101,
wrapped: true,
});
assert.equal(resolveAutocompleteCommandStartRow(after.term as never), 22);
});
test("popup viewport follows the command start row after wrap-induced scroll", () => {
const before = makeWrappedCommandTerm({
cursorY: 23,
baseY: 100,
viewportY: 100,
wrapped: false,
});
const after = makeWrappedCommandTerm({
cursorY: 23,
baseY: 101,
viewportY: 101,
wrapped: true,
});
const beforeAnchor = resolveAutocompletePopupAnchorInViewport(
before.term as never,
before.container,
5,
16,
23,
);
const afterAnchor = resolveAutocompletePopupAnchorInViewport(
after.term as never,
after.container,
5,
16,
23,
);
assert.equal(beforeAnchor.expandUpward, true);
assert.equal(afterAnchor.expandUpward, true);
assert.equal(beforeAnchor.anchorTop, before.screenTop + 23 * before.cellHeight);
assert.equal(afterAnchor.anchorTop, after.screenTop + 22 * after.cellHeight);
assert.equal(afterAnchor.anchorTop, beforeAnchor.anchorTop - after.cellHeight);
const stored = nextAutocompletePopupAnchorViewport(
{
left: beforeAnchor.anchorLeft,
top: beforeAnchor.anchorTop,
bottom: beforeAnchor.anchorBottom,
},
beforeAnchor.expandUpward,
afterAnchor,
);
assert.ok(stored, "stored viewport should update when the command start row moves");
assert.equal(stored?.viewport.top, afterAnchor.anchorTop);
assert.equal(stored?.expandUpward, true);
});
test("resolveAutocompleteAnchorInViewport pins an upward popup to the wrapped command start", () => {
const { term, container, cellHeight, screenTop } = makeWrappedCommandTerm({
cursorY: 23,
baseY: 101,
viewportY: 101,
wrapped: true,
});
const atCursor = resolveAutocompleteAnchorInViewport(term as never, container, 5, 16, 23, 23);
const atStart = resolveAutocompleteAnchorInViewport(term as never, container, 5, 16, 23, 22);
assert.equal(atCursor.expandUpward, true);
assert.equal(atStart.expandUpward, true);
assert.equal(atCursor.anchorTop, screenTop + 23 * cellHeight);
assert.equal(atStart.anchorTop, screenTop + 22 * cellHeight);
assert.ok(
atStart.anchorTop < atCursor.anchorTop,
"command-start anchor must sit above the cursor so the popup cannot cover the first wrapped line",
);
});
test("nextAutocompletePopupAnchorViewport is a no-op when the stored viewport is already current", () => {
const anchor = {
anchorLeft: 10,
anchorTop: 20,
anchorBottom: 40,
expandUpward: true,
};
assert.equal(
nextAutocompletePopupAnchorViewport(
{ left: 10, top: 20, bottom: 40 },
true,
anchor,
),
null,
);
});