mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566af95700 | ||
|
|
5817c01e5c | ||
|
|
0ade59b885 | ||
|
|
5d3429fa82 | ||
|
|
2725823334 | ||
|
|
6668e208b5 | ||
|
|
d5a7c704b3 | ||
|
|
14d906bca2 | ||
|
|
f6f4ff4c58 | ||
|
|
07e76b5e48 |
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user