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
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
/**
|
||
* 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 B–F (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 },
|
||
};
|
||
}
|