Compare commits

...
13 changed files with 937 additions and 87 deletions

No files matched your search

+223
View File
@@ -274,6 +274,229 @@ test("self-heals a stale anchor on render while no adjustToInput has fired", ()
}
});
test("self-heal adopts live X/Y when echo wraps instead of double-counting", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Prompt ends at col 8; four pending cells predict X=12 on a 10-col
// terminal (wraps to col 2 / row 1). When echo lands there, Math.max
// on X alone would keep 12 and paint the ghost on row 2.
term.cols = 10;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 0;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Predicted wrap: col 2 on row 1 → left 18px, top 18px.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 1;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heal adopts live X when a bottom-row wrap scrolls instead of changing Y", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Bottom-row prediction: prompt at col 8 + 4 pending cells → X=12.
// Echo scrolls the buffer so Y stays on the last row while live X
// becomes the normalized wrap column (2). Math.max would keep 12 and
// paint the ghost one row below the visible screen.
term.cols = 10;
term.rows = 24;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 23;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Pre-echo: col 2 on predicted row 24 → top 24*18.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "432px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 23;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "414px");
} finally {
restoreDocument();
}
});
test("anchors ghost after wide pre-echo input when the line has not echoed yet", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
// Live cursor still at the prompt; typed "部署" is only in the keystroke buffer.
addon.show("部署脚本", "部署");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "脚本");
// cursorX=2 + 4 wide cells → column 6 → left 54px.
assert.equal(ghost.style.left, "54px");
} finally {
restoreDocument();
}
});
test("anchors ghost using only the unechoed suffix after a partial shell echo", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shell has echoed "$ doc"; buffered input is still the full "docker".
term.buffer.active.cursorX = 5;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ doc",
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Unechoed "ker" is 3 cells → column 8 → left 72px (not 5+6=11).
assert.equal(ghost.style.left, "72px");
} finally {
restoreDocument();
}
});
test("anchors ghost by cell columns when the prompt is a multi-code-unit emoji", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Family ZWJ emoji is many UTF-16 units but only 2 terminal cells.
// A UTF-16 slice(0, cursorX) would stop inside the emoji and treat the
// fully-echoed "docker" as unechoed, shifting the ghost right.
const emoji = "👨‍👩‍👧‍👦";
const cells: Array<{ chars: string; width: number }> = [
{ chars: emoji, width: 2 },
{ chars: " ", width: 1 },
{ chars: "$", width: 1 },
{ chars: " ", width: 1 },
...Array.from("docker", (ch) => ({ chars: ch, width: 1 })),
];
const totalCols = cells.reduce((sum, cell) => sum + cell.width, 0);
term.buffer.active.cursorX = totalCols;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = totalCols,
) => {
let col = 0;
let text = "";
for (const cell of cells) {
if (col >= endColumn) break;
if (col >= startColumn) text += cell.chars;
col += cell.width;
}
return text;
},
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Fully echoed → stay at live cursor (11 cells → 99px), not 11+6.
assert.equal(ghost.style.left, "99px");
} finally {
restoreDocument();
}
});
test("does not overshoot the ghost when the echoed command already wrapped", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// cols=10, prompt "$ " + "docker com" wraps; cursor sits on the
// continuation row after a fully-echoed "docker compose".
// Physical rows: "$ docker c" | "ompose|"
term.cols = 10;
term.buffer.active.baseY = 0;
term.buffer.active.cursorY = 1;
term.buffer.active.cursorX = 6;
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "ompose" },
};
term.buffer.active.getLine = (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: (
_trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => {
if (startColumn !== undefined && endColumn !== undefined) {
return row.text.padEnd(endColumn).slice(startColumn, endColumn);
}
return row.text;
},
};
};
addon.activate(term as never);
addon.show("docker compose up", "docker compose");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " up");
// Fully echoed across the wrap → anchor stays at live cursor (col 6),
// not liveX + full input width.
assert.equal(ghost.style.left, "54px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
@@ -59,6 +59,61 @@ test("keeps raw input when a standard shell prompt echo is still behind", () =>
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, null);
});
test("uses reliable typed buffer when prompt echo has not started (IME / high-latency commit)", () => {
const term = createFakeTerm("$ ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.prompt.cursorOffset, 2);
// Pre-echo input is surfaced for alignment, but empty echo alone must not
// authorize history recording or any completions (password-shaped
// `read -s -p '$ '` prompts look identical until echo arrives).
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on padded themed prompts", () => {
// robbyrussell-style PS1 pads after the arrow: detectPrompt keeps the
// trailing space in userInput, which must still count as visually empty.
const term = createFakeTerm("➜ ", 3);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on Nerd Font themed terminators", () => {
const term = createFakeTerm(" ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("does not treat empty-echo shell-shaped prompts as validated typed input", () => {
const term = createFakeTerm("$ ", 2);
const secret = "s3cret-token";
const result = getAlignedPrompt(term as never, secret, true);
assert.equal(result.prompt.isAtPrompt, true);
// Keystroke buffer is still visible on the prompt view, but empty echo
// must not authorize history recording or suggestion acceptance.
assert.equal(result.prompt.userInput, secret);
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("still trims prompt decorations out of the detected input", () => {
const term = createFakeTerm("➜ ~ do", 7);
@@ -95,6 +95,7 @@ export function TerminalAutocomplete({
) => {
const normalizedProtocol: NetcattyTerminalSessionSnapshot['protocol'] = protocol ?? "ssh";
const pluginRegistry = isPluginCompletionProviderAvailable?.() === false
|| options.allowExternalProviders === false
|| !shouldUsePluginTerminalCompletionProvider({
sensitiveInputActive: sensitiveInputActiveRef.current === true,
promptText: options.promptText,
@@ -10,42 +10,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
* (CJK ideographs, fullwidth forms, most emoji, hangul syllables) and
* 1 otherwise. Not full wcwidth — just enough to keep the predicted
* ghost column from drifting by one cell per CJK char typed.
*/
function codePointCellWidth(cp: number): number {
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function stringCellWidth(s: string): number {
let w = 0;
for (const ch of s) {
const cp = ch.codePointAt(0) ?? 0;
w += codePointCellWidth(cp);
}
return w;
}
import { stringCellWidth } from "./terminalStringCellWidth";
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
@@ -54,6 +19,15 @@ function commonPrefixLength(a: string, b: string): number {
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();
@@ -67,6 +41,55 @@ function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean
);
}
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;
@@ -201,8 +224,36 @@ export class GhostTextAddon implements IDisposable {
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
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;
@@ -399,14 +450,34 @@ export class GhostTextAddon implements IDisposable {
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fires during the SSH
// keystroke→echo gap, cursorX captured there is still the
// 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, re-read live cursor on each render tick so
// the anchor snaps to the echoed position once it arrives.
// 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) {
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
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);
@@ -417,12 +488,13 @@ export class GhostTextAddon implements IDisposable {
// 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))
? 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;
@@ -66,6 +66,14 @@ export interface AlignedPromptResult {
* to record it as the executed command.
*/
alignedTyped: string | null;
/**
* When false, `prompt.userInput` was filled from the keystroke buffer
* before any shell echo. Empty echo is also what echo-disabled password
* prompts look like, so callers must not surface or accept completions
* (built-in or external) and must not authorize history recording
* (`alignedTyped`). Omitted/true means the live line validated input.
*/
allowExternalProviders?: boolean;
}
function getCursorLinePrefix(term: XTerm): string | null {
@@ -392,6 +400,10 @@ function canUseReliablePromptPrefix(
raw: PromptDetectionResult,
typedBuffer: string,
): boolean {
// Empty echo alone is not validation: echo-disabled prompts can look like
// a normal shell PS1 (e.g. `read -s -p '$ '`), and treating the keystroke
// buffer as alignedTyped would authorize history recording. Pre-echo
// autocomplete uses a separate path that keeps alignedTyped null.
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
return false;
}
@@ -909,6 +921,22 @@ export function getAlignedPrompt(
alignedTyped: typedBuffer,
};
}
// No echo yet (CJK IME / high-latency SSH): surface the keystroke buffer
// on prompts detectPrompt already recognizes (#2813), but do not set
// alignedTyped. Empty / whitespace-only echo is also what echo-disabled
// password prompts and padded themed PS1s look like, so this path must
// not authorize history recording, built-in suggestion acceptance, or
// third-party completion providers until echo validates the line.
if (
raw.userInput.trim().length === 0 &&
(allowsShortPromptEcho(raw.promptText) || isThemedPromptText(raw.promptText))
) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: null,
allowExternalProviders: false,
};
}
}
const cursorPrefixCandidates = [
@@ -2,10 +2,12 @@
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Each suggestion carries the full Snippet so the
* accept path can run it through the canonical executeSnippetCommand.
* above substring matches). Chinese labels also match via pinyin / initials
* through the shared search matcher (#2813). Each suggestion carries the full
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
@@ -34,7 +36,12 @@ export function getSnippetSuggestions(
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
// Literal prefix/substring first (cheap); fall back to shared smart matcher
// so Chinese titles surface for pinyin / initials the same way host search does.
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
if (!matches) continue;
out.push({
@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { CompletionSuggestion } from "./completionEngine";
import type { PromptDetectionResult } from "./promptDetector";
import type { SubDirPanel } from "./useTerminalAutocomplete";
import { stringCellWidth } from "./terminalStringCellWidth";
import { getXTermCellDimensions } from "./xtermUtils";
export function resolveAutocompleteCwd(
@@ -338,34 +339,79 @@ function shouldExpandAutocompleteUpward(
return cursorY > 2 && spaceAbovePx >= spaceBelowPx;
}
/** Predicted cursor cell for popup anchoring (column within the row + row). */
export type AutocompleteCursorCell = {
column: number;
row: number;
};
/**
* Best-effort cursor column for popup anchoring. xterm's helper textarea and
* Best-effort cursor cell for popup anchoring. xterm's helper textarea and
* buffer.cursorX can lag behind the keystroke that triggered completion, so
* derive the column from the aligned prompt when the command still fits on one
* row.
* derive the column from the aligned prompt and wrap onto following rows when
* unechoed wide input crosses `term.cols`.
*
* When the live cursor already sits on a soft-wrapped continuation row,
* measure from the logical line start so a still-unechoed `userInput` suffix
* advances past the partial wrap instead of anchoring at the lagged cell.
*
* A wrap past the last visible row scrolls the buffer; xterm keeps the cursor
* on `term.rows - 1`. Clamp the predicted row so a completion that resolves
* before that scroll does not place the popup one cell below the grid.
*/
export function resolveAutocompleteCursorCell(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): AutocompleteCursorCell {
const buffer = term.buffer.active;
const cols = Math.max(1, Number(term.cols) || 80);
const termRows = Number(term.rows);
const absY = buffer.cursorY + buffer.baseY;
// Walk back to the first physical row of this soft-wrapped logical line.
let startAbsY = absY;
let startLine = buffer.getLine(startAbsY);
while (startLine?.isWrapped && startAbsY > 0) {
startAbsY -= 1;
startLine = buffer.getLine(startAbsY);
}
const startRowY = startAbsY - buffer.baseY;
let fromLine = (buffer.cursorY - startRowY) * cols + buffer.cursorX;
const cursorLine = buffer.getLine(absY);
if (cursorLine) {
const lineText = cursorLine.translateToString(false);
const tail = lineText.substring(buffer.cursorX).trimEnd();
if (tail.length === 0) {
const endCol = Math.max(buffer.cursorX, lineText.trimEnd().length);
fromLine = (buffer.cursorY - startRowY) * cols + endCol;
}
}
// Use xterm's active Unicode width so CJK / emoji / fullwidth glyphs in
// the synthetic pre-echo userInput advance the popup with the same cell
// count as the real cursor (#2813).
const fromPrompt =
stringCellWidth(prompt.promptText, term) + stringCellWidth(prompt.userInput, term);
const rawColumn = Math.max(fromLine, fromPrompt);
const predictedRow = Math.max(0, startRowY + Math.floor(rawColumn / cols));
// Only clamp when the terminal reports a real viewport height; missing
// `rows` (tests/mocks) must not collapse every wrap onto row 0.
const row = Number.isFinite(termRows) && termRows > 0
? Math.min(predictedRow, termRows - 1)
: predictedRow;
return {
column: rawColumn % cols,
row,
};
}
/** Column-only helper for callers that do not need the predicted wrap row. */
export function resolveAutocompleteCursorColumn(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): number {
const buffer = term.buffer.active;
const absY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(absY);
if (line?.isWrapped) {
return buffer.cursorX;
}
let fromLine = buffer.cursorX;
if (line) {
const lineText = line.translateToString(false);
const tail = lineText.substring(buffer.cursorX).trimEnd();
if (tail.length === 0) {
fromLine = Math.max(buffer.cursorX, lineText.trimEnd().length);
}
}
const fromPrompt = prompt.promptText.length + prompt.userInput.length;
return Math.max(fromLine, fromPrompt);
return resolveAutocompleteCursorCell(term, prompt).column;
}
/** Clamp autocomplete popups to the active terminal screen in split workspaces.
@@ -413,6 +459,7 @@ export function resolveAutocompleteAnchorInViewport(
container: HTMLElement | null,
itemCount: number,
cursorColumn = term.buffer.active.cursorX,
cursorRow = term.buffer.active.cursorY,
): AutocompleteViewportAnchor {
const empty: AutocompleteViewportAnchor = {
anchorLeft: 0,
@@ -422,8 +469,6 @@ export function resolveAutocompleteAnchorInViewport(
};
if (!container || !term.element) return empty;
const buffer = term.buffer.active;
const cursorY = buffer.cursorY;
const rows = Math.max(1, term.rows);
const estimatedPopupHeight = estimatePopupHeight(itemCount);
const dims = getXTermCellDimensions(term);
@@ -434,15 +479,15 @@ export function resolveAutocompleteAnchorInViewport(
?? container;
const screenRect = screen.getBoundingClientRect();
const anchorLeft = screenRect.left + cursorColumn * dims.width;
const anchorTop = screenRect.top + cursorY * dims.height;
const anchorBottom = screenRect.top + (cursorY + 1) * dims.height;
const spaceBelow = Math.max(0, (rows - cursorY - 1) * dims.height);
const spaceAbove = Math.max(0, cursorY * dims.height);
const anchorTop = screenRect.top + cursorRow * dims.height;
const anchorBottom = screenRect.top + (cursorRow + 1) * dims.height;
const spaceBelow = Math.max(0, (rows - cursorRow - 1) * dims.height);
const spaceAbove = Math.max(0, cursorRow * dims.height);
return {
anchorLeft,
anchorTop,
anchorBottom,
expandUpward: shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight),
expandUpward: shouldExpandAutocompleteUpward(cursorRow, spaceBelow, spaceAbove, estimatedPopupHeight),
};
}
@@ -0,0 +1,104 @@
/**
* Terminal cell-column width for autocomplete / ghost positioning.
*
* When an xterm instance is available, prefer its active Unicode provider
* (`15-graphemes` via UnicodeGraphemesAddon) so emoji / VS-16 clusters match
* the cursor advance. Fall back to a small East-Asian-Width-style classifier
* for unit fakes that lack `_core.unicodeService`.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
type UnicodeServiceLike = {
getStringCellWidth?: (s: string) => number;
};
type TermWithUnicodeService = {
_core?: {
unicodeService?: UnicodeServiceLike;
};
};
const unicodeMarkPattern = /\p{Mark}/u;
function codePointCellWidth(cp: number): number {
// Zero-width joiners / format / variation selectors / marks — xterm
// folds these into the surrounding grapheme (wcwidth 0 or shouldJoin).
if (
cp === 0x00ad ||
cp === 0x200d || // ZWJ
(cp >= 0x200b && cp <= 0x200f) || // ZWSP..RLM
(cp >= 0x202a && cp <= 0x202e) || // bidi overrides
(cp >= 0x2060 && cp <= 0x206f) || // word joiner, invisible ops
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
cp === 0xfeff ||
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // Emoji skin-tone modifiers
(cp >= 0xe0100 && cp <= 0xe01ef) || // Variation Selectors Supplement
unicodeMarkPattern.test(String.fromCodePoint(cp))
) {
return 0;
}
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function graphemeCellWidth(grapheme: string): number {
let max = 0;
for (const ch of grapheme) {
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
if (w > max) max = w;
}
return max;
}
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function fallbackStringCellWidth(s: string): number {
if (graphemeSegmenter) {
let w = 0;
for (const { segment } of graphemeSegmenter.segment(s)) {
w += graphemeCellWidth(segment);
}
return w;
}
// Fallback without Segmenter: sum code-point widths (ZWJ/marks already 0).
let w = 0;
for (const ch of s) {
w += codePointCellWidth(ch.codePointAt(0) ?? 0);
}
return w;
}
/** Terminal cell columns occupied by `s` (wide glyphs / grapheme clusters). */
export function stringCellWidth(
s: string,
term?: XTerm | TermWithUnicodeService | null,
): number {
if (!s) return 0;
const unicodeService = (term as TermWithUnicodeService | null | undefined)
?._core?.unicodeService;
const getWidth = unicodeService?.getStringCellWidth;
if (typeof getWidth === "function") {
return getWidth.call(unicodeService, s);
}
return fallbackStringCellWidth(s);
}
@@ -32,7 +32,7 @@ import {
areSubDirPanelsEqual,
areSuggestionsEqual,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteCursorColumn,
resolveAutocompleteCursorCell,
resolveAutocompleteCwdWithSource,
} from "./terminalAutocompleteLayout";
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
@@ -72,6 +72,9 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
historyScope: "host",
};
/** Max time to poll for shell echo after a pre-echo debounce cycle (#2813). */
const ECHO_VALIDATION_MAX_WAIT_MS = 3000;
/**
* Whether completion work is worth doing — i.e. whether anything would
* actually be rendered. With both the popup and ghost text disabled, querying
@@ -124,6 +127,11 @@ export interface AutocompleteState {
type HostCompletionProviderOptions = Parameters<typeof getCompletions>[1] & {
/** Host-owned prompt identity used to gate third-party Provider access. */
promptText: string;
/**
* False when input was synthesized from the pre-echo keystroke buffer.
* External Providers must stay disabled until the live line validates input.
*/
allowExternalProviders?: boolean;
/** Aborted whenever the prompt/session security state invalidates this query. */
signal?: AbortSignal;
};
@@ -213,6 +221,14 @@ export function useTerminalAutocomplete(
const ghostAddonRef = useRef<GhostTextAddon | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Pre-echo debounce cycles must poll until the live line validates the
* keystroke buffer (or we give up). PTY echo updates xterm only and does
* not schedule another fetchSuggestions — without this, whole-word IME /
* high-latency SSH commits never show completions until the next key.
*/
const echoValidationTypedRef = useRef<string | null>(null);
const echoValidationStartedAtRef = useRef<number | null>(null);
const lastKeystrokeRef = useRef<number>(0);
const lastPromptRef = useRef<PromptDetectionResult | null>(null);
const disposedRef = useRef(false);
@@ -346,6 +362,8 @@ export function useTerminalAutocomplete(
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
echoValidationTypedRef.current = null;
echoValidationStartedAtRef.current = null;
ghostAddonRef.current?.hide();
completionAbortRef.current?.abort();
completionAbortRef.current = null;
@@ -364,14 +382,18 @@ export function useTerminalAutocomplete(
setState((prev) => {
if (!prev.popupVisible || prev.suggestions.length === 0) return prev;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
const cursorColumn = prompt.isAtPrompt
? resolveAutocompleteCursorColumn(term, prompt)
: term.buffer.active.cursorX;
const cursorCell = prompt.isAtPrompt
? resolveAutocompleteCursorCell(term, prompt)
: {
column: term.buffer.active.cursorX,
row: term.buffer.active.cursorY,
};
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
prev.suggestions.length,
cursorColumn,
cursorCell.column,
cursorCell.row,
);
// Force a re-render even when the relative cursor cell hasn't changed.
@@ -669,12 +691,49 @@ export function useTerminalAutocomplete(
return;
}
const { prompt, allowExternalProviders = true } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
lastPromptRef.current = prompt;
// Pre-echo keystroke buffer can look identical to an echo-disabled
// password prompt (`read -s -p '$ '`). Do not render or accept
// built-in history/snippet suggestions until the shell echoes input.
// Incoming PTY echo does not re-schedule fetches, so keep polling this
// debounce cycle until the live line validates — or until max wait
// (silent prompts never echo). clearState cancels timers and echo-wait
// refs; re-arm the wait afterward when still within the window.
if (allowExternalProviders === false) {
const typed = typedInputBufferRef.current;
const startedAt =
echoValidationTypedRef.current === typed &&
echoValidationStartedAtRef.current != null
? echoValidationStartedAtRef.current
: Date.now();
clearState();
const withinWait =
typed.length > 0 &&
!disposedRef.current &&
Date.now() - startedAt < ECHO_VALIDATION_MAX_WAIT_MS;
if (withinWait) {
echoValidationTypedRef.current = typed;
echoValidationStartedAtRef.current = startedAt;
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestionsRef.current();
}, settingsRef.current.debounceMs);
}
return;
}
echoValidationTypedRef.current = null;
echoValidationStartedAtRef.current = null;
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
lastPromptRef.current = prompt;
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
clearState();
return;
@@ -716,6 +775,7 @@ export function useTerminalAutocomplete(
cwdSource: cwdResolution.source,
snippets: snippetsRef.current,
promptText: prompt.promptText,
allowExternalProviders,
signal: completionController.signal,
});
} finally {
@@ -766,12 +826,13 @@ export function useTerminalAutocomplete(
// Live-preview baseline: the typed input these suggestions completed.
previewBaselineRef.current = input;
previewActiveRef.current = false;
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
const cursorCell = resolveAutocompleteCursorCell(term, currentPrompt);
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
completions.length,
cursorColumn,
cursorCell.column,
cursorCell.row,
);
startTransition(() => {
setState((prev) => {
@@ -46,3 +46,17 @@ test("no match returns empty; empty input returns empty", () => {
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
});
test("matches Chinese labels by literal Chinese input", () => {
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply -f ." });
const out = getSnippetSuggestions("部署", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].snippet?.id, "zh");
assert.equal(out[0].displayText, "部署服务");
});
test("matches Chinese labels by pinyin and initials (smart suggest)", () => {
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply -f ." });
assert.equal(getSnippetSuggestions("bushu", [s], {})[0]?.snippet?.id, "zh");
assert.equal(getSnippetSuggestions("bsfw", [s], {})[0]?.snippet?.id, "zh");
});
@@ -6,6 +6,7 @@ import {
computeAutocompletePopupPlacement,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
resolveAutocompleteCursorCell,
resolveAutocompleteCursorColumn,
type PopupPlacementInput,
} from "./autocomplete/terminalAutocompleteLayout.ts";
@@ -274,6 +275,144 @@ test("resolveAutocompleteCursorColumn prefers prompt-aligned column when xterm l
assert.equal(column, "root@host:~# ".length + 1);
});
test("resolveAutocompleteCursorColumn counts wide glyphs as two cells for pre-echo input", () => {
const term = {
cols: 80,
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
userInput: "部署",
});
// "$ " = 2 cells, each CJK ideograph = 2 cells → column 6 (not char-length 4).
assert.equal(column, 6);
});
test("resolveAutocompleteCursorCell wraps synthetic pre-echo columns onto the next row", () => {
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
// Four wide cells after a prompt ending at column 8 → raw column 12.
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorCell clamps a bottom-row wrap to the visible grid", () => {
// Pending wrap from the last visible row would predict row === term.rows,
// but xterm scrolls and keeps the cursor on term.rows - 1.
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 23,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 23);
});
test("resolveAutocompleteCursorCell advances past a partial wrap using unechoed userInput", () => {
// cols=10: "$ docker c" | "o|" — echo wrapped after a prefix while the
// buffered input is still the full "docker compose". Anchoring at the
// live wrapped cell (col 1) would leave the popup behind.
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "o" },
};
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 1,
cursorY: 1,
baseY: 0,
getLine: (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: () => row.text,
};
},
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "docker compose",
});
// "$ " + "docker compose" = 16 cells → col 6 on row 1.
assert.equal(cell.column, 6);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorColumn counts ZWJ emoji graphemes as one wide cluster", () => {
const term = {
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
// Technologist emoji is one grapheme (👨 + ZWJ + 💻); xterm paints it
// as two cells, not five code-point widths.
userInput: "👨‍💻",
});
assert.equal(column, 4);
});
test("short popup flips upward when the cursor is at the bottom of the screen", () => {
// Regression for issue #1710: a *short* suggestion list must flip up when
// the anchor line is the last visible row, even if there is a tiny positive
@@ -58,7 +58,7 @@ test('disabled or absent plugin hosts do not receive terminal completion request
const autocompleteSource = readFileSync(new URL('./TerminalAutocomplete.tsx', import.meta.url), 'utf8');
assert.match(
autocompleteSource,
/const pluginRegistry = isPluginCompletionProviderAvailable\?\.\(\) === false[\s\S]*?shouldUsePluginTerminalCompletionProvider[\s\S]*?\? null\s*\n\s*: getWindowPluginTerminalProviderRegistry\(\)/,
/const pluginRegistry = isPluginCompletionProviderAvailable\?\.\(\) === false[\s\S]*?options\.allowExternalProviders === false[\s\S]*?shouldUsePluginTerminalCompletionProvider[\s\S]*?\? null\s*\n\s*: getWindowPluginTerminalProviderRegistry\(\)/,
);
assert.match(
terminalSource,
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
const require = createRequire(import.meta.url);
test("stringCellWidth counts ASCII as one cell each", () => {
assert.equal(stringCellWidth("docker"), 6);
});
test("stringCellWidth counts CJK ideographs as two cells each", () => {
assert.equal(stringCellWidth("部署"), 4);
});
test("stringCellWidth collapses ZWJ emoji to one wide grapheme", () => {
assert.equal(stringCellWidth("👨‍💻"), 2);
});
test("stringCellWidth ignores combining marks inside a grapheme", () => {
// e + combining acute accent
assert.equal(stringCellWidth("e\u0301"), 1);
});
test("stringCellWidth matches xterm 15-graphemes for common emoji clusters", () => {
class El {
tagName: string;
style: Record<string, string> = {};
children: El[] = [];
classList = { add() {}, remove() {}, contains() { return false; } };
constructor(tag: string) { this.tagName = tag; }
appendChild(c: El) { this.children.push(c); return c; }
removeChild(c: El) { return c; }
addEventListener() {}
removeEventListener() {}
setAttribute() {}
getAttribute() { return null; }
getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; }
remove() {}
}
const previous = {
document: globalThis.document,
window: globalThis.window,
HTMLElement: globalThis.HTMLElement,
Element: globalThis.Element,
DocumentFragment: globalThis.DocumentFragment,
getComputedStyle: globalThis.getComputedStyle,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
};
// Minimal DOM so @xterm/xterm can construct a Terminal in node tests.
(globalThis as { document?: unknown }).document = {
createElement: (t: string) => new El(t),
createDocumentFragment: () => new El("frag"),
addEventListener() {},
removeEventListener() {},
};
(globalThis as { window?: unknown }).window = globalThis;
(globalThis as { HTMLElement?: unknown }).HTMLElement = El;
(globalThis as { Element?: unknown }).Element = El;
(globalThis as { DocumentFragment?: unknown }).DocumentFragment = El;
(globalThis as { getComputedStyle?: unknown }).getComputedStyle = () => ({
getPropertyValue: () => "",
});
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = (
cb: (t: number) => void,
) => setTimeout(() => cb(0), 0);
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (
id: number,
) => clearTimeout(id);
try {
const xterm = require("@xterm/xterm") as typeof import("@xterm/xterm");
const graphemes = require("@xterm/addon-unicode-graphemes") as typeof import("@xterm/addon-unicode-graphemes");
const term = new xterm.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
term.loadAddon(new graphemes.UnicodeGraphemesAddon());
term.unicode.activeVersion = "15-graphemes";
const samples = ["🇨🇳", "1️⃣", "©️", "🖥", "👨‍💻", "部署", "docker"];
for (const s of samples) {
const expected = (
term as unknown as {
_core: { unicodeService: { getStringCellWidth: (v: string) => number } };
}
)._core.unicodeService.getStringCellWidth(s);
assert.equal(
stringCellWidth(s, term),
expected,
`${JSON.stringify(s)} should match xterm width ${expected}`,
);
}
// These disagree with the hand-rolled fallback; the term path must win.
assert.equal(stringCellWidth("🇨🇳", term), 2);
assert.equal(stringCellWidth("1️⃣", term), 2);
assert.equal(stringCellWidth("©️", term), 2);
assert.equal(stringCellWidth("🖥", term), 1);
} finally {
Object.assign(globalThis, previous);
}
});