Files
NetMesh/components/terminal/autocomplete/GhostTextAddon.ts

538 lines
20 KiB
TypeScript
Raw Normal View History

/**
* Ghost Text addon for xterm.js.
* Renders inline suggestion text after the cursor in a dimmed style,
* similar to fish shell's autosuggestions.
*
* Uses a CSS overlay positioned relative to the terminal cursor,
* avoiding modification of the terminal buffer.
*/
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
import { stringCellWidth } from "./terminalStringCellWidth";
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;
}
/** Longest prefix of `input` that is already a suffix of `beforeCursor`. */
function echoedInputPrefixLength(beforeCursor: string, input: string): number {
let n = Math.min(beforeCursor.length, input.length);
while (n > 0 && !beforeCursor.endsWith(input.slice(0, n))) {
n -= 1;
}
return n;
}
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] === " "
);
}
type BufferLineLike = {
isWrapped?: boolean;
translateToString?: (
trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => string;
};
type ActiveBufferLike = {
baseY: number;
cursorY: number;
cursorX: number;
getLine?: (y: number) => BufferLineLike | undefined;
};
/**
* Text before the cursor across wrapped physical rows. `getLine` only returns
* one row, so a wrapped command's current row cannot end with the full
* `currentInput` callers must reconstruct the logical line or they will
* treat already-echoed text as unechoed.
*/
function readBeforeCursorAcrossWraps(
buf: ActiveBufferLike,
cols: number,
): string | null {
if (typeof buf.getLine !== "function") return null;
const absY = buf.baseY + buf.cursorY;
let line = buf.getLine(absY);
if (!line || typeof line.translateToString !== "function") return null;
// cursorX is a cell column, not a UTF-16 offset — slice() breaks on
// wide / multi-code-unit graphemes (emoji prompts, CJK).
let beforeCursor = line.translateToString(false, 0, buf.cursorX);
let y = absY;
while (line.isWrapped && y > 0) {
y -= 1;
line = buf.getLine(y);
if (!line || typeof line.translateToString !== "function") break;
// Keep wrap seams aligned with the terminal width (do not trimRight).
const rowCols = cols > 0 ? cols : undefined;
const rowText = rowCols === undefined
? line.translateToString(false)
: line.translateToString(false, 0, rowCols);
beforeCursor = rowText + beforeCursor;
}
return beforeCursor;
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private hintElement: HTMLSpanElement | null = null;
private hintActive = false;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
private anchorCursorX = 0;
/** Cursor row captured at show() time. */
private anchorCursorY = 0;
/** Length of currentInput at show() time lets adjustToInput shift left
* by (newInput.length - anchorInputLength) cells without having to
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
private anchorInputLength = 0;
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
private lastTop = -1;
activate(term: XTerm): void {
this.term = term;
const termElement = term.element;
if (!termElement) return;
this.containerElement = document.createElement("div");
this.containerElement.className = "xterm-ghost-text-container";
Object.assign(this.containerElement.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
// Sit above xterm's canvas — xterm's default renderer paints its
// theme.background across every cell including empty ones, so a
// ghost placed beneath the canvas would be completely occluded.
zIndex: "1",
});
this.ghostElement = document.createElement("span");
this.ghostElement.className = "xterm-ghost-text";
Object.assign(this.ghostElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.ghostElement);
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
// independently of autocomplete suggestions and never accepted as input.
this.hintElement = document.createElement("span");
this.hintElement.className = "xterm-inline-hint";
Object.assign(this.hintElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.hintElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
} else {
termElement.appendChild(this.containerElement);
}
this.disposables.push(
term.onRender(() => {
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/#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;
}
this.updatePosition();
}),
);
// Invalidate cell dimension cache on resize so measurements stay
// accurate, and force a pixel-coord recompute on the next render —
// otherwise the lastLeft/lastTop short-circuit in updatePosition
// would keep the ghost at stale pixel coordinates until the user
// typed again.
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
this.lastLeft = -1;
this.lastTop = -1;
if (this.isVisible()) this.updatePosition();
if (this.hintActive) this.updateHintPosition();
}),
);
}
/**
* Show ghost text suggestion.
* @param fullSuggestion The complete suggested command
* @param currentInput The text the user has typed so far
*/
show(fullSuggestion: string, currentInput: string): void {
if (this.disposed || !this.ghostElement || !this.term) return;
const ghostText = fullSuggestion.startsWith(currentInput)
? fullSuggestion.substring(currentInput.length)
: "";
if (!ghostText) {
this.hide();
return;
}
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
const buf = this.term.buffer.active;
const liveX = buf.cursorX;
// When show() runs before the shell echoes `currentInput` (CJK IME /
// high-latency SSH), live cursorX is still at the prompt. Advance the
// anchor by the pending input's cell width so the ghost sits after it
// instead of painting over it. Skip the probe when getLine is unavailable
// (unit fakes) so those tests keep the legacy "cursor already at end"
// contract.
let anchorX = liveX;
if (
currentInput.length > 0 &&
typeof buf.getLine === "function"
) {
const beforeCursor = readBeforeCursorAcrossWraps(
buf as ActiveBufferLike,
this.term.cols,
);
if (beforeCursor !== null && !beforeCursor.endsWith(currentInput)) {
// Shell may have echoed only a prefix (e.g. "$ doc" while
// currentInput is "docker"). Advance by the unechoed suffix only —
// adding the full input width on top of a partially-advanced liveX
// overshoots and Math.max self-heal cannot move the ghost left.
const unechoed = currentInput.slice(
echoedInputPrefixLength(beforeCursor, currentInput),
);
anchorX = liveX + stringCellWidth(unechoed, this.term);
}
}
this.anchorCursorX = anchorX;
this.anchorCursorY = buf.cursorY;
this.anchorInputLength = currentInput.length;
// Force position recalc since the text also changed.
this.lastLeft = -1;
this.lastTop = -1;
this.updatePosition();
this.ghostElement.textContent = ghostText;
this.ghostElement.style.display = "block";
// Set font properties once per show (not per frame in updatePosition)
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
}
hide(): void {
if (this.ghostElement) {
this.ghostElement.style.display = "none";
this.ghostElement.textContent = "";
}
this.currentSuggestion = "";
this.currentInput = "";
this.anchorInputLength = 0;
}
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
* hint). Independent of autocomplete suggestions; never accepted as input. */
showHint(text: string): void {
if (this.disposed || !this.hintElement || !this.term) return;
this.hintActive = true;
this.hintElement.textContent = text;
this.hintElement.style.display = "block";
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
this.updateHintPosition();
}
hideHint(): void {
this.hintActive = false;
if (this.hintElement) {
this.hintElement.style.display = "none";
this.hintElement.textContent = "";
}
}
isHintActive(): boolean {
return this.hintActive;
}
private updateHintPosition(): void {
if (!this.term || !this.hintElement) return;
const dims = getXTermCellDimensions(this.term);
const buf = this.term.buffer.active;
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
this.hintElement.style.lineHeight = `${dims.height}px`;
this.hintElement.style.height = `${dims.height}px`;
}
/**
* Re-align the ghost against a freshly-updated user input synchronously.
* Called from handleInput on every keystroke that mutates the typed
* buffer so ghost text never falls out of sync with what the user has
* actually typed.
*
* Implementation relies on the predict-anchor-shift trick rather than
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
* keystroke yet at this point, so cursorX still points at the
* pre-keystroke column. Instead we track the cursor column captured
* at show() time and advance the ghost's left by the number of chars
* typed since so the tail aligns with where the real cursor *will*
* land once the echo arrives, even across SSH round-trip latency.
*/
adjustToInput(newInput: string): void {
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
if (!this.currentSuggestion.startsWith(newInput)) {
this.hide();
return;
}
this.currentInput = newInput;
const ghostText = this.currentSuggestion.substring(newInput.length);
if (!ghostText) {
this.hide();
return;
}
// Force position recomputation — updatePosition skips DOM writes
// when the left/top cache hasn't changed, but we also need the new
// textContent to flush.
this.lastLeft = -1;
this.lastTop = -1;
this.ghostElement.textContent = ghostText;
this.updatePosition();
this.ghostElement.style.display = "block";
}
/**
* Apply a single keystroke's effect to the ghost without consulting the
* outer typed-input buffer. Used when that buffer's reliability flag is
* off (post-Tab, history recall, cursor moves) without this hook the
* gate at handleInput's adjustToInput call would freeze the ghost at
* the previous show()'s tail, and a subsequent -accept would paste
* that stale tail on top of the chars typed in the meantime
* (sttop/dduplicate-glyph bug, issue #906).
*
* Only forwards events the ghost can locally re-derive: a printable
* char appends, Backspace/DEL slices off one char, Ctrl-W performs
* the same trailing-word erase as zsh/bash. Anything else (escape
* sequences, other control codes) is treated as a no-op those
* paths already clearState() in handleInput, so by the time the user
* could trigger an accept, the ghost is gone.
*/
applyKeystroke(data: string): void {
if (this.disposed || !this.currentSuggestion || !data) return;
let nextInput: string;
if (data === "\x7f" || data === "\b") {
if (this.currentInput.length === 0) return;
nextInput = this.currentInput.slice(0, -1);
} else if (data === "\x17") {
const erased = this.currentInput.replace(/\s*\S+\s*$/, "");
if (erased === this.currentInput) return;
nextInput = erased;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
nextInput = this.currentInput + data;
} else {
return;
}
this.adjustToInput(nextInput);
}
getSuggestion(): string {
return this.currentSuggestion;
}
isVisible(): boolean {
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
this.currentSuggestion);
}
/**
* True when the ghost has a live suggestion even if it's momentarily
* shown underneath the real text while the user keeps typing within
* the prediction. Accept-path gates should use this instead of
* isVisible() so the suggestion remains available even while its
* leading characters are fully covered by real glyphs.
*/
isActive(): boolean {
return !this.disposed && !!this.currentSuggestion;
}
getGhostText(): string {
if (!this.currentSuggestion) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
}
getNextWord(): string {
const ghost = this.getGhostText();
if (!ghost) return "";
const trimmed = ghost.replace(/^\s+/, "");
const leadingSpace = ghost.length - trimmed.length;
if (trimmed.length === 0) return ghost; // Only whitespace
// Search for word boundary starting from index 1 (skip leading separator chars like /)
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
if (wordEnd < 0) return ghost; // Single word, accept all
// Include leading whitespace + the word up to (and including) the separator
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
/**
* 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) 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 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);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fired during the SSH
// keystroke→echo gap without a line probe, cursorX may still be the
// pre-echo column. While no adjustToInput has moved us from the
// show-time baseline, adopt a live cursor that has advanced (echo
// caught up). Use max on the same row so a cell-width-predicted
// pre-echo anchor is not collapsed back onto the prompt before echo
// arrives. When the live row advances, the predicted X may already
// encode a wrap (column >= cols); adopting the live X/Y pair avoids
// counting that wrap again in the modulo math below.
// When the predicted wrap happens on the bottom row, the echo scrolls
// the buffer and Y stays put — adopt live X/Y once it matches the
// normalized wrap column so Math.max cannot keep the unnormalized X.
if (this.currentInput.length === this.anchorInputLength) {
const liveX = this.term.buffer.active.cursorX;
const liveY = this.term.buffer.active.cursorY;
const cols = Math.max(1, this.term.cols);
if (liveY !== this.anchorCursorY) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else if (
this.anchorCursorX >= cols &&
liveX === this.anchorCursorX % cols
) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else {
this.anchorCursorX = Math.max(this.anchorCursorX, liveX);
}
}
const dims = getXTermCellDimensions(this.term);
// Advance (or walk back) the anchor column by the cell width of
// whatever the user has typed since show() was called. Using cell
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
// negative delta by shrinking currentInput below anchorInputLength.
const cellDelta = this.currentInput.length >= this.anchorInputLength
? stringCellWidth(this.currentInput.slice(this.anchorInputLength), this.term)
: -stringCellWidth(
// currentSuggestion[0..anchorInputLength] equals what was typed
// when show() fired (prefix-match invariant), so its slice gives
// the correct cell widths for the deleted glyphs.
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
this.term,
);
const cols = Math.max(1, this.term.cols);
const targetCol = this.anchorCursorX + cellDelta;
// Wrap the predicted cursor position across line boundaries in both
// directions — the real xterm cursor wraps to the next row once it
// crosses cols forward, and to the previous row when a deletion
// crosses back past column 0. JS `%` returns negative for negative
// dividends, so normalize both col and rowOffset explicitly.
let col = targetCol % cols;
let rowOffset = Math.floor(targetCol / cols);
if (col < 0) {
col += cols;
}
// Clamp to the visible top row so a runaway negative delta (e.g.
// deleted past the prompt) doesn't render above the terminal.
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
const left = col * dims.width;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;
this.lastLeft = left;
this.lastTop = top;
this.ghostElement.style.left = `${left}px`;
this.ghostElement.style.top = `${top}px`;
this.ghostElement.style.lineHeight = `${dims.height}px`;
this.ghostElement.style.height = `${dims.height}px`;
}
dispose(): void {
this.disposed = true;
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.hintElement = null;
this.term = null;
}
}