Fix ghost autocomplete after Topsec backspace echo

This commit is contained in:
bincxz
2026-06-29 11:19:31 +08:00
parent 30d78ebc5a
commit 6c04cecbba
2 changed files with 250 additions and 11 deletions
+214
View File
@@ -479,3 +479,217 @@ test("hides the ghost on render when the device echoed untracked input (#1013)",
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();
}
});
@@ -47,6 +47,26 @@ function stringCellWidth(s: string): number {
return w;
}
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
let i = 0;
while (i < max && a[i] === b[i]) i += 1;
return i;
}
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
if (!ghostText || !afterCursor) return false;
const visibleAfterCursor = afterCursor.trimEnd();
const overlap = commonPrefixLength(ghostText, visibleAfterCursor);
if (overlap <= 0) return false;
if (ghostText.slice(0, overlap).trim().length === 0) return false;
return (
overlap === ghostText.length ||
overlap === visibleAfterCursor.length ||
afterCursor[overlap] === " "
);
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
@@ -135,9 +155,9 @@ export class GhostTextAddon implements IDisposable {
if (this.hintActive) this.updateHintPosition();
if (!this.isVisible()) return;
// Fail-safe: if the device echoed input we didn't track (some bastion
// hosts / network OS, #1013), hide rather than draw the ghost over
// already-typed text. Done here (post-echo render) rather than in
// show()/adjustToInput so it never fights the keystroke-time path.
// hosts / network OS, #1013/#1060), hide rather than draw the ghost
// over already-visible text. Done here (post-echo render) rather than
// in show()/adjustToInput so it never fights the keystroke-time path.
if (this.realLineHasUntrackedInput()) {
this.hide();
return;
@@ -331,7 +351,7 @@ export class GhostTextAddon implements IDisposable {
}
getGhostText(): string {
if (!this.currentSuggestion || !this.currentInput) return "";
if (!this.currentSuggestion) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
@@ -355,19 +375,24 @@ export class GhostTextAddon implements IDisposable {
}
/**
* True when the real terminal line has more input than we tracked, so
* rendering the ghost would paint over already-typed characters. See
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
* we can't judge (non-ASCII, echo still catching up), so the ghost only
* gets suppressed when corruption is actually imminent.
* True when the real terminal line has input we did not track, or already
* visible text exactly matches the ghost we are about to paint. See
* ./ghostTextConsistency and issues #1013 and #1060. Returns false on
* hosts/inputs we can't judge (non-ASCII, echo still catching up), so the
* ghost only gets suppressed when corruption is actually imminent.
*/
private realLineHasUntrackedInput(): boolean {
if (!this.term || !this.currentInput) return false;
if (!this.term) return false;
const buf = this.term.buffer.active;
if (typeof buf?.getLine !== "function") return false;
const line = buf.getLine(buf.baseY + buf.cursorY);
if (!line || typeof line.translateToString !== "function") return false;
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
const lineText = line.translateToString(false);
const beforeCursor = lineText.slice(0, buf.cursorX);
const afterCursor = lineText.slice(buf.cursorX);
const ghostText = this.getGhostText();
if (hasVisibleGhostPrefix(ghostText, afterCursor)) return true;
if (!this.currentInput) return false;
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
}