Files
NetMesh/components/terminal/normalizeTerminalSelection.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

437 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Normalize an xterm selection into clipboard-ready logical text.
*
* xterm's getSelection() already joins soft-wrapped rows (isWrapped) and trims
* *empty* buffer cells, but TUI apps often pad rows with real space characters.
* Those written spaces survive copy and corrupt pasted paragraphs/code blocks.
*
* This helper rebuilds the selection from buffer coordinates so we can:
* - strip written trailing padding on completed physical rows
* - join only rows marked soft-wrapped by xterm
* - keep genuine hard line breaks
* - preserve rectangular (column) selections and partial end-column spaces
* - convert non-breaking spaces like xterm's selectionText path
*/
export type SelectionBufferCell = {
getChars?: () => string;
getCode?: () => number;
getWidth?: () => number;
};
export type SelectionBufferLine = {
isWrapped?: boolean;
length: number;
/**
* Public xterm API — preferred for column-accurate content-end measurement
* (wide characters, empty cells).
*/
getCell?: (x: number, cell?: SelectionBufferCell) => SelectionBufferCell | undefined;
/**
* xterm semantics: trimRight only drops empty cells (getTrimmedLength),
* not written ASCII spaces used as display padding.
*/
translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string;
};
export type SelectionBuffer = {
getLine(y: number): SelectionBufferLine | undefined;
};
export type SelectionPosition = {
start: { x: number; y: number };
end: { x: number; y: number };
};
export type SelectionTerminal = {
getSelection?: () => string;
getSelectionPosition?: () => SelectionPosition | undefined | null;
buffer: {
active: SelectionBuffer;
};
/** Present on real xterm Terminal instances; used only to detect column select. */
_core?: {
_selectionService?: {
/** xterm SelectionMode: NORMAL=0, WORD=1, LINE=2, COLUMN=3 */
_activeSelectionMode?: number;
};
};
};
/** Matches xterm SelectionMode.COLUMN */
const SELECTION_MODE_COLUMN = 3;
const ALL_NON_BREAKING_SPACE_REGEX = /\u00a0/g;
/**
* Characters that strongly mean the next soft-wrapped row continues the same
* token (path/URL fragment). Sentence punctuation like `.` `?` `:` is NOT
* included here — those need surrounding URL/path context.
*/
const PATH_TOKEN_END = new Set("\\/@#&=+%".split(""));
const PATH_TOKEN_START = new Set("\\/-_.".split(""));
/**
* Selection text for clipboard / paste-selection / AI attach.
* When `normalize` is false, returns raw xterm getSelection() (screen cells).
* When true (default), strips display padding and joins soft wraps.
*/
export function getTerminalSelectionForClipboard(
term: SelectionTerminal,
normalize = true,
): string {
if (!normalize) {
return term.getSelection?.() ?? "";
}
return getNormalizedTerminalSelection(term);
}
/**
* Return clipboard-ready text for the current terminal selection.
* Falls back to term.getSelection() when position/buffer APIs are unavailable.
*/
export function getNormalizedTerminalSelection(term: SelectionTerminal): string {
const range = term.getSelectionPosition?.() ?? null;
if (!range) {
return normalizeClipboardText(term.getSelection?.() ?? "");
}
const { start, end } = normalizeSelectionRange(range);
if (end.y < start.y) {
return "";
}
if (isColumnSelectionMode(term)) {
return buildColumnSelection(term.buffer.active, start, end);
}
return buildLinearSelection(term.buffer.active, start, end);
}
function isColumnSelectionMode(term: SelectionTerminal): boolean {
return term._core?._selectionService?._activeSelectionMode === SELECTION_MODE_COLUMN;
}
function buildColumnSelection(
buffer: SelectionBuffer,
start: { x: number; y: number },
end: { x: number; y: number },
): string {
if (start.x === end.x) {
return "";
}
const startCol = Math.min(start.x, end.x);
const endCol = Math.max(start.x, end.x);
const rows: string[] = [];
for (let y = start.y; y <= end.y; y += 1) {
const line = buffer.getLine(y);
if (!line) {
rows.push("");
continue;
}
// Keep right-edge spaces: the user selected those columns on purpose.
rows.push(line.translateToString(true, startCol, endCol));
}
return normalizeClipboardText(rows.join("\n"));
}
function buildLinearSelection(
buffer: SelectionBuffer,
start: { x: number; y: number },
end: { x: number; y: number },
): string {
const logicalLines: string[] = [];
let current = "";
for (let y = start.y; y <= end.y; y += 1) {
const line = buffer.getLine(y);
if (!line) {
if (current.length > 0 || logicalLines.length > 0) {
logicalLines.push(trimCompletedRowPadding(current));
current = "";
}
continue;
}
const startCol = y === start.y ? start.x : 0;
// Always pass an exclusive end column. xterm.js 6 may serve a no-endCol
// translateToString(true) from its string cache via String.trimEnd(),
// which drops a wrap-boundary space (#3023).
const endCol = y === end.y ? end.x : line.length;
const rowText = line.translateToString(true, startCol, endCol);
if (y === start.y) {
current = rowText;
continue;
}
if (line.isWrapped) {
current = joinSoftWrappedRows(current, rowText);
continue;
}
logicalLines.push(trimCompletedRowPadding(current));
current = rowText;
}
// Last logical segment: only strip padding when the selection ends at/after
// the buffer line's content end (full-row copy). Partial end columns keep
// explicitly selected trailing spaces.
const lastLine = buffer.getLine(end.y);
const selectionEndsAtRowEnd =
!lastLine || end.x >= lastLine.length || end.x >= measureContentEnd(lastLine);
logicalLines.push(
selectionEndsAtRowEnd ? trimCompletedRowPadding(current) : current,
);
return normalizeClipboardText(logicalLines.join("\n"));
}
/**
* Join two physical rows that xterm marked as a soft wrap.
*
* Trailing whitespace is either a real word separator or TUI display padding.
* Policy (tuned for #2162 Pi/TUI prose + common tokens):
* - no trailing ws → mid-word auto-wrap, concatenate tightly
* - strong token continuation (CJK / path punctuation / URL query) → tight
* - multi-space padding on a URL/path mid-token (alnum|alnum) → tight
* - otherwise (single or multi-space between ordinary words) → one space
*/
export function joinSoftWrappedRows(previousRaw: string, nextRaw: string): string {
const trailingWhitespace = countTrailingHorizontalWhitespace(previousRaw);
const left = trailingWhitespace > 0 ? previousRaw.slice(0, -trailingWhitespace) : previousRaw;
if (!nextRaw) {
return left;
}
if (trailingWhitespace === 0) {
return left + nextRaw;
}
if (/^\s/u.test(nextRaw)) {
return left + nextRaw;
}
if (!left) {
return nextRaw;
}
// Strong continuations (CJK, path/URL punctuation, trailing hyphen) ignore pad
// spaces. Deliberately do NOT treat "complete URL/flag + padding + next word"
// as a mid-token join — multi-space pad after a finished token is a word boundary.
if (isStrongTokenContinuation(left, nextRaw)) {
return left + nextRaw;
}
// Prose word boundary: single-space wrap or multi-space TUI padding between words.
return `${left} ${nextRaw}`;
}
function isStrongTokenContinuation(left: string, next: string): boolean {
const leftEnd = lastCodePointChar(left);
const nextStart = firstCodePointChar(next);
if (!leftEnd || !nextStart) return false;
const leftEndCp = leftEnd.codePointAt(0) ?? 0;
const nextStartCp = nextStart.codePointAt(0) ?? 0;
// CJK / Hangul runs and CJK punctuation should not gain Latin-style spaces.
if (isCjkRelatedCodePoint(leftEndCp) || isCjkPunctuation(leftEnd)) {
return isCjkRelatedCodePoint(nextStartCp) || isCjkPunctuation(nextStart);
}
const trailingToken = getTrailingToken(left);
// Path/URL fragment separators at the end of the trailing token.
if (PATH_TOKEN_END.has(leftEnd) && looksLikeUrlOrPath(trailingToken)) {
return true;
}
// Query/fragment delimiters after a URL/path token.
if (
looksLikeUrlOrPath(trailingToken) &&
(nextStart === "?" || nextStart === "#" || nextStart === "&")
) {
return true;
}
if (
looksLikeUrlOrPath(trailingToken) &&
(leftEnd === "?" || leftEnd === "#" || leftEnd === "&") &&
(isAsciiWordChar(nextStart) || nextStart === "=" || nextStart === "-" || nextStart === "_")
) {
return true;
}
// e.g. "foo/" + "bar" when trailing token is path/URL-like.
if (PATH_TOKEN_START.has(nextStart) && (isAsciiWordChar(leftEnd) || "/\\-_:.".includes(leftEnd))) {
if (looksLikeUrlOrPath(trailingToken) || leftEnd === "/" || leftEnd === "\\") {
return true;
}
}
// URL/path joiners (_ :) — not "." (sentence end after URL).
if (
looksLikeUrlOrPath(trailingToken) &&
isAsciiWordChar(nextStart) &&
(leftEnd === "_" || leftEnd === ":")
) {
return true;
}
// Hyphenated words at the hyphen: "state-" + "of-the-art".
// Do not join complete flags like "--verbose" + "to" (left ends with a letter).
if (leftEnd === "-" && (isAsciiWordChar(nextStart) || nextStart === "-")) {
return true;
}
return false;
}
function getTrailingToken(text: string): string {
const match = text.match(/(\S+)$/u);
return match?.[1] ?? text;
}
function looksLikeUrlOrPath(token: string): boolean {
if (!token) return false;
if (/[a-z][a-z0-9+.-]*:\/\//i.test(token)) return true;
if (token.includes("www.")) return true;
// Absolute or deep relative paths: /usr/local/... or foo/bar/baz
if (token.startsWith("/") || token.startsWith("./") || token.startsWith("../")) return true;
if ((token.match(/\//g) ?? []).length >= 1 && /[A-Za-z0-9]/u.test(token)) return true;
// Windows / UNC paths: C:\Users\... or \\server\share
if (/^[A-Za-z]:[\\/]/u.test(token)) return true;
if (token.startsWith("\\\\")) return true;
if ((token.match(/\\/g) ?? []).length >= 1 && /[A-Za-z0-9]/u.test(token)) return true;
return false;
}
function isAsciiWordChar(char: string): boolean {
return /^[A-Za-z0-9]$/u.test(char);
}
function isCjkPunctuation(char: string): boolean {
const cp = char.codePointAt(0) ?? 0;
// Common fullwidth / CJK punctuation used after ideographs.
return (
(cp >= 0x3000 && cp <= 0x303f) ||
(cp >= 0xff01 && cp <= 0xff60) ||
char === "" ||
char === "。" ||
char === "、" ||
char === "" ||
char === "" ||
char === "" ||
char === ""
);
}
function firstCodePointChar(text: string): string {
if (!text) return "";
const cp = text.codePointAt(0);
if (cp === undefined) return "";
return String.fromCodePoint(cp);
}
function lastCodePointChar(text: string): string {
if (!text) return "";
const chars = Array.from(text);
return chars[chars.length - 1] ?? "";
}
function isCjkRelatedCodePoint(cp: number): boolean {
return (
(cp >= 0x1100 && cp <= 0x11ff) || // Hangul Jamo
(cp >= 0x3040 && cp <= 0x30ff) || // Hiragana / Katakana
(cp >= 0x3130 && cp <= 0x318f) || // Hangul Compatibility Jamo
(cp >= 0x3400 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
(cp >= 0xff66 && cp <= 0xff9d) || // Half-width Katakana
(cp >= 0x20000 && cp <= 0x2ceaf) // CJK Extension BF (supplementary planes)
);
}
function countTrailingHorizontalWhitespace(text: string): number {
let count = 0;
for (let i = text.length - 1; i >= 0; i -= 1) {
const ch = text[i];
if (ch === " " || ch === "\t" || ch === "\f" || ch === "\v") {
count += 1;
continue;
}
break;
}
return count;
}
/**
* Strip written display-padding spaces that survive xterm's empty-cell trim.
* Only trailing horizontal whitespace is removed.
*/
export function trimWrittenPadding(text: string): string {
return text.replace(/[ \t\f\v]+$/u, "");
}
/** Alias used when a completed physical/hard row may still carry TUI padding. */
function trimCompletedRowPadding(text: string): string {
return trimWrittenPadding(text);
}
/**
* Column after the last non-empty cell on the line (public xterm cell widths).
* Falls back to string length only when getCell is unavailable (tests/fakes).
*/
export function measureContentEnd(line: SelectionBufferLine): number {
if (typeof line.getCell === "function") {
for (let x = line.length - 1; x >= 0; x -= 1) {
const cell = line.getCell(x);
if (!cell) continue;
const chars = cell.getChars?.() ?? "";
const code = cell.getCode?.() ?? 0;
if (chars.length > 0 || code !== 0) {
const width = cell.getWidth?.() ?? 1;
return x + Math.max(1, width);
}
}
return 0;
}
return line.translateToString(true).length;
}
function normalizeClipboardText(text: string): string {
const normalized = text.replace(ALL_NON_BREAKING_SPACE_REGEX, " ");
// Match xterm selectionText: Windows uses CRLF between logical lines.
if (isWindowsPlatform() && normalized.includes("\n") && !normalized.includes("\r\n")) {
return normalized.replace(/\n/g, "\r\n");
}
return normalized;
}
function isWindowsPlatform(): boolean {
if (typeof navigator !== "undefined") {
const platform = navigator.platform || "";
const ua = navigator.userAgent || "";
if (/Win/i.test(platform) || /Windows/i.test(ua)) return true;
}
if (typeof process !== "undefined" && typeof process.platform === "string") {
return process.platform === "win32";
}
return false;
}
function normalizeSelectionRange(range: SelectionPosition): SelectionPosition {
const { start, end } = range;
if (start.y < end.y || (start.y === end.y && start.x <= end.x)) {
return {
start: { x: Math.max(0, start.x), y: start.y },
end: { x: Math.max(0, end.x), y: end.y },
};
}
return {
start: { x: Math.max(0, end.x), y: end.y },
end: { x: Math.max(0, start.x), y: start.y },
};
}