/** * 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; } }