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 = { 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 = { [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, ); });