Files
NetMesh/components/terminal/normalizeTerminalSelection.ts

437 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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 },
};
}