import type { Terminal as XTerm } from "@xterm/xterm"; type PasteTarget = Pick & Partial> & { 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(); const pasteInputScrollStates = new WeakMap(); const pasteBroadcastStates = new WeakMap(); const terminalProtocolReplyStates = new WeakMap(); 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 = (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, 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>).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).write(cleanupData); return cleanupData; }