diff --git a/components/terminal/GhostTextAddon.test.ts b/components/terminal/GhostTextAddon.test.ts index 19bbd4428..f06a9419b 100644 --- a/components/terminal/GhostTextAddon.test.ts +++ b/components/terminal/GhostTextAddon.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + } +}); diff --git a/components/terminal/autocomplete/GhostTextAddon.ts b/components/terminal/autocomplete/GhostTextAddon.ts index a8649402e..a95901d76 100644 --- a/components/terminal/autocomplete/GhostTextAddon.ts +++ b/components/terminal/autocomplete/GhostTextAddon.ts @@ -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); }