[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
427
components/terminal/normalizeTerminalSelection.test.ts
Normal file
427
components/terminal/normalizeTerminalSelection.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
import {
|
||||
getNormalizedTerminalSelection,
|
||||
getTerminalSelectionForClipboard,
|
||||
joinSoftWrappedRows,
|
||||
trimWrittenPadding,
|
||||
type SelectionBufferLine,
|
||||
type SelectionTerminal,
|
||||
} from "./normalizeTerminalSelection.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
||||
Terminal: typeof XTermType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fake line that matches real xterm translateToString(true) semantics:
|
||||
* trimRight only drops *empty* cells (trailing chars that were never written),
|
||||
* not written ASCII spaces used as display padding.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
options: {
|
||||
isWrapped?: boolean;
|
||||
emptyCells?: number;
|
||||
cellWidths?: number[];
|
||||
/**
|
||||
* Model xterm.js 6 string-cache: a no-endColumn translateToString(true)
|
||||
* drops written trailing spaces via String.trimEnd().
|
||||
*/
|
||||
canonicalTrimEnd?: boolean;
|
||||
} = {},
|
||||
): SelectionBufferLine {
|
||||
const emptyCells = options.emptyCells ?? 0;
|
||||
// Optional per-character terminal widths (default 1). Used to model CJK.
|
||||
const widths = options.cellWidths ?? Array.from(text, () => 1);
|
||||
let colLength = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
colLength += widths[i] ?? 1;
|
||||
}
|
||||
const fullCols = colLength + emptyCells;
|
||||
// Map each terminal column to a char (wide chars occupy two cols; second is spacer).
|
||||
const cols: string[] = [];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i]!;
|
||||
const w = widths[i] ?? 1;
|
||||
cols.push(ch);
|
||||
for (let extra = 1; extra < w; extra += 1) {
|
||||
cols.push(""); // wide-char continuation column
|
||||
}
|
||||
}
|
||||
while (cols.length < fullCols) {
|
||||
cols.push("\0");
|
||||
}
|
||||
return {
|
||||
isWrapped: options.isWrapped ?? false,
|
||||
length: fullCols,
|
||||
getCell(x: number) {
|
||||
if (x < 0 || x >= cols.length) return undefined;
|
||||
const cell = cols[x];
|
||||
if (cell === "\0" || cell === undefined) {
|
||||
return { getChars: () => "", getCode: () => 0, getWidth: () => 1 };
|
||||
}
|
||||
if (cell === "") {
|
||||
// Wide-character continuation column.
|
||||
return { getChars: () => "", getCode: () => 0, getWidth: () => 0 };
|
||||
}
|
||||
const width = cell === "中" || /[\u3400-\u9fff]/u.test(cell) ? 2 : 1;
|
||||
return {
|
||||
getChars: () => cell,
|
||||
getCode: () => cell.codePointAt(0) ?? 0,
|
||||
getWidth: () => width,
|
||||
};
|
||||
},
|
||||
translateToString(trimRight = false, startColumn = 0, endColumn?: number) {
|
||||
const endSpecified = arguments.length >= 3;
|
||||
let end = Math.max(startColumn, Math.min(endColumn ?? fullCols, fullCols));
|
||||
if (trimRight) {
|
||||
while (end > startColumn && (cols[end - 1] === "\0" || cols[end - 1] === "")) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
let result = "";
|
||||
for (let c = Math.max(0, startColumn); c < end; c += 1) {
|
||||
const cell = cols[c];
|
||||
if (cell && cell !== "\0") result += cell;
|
||||
}
|
||||
if (trimRight && options.canonicalTrimEnd && !endSpecified) {
|
||||
return result.trimEnd();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTerm(
|
||||
lines: Array<{
|
||||
text: string;
|
||||
isWrapped?: boolean;
|
||||
emptyCells?: number;
|
||||
cellWidths?: number[];
|
||||
canonicalTrimEnd?: boolean;
|
||||
}>,
|
||||
range: { start: { x: number; y: number }; end: { x: number; y: number } } | null,
|
||||
options: {
|
||||
rawSelection?: string;
|
||||
columnSelect?: boolean;
|
||||
} = {},
|
||||
): SelectionTerminal {
|
||||
const bufferLines = lines.map((line) =>
|
||||
makeLine(line.text, {
|
||||
isWrapped: line.isWrapped,
|
||||
emptyCells: line.emptyCells,
|
||||
cellWidths: line.cellWidths,
|
||||
canonicalTrimEnd: line.canonicalTrimEnd,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
getSelection: () => options.rawSelection ?? "",
|
||||
getSelectionPosition: () => range,
|
||||
buffer: {
|
||||
active: {
|
||||
getLine: (y) => bufferLines[y],
|
||||
},
|
||||
},
|
||||
_core: options.columnSelect
|
||||
? { _selectionService: { _activeSelectionMode: 3 } }
|
||||
: { _selectionService: { _activeSelectionMode: 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
test("trimWrittenPadding removes written trailing spaces but keeps internal ones", () => {
|
||||
assert.equal(trimWrittenPadding("hello "), "hello");
|
||||
assert.equal(trimWrittenPadding(" hello world "), " hello world");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows keeps a single trailing space as a word separator", () => {
|
||||
assert.equal(joinSoftWrappedRows("hello ", "world"), "hello world");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows concatenates mid-word wraps tightly", () => {
|
||||
assert.equal(joinSoftWrappedRows("hel", "lo world"), "hello world");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows does not invent spaces inside URL/path tokens", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("https://example.com/very/long/ ", "path"),
|
||||
"https://example.com/very/long/path",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows does not invent spaces before CJK punctuation", () => {
|
||||
assert.equal(joinSoftWrappedRows("你好 ", ",世界"), "你好,世界");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows collapses multi-space prose padding to one space", () => {
|
||||
// Pi/TUI word-wrapped prose: padding between words must not glue them.
|
||||
assert.equal(joinSoftWrappedRows("the most ", "reliable"), "the most reliable");
|
||||
assert.equal(joinSoftWrappedRows("shifts ", "across"), "shifts across");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows does not invent spaces between CJK characters", () => {
|
||||
assert.equal(joinSoftWrappedRows("最 ", "稳"), "最稳");
|
||||
// Single pad cell must not bypass the CJK check.
|
||||
assert.equal(joinSoftWrappedRows("最 ", "稳"), "最稳");
|
||||
assert.equal(joinSoftWrappedRows("한 ", "글"), "한글");
|
||||
assert.equal(joinSoftWrappedRows("你好。 ", "世界"), "你好。世界");
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows keeps a space after sentence-ending punctuation", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("First sentence. ", "Next sentence"),
|
||||
"First sentence. Next sentence",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows only uses the trailing token for URL detection", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("See https://x.test for more ", "details"),
|
||||
"See https://x.test for more details",
|
||||
);
|
||||
// Single trailing space after a URL is a real word separator.
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("See https://example.com ", "today"),
|
||||
"See https://example.com today",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows keeps path separators attached across wraps", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("https://example.com/very/long/ ", "path"),
|
||||
"https://example.com/very/long/path",
|
||||
);
|
||||
// Complete URL + padding + next word stays two words (not mid-token glue).
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("See https://example.com ", "today"),
|
||||
"See https://example.com today",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows preserves Windows paths and URL query delimiters", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("C:\\Users\\alice\\ ", "file.txt"),
|
||||
"C:\\Users\\alice\\file.txt",
|
||||
);
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("https://example.com/path ", "?q=netcatty"),
|
||||
"https://example.com/path?q=netcatty",
|
||||
);
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("https://example.com/search? ", "q=netcatty"),
|
||||
"https://example.com/search?q=netcatty",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows keeps sentence break after a URL", () => {
|
||||
assert.equal(
|
||||
joinSoftWrappedRows("See https://example.com. ", "Next sentence"),
|
||||
"See https://example.com. Next sentence",
|
||||
);
|
||||
});
|
||||
|
||||
test("joinSoftWrappedRows keeps hyphenated words intact at the hyphen", () => {
|
||||
assert.equal(joinSoftWrappedRows("state- ", "of-the-art"), "state-of-the-art");
|
||||
// Complete flag + next word keeps a boundary.
|
||||
assert.equal(joinSoftWrappedRows("Use --verbose ", "to inspect"), "Use --verbose to inspect");
|
||||
});
|
||||
|
||||
test("preserves partial trailing spaces after wide characters using column ends", () => {
|
||||
// "中 X" with 中 width 2 → columns: [中][ ][ ][ ][X] roughly.
|
||||
// Select through the spaces after 中 but before X.
|
||||
const term = makeTerm(
|
||||
[{ text: "中 X", cellWidths: [2, 1, 1, 1] }],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
||||
);
|
||||
// end.x=4 is before content end column of X; keep selected spaces.
|
||||
assert.equal(getNormalizedTerminalSelection(term), "中 ");
|
||||
});
|
||||
|
||||
test("joins soft-wrapped rows and collapses prose padding to one space", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "Pi: use /copy is the most " },
|
||||
{ text: "reliable option ", isWrapped: true },
|
||||
{ text: "next hard line " },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 28, y: 2 } },
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getNormalizedTerminalSelection(term),
|
||||
"Pi: use /copy is the most reliable option\nnext hard line",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a single trailing space as a word-boundary soft wrap", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "hello " },
|
||||
{ text: "world ", isWrapped: true },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 8, y: 1 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "hello world");
|
||||
});
|
||||
|
||||
test("preserves hard line breaks between non-wrapped rows while trimming padding", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "line one " },
|
||||
{ text: "line two " },
|
||||
{ text: "line three " },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 11, y: 2 } },
|
||||
);
|
||||
|
||||
assert.equal(getNormalizedTerminalSelection(term), "line one\nline two\nline three");
|
||||
});
|
||||
|
||||
test("preserves explicitly selected trailing spaces on a partial last row", () => {
|
||||
// "abc def" — select columns 0..5 → "abc " must keep the spaces.
|
||||
const term = makeTerm(
|
||||
[{ text: "abc def" }],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "abc ");
|
||||
});
|
||||
|
||||
test("empty-cell trim from xterm still applies before written-space trim", () => {
|
||||
const term = makeTerm(
|
||||
[{ text: "hello", emptyCells: 10 }],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 15, y: 0 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "hello");
|
||||
});
|
||||
|
||||
test("respects partial column selection on first and last rows", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "xxhello worldyy" },
|
||||
{ text: "continued here!", isWrapped: true },
|
||||
],
|
||||
{ start: { x: 2, y: 0 }, end: { x: 10, y: 1 } },
|
||||
);
|
||||
|
||||
// Last-row end.x=10 selects "continued " (including the space); keep it.
|
||||
assert.equal(getNormalizedTerminalSelection(term), "hello worldyycontinued ");
|
||||
});
|
||||
|
||||
test("falls back to getSelection when position is unavailable", () => {
|
||||
const term = makeTerm([{ text: "abc" }], null, { rawSelection: "fallback text" });
|
||||
assert.equal(getNormalizedTerminalSelection(term), "fallback text");
|
||||
});
|
||||
|
||||
test("getTerminalSelectionForClipboard respects normalize flag", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "hello " },
|
||||
{ text: "world ", isWrapped: true },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 8, y: 1 } },
|
||||
{ rawSelection: "hello \nworld " },
|
||||
);
|
||||
assert.equal(getTerminalSelectionForClipboard(term, true), "hello world");
|
||||
assert.equal(getTerminalSelectionForClipboard(term, false), "hello \nworld ");
|
||||
});
|
||||
|
||||
test("returns empty string for empty range and normalizes inverted ranges", () => {
|
||||
const empty = makeTerm([{ text: "abc" }], { start: { x: 1, y: 0 }, end: { x: 1, y: 0 } });
|
||||
assert.equal(getNormalizedTerminalSelection(empty), "");
|
||||
|
||||
const inverted = makeTerm(
|
||||
[
|
||||
{ text: "alpha " },
|
||||
{ text: "beta " },
|
||||
],
|
||||
{ start: { x: 6, y: 1 }, end: { x: 0, y: 0 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(inverted), "alpha\nbeta");
|
||||
});
|
||||
|
||||
test("handles multi-row soft wrap chains", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "aaa" },
|
||||
{ text: "bbb", isWrapped: true },
|
||||
{ text: "ccc", isWrapped: true },
|
||||
{ text: "ddd" },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 3, y: 3 } },
|
||||
);
|
||||
|
||||
assert.equal(getNormalizedTerminalSelection(term), "aaabbbccc\nddd");
|
||||
});
|
||||
|
||||
test("preserves rectangular column selection including right-edge spaces", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "ab efghij" },
|
||||
{ text: "01 56789x" },
|
||||
{ text: "AB EFGHIJ" },
|
||||
],
|
||||
{ start: { x: 2, y: 0 }, end: { x: 5, y: 2 } },
|
||||
{ columnSelect: true },
|
||||
);
|
||||
|
||||
// Columns 2..5 include the intentional spaces.
|
||||
assert.equal(getNormalizedTerminalSelection(term), " e\n 5\n E");
|
||||
});
|
||||
|
||||
test("converts non-breaking spaces to regular spaces", () => {
|
||||
const term = makeTerm(
|
||||
[{ text: "hello\u00a0world " }],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 13, y: 0 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "hello world");
|
||||
});
|
||||
|
||||
test("soft-wrapped CJK with padding joins without inserted spaces", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "Pi: 用 /copy 最 " },
|
||||
{ text: "稳 ", isWrapped: true },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 18, y: 1 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "Pi: 用 /copy 最稳");
|
||||
});
|
||||
|
||||
test("keeps a wrap-boundary space that canonical translateToString trimEnd would drop", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "|hbase ", canonicalTrimEnd: true },
|
||||
{ text: "shell", isWrapped: true, canonicalTrimEnd: true },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "|hbase shell");
|
||||
});
|
||||
|
||||
test("real xterm keeps a wrap-boundary space after an untrimmed string-cache prime", async () => {
|
||||
const xterm = new XTerm({ cols: 20, rows: 8, scrollback: 20, allowProposedApi: true });
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
xterm.write("12345678901234hbase shell\r\n", resolve);
|
||||
});
|
||||
// Prompt detector / autocomplete / keyword highlight all call this first.
|
||||
xterm.buffer.active.getLine(0)?.translateToString(false);
|
||||
|
||||
const wrapped: SelectionTerminal = {
|
||||
getSelection: () => xterm.getSelection(),
|
||||
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } }),
|
||||
buffer: xterm.buffer,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
xterm.buffer.active.getLine(0)?.translateToString(true),
|
||||
"12345678901234hbase",
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(wrapped), "12345678901234hbase shell");
|
||||
} finally {
|
||||
xterm.dispose();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user