[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
537
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
537
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user