Files
Netcatty/components/terminal/GhostTextAddon.test.ts
T
陈大猫 73b27ad7c4 fix(autocomplete): sync ghost text to live input on every keystroke (#815)
* fix(autocomplete): sync ghost text to live input on every keystroke

Ghost text was displayed based on whatever input was passed to
GhostTextAddon.show() at fetch time. Between a user's keystroke and
the next debounced fetchSuggestions firing (~100ms), the on-screen
line had already advanced one character but ghost.getGhostText() still
returned the pre-update tail. Pressing → during that window pasted the
stale tail on top of the new char — e.g. type "do", suggestion shows
"cker ls"; type "c", accept immediately → "doc" + "cker ls" lands as
"doccker ls" instead of the expected "docker ls".

Two-layer fix:

1. New GhostTextAddon.adjustToInput(newInput) that re-renders the ghost
   against a fresh input without waiting for a new fetch: shrinks /
   grows the tail if the suggestion still prefix-matches, hides
   otherwise. Called from handleInput after every buffer mutation
   (printable, backspace, Ctrl-W, paste tail) when the buffer is
   reliable. Unreliable-buffer paths skip the call to avoid making the
   ghost lie.

2. Defense-in-depth at both ghost-accept sites (→ and Ctrl-→):
   recompute the tail against the live typed buffer instead of trusting
   getGhostText's show()-time state. If the suggestion no longer
   prefixes the live buffer, hide without writing. Ctrl-→ additionally
   resyncs ghost.show() to the live buffer before picking the next word
   so getNextWord operates on an up-to-date tail.

* fix(autocomplete): defer ghost text updates to the next xterm render

The previous pass made adjustToInput re-show the ghost synchronously on
every keystroke, but xterm hasn't echoed the triggering char yet at
that moment — cursorX is still the pre-keystroke position. Painting
the shrunken tail there left it visibly overlapping with the char
xterm was about to draw, and the ghost only snapped to the right
column on the next onRender tick. That one-frame overlap is the
"jitter" the reporter still saw.

Switch adjustToInput to a defer-and-reapply pattern:

- On every keystroke that should re-align the ghost, stash the desired
  input in pendingInput and hide the element immediately. The
  transient blank frame is preferable to an overlap glyph.
- The existing term.onRender listener now checks for a pending update
  first: by that tick xterm has processed the echo, cursorX has
  advanced, and we can paint the new tail at the correct column via
  applyInputUpdate.
- New isActive() exposes "has a live suggestion even if hidden waiting
  for render" so a fast "type + →" / "type + Ctrl-→" sequence in the
  hide-until-render gap still hits the accept branch and grabs the
  recomputed tail from the live buffer.

show() and hide() clear pendingInput so an explicit state change
supersedes any queued adjust.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost text, predict-anchor-shift on each keystroke

The previous refactor broke inline completion entirely:

1. useTerminalAutocomplete force-disabled showGhostText whenever
   showPopupMenu was on — and both are true by default, so ghost
   never rendered.
2. GhostTextAddon put its overlay container *under* xterm's screen
   via insertBefore + no z-index. xterm's default renderer paints
   theme.background across every cell including empty ones, so the
   ghost was fully occluded by the canvas even when the hook *did*
   call show().

Fixes both issues and lands the correct per-keystroke strategy the
jitter report was asking for:

- Drop the showGhostText-vs-showPopupMenu gate; respect user settings.
- Put the ghost container back on top of the screen (appendChild +
  z-index 1).
- Track anchorInputLength at show() time. adjustToInput now advances
  the ghost's left by (newInput.length - anchorInputLength) cells
  *synchronously* — i.e. it predicts where xterm's cursor will land
  once the echo arrives, instead of re-reading the live cursorX that
  hasn't advanced yet. textContent is trimmed in the same call, so
  ghost + real-input stay aligned across SSH echo latency with no
  one-frame overlap or blank gap.
- Updated GhostTextAddon.test.ts expectations for the new behavior
  (and cast the fake-document through unknown to fix the pre-existing
  TS error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): address ghost text review feedback

Follow-ups on the predict-anchor-shift from the previous commit,
based on a code-reviewer pass:

- Backspace / Ctrl-W de-sync: updatePosition's Math.max(0, ...) was
  clamping the delta to zero when newInput shrank below the show-time
  input length. The ghost then stayed pinned at the original anchor
  column while the real cursor walked back left, leaving a gap
  between the cursor and the ghost. Let the delta go negative so the
  ghost tracks the cursor backwards; clamp the resulting left at 0
  instead of clamping the delta.
- Resize staleness: onResize now also resets lastLeft/lastTop and
  re-renders, so the dedup cache in updatePosition doesn't hide a
  now-stale pixel coordinate after xterm recomputes cell dims.
- Added a regression test for the backspace path covering both the
  step-back-below-anchor case and the clamp-at-0-on-overshoot case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): don't accept whole suggestion when buffer is unreliable

Codex flagged (#815 P1 ×2) that the live-buffer recompute on → and
Ctrl-→ falls into a degenerate path when typedBufferReliableRef is
false. My previous cut used live = "" as the fallback, but
fullSuggestion.startsWith("") is always true — so:

- → would write the entire suggestion over whatever is on the line
  (post history-recall ↑, Ctrl-R reverse search, etc.).
- Ctrl-→ would reanchor the ghost at the start and getNextWord would
  hand back the first token, duplicating leading content on top of
  the recalled command.

When the buffer is unreliable, empty buffer ≠ empty line — the line
has content we're not tracking. Fall back to the ghost's own cached
state instead of recomputing:

- → reliable: recompute tail vs live buffer, flip buffer to the
  accepted suggestion, reliability back on.
- → unreliable: use ghost.getGhostText() (shown-at-show-time tail)
  and don't touch the buffer/reliability flag.
- Ctrl-→ reliable: resync ghost to live, then proceed as before.
- Ctrl-→ unreliable: skip the resync, derive the shrink baseline from
  fullSuggestion - current-ghost-tail so the next-word logic still
  works off whatever the ghost was actually showing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): hide ghost on single-byte cursor/recall control chars

Reviewer caught that Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E and
friends flip typedBufferReliableRef to false but don't hide the
ghost — leaving it rendering a tail tied to the pre-recall line. The
previous commit's unreliable-→ fallback then reads that stale tail
via ghost.getGhostText() and writes it onto the recalled line,
reproducing the very duplication class the fallback was meant to
prevent (just triggered by Ctrl-P instead of ↑).

Mirror what the escape-sequence branch already does: clearState() +
return. Once the ghost is hidden, ghost.isActive() is false at the →
and Ctrl-→ gates, so the accept-path doesn't fire at all until a
fresh fetchSuggestions re-anchors it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): drop accepted-command cache on cursor/recall keys

Reviewer pointed out that the early returns in the single-byte
ctrl-char and escape-sequence branches leave lastAcceptedCommandRef
untouched. If the user accepts a suggestion via → and then immediately
hits Ctrl-R or ↑ to pick a different command, the fast Enter path
(lines ~611-612) still reads the cached accepted command and records
it — logging the old suggestion instead of whichever command the
reverse-search or history-recall actually ran.

Null lastAcceptedCommandRef at the top of both branches (same place
we hide the ghost and flip reliability off) so accept + recall + Enter
records the recalled command, not the stale accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): also null accepted-command cache on Ctrl-C / Ctrl-U

Reviewer flagged this class of bug is still reachable via Ctrl-C /
Ctrl-U. The branch handling those kills the zle line, but the early
return leaves lastAcceptedCommandRef pointing at a command that is
no longer on the line: accept "git status" via → → Ctrl-C to abandon
→ type "ls" → Enter logs "git status" via the fast path instead of
"ls".

Same one-liner as the other early-return branches: null the cache
alongside clearState(). Now the cache's lifetime truly ends at any
event that invalidates the accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): null accepted-command cache on bracketed paste too

Fifth-pass reviewer caught the last symmetric gap: the bracketed-paste
branch appends pasted bytes to the buffer but leaves lastAcceptedCommandRef
set. Accept "git status" via → then bracketed-paste " --short" (no
embedded newline), press Enter — the fast path at line 611 still reads
"git status" and logs that instead of "git status --short".

Mirror the non-bracketed paste branch: null the cache before clearState()
returns. All handleInput paths that extend or invalidate the line now
consistently end the cache's lifetime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): predict ghost column by cell width + wrap at EOL

Review caught two geometry bugs in GhostTextAddon.updatePosition that
only surfaced outside the ASCII happy path:

- CJK / fullwidth / emoji glyphs occupy two xterm cells but the
  predictor advanced by one char-length per code unit, so ghost
  drifted one cell left for every wide char typed and visibly
  overlapped the user's glyph.
- When the predicted column crossed term.cols the real cursor wrapped
  to the next row, but the predictor just piled more pixels onto
  `left` — ghost walked off the right edge instead of following
  onto the next line.

Fix both by switching from code-unit count to a small EAW-style
width classifier, then applying row wrapping via
  col = (anchorX + cellDelta) % cols
  rowOffset = Math.floor((anchorX + cellDelta) / cols)
against the current term.cols. Fake terminal in the test suite now
exposes cols/rows so the unit tests can exercise both invariants:

- "advances the anchor by two cells when a CJK glyph is typed"
- "wraps the ghost to the next row when the predicted column crosses cols"

Known limitation the review already flagged: on backspace-after-wide
we don't have per-grapheme widths to reverse exactly, so the negative
delta falls back to code-unit width on the deleted slice. The slice
is `currentSuggestion[currentInput.length..anchorInputLength]` which
is the same text the user would have typed, so it's correct when
only ASCII edits; wide-char backspace can still drift by one cell.
Fixing this cleanly needs a per-grapheme buffer and is out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): honor showGhostText toggle while a ghost is on screen

Codex flagged (#815 P2) that fetchSuggestions gates new ghost shows
on settingsRef.current.showGhostText, but handleInput's adjustToInput
call had no such guard. A ghost that was already active at the moment
the user turned showGhostText off would keep tracking the typed
buffer via adjustToInput on every keystroke, so the "disabled" setting
only took hold after some unrelated path called clearState().

Two-part fix:

- Add a useEffect watching settings.showGhostText. When it flips false,
  hide the active ghost immediately so the disabled setting applies to
  whatever was already on screen.
- Gate the adjustToInput call in handleInput behind
  settingsRef.current.showGhostText too, so subsequent keystrokes under
  the disabled setting don't try to move or re-show a ghost.

Codex's earlier P2 about wrap-at-EOL on line 236 is already resolved
by e61f0e8b (predict-column-with-wrap + CJK width); that comment is
against an older commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): self-heal stale anchor + handle backward-wrap on delete

Codex flagged two real geometry gaps in the predict-anchor-shift math:

1. Stale anchor on high-latency shells. show() captures cursorX from
   xterm at debounce-fire time, but under SSH round-trip latency the
   user's latest keystroke may not have echoed yet — cursorX is still
   the pre-echo column. With updatePosition now purely anchor-based
   (no longer reading live cursorX on every render), that stale anchor
   becomes frozen; the ghost stays one-plus cells off for the whole
   suggestion session until another show() rebuilds it.
2. Backspace crossing a wrapped row boundary. Math.max(0, ...) clamped
   targetCol at zero, so deletions past column 0 stayed pinned to the
   current row instead of wrapping back to the previous row — exactly
   the symmetric case the forward wrap added in e61f0e8b handles.

Fixes:

- Self-heal in updatePosition: while no adjustToInput has moved us
  from the show-time baseline (currentInput.length === anchorInputLength),
  re-read live cursorX/Y each render tick. Once the user starts typing
  the anchor is frozen and delta math takes over.
- Normalize the wrap for negative targetCol: `col = targetCol % cols`
  plus `if (col < 0) col += cols`, `rowOffset = Math.floor(targetCol/cols)`
  naturally yielding -1 on underflow. Clamp `top` at row 0 so a
  runaway negative doesn't render above the terminal.

Two new tests cover both invariants:
- "self-heals a stale anchor on render while no adjustToInput has fired"
- "wraps the ghost to the previous row when deletion crosses a row boundary"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost/popup mutual-exclusivity guard in hook

Codex flagged (#815 P2) that dropping the popup-wins-over-ghost
normalization inside useTerminalAutocomplete weakens the hook's own
defensive invariant. The repo enforces mutual exclusivity in two
places already — SettingsTerminalTab toggles one off when the other
turns on, and domain/models.ts normalizes stored settings so
autocompletePopupMenu === true forces autocompleteGhostText to false
— so on the normal Terminal.tsx → store path only one of the two
arrives as true. But the hook's own defaults (DEFAULT_AUTOCOMPLETE_SETTINGS)
have both flags true, and any caller that builds settings directly
from those defaults (tests, future embedders) would end up rendering
popup + inline ghost simultaneously against the repo-wide contract.

Restore the guard, comment it as defensive rather than load-bearing
so future readers don't mistake it for the hiding-invisible-ghost
bug I was fixing last time (that was really the insertBefore /
z-index issue in GhostTextAddon.ts, not this normalization).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:06:26 +08:00

326 lines
9.3 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("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();
}
});