[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:
486
components/terminal/runtime/terminalUserPaste.ts
Normal file
486
components/terminal/runtime/terminalUserPaste.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
|
||||
Partial<Pick<XTerm, "cols" | "rows" | "write">> & {
|
||||
modes?: { bracketedPasteMode?: boolean };
|
||||
options?: { ignoreBracketedPasteMode?: boolean };
|
||||
};
|
||||
|
||||
type PasteOptions = {
|
||||
scrollOnPaste?: boolean;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
};
|
||||
|
||||
type BroadcastUserInputOptions = {
|
||||
isBroadcastEnabled?: boolean;
|
||||
hasBroadcastInputHandler?: boolean;
|
||||
};
|
||||
|
||||
type PasteDisplayState = {
|
||||
expiresAt: number;
|
||||
clearPending: number;
|
||||
pasteEchoFragments: string[];
|
||||
inPasteEchoActiveRegion: boolean;
|
||||
};
|
||||
|
||||
type PasteInputScrollState = {
|
||||
expiresAt: number;
|
||||
remainingDataVariants: string[];
|
||||
};
|
||||
|
||||
type TerminalProtocolReplyState = {
|
||||
expiresAt: number;
|
||||
pendingCursorPositionReports: number;
|
||||
cursorPositionReportFragment: string;
|
||||
};
|
||||
|
||||
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
|
||||
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const terminalProtocolReplyStates = new WeakMap<object, TerminalProtocolReplyState>();
|
||||
const LONG_PASTE_MIN_LENGTH = 200;
|
||||
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
|
||||
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
|
||||
const TERMINAL_PROTOCOL_REPLY_WINDOW_MS = 4000;
|
||||
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
|
||||
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
const MIN_PASTE_ECHO_FRAGMENT_LENGTH = 6;
|
||||
const ESC = "\x1b";
|
||||
const BEL = "\x07";
|
||||
|
||||
const getNow = () => Date.now();
|
||||
|
||||
const isStateActive = <T extends { expiresAt: number }>(state: T | undefined): state is T =>
|
||||
!!state && state.expiresAt > getNow();
|
||||
|
||||
const stripReadlineActiveRegion = (data: string): string =>
|
||||
data
|
||||
.split(READLINE_ACTIVE_REGION_START)
|
||||
.join("")
|
||||
.split(READLINE_ACTIVE_REGION_END)
|
||||
.join("");
|
||||
|
||||
const isCsiFinalByte = (char: string): boolean => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 0x40 && code <= 0x7e;
|
||||
};
|
||||
|
||||
const stripAnsiEscapeSequences = (data: string): string => {
|
||||
let plainText = "";
|
||||
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const char = data[index];
|
||||
if (char !== ESC) {
|
||||
plainText += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = data[index + 1];
|
||||
if (nextChar === "[") {
|
||||
index += 2;
|
||||
while (index < data.length && !isCsiFinalByte(data[index])) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "]") {
|
||||
index += 2;
|
||||
while (index < data.length) {
|
||||
if (data[index] === BEL) break;
|
||||
if (data[index] === ESC && data[index + 1] === "\\") {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return plainText;
|
||||
};
|
||||
|
||||
const stripNonLineBreakControls = (data: string): string => {
|
||||
let plainText = "";
|
||||
for (const char of data) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (char === "\n" || (code >= 0x20 && code !== 0x7f)) {
|
||||
plainText += char;
|
||||
}
|
||||
}
|
||||
return plainText;
|
||||
};
|
||||
|
||||
const getPlainTerminalText = (data: string): string =>
|
||||
stripNonLineBreakControls(
|
||||
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
||||
);
|
||||
|
||||
type CursorPositionReportMatch =
|
||||
| { type: "complete"; length: number }
|
||||
| { type: "prefix" }
|
||||
| { type: "none" };
|
||||
|
||||
const isAsciiDigit = (char: string): boolean => char >= "0" && char <= "9";
|
||||
|
||||
const matchCursorPositionReportFromStart = (data: string): CursorPositionReportMatch => {
|
||||
if (!data.startsWith(ESC)) return { type: "none" };
|
||||
if (data.length === 1) return { type: "prefix" };
|
||||
if (data[1] !== "[") return { type: "none" };
|
||||
if (data.length === 2) return { type: "prefix" };
|
||||
|
||||
let index = 2;
|
||||
if (data[index] === "?") {
|
||||
index += 1;
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
}
|
||||
|
||||
let rowDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
rowDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (rowDigits === 0 || data[index] !== ";") return { type: "none" };
|
||||
|
||||
index += 1;
|
||||
let columnDigits = 0;
|
||||
while (index < data.length && isAsciiDigit(data[index])) {
|
||||
columnDigits += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (index === data.length) return { type: "prefix" };
|
||||
if (columnDigits === 0 || data[index] !== "R") return { type: "none" };
|
||||
|
||||
return { type: "complete", length: index + 1 };
|
||||
};
|
||||
|
||||
const getPasteEchoFragments = (text: string): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
getPlainTerminalText(text)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH),
|
||||
),
|
||||
);
|
||||
|
||||
const preparePasteTextForXterm = (text: string): string => text.replace(/\r?\n/g, "\r");
|
||||
|
||||
const getPasteInputDataVariants = (text: string): string[] => {
|
||||
const preparedText = preparePasteTextForXterm(text);
|
||||
return Array.from(
|
||||
new Set([
|
||||
preparedText,
|
||||
`${BRACKETED_PASTE_START}${preparedText}${BRACKETED_PASTE_END}`,
|
||||
]),
|
||||
).filter((candidate) => candidate.length > 0);
|
||||
};
|
||||
|
||||
const getPasteInputData = (term: PasteTarget, text: string): string => {
|
||||
const preparedText = preparePasteTextForXterm(text);
|
||||
if (term.modes?.bracketedPasteMode && !term.options?.ignoreBracketedPasteMode) {
|
||||
return `${BRACKETED_PASTE_START}${preparedText}${BRACKETED_PASTE_END}`;
|
||||
}
|
||||
return preparedText;
|
||||
};
|
||||
|
||||
const isExpectedPasteEcho = (data: string, state: PasteDisplayState): boolean => {
|
||||
if (state.pasteEchoFragments.length === 0) return false;
|
||||
|
||||
const candidateLines = getPlainTerminalText(stripReadlineActiveRegion(data))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH);
|
||||
|
||||
return candidateLines.some((line) =>
|
||||
state.pasteEchoFragments.some((fragment) => fragment.includes(line) || line.includes(fragment)),
|
||||
);
|
||||
};
|
||||
|
||||
const stripMatchedReadlineActiveRegion = (
|
||||
data: string,
|
||||
state: PasteDisplayState,
|
||||
): { data: string; matched: boolean } => {
|
||||
let index = 0;
|
||||
let matched = false;
|
||||
let nextData = "";
|
||||
|
||||
while (index < data.length) {
|
||||
if (state.inPasteEchoActiveRegion) {
|
||||
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, index);
|
||||
if (endIndex === -1) {
|
||||
nextData += data.slice(index);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
nextData += data.slice(index, endIndex);
|
||||
index = endIndex + READLINE_ACTIVE_REGION_END.length;
|
||||
state.inPasteEchoActiveRegion = false;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = data.indexOf(READLINE_ACTIVE_REGION_START, index);
|
||||
if (startIndex === -1) {
|
||||
nextData += data.slice(index);
|
||||
break;
|
||||
}
|
||||
|
||||
nextData += data.slice(index, startIndex);
|
||||
const contentStart = startIndex + READLINE_ACTIVE_REGION_START.length;
|
||||
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, contentStart);
|
||||
|
||||
if (endIndex === -1) {
|
||||
const highlightedContent = data.slice(contentStart);
|
||||
if (isExpectedPasteEcho(highlightedContent, state)) {
|
||||
nextData += highlightedContent;
|
||||
state.inPasteEchoActiveRegion = true;
|
||||
matched = true;
|
||||
} else {
|
||||
nextData += data.slice(startIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const highlightedContent = data.slice(contentStart, endIndex);
|
||||
if (isExpectedPasteEcho(highlightedContent, state)) {
|
||||
nextData += highlightedContent;
|
||||
matched = true;
|
||||
} else {
|
||||
nextData += data.slice(startIndex, endIndex + READLINE_ACTIVE_REGION_END.length);
|
||||
}
|
||||
|
||||
index = endIndex + READLINE_ACTIVE_REGION_END.length;
|
||||
}
|
||||
|
||||
return { data: nextData, matched };
|
||||
};
|
||||
|
||||
const estimateRows = (text: string, cols: number): number => {
|
||||
const width = Math.max(1, cols);
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0);
|
||||
};
|
||||
|
||||
const shouldApplyPasteDisplayFix = (term: PasteTarget, text: string): boolean => {
|
||||
if (text.length < LONG_PASTE_MIN_LENGTH) return false;
|
||||
|
||||
const lineCount = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").length;
|
||||
const rows = typeof term.rows === "number" && term.rows > 0 ? term.rows : 24;
|
||||
const cols = typeof term.cols === "number" && term.cols > 0 ? term.cols : 80;
|
||||
|
||||
return lineCount >= rows - 1 || estimateRows(text, cols) >= rows - 1;
|
||||
};
|
||||
|
||||
export function pasteTextIntoTerminal(
|
||||
term: PasteTarget,
|
||||
text: string,
|
||||
options: PasteOptions = {},
|
||||
): void {
|
||||
if (!text) return;
|
||||
|
||||
if (shouldApplyPasteDisplayFix(term, text)) {
|
||||
pasteDisplayStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_DISPLAY_FIX_WINDOW_MS,
|
||||
clearPending: 0,
|
||||
pasteEchoFragments: getPasteEchoFragments(text),
|
||||
inPasteEchoActiveRegion: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.scrollOnPaste === false) {
|
||||
pasteInputScrollStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
|
||||
remainingDataVariants: getPasteInputDataVariants(text),
|
||||
});
|
||||
} else {
|
||||
pasteInputScrollStates.delete(term);
|
||||
}
|
||||
|
||||
if (options.onPasteData) {
|
||||
const pasteData = getPasteInputData(term, text);
|
||||
const didBroadcast = options.onPasteData(pasteData) === true;
|
||||
if (didBroadcast) {
|
||||
pasteBroadcastStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
|
||||
remainingDataVariants: [pasteData],
|
||||
});
|
||||
} else {
|
||||
pasteBroadcastStates.delete(term);
|
||||
}
|
||||
} else {
|
||||
pasteBroadcastStates.delete(term);
|
||||
}
|
||||
|
||||
term.paste(text);
|
||||
|
||||
if (!options.scrollOnPaste) return;
|
||||
|
||||
term.scrollToBottom();
|
||||
const scheduleFrame =
|
||||
options.requestAnimationFrame ??
|
||||
(typeof globalThis.requestAnimationFrame === "function"
|
||||
? globalThis.requestAnimationFrame.bind(globalThis)
|
||||
: undefined);
|
||||
|
||||
if (scheduleFrame) {
|
||||
scheduleFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSuppressTerminalInputScrollForUserPaste(term: object, data: string): boolean {
|
||||
return consumePasteInputState(pasteInputScrollStates, term, data);
|
||||
}
|
||||
|
||||
export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data: string): boolean {
|
||||
return consumePasteInputState(pasteBroadcastStates, term, data);
|
||||
}
|
||||
|
||||
export function markExpectedTerminalCursorPositionReport(term: object): void {
|
||||
const currentState = terminalProtocolReplyStates.get(term);
|
||||
const activeState = isStateActive(currentState)
|
||||
? currentState
|
||||
: {
|
||||
expiresAt: 0,
|
||||
pendingCursorPositionReports: 0,
|
||||
cursorPositionReportFragment: "",
|
||||
};
|
||||
|
||||
terminalProtocolReplyStates.set(term, {
|
||||
expiresAt: getNow() + TERMINAL_PROTOCOL_REPLY_WINDOW_MS,
|
||||
pendingCursorPositionReports: activeState.pendingCursorPositionReports + 1,
|
||||
cursorPositionReportFragment: activeState.cursorPositionReportFragment,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSuppressTerminalProtocolReplyBroadcast(term: object, data: string): boolean {
|
||||
const state = terminalProtocolReplyStates.get(term);
|
||||
if (!isStateActive(state)) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.pendingCursorPositionReports <= 0 || data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let remainingData = `${state.cursorPositionReportFragment}${data}`;
|
||||
let consumedCursorPositionReports = 0;
|
||||
|
||||
while (remainingData.length > 0) {
|
||||
const match = matchCursorPositionReportFromStart(remainingData);
|
||||
if (match.type === "none") {
|
||||
state.cursorPositionReportFragment = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (match.type === "prefix") {
|
||||
if (consumedCursorPositionReports >= state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = remainingData;
|
||||
return true;
|
||||
}
|
||||
|
||||
consumedCursorPositionReports += 1;
|
||||
if (consumedCursorPositionReports > state.pendingCursorPositionReports) {
|
||||
return false;
|
||||
}
|
||||
remainingData = remainingData.slice(match.length);
|
||||
}
|
||||
|
||||
state.pendingCursorPositionReports -= consumedCursorPositionReports;
|
||||
state.cursorPositionReportFragment = "";
|
||||
if (state.pendingCursorPositionReports <= 0) {
|
||||
terminalProtocolReplyStates.delete(term);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldBroadcastTerminalUserInput(
|
||||
term: object,
|
||||
data: string,
|
||||
options: BroadcastUserInputOptions,
|
||||
): boolean {
|
||||
const isSuppressedUserPaste = shouldSuppressTerminalBroadcastForUserPaste(term, data);
|
||||
const isSuppressedTerminalProtocolReply = shouldSuppressTerminalProtocolReplyBroadcast(term, data);
|
||||
return (
|
||||
!isSuppressedUserPaste &&
|
||||
!isSuppressedTerminalProtocolReply &&
|
||||
!!options.isBroadcastEnabled &&
|
||||
!!options.hasBroadcastInputHandler
|
||||
);
|
||||
}
|
||||
|
||||
function consumePasteInputState(
|
||||
states: WeakMap<object, PasteInputScrollState>,
|
||||
term: object,
|
||||
data: string,
|
||||
): boolean {
|
||||
const state = states.get(term);
|
||||
if (!isStateActive(state)) {
|
||||
states.delete(term);
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchingIndex = state.remainingDataVariants.findIndex((candidate) => {
|
||||
if (candidate === data) return true;
|
||||
return candidate.startsWith(data);
|
||||
});
|
||||
if (matchingIndex === -1) return false;
|
||||
|
||||
const candidate = state.remainingDataVariants[matchingIndex];
|
||||
if (candidate.length > data.length) {
|
||||
state.remainingDataVariants[matchingIndex] = candidate.slice(data.length);
|
||||
} else {
|
||||
states.delete(term);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function prepareTerminalDataForUserPasteDisplay(term: object, data: string): string {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return data;
|
||||
|
||||
const strippedActiveRegion = stripMatchedReadlineActiveRegion(data, state);
|
||||
if (strippedActiveRegion.matched) {
|
||||
state.clearPending = Math.max(state.clearPending, 3);
|
||||
return strippedActiveRegion.data;
|
||||
}
|
||||
|
||||
const isPasteEcho = isExpectedPasteEcho(data, state);
|
||||
if (isPasteEcho && (data.length > LONG_PASTE_MIN_LENGTH || data.includes("\r"))) {
|
||||
state.clearPending = Math.max(state.clearPending, 1);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function clearPasteResidualAfterTerminalWrite(term: object): string | null {
|
||||
const state = pasteDisplayStates.get(term);
|
||||
if (!isStateActive(state)) return null;
|
||||
if (state.clearPending <= 0) return null;
|
||||
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return null;
|
||||
|
||||
// Readline can leave stale cells to the right of the cursor after very long
|
||||
// bracketed paste redraws; clear them locally without sending bytes upstream.
|
||||
state.clearPending -= 1;
|
||||
const cleanupData = "\x1b[K";
|
||||
(term as Pick<XTerm, "write">).write(cleanupData);
|
||||
return cleanupData;
|
||||
}
|
||||
Reference in New Issue
Block a user