fix #1013: stop ghost text from drawing over untracked echoed input (#1042)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length) after the cursor, where trackedInput is a client-side reconstruction of the command line (buffer heuristics + keystroke prediction, to mask SSH echo latency). On hosts with non-standard echo — hardware bastion hosts / network OS like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and the ghost gets painted over characters the user already typed (`int` + ghost `terface` -> `intterface`). Add a fail-safe consistency check: on each post-echo render, if the real terminal line before the cursor contains the tracked input followed by more untracked, non-whitespace characters (reality is AHEAD of what we tracked), hide the ghost instead of drawing it over real text. SSH echo latency is the opposite case (the line is a prefix-behind of the tracked input) and is deliberately not flagged, so the ghost stays responsive on slow links. The check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so it can only ever suppress a ghost that would otherwise corrupt — never change correct behaviour. This converts the recurring "ghost shows already-typed characters" bug into "ghost simply doesn't show" on devices we can't track reliably. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -450,3 +450,32 @@ test("applyKeystroke: ignores non-typing data (escape sequences, control codes)"
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
@@ -112,9 +113,16 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
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.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -291,6 +299,23 @@ export class GhostTextAddon implements IDisposable {
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term || !this.currentInput) 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);
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
|
||||
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Fail-safe consistency check for inline (ghost-text) suggestions.
|
||||
*
|
||||
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
|
||||
* cursor, where `trackedInput` is what the client thinks the user has typed.
|
||||
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
|
||||
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
|
||||
* of sync with what is actually on the terminal line, and the ghost ends up
|
||||
* painted over characters the user already typed (`int` + ghost `terface` →
|
||||
* `intterface`).
|
||||
*
|
||||
* This detects the one direction that produces visible corruption: the real
|
||||
* line being AHEAD of the tracked input (it contains the tracked input
|
||||
* followed by more, untracked characters). SSH echo latency is the opposite
|
||||
* case — the line is a prefix-behind of the tracked input — and is
|
||||
* intentionally NOT flagged, so the ghost stays responsive on slow links.
|
||||
*
|
||||
* Returns true when the caller should hide the ghost.
|
||||
*/
|
||||
export function lineHasUntrackedTrailingInput(
|
||||
trackedInput: string,
|
||||
lineBeforeCursor: string,
|
||||
): boolean {
|
||||
// Single chars match too loosely to judge reliably; let them through.
|
||||
if (trackedInput.length < 2) return false;
|
||||
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
|
||||
// existing wide-char (CJK / emoji) handling is left untouched.
|
||||
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
|
||||
|
||||
// Use the last occurrence so a prompt or command that repeats the same token
|
||||
// earlier on the line doesn't shadow the freshly-typed input.
|
||||
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
|
||||
if (idx < 0) {
|
||||
// Tracked input isn't on screen yet — the echo is still catching up
|
||||
// (latency). Keep the ghost; reality being behind never corrupts.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-whitespace characters between the tracked input and the cursor mean the
|
||||
// device echoed input we never tracked → the ghost would overlap real text.
|
||||
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
|
||||
}
|
||||
45
components/terminal/ghostTextConsistency.test.ts
Normal file
45
components/terminal/ghostTextConsistency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
|
||||
|
||||
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
|
||||
});
|
||||
|
||||
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
|
||||
// Tracked is one char behind what the device actually shows.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
|
||||
});
|
||||
|
||||
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
|
||||
// The tracked input hasn't been fully echoed yet — reality being behind
|
||||
// never corrupts, so the ghost must stay.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
|
||||
});
|
||||
|
||||
test("ignores trailing whitespace after the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
|
||||
});
|
||||
|
||||
test("hides when untracked non-space input follows the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
|
||||
});
|
||||
|
||||
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
|
||||
// Prompt contains 'int'; the real typed 'int' is the one at the end.
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
|
||||
});
|
||||
|
||||
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
|
||||
});
|
||||
|
||||
test("skips single-character input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
|
||||
});
|
||||
|
||||
test("returns false when the tracked input isn't on the line yet (latency)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user