919 lines
29 KiB
TypeScript
919 lines
29 KiB
TypeScript
|
|
import test from "node:test";
|
||
|
|
import assert from "node:assert/strict";
|
||
|
|
|
||
|
|
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
|
||
|
|
|
||
|
|
type RenderListener = () => void;
|
||
|
|
type ResizeListener = () => void;
|
||
|
|
|
||
|
|
class FakeElement {
|
||
|
|
public readonly style: Record<string, string> = {};
|
||
|
|
public textContent = "";
|
||
|
|
public className = "";
|
||
|
|
public children: FakeElement[] = [];
|
||
|
|
|
||
|
|
appendChild(child: FakeElement): FakeElement {
|
||
|
|
this.children.push(child);
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
|
||
|
|
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
|
||
|
|
if (!referenceNode) {
|
||
|
|
this.children.push(child);
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
const index = this.children.indexOf(referenceNode);
|
||
|
|
if (index < 0) {
|
||
|
|
this.children.push(child);
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
this.children.splice(index, 0, child);
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
|
||
|
|
remove(): void {
|
||
|
|
// No-op for tests.
|
||
|
|
}
|
||
|
|
|
||
|
|
querySelector(selector: string): FakeElement | null {
|
||
|
|
if (selector === ".xterm-screen") {
|
||
|
|
return this.children.find((child) => child.className === "xterm-screen") ?? null;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function installFakeDocument(): () => void {
|
||
|
|
const previousDocument = globalThis.document;
|
||
|
|
const fakeDocument = {
|
||
|
|
createElement() {
|
||
|
|
return new FakeElement();
|
||
|
|
},
|
||
|
|
} as unknown as Document;
|
||
|
|
Object.defineProperty(globalThis, "document", {
|
||
|
|
configurable: true,
|
||
|
|
value: fakeDocument,
|
||
|
|
});
|
||
|
|
return () => {
|
||
|
|
if (previousDocument === undefined) {
|
||
|
|
delete (globalThis as { document?: Document }).document;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
Object.defineProperty(globalThis, "document", {
|
||
|
|
configurable: true,
|
||
|
|
value: previousDocument,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createFakeTerm() {
|
||
|
|
const renderListeners: RenderListener[] = [];
|
||
|
|
const resizeListeners: ResizeListener[] = [];
|
||
|
|
const element = new FakeElement();
|
||
|
|
const screen = new FakeElement();
|
||
|
|
screen.className = "xterm-screen";
|
||
|
|
element.appendChild(screen);
|
||
|
|
|
||
|
|
const term = {
|
||
|
|
element,
|
||
|
|
cols: 80,
|
||
|
|
rows: 24,
|
||
|
|
options: {
|
||
|
|
fontSize: 14,
|
||
|
|
fontFamily: "monospace",
|
||
|
|
},
|
||
|
|
buffer: {
|
||
|
|
active: {
|
||
|
|
cursorX: 2,
|
||
|
|
cursorY: 0,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
_core: {
|
||
|
|
_renderService: {
|
||
|
|
dimensions: {
|
||
|
|
css: {
|
||
|
|
cell: {
|
||
|
|
width: 9,
|
||
|
|
height: 18,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
onRender(listener: RenderListener) {
|
||
|
|
renderListeners.push(listener);
|
||
|
|
return {
|
||
|
|
dispose() {
|
||
|
|
const index = renderListeners.indexOf(listener);
|
||
|
|
if (index >= 0) renderListeners.splice(index, 1);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
onResize(listener: ResizeListener) {
|
||
|
|
resizeListeners.push(listener);
|
||
|
|
return {
|
||
|
|
dispose() {
|
||
|
|
const index = resizeListeners.indexOf(listener);
|
||
|
|
if (index >= 0) resizeListeners.splice(index, 1);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
term,
|
||
|
|
ghostElement: () => screen.children[0]?.children[0] ?? null,
|
||
|
|
fireRender() {
|
||
|
|
for (const listener of [...renderListeners]) listener();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("shifts ghost to predicted cursor column as matching input is typed", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "do");
|
||
|
|
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.style.display, "block");
|
||
|
|
assert.equal(ghost.textContent, "cker");
|
||
|
|
// show() anchored at cursorX=2, cell width=9 → left=18.
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
|
||
|
|
addon.adjustToInput("doc");
|
||
|
|
|
||
|
|
// After one matching char, the ghost predicts the cursor has moved
|
||
|
|
// to column 3 and trims "c" from the tail so the next char starts
|
||
|
|
// where the echo will land. Not waiting for xterm's render keeps
|
||
|
|
// ghost + real input aligned across SSH echo latency.
|
||
|
|
assert.equal(ghost.style.display, "block");
|
||
|
|
assert.equal(ghost.textContent, "ker");
|
||
|
|
assert.equal(ghost.style.left, "27px");
|
||
|
|
assert.equal(addon.getGhostText(), "ker");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "do");
|
||
|
|
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
|
||
|
|
addon.adjustToInput("doc");
|
||
|
|
assert.equal(ghost.textContent, "ker");
|
||
|
|
assert.equal(ghost.style.left, "27px");
|
||
|
|
|
||
|
|
// Backspace below the anchor input — the ghost should shift *left*,
|
||
|
|
// not stay pinned at the show-time anchor column. Pinning would
|
||
|
|
// leave a visual gap between the real cursor and the ghost.
|
||
|
|
addon.adjustToInput("d");
|
||
|
|
assert.equal(ghost.textContent, "ocker");
|
||
|
|
// anchor was cursorX=2 captured at show(); "d" is 1 char below
|
||
|
|
// anchorInputLength=2 → predicted cursor column = 1.
|
||
|
|
assert.equal(ghost.style.left, "9px");
|
||
|
|
|
||
|
|
// Backspace past the anchor back to empty: left is clamped at 0.
|
||
|
|
addon.adjustToInput("");
|
||
|
|
assert.equal(ghost.textContent, "docker");
|
||
|
|
assert.equal(ghost.style.left, "0px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("advances the anchor by two cells when a CJK glyph is typed", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
// Suggestion starts with a CJK char so the prefix-match survives
|
||
|
|
// the next keystroke.
|
||
|
|
addon.show("你好世界", "");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
|
||
|
|
addon.adjustToInput("你");
|
||
|
|
|
||
|
|
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
|
||
|
|
assert.equal(ghost.textContent, "好世界");
|
||
|
|
assert.equal(ghost.style.left, "36px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
|
||
|
|
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
|
||
|
|
// wrap to col 3 of row 1.
|
||
|
|
term.cols = 10;
|
||
|
|
term.buffer.active.cursorX = 8;
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("abcdefghij", "ab");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.style.top, "0px");
|
||
|
|
|
||
|
|
addon.adjustToInput("abcde");
|
||
|
|
|
||
|
|
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
|
||
|
|
// cellWidth=9, cellHeight=18.
|
||
|
|
assert.equal(ghost.textContent, "fghij");
|
||
|
|
assert.equal(ghost.style.left, "9px");
|
||
|
|
assert.equal(ghost.style.top, "18px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
// show() captures cursorX=2 — simulate this firing during the
|
||
|
|
// keystroke→echo gap by later advancing the live cursor and
|
||
|
|
// verifying the ghost anchor snaps to the echoed position.
|
||
|
|
addon.show("docker", "do");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
|
||
|
|
term.buffer.active.cursorX = 5;
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
// Input hasn't moved from the show-time baseline, so updatePosition
|
||
|
|
// re-reads live cursor: new left = 5 * 9 = 45px.
|
||
|
|
assert.equal(ghost.style.left, "45px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("self-heal adopts live X/Y when echo wraps instead of double-counting", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Prompt ends at col 8; four pending cells predict X=12 on a 10-col
|
||
|
|
// terminal (wraps to col 2 / row 1). When echo lands there, Math.max
|
||
|
|
// on X alone would keep 12 and paint the ghost on row 2.
|
||
|
|
term.cols = 10;
|
||
|
|
term.buffer.active.cursorX = 8;
|
||
|
|
term.buffer.active.cursorY = 0;
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
translateToString: () => "$ ",
|
||
|
|
});
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("abcdefghij", "abcd");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
// Predicted wrap: col 2 on row 1 → left 18px, top 18px.
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
assert.equal(ghost.style.top, "18px");
|
||
|
|
|
||
|
|
term.buffer.active.cursorX = 2;
|
||
|
|
term.buffer.active.cursorY = 1;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
isWrapped: true,
|
||
|
|
translateToString: () => "abcd",
|
||
|
|
});
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
assert.equal(ghost.style.top, "18px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("self-heal adopts live X when a bottom-row wrap scrolls instead of changing Y", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Bottom-row prediction: prompt at col 8 + 4 pending cells → X=12.
|
||
|
|
// Echo scrolls the buffer so Y stays on the last row while live X
|
||
|
|
// becomes the normalized wrap column (2). Math.max would keep 12 and
|
||
|
|
// paint the ghost one row below the visible screen.
|
||
|
|
term.cols = 10;
|
||
|
|
term.rows = 24;
|
||
|
|
term.buffer.active.cursorX = 8;
|
||
|
|
term.buffer.active.cursorY = 23;
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
translateToString: () => "$ ",
|
||
|
|
});
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("abcdefghij", "abcd");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
// Pre-echo: col 2 on predicted row 24 → top 24*18.
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
assert.equal(ghost.style.top, "432px");
|
||
|
|
|
||
|
|
term.buffer.active.cursorX = 2;
|
||
|
|
term.buffer.active.cursorY = 23;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
isWrapped: true,
|
||
|
|
translateToString: () => "abcd",
|
||
|
|
});
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(ghost.style.left, "18px");
|
||
|
|
assert.equal(ghost.style.top, "414px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("anchors ghost after wide pre-echo input when the line has not echoed yet", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
translateToString: () => "$ ",
|
||
|
|
});
|
||
|
|
addon.activate(term as never);
|
||
|
|
// Live cursor still at the prompt; typed "部署" is only in the keystroke buffer.
|
||
|
|
addon.show("部署脚本", "部署");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, "脚本");
|
||
|
|
// cursorX=2 + 4 wide cells → column 6 → left 54px.
|
||
|
|
assert.equal(ghost.style.left, "54px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("anchors ghost using only the unechoed suffix after a partial shell echo", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Shell has echoed "$ doc"; buffered input is still the full "docker".
|
||
|
|
term.buffer.active.cursorX = 5;
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
translateToString: () => "$ doc",
|
||
|
|
});
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker compose", "docker");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, " compose");
|
||
|
|
// Unechoed "ker" is 3 cells → column 8 → left 72px (not 5+6=11).
|
||
|
|
assert.equal(ghost.style.left, "72px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("anchors ghost by cell columns when the prompt is a multi-code-unit emoji", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Family ZWJ emoji is many UTF-16 units but only 2 terminal cells.
|
||
|
|
// A UTF-16 slice(0, cursorX) would stop inside the emoji and treat the
|
||
|
|
// fully-echoed "docker" as unechoed, shifting the ghost right.
|
||
|
|
const emoji = "👨👩👧👦";
|
||
|
|
const cells: Array<{ chars: string; width: number }> = [
|
||
|
|
{ chars: emoji, width: 2 },
|
||
|
|
{ chars: " ", width: 1 },
|
||
|
|
{ chars: "$", width: 1 },
|
||
|
|
{ chars: " ", width: 1 },
|
||
|
|
...Array.from("docker", (ch) => ({ chars: ch, width: 1 })),
|
||
|
|
];
|
||
|
|
const totalCols = cells.reduce((sum, cell) => sum + cell.width, 0);
|
||
|
|
term.buffer.active.cursorX = totalCols;
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.getLine = () => ({
|
||
|
|
translateToString: (
|
||
|
|
_trimRight?: boolean,
|
||
|
|
startColumn = 0,
|
||
|
|
endColumn = totalCols,
|
||
|
|
) => {
|
||
|
|
let col = 0;
|
||
|
|
let text = "";
|
||
|
|
for (const cell of cells) {
|
||
|
|
if (col >= endColumn) break;
|
||
|
|
if (col >= startColumn) text += cell.chars;
|
||
|
|
col += cell.width;
|
||
|
|
}
|
||
|
|
return text;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker compose", "docker");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, " compose");
|
||
|
|
// Fully echoed → stay at live cursor (11 cells → 99px), not 11+6.
|
||
|
|
assert.equal(ghost.style.left, "99px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("does not overshoot the ghost when the echoed command already wrapped", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// cols=10, prompt "$ " + "docker com" wraps; cursor sits on the
|
||
|
|
// continuation row after a fully-echoed "docker compose".
|
||
|
|
// Physical rows: "$ docker c" | "ompose|"
|
||
|
|
term.cols = 10;
|
||
|
|
term.buffer.active.baseY = 0;
|
||
|
|
term.buffer.active.cursorY = 1;
|
||
|
|
term.buffer.active.cursorX = 6;
|
||
|
|
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
|
||
|
|
0: { text: "$ docker c" },
|
||
|
|
1: { isWrapped: true, text: "ompose" },
|
||
|
|
};
|
||
|
|
term.buffer.active.getLine = (y: number) => {
|
||
|
|
const row = lines[y];
|
||
|
|
if (!row) return undefined;
|
||
|
|
return {
|
||
|
|
isWrapped: row.isWrapped,
|
||
|
|
translateToString: (
|
||
|
|
_trimRight?: boolean,
|
||
|
|
startColumn?: number,
|
||
|
|
endColumn?: number,
|
||
|
|
) => {
|
||
|
|
if (startColumn !== undefined && endColumn !== undefined) {
|
||
|
|
return row.text.padEnd(endColumn).slice(startColumn, endColumn);
|
||
|
|
}
|
||
|
|
return row.text;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker compose up", "docker compose");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, " up");
|
||
|
|
// Fully echoed across the wrap → anchor stays at live cursor (col 6),
|
||
|
|
// not liveX + full input width.
|
||
|
|
assert.equal(ghost.style.left, "54px");
|
||
|
|
assert.equal(ghost.style.top, "18px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
term.cols = 10;
|
||
|
|
term.buffer.active.cursorX = 1;
|
||
|
|
term.buffer.active.cursorY = 1;
|
||
|
|
addon.activate(term as never);
|
||
|
|
// Anchored at row 1 col 1 with 5 chars already typed.
|
||
|
|
addon.show("abcdefghij", "abcde");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
|
||
|
|
// Backspace back to 2 chars — delta = -3 across a row boundary.
|
||
|
|
addon.adjustToInput("ab");
|
||
|
|
|
||
|
|
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
|
||
|
|
assert.equal(ghost.textContent, "cdefghij");
|
||
|
|
assert.equal(ghost.style.left, "72px");
|
||
|
|
assert.equal(ghost.style.top, "0px");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides ghost immediately when input no longer matches suggestion", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "do");
|
||
|
|
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.style.display, "block");
|
||
|
|
|
||
|
|
addon.adjustToInput("dox");
|
||
|
|
|
||
|
|
assert.equal(ghost.style.display, "none");
|
||
|
|
assert.equal(ghost.textContent, "");
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("applyKeystroke: printable char trims ghost tail when buffer is unreliable (issue #906)", () => {
|
||
|
|
// Repro for issue #906: after Tab passes to shell and the typed-buffer
|
||
|
|
// is flagged unreliable, the ghost addon's currentInput is the only
|
||
|
|
// source of truth for what the user has typed since the last show().
|
||
|
|
// Without applyKeystroke, line 798's reliability gate prevents
|
||
|
|
// adjustToInput from firing and the ghost retains its show-time tail
|
||
|
|
// — when the next keystroke advances the cursor, the stale tail
|
||
|
|
// overlaps the just-typed glyph (e.g., typing 't' after 'systemctl s'
|
||
|
|
// makes the screen read 'systemctl sttop firewalld').
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("systemctl stop firewalld", "systemctl s");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, "top firewalld");
|
||
|
|
|
||
|
|
addon.applyKeystroke("t");
|
||
|
|
|
||
|
|
// Ghost tail must shrink by exactly one char so when the shell
|
||
|
|
// echoes 't', the next visible glyph after the cursor is 'o', not
|
||
|
|
// 't' (which would render as 'sttop').
|
||
|
|
assert.equal(ghost.textContent, "op firewalld");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("applyKeystroke: backspace re-grows ghost tail by one char", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "doc");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, "ker");
|
||
|
|
|
||
|
|
addon.applyKeystroke("\x7f");
|
||
|
|
|
||
|
|
assert.equal(ghost.textContent, "cker");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("applyKeystroke: Ctrl+W word-erases trailing word from currentInput", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
// Mid-suggestion: user has typed two words; Ctrl+W should drop the
|
||
|
|
// tail word and let the ghost regrow to cover what was erased.
|
||
|
|
addon.show("git commit -m wip", "git com");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
assert.equal(ghost.textContent, "mit -m wip");
|
||
|
|
|
||
|
|
addon.applyKeystroke("\x17");
|
||
|
|
|
||
|
|
// The same /\s*\S+\s*$/ regex used by handleInput consumes the
|
||
|
|
// leading whitespace too, so "git com" → "git"; the ghost regrows
|
||
|
|
// to cover the now-uncovered leading space + remainder.
|
||
|
|
assert.equal(ghost.textContent, " commit -m wip");
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("applyKeystroke: hides ghost when next char diverges from suggestion", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "do");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
|
||
|
|
// 'x' breaks the prefix invariant — ghost must hide immediately so
|
||
|
|
// a → -accept after this point can't pull a stale tail onto a line
|
||
|
|
// that no longer matches the suggestion.
|
||
|
|
addon.applyKeystroke("x");
|
||
|
|
|
||
|
|
assert.equal(ghost.style.display, "none");
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("applyKeystroke: ignores non-typing data (escape sequences, control codes)", () => {
|
||
|
|
// Escape sequences and other control codes are routed through
|
||
|
|
// clearState() in handleInput, not propagated to the ghost — but we
|
||
|
|
// want applyKeystroke to be a safe no-op if accidentally called with
|
||
|
|
// them (defense in depth).
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("docker", "do");
|
||
|
|
const ghost = ghostElement();
|
||
|
|
assert.ok(ghost);
|
||
|
|
const tailBefore = ghost.textContent;
|
||
|
|
|
||
|
|
addon.applyKeystroke("\x1b[A"); // up-arrow escape sequence
|
||
|
|
addon.applyKeystroke("\x01"); // Ctrl+A
|
||
|
|
addon.applyKeystroke(""); // empty
|
||
|
|
|
||
|
|
assert.equal(ghost.textContent, tailBefore);
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
} finally {
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
// We believe only "network in" is typed; suggestion is the full command.
|
||
|
|
addon.show("network interface show", "network in");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
// The real line shows MORE than we tracked: a bastion host echoed the
|
||
|
|
// next char ("t") that our client-side buffer never recorded.
|
||
|
|
const line = "ecOS# network int";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = line.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => line });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost when TopsecOS-style backspace leaves stale text after the cursor (#1060)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
// The user deleted back from "system..." to "syst", so the tracked
|
||
|
|
// input is shorter and the ghost regrows to "em license show".
|
||
|
|
addon.show("system license show", "syst");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
// TopsecOS devices shown in #1060 leave the old suffix visible after
|
||
|
|
// the cursor while processing Backspace, so the real buffer looks like:
|
||
|
|
// TopsecOS# syst|em license show
|
||
|
|
const beforeCursor = "TopsecOS# syst";
|
||
|
|
const line = `${beforeCursor}em license show`;
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => line });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost when only the deleted prefix remains after the cursor (#1060)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "syst");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "TopsecOS# syst";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor}em` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost when TopsecOS-style backspace leaves stale text after empty input (#1060)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "TopsecOS# ";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor}system license show` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost when stale text is followed by right-side status text (#1060)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "syst");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "TopsecOS# syst";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor}em 12:34 ok` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("hides the ghost when a stale argument suffix starts with a space (#1060)", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "system");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "TopsecOS# system";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor} license show` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), false);
|
||
|
|
assert.equal(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps the ghost when unrelated right-side prompt text is visible", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "syst");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "host# syst";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
assert.notEqual(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps the ghost when only right-side spacing overlaps a space-prefixed suffix", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "system");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "host# system";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
assert.notEqual(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps the ghost when adjacent right-side text only shares the first character", () => {
|
||
|
|
const restoreDocument = installFakeDocument();
|
||
|
|
const { term, ghostElement, fireRender } = createFakeTerm();
|
||
|
|
const addon = new GhostTextAddon();
|
||
|
|
|
||
|
|
try {
|
||
|
|
addon.activate(term as never);
|
||
|
|
addon.show("system license show", "syst");
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
|
||
|
|
const beforeCursor = "host# syst";
|
||
|
|
const active = term.buffer.active as Record<string, unknown>;
|
||
|
|
active.baseY = 0;
|
||
|
|
active.cursorX = beforeCursor.length;
|
||
|
|
active.getLine = () => ({ translateToString: () => `${beforeCursor}error` });
|
||
|
|
|
||
|
|
fireRender();
|
||
|
|
|
||
|
|
assert.equal(addon.isActive(), true);
|
||
|
|
assert.notEqual(ghostElement()?.style.display, "none");
|
||
|
|
} finally {
|
||
|
|
addon.dispose();
|
||
|
|
restoreDocument();
|
||
|
|
}
|
||
|
|
});
|