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
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
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;
|
|
}
|