import { sliceStringByCellColumns, stringCellWidth, } from "../../terminal/autocomplete/terminalStringCellWidth"; import type { HistoryPreviewRow } from "./terminalHistoryScrollOverride"; const outputGraphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); /** * Plain-text transcript of what a session printed, used as the alternate-screen * history preview data source (#2516). * * Inside screen/vim/codex the app owns the alternate buffer, so xterm's normal * buffer has no scrollback and the preview has nothing to show. The output * stream is the only usable source, so every display chunk fed to the log * capture is reduced to plain lines here: row moves separate printed lines, a bare * `\r` overwrites the line it restarts (progress bars/spinners stay one line), * printable output wraps at the reported viewport width the way xterm's * deferred autowrap does, and the retained tail is bounded. * * Feed it the chunk *after* the programmatic command rewriter (masked commands * never reach the preview) but *before* the replay-safe sanitizer, which drops * alternate-screen output on purpose. */ const ESC = "\x1b"; const BEL = "\x07"; const C1_CSI = "\x9b"; const C1_ST = "\x9c"; // Cap on a partially received escape sequence, mirroring the replay sanitizer. const MAX_PENDING_ESCAPE_CHARS = 4096; export const DEFAULT_OUTPUT_HISTORY_MAX_LINES = 2000; export const DEFAULT_OUTPUT_HISTORY_MAX_CHARS = 200_000; /** xterm's default tab stop interval, used when expanding tabs for the preview. */ const TERMINAL_TAB_STOP_COLUMNS = 8; // Bounded look-ahead windows keep long non-ASCII spans linear. The per-row // piece and the deferred-wrap grapheme probe below otherwise slice/measure // the whole unconsumed suffix once per viewport row they fill, making capture // quadratic (and synchronously blocking the renderer) for large CJK chunks. // The slack absorbs zero-width grapheme runs a row can also absorb; a // grapheme longer than a window just costs one bounded fallback. const WRAP_PROBE_UTF16_UNITS = 64; // 8-bit (C1) control string introducers: DCS, SOS, OSC, PM, APC. Their // payloads must be consumed with them, not left as transcript text. const isC1ControlStringIntroducer = (ch: string): boolean => ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f"; const isEscapeIntroducer = (ch: string): boolean => ch === ESC || ch === C1_CSI || isC1ControlStringIntroducer(ch); const findEscapeIntroducer = (input: string, from: number): number => { for (let i = from; i < input.length; i += 1) { if (isEscapeIntroducer(input[i])) return i; } return -1; }; const consumeCsiBody = (input: string, from: number): number | null => { for (let i = from; i < input.length; i += 1) { // Parameter/intermediate bytes are 0x20-0x3f; the final byte is 0x40-0x7e. if (input[i] >= "@" && input[i] <= "~") return i + 1; } return null; }; const consumeControlString = (input: string, from: number): number | null => { for (let i = from; i < input.length; i += 1) { const ch = input[i]; if (ch === BEL) return i + 1; if (ch === ESC && input[i + 1] === "\\") return i + 2; if (ch === C1_ST) return i + 1; } return null; }; const isControlStringIntroducer = (ch: string): boolean => ch === "]" || ch === "P" || ch === "^" || ch === "_" || ch === "X"; /** Length of the introducer bytes of the escape sequence starting at `start`. */ const escapeIntroducerLength = (input: string, start: number): number => { if (input[start] !== ESC) return 1; const second = input[start + 1]; return second === "[" || isControlStringIntroducer(second) ? 2 : 1; }; /** Marks an erase-in-line the transcript must apply (kept in stripper output). */ const ERASE_TO_END_OF_LINE = "\x1f"; /** Marks `CSI 1 K` (erase from the line start through the cursor, inclusive). */ const ERASE_START_TO_CURSOR = "\x1e"; /** Marks `CSI 2 K` (erase the whole line). */ const ERASE_WHOLE_LINE = "\x1d"; /** * Marks a control sequence the stripper removed. xterm's grapheme provider * resets its preceding-join state at every control it parses, so a cluster's * base and continuation separated by one (an SGR between display chunks, say) * stay separate graphemes; the writer must not rejoin them across the gap. * A C0 byte the transcript filters (`keepTranscriptChars` drops \x06), so only * this marker can produce it in stripper output. */ const CONTROL_BOUNDARY = "\x06"; /** * The erase-in-line sequences progress lines use (`\r` + text + `\x1b[K`) must * be applied to the transcript, or the stale suffix of a shorter redraw shows * up as text. Returns the marker for the erase mode: every variant (default/0, * 1, 2) has a transcript equivalent, applied by the writer against its tracked * cursor. */ const eraseInLineMarkerFor = (input: string, start: number, end: number): string | null => { if (input[end - 1] !== "K") return null; const introducerLength = escapeIntroducerLength(input, start); const mode = input.slice(start + introducerLength, end - 1).split(";")[0]; if (mode === "1") return ERASE_START_TO_CURSOR; if (mode === "2") return ERASE_WHOLE_LINE; return ERASE_TO_END_OF_LINE; }; // ECMA-48 nF sequences: zero or more intermediates (0x20-0x2f) then one final // byte (0x30-0x7e), e.g. `ESC ( B` (charset) or `ESC # 8` (DECALN). const isEscapeIntermediateByte = (ch: string): boolean => ch >= " " && ch <= "/"; const isEscapeFinalByte = (ch: string): boolean => ch >= "0" && ch <= "~"; /** Index past the escape sequence at `start`, or null while it is incomplete. */ const consumeEscapeSequence = (input: string, start: number): number | null => { const introducer = input[start]; if (introducer === C1_CSI) return consumeCsiBody(input, start + 1); if (isC1ControlStringIntroducer(introducer)) return consumeControlString(input, start + 1); const second = input[start + 1]; if (second === undefined) return null; if (second === "[") return consumeCsiBody(input, start + 2); if (isControlStringIntroducer(second)) return consumeControlString(input, start + 2); let end = start + 1; while (end < input.length && isEscapeIntermediateByte(input[end])) end += 1; if (end >= input.length) return null; // No final byte: leave the trailing byte to the transcript filters, which // drop control characters, instead of eating a meaningful one. if (!isEscapeFinalByte(input[end])) return end; return end + 1; }; /** * Keep the line-editing controls the transcript needs and printable text; drop * every other control character (BEL, SO/SI, NUL, stray C1 bytes, ...). */ const keepTranscriptChars = (span: string): string => { // Fast path: most spans are plain text with nothing to filter. let needsFiltering = false; for (let i = 0; i < span.length; i += 1) { const code = span.charCodeAt(i); if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { needsFiltering = true; break; } } if (!needsFiltering) return span; let kept = ""; for (let i = 0; i < span.length; i += 1) { const code = span.charCodeAt(i); if (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f)) { kept += span[i]; } else if (code === 8 || code === 9 || code === 10 || code === 13 || code === 31) { kept += span[i]; } } return kept; }; /** * A private-mode sequence is preserved when it carries a tracked mode (DECOM 6 * or DECAWM 7), even alongside unrelated modes like `CSI ?6;25h`: xterm applies * every parameter and drops only the untracked ones during interpretation. */ const isTrackedDecPrivateModeSequence = (sequence: string): boolean => { const match = /^\?([0-9;]+)([hl])$/.exec(sequence); if (!match) return false; return match[1].split(";").some((p) => p === "6" || p === "7"); }; /** * Reduce a display chunk to plain text: escape sequences are removed while * `\n` / `\r` / `\b` / `\t` and the erase-in-line marker survive so the history * can track line edits. */ export const stripTerminalDisplayToPlainText = ( chunk: string, pending: string = "", preserveRowControls = false, ): { text: string; pending: string } => { const input = pending + chunk; let output = ""; let i = 0; while (i < input.length) { if (isEscapeIntroducer(input[i])) { const end = consumeEscapeSequence(input, i); if (end === null) { const rest = input.slice(i); if (rest.length <= MAX_PENDING_ESCAPE_CHARS) { return { text: output, pending: rest }; } // Cap the retained tail but keep its introducer: without it the payload // of an oversized control string would be re-parsed as transcript text // on the next chunk. const introducerLength = escapeIntroducerLength(input, i); return { text: output, pending: rest.slice(0, introducerLength) + rest.slice(rest.length - (MAX_PENDING_ESCAPE_CHARS - introducerLength)), }; } const sequence = input.slice(i + escapeIntroducerLength(input, i), end); // Only the history writer consumes cursor-row/column controls. // Standalone plain text stripping keeps its original contract. DEC // origin/autowrap mode changes, cursor save/restore, and RIS ride along // so rows resolve the way the terminal resolves them. const isCsi = input[i] === C1_CSI || input[i + 1] === "["; const passesRowControl = isCsi && (/^[0-9;]*[HfABCDEFdDrG]$/.test(sequence) || isTrackedDecPrivateModeSequence(sequence) || /^[su]$/.test(sequence)); const isBareEscFinal = !isCsi && escapeIntroducerLength(input, i) === 1 && /^[78cDEM]$/.test(sequence); const passes = preserveRowControls && (passesRowControl || isBareEscFinal); if (passes) output += input.slice(i, end); const eraseInLine = eraseInLineMarkerFor(input, i, end); if (eraseInLine) output += eraseInLine; else if (preserveRowControls && !passes) output += CONTROL_BOUNDARY; i = end; continue; } const next = findEscapeIntroducer(input, i + 1); const end = next === -1 ? input.length : next; const kept = keepTranscriptChars(input.slice(i, end)); if (kept) output += kept; i = end; } return { text: output, pending: "" }; }; /** * The live xterm instance whose Unicode provider measures cell widths. The * configured `15-graphemes` runtime intentionally disagrees with the local * fallback for some graphemes (e.g. `terminalStringCellWidth.test.ts` records * `🖥` as one xterm cell while the fallback counts two), so every wrap / * overwrite decision here must measure the way the terminal measures. */ type WidthTerm = NonNullable[1]>; const isAsciiOnly = (text: string): boolean => { for (let i = 0; i < text.length; i += 1) { if (text.charCodeAt(i) > 0x7f) return false; } return true; }; /** Cell columns a transcript span occupies (wide glyphs count twice). */ const pieceCellWidth = (text: string, term?: WidthTerm | null): number => isAsciiOnly(text) ? text.length : stringCellWidth(text, term); /** UTF-16 units of the first grapheme of `text` ("" when empty). */ const firstGraphemeUnits = (text: string): string => outputGraphemeSegmenter.segment(text)[Symbol.iterator]().next().value?.segment ?? ""; /** UTF-16 units of the last grapheme of `text` ("" when empty). */ const lastGraphemeUnits = (text: string): string => { let last = ""; for (const { segment } of outputGraphemeSegmenter.segment(text)) last = segment; return last; }; /** * Start cell of the first grapheme that begins at or after `cell`. A wide * grapheme straddling `cell` is skipped, so a cell boundary that cuts into it * extends to the grapheme's end (xterm erases whole intersected cells). */ const firstGraphemeStartCellAtOrAfter = ( text: string, cell: number, term?: WidthTerm | null, ): number => { if (isAsciiOnly(text)) return Math.min(cell, text.length); let startCell = 0; for (const { segment } of outputGraphemeSegmenter.segment(text)) { if (startCell >= cell) return startCell; startCell += pieceCellWidth(segment, term); } return startCell; }; /** * Wrap one transcript line into viewport-sized rows. Continuation rows carry * the same `isWrapped` flag xterm uses so soft-wrapped selection joins keep * working (see joinHistoryPreviewSelectionText). */ export const wrapOutputHistoryLineToRows = ( text: string, cols: number, term?: WidthTerm | null, ): string[] => { if (cols < 1) return [text]; const ascii = isAsciiOnly(text); const width = ascii ? text.length : stringCellWidth(text, term); if (width <= cols) return [text]; const rows: string[] = []; let startCell = 0; while (startCell < width) { // ASCII lines skip the grapheme segmenter; a transcript can hold long lines. const row = ascii ? text.slice(startCell, startCell + cols) : sliceStringByCellColumns(text, startCell, startCell + cols, term); if (!row) break; rows.push(row); startCell += ascii ? row.length : stringCellWidth(row, term); } return rows.length > 0 ? rows : [text]; }; const buildPreviewRows = ( lines: readonly string[], lineStartsWrapped: readonly boolean[], cols: number, term?: WidthTerm | null, ): HistoryPreviewRow[] => { const rows: HistoryPreviewRow[] = []; // A line committed by an automatic wrap is itself the continuation row of // the previous one, so rejoin each soft-wrapped run before re-wrapping: // reflowing the segments independently would keep them on separate rows at // a widened preview, unlike xterm's single row after its resize reflow. let pending = ""; let pendingStartsWrapped = false; let hasPending = false; const flush = () => { const wrapped = wrapOutputHistoryLineToRows(pending, cols, term); for (let row = 0; row < wrapped.length; row += 1) { // The run's first row keeps the join to the (possibly trimmed) // predecessor; the re-wrapped rows continue it. rows.push({ isWrapped: row > 0 || (row === 0 && pendingStartsWrapped), text: wrapped[row], }); } }; for (let index = 0; index < lines.length; index += 1) { const startsWrapped = lineStartsWrapped[index] ?? false; // An empty pending is a predecessor xterm erased to blank but still shows // as an occupied row (e.g. five columns, `abcde\x1b[2KX`): joining its // continuation here would drop that row and shift every later preview // row, so flush it as its own row before the run continues. if (startsWrapped && hasPending && pending !== "") { pending += lines[index]; } else { if (hasPending) flush(); hasPending = true; pending = lines[index]; pendingStartsWrapped = startsWrapped; } } if (hasPending) flush(); return rows; }; export const clampOutputHistoryPreviewTop = ( top: number, totalRows: number, rows: number, ): number => { const maxTop = Math.max(0, totalRows - Math.max(1, rows)); return Math.max(0, Math.min(maxTop, top)); }; export const nextOutputHistoryPreviewTop = ({ currentTop, lines, rows, totalRows, }: { currentTop: number | null; lines: number; rows: number; totalRows: number; }): number => clampOutputHistoryPreviewTop( clampOutputHistoryPreviewTop( currentTop ?? Math.max(0, totalRows - Math.max(1, rows)), totalRows, rows, ) + lines, totalRows, rows, ); export interface OutputHistoryPreviewWindow { rows: HistoryPreviewRow[]; totalRows: number; } export interface TerminalOutputHistoryPreview { append(chunk: string): void; clear(): void; /** Transcript lines, oldest first (the open last line included). */ getLines(): readonly string[]; /** Viewport rows the retained transcript wraps to at `cols` columns. */ getPreviewRowCount(cols: number): number; getPreviewRows(params: { cols: number; rows: number; top: number }): OutputHistoryPreviewWindow; /** * Report the live terminal's viewport row count so cursor-row moves are * clamped the way the terminal clamps them: `CSI n B`/`E` and absolute * positions past the bottom row stay on it instead of registering as * historical row transitions. Unset (0) keeps the unclamped behavior. */ setViewportRows(rows: number): void; /** * Report the live terminal's viewport column count so absolute cursor * columns past the last one clamp to it, exactly like xterm does. Unset * (0) keeps the unclamped behavior. */ setViewportCols(cols: number): void; /** Synchronize an actual xterm resize, including its zero-based cursor after reflow. */ syncViewportAfterResize(size: { cols: number; rows: number; cursorX: number; cursorY: number; cursorLine: string; isWrapped: boolean; }): void; /** * Report the live terminal so wrap decisions use its Unicode width provider * (the configured `15-graphemes` runtime) instead of the local fallback, * which disagrees with xterm for some graphemes. Unset keeps the fallback. */ setWidthTerminal(term: WidthTerm | null): void; } export const createTerminalOutputHistoryPreview = (options?: { maxLines?: number; maxChars?: number; }): TerminalOutputHistoryPreview => { const maxLines = Math.max(1, options?.maxLines ?? DEFAULT_OUTPUT_HISTORY_MAX_LINES); const maxChars = Math.max(1, options?.maxChars ?? DEFAULT_OUTPUT_HISTORY_MAX_CHARS); let lines: string[] = []; // lines[i] began as an automatic-wrap continuation (see wrapCursor), so the // preview's first row for it must carry xterm's isWrapped flag and join the // previous line's last row during selection. let lineWrapFlags: boolean[] = []; let currentStartsWrapped = false; let current = ""; let cursor = 0; let cursorCell = 0; let screenRow = 1; let currentCellWidth = 0; let totalChars = 0; let pendingEscape = ""; let viewportRows = 0; let viewportCols = 0; // The live xterm instance backing this transcript; its Unicode provider // measures the cell widths every wrap decision below commits to. let widthTerm: WidthTerm | null = null; // Active DECSTBM scroll margins (1-based rows); Infinity means "viewport bottom". let scrollTopMargin = 1; let scrollBottomMargin = Infinity; // DEC origin mode (DECOM): absolute rows become relative to the top margin // and clamp within the region. let originMode = false; // DECAWM: while off, printable characters never wrap to the next row; they // overwrite the last column instead. let autowrap = true; // Cursor saved by SC/DECSC (CSI s / ESC 7) for CSI u / ESC 8 to restore. let savedCursor: { row: number; cell: number; originMode: boolean; autowrap: boolean } | null = null; // UTF-16 units of the grapheme the last appending write left open at the // line tail, plus the cursor cell just past it. The backend can split a // grapheme across display chunks (base in one chunk, its ZWJ / combining // mark / trailing surrogate in the next) while xterm's grapheme provider // joins them across writes, so the next span must be able to rejoin it. let openGrapheme: string | null = null; let openGraphemeCell = 0; let cacheDirty = true; let cacheCols = -1; let cacheRows: HistoryPreviewRow[] = []; const commitCurrentLine = () => { lines.push(current); lineWrapFlags.push(currentStartsWrapped); totalChars += current.length; current = ""; openGrapheme = null; currentCellWidth = 0; cursor = 0; cursorCell = 0; currentStartsWrapped = false; // Never trim away the last retained line. while (lines.length > maxLines || (totalChars > maxChars && lines.length > 1)) { const dropped = lines.shift(); if (dropped === undefined) break; lineWrapFlags.shift(); totalChars -= dropped.length; } }; /** * Move the tracked cursor to the start of the next screen row the way xterm * does when printable output crosses the last column: the open line is * committed at the wrap column so a later cursor-addressed redraw targets * the row the terminal actually wrapped to. */ const wrapCursor = () => { const screenBottom = viewportRows > 0 ? viewportRows : 1_000_000; const bottomLimit = screenRow <= scrollBottomMargin ? Math.min(scrollBottomMargin, screenBottom) : screenBottom; commitCurrentLine(); // The continuation row xterm moved to is a soft wrap: mark the next // committed line so the preview keeps the wrapped-row join. currentStartsWrapped = true; screenRow = Math.min(bottomLimit, screenRow + 1); cursor = 0; cursorCell = 0; currentCellWidth = 0; }; /** * Write one span at the open line's cursor. A cursor-addressed redraw * (cursor-home CSI stripped, no LF) would keep appending frames to the open * line forever, so the budget is enforced here: spans are written in bounded * pieces, committing a full line at the cap, so nothing is dropped and the * copy every write makes stays bounded. With a known viewport width, spans * are additionally capped at the remaining columns so xterm's deferred * autowrap splits the line where the terminal wraps it. */ const writeSpan = (span: string) => { let offset = 0; let protectedFinalWideCell = false; // ASCII-ness is monotone under slicing; decide once so the per-row cap // below stays linear for long spans. let spanIsAscii = isAsciiOnly(span); // The previous chunk may have ended mid-grapheme (its base was written // while this span begins with the continuation: a ZWJ sequence, a // combining mark, or the trailing surrogate of a pair). xterm's grapheme // provider joins the cluster across writes, but segmenting this span // alone treats the continuation as a standalone grapheme: a leading ZWJ // sequence measures wrong and a trailing low surrogate slices a cell // mid-surrogate-pair. Rejoin instead: drop the provisional open grapheme // from the line tail and let the joined cluster be written in its place, // so the width and wrap decisions below match the terminal's. if ( openGrapheme !== null && !spanIsAscii && current && cursor === current.length && cursorCell === openGraphemeCell ) { const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS); const head = openGrapheme + span.slice(0, probeEnd); let first = firstGraphemeUnits(head); if ( first.length === head.length && probeEnd === WRAP_PROBE_UTF16_UNITS && span.length > probeEnd ) { // The probe filled its window and may continue past it; only then // pay for re-segmenting the full remaining span. first = firstGraphemeUnits(openGrapheme + span); } if (first.length > openGrapheme.length) { // The joined cluster replaces the open grapheme: subtract its width // so the joined grapheme below lands on exactly the same cells. const openWidth = pieceCellWidth(openGrapheme, widthTerm); current = current.slice(0, current.length - openGrapheme.length); cursor = current.length; cursorCell = Math.max(0, cursorCell - openWidth); currentCellWidth = Math.max(0, currentCellWidth - openWidth); span = first + span.slice(first.length - openGrapheme.length); spanIsAscii = false; } } // Cap each piece at about one row of cells (plus probe slack): the width // measurement and column slicing below cost O(piece), so an uncapped // piece would rescan the entire remaining span once per row it filled. // Iterating over more, smaller pieces appends the same characters in the // same order, so the transcript is unchanged. Without a viewport width // the whole span is consumed in a single pass, so no cap is needed. const pieceCap = viewportCols > 0 ? viewportCols * 2 + WRAP_PROBE_UTF16_UNITS : Number.POSITIVE_INFINITY; while (offset < span.length) { if (!autowrap && viewportCols > 0) { // Xterm leaves the wide leading cell intact when overflow writes land // on its final continuation cell. A later print span starting on that // continuation cell can erase it, so keep this protection span-local. if (!protectedFinalWideCell && cursorCell >= viewportCols && currentCellWidth === viewportCols && !isAsciiOnly(current)) { const prefix = sliceStringByCellColumns(current, 0, viewportCols - 1, widthTerm); protectedFinalWideCell = pieceCellWidth(prefix, widthTerm) < viewportCols - 1; } if (protectedFinalWideCell) { const first = spanIsAscii ? span[offset] : firstGraphemeUnits(span.slice(offset)); const width = spanIsAscii ? 1 : pieceCellWidth(first, widthTerm); cursorCell = width === 2 ? viewportCols - 1 : viewportCols; cursor = width === 2 ? sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length : current.length; offset += first.length; continue; } } // xterm defers the wrap until the next printable character arrives; a // cursor move or carriage return in between cancels it instead. if (viewportCols > 0 && cursorCell >= viewportCols) { if (autowrap) { // A zero-width grapheme (a combining mark that arrived in a later // display chunk) joins the final cell of the current row instead of // starting the next one; the wrap stays deferred behind it. That // only holds while a valid preceding grapheme ends the row // (`openGrapheme` still set): a control xterm parsed reset its // preceding-join state, so a standalone mark must not attach to the // prior row's cell — xterm wraps and the mark begins the next row. const rest = spanIsAscii ? "" : span.slice(offset, offset + WRAP_PROBE_UTF16_UNITS); let first = rest ? outputGraphemeSegmenter.segment(rest)[Symbol.iterator]().next().value?.segment : undefined; if ( first !== undefined && rest.length === WRAP_PROBE_UTF16_UNITS && first.length === rest.length ) { // The first grapheme fills the probe window and may continue past // it; only then pay for slicing the full remaining suffix. first = outputGraphemeSegmenter .segment(span.slice(offset))[Symbol.iterator]().next().value?.segment; } if ( openGrapheme !== null && first !== undefined && stringCellWidth(first, widthTerm) === 0 && cursor >= current.length && current.length < maxChars ) { current += first; cursor = current.length; // The tracked open grapheme no longer describes the tail (the // zero-width piece extends it in place); stop offering a join. openGrapheme = null; offset += first.length; continue; } wrapCursor(); } else { // DECAWM disabled: keep writing on the current row, overwriting the // last column the way xterm does. cursorCell = viewportCols - 1; cursor = cursorCell === 0 ? 0 : isAsciiOnly(current) ? cursorCell : sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length; } } if (current.length >= maxChars) commitCurrentLine(); const width = currentCellWidth; if (cursorCell > width) { current += " ".repeat(cursorCell - width); cursor = current.length; // Track the materialized padding so a discarded glyph (a wide one that // cannot fit with DECAWM off) does not make the next character pad the // same gap a second time. currentCellWidth = Math.max(width, cursorCell); } // A span may open with a zero-width grapheme (a combining mark, or a ZWJ // whose base ended an earlier chunk after a control reset the // preceding-join state). Append it onto the open line directly, the way // the deferred-wrap path above does: column slicing drops a leading // zero-width cluster from a cut piece, which would desynchronize the // span offset and re-write the grapheme that follows it. if (!spanIsAscii && cursor >= current.length) { const rest = span.slice(offset, offset + WRAP_PROBE_UTF16_UNITS); let first = rest ? outputGraphemeSegmenter.segment(rest)[Symbol.iterator]().next().value?.segment : undefined; if ( first !== undefined && rest.length === WRAP_PROBE_UTF16_UNITS && first.length === rest.length ) { // The probe filled its window and may continue past it; only then // pay for slicing the full remaining suffix. first = outputGraphemeSegmenter .segment(span.slice(offset))[Symbol.iterator]().next().value?.segment; } if (first !== undefined && stringCellWidth(first, widthTerm) === 0) { current += first; cursor = current.length; // The zero-width piece extends the tail in place; stop offering a join. openGrapheme = null; offset += first.length; continue; } } // The maxChars budget still wins when it is smaller (that slice keeps // the established behavior). Otherwise cut the piece at a grapheme // boundary: slicing mid-grapheme would regroup combining marks / ZWJ // clusters and corrupt the transcript, while a boundary-aligned cut // keeps every piece's segmentation identical to the span's. let pieceEnd = offset + (maxChars - current.length); if (pieceEnd - offset > pieceCap) { const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS); let cutLength = 0; let end = window.length; for (const { segment } of outputGraphemeSegmenter.segment(window)) { if (cutLength + segment.length > pieceCap) { // A single grapheme wider than the cap (a base character trailed // by many combining marks) cannot be split at a boundary; take the // whole first grapheme rather than an empty piece, which would // drop this grapheme and everything after it. end = cutLength > 0 ? cutLength : Math.min(segment.length, window.length); break; } cutLength += segment.length; } pieceEnd = offset + end; } const piece = span.slice(offset, pieceEnd); if (!piece) return; let fitting = piece; let fittingWidth: number | undefined; if (viewportCols > 0) { const room = viewportCols - cursorCell; if (spanIsAscii) { fitting = piece.slice(0, room); } else { const fullWidth = stringCellWidth(piece, widthTerm); if (fullWidth <= room) { fittingWidth = fullWidth; } else { fitting = sliceStringByCellColumns(piece, 0, room, widthTerm); } } if (!fitting) { if (cursorCell > 0) { if (autowrap) { // The next glyph is wider than the remaining columns; xterm wraps // and places it on the following row. wrapCursor(); } else { // Match xterm: a wide glyph that cannot fit with DECAWM off is // discarded. Consume the entire grapheme so this loop advances // and later narrow characters can still overwrite the last cell. const first = outputGraphemeSegmenter.segment(piece)[Symbol.iterator]().next().value; offset += first?.segment.length ?? piece.length; } continue; } // Wider than the whole viewport: write only the first grapheme so // the loop wraps the rest the way xterm wraps after an oversized // glyph, instead of pinning later characters to this row. fitting = firstGraphemeUnits(piece); } } const pieceAscii = isAsciiOnly(fitting); const pieceWidth = fittingWidth ?? (pieceAscii ? fitting.length : stringCellWidth(fitting, widthTerm)); const appending = cursor >= current.length; if (appending) { current += fitting; } else { // The tail is overwritten, so the tracked open grapheme (if any) no // longer ends the line. openGrapheme = null; if (pieceAscii && isAsciiOnly(current)) { current = current.slice(0, cursor) + fitting + current.slice(cursor + fitting.length); } else { // Cursor coordinates count cells, while strings count UTF-16 units. // Replace entire intersected graphemes and blank any remaining half // of a wide glyph, retaining the columns of the untouched suffix. const prefix = sliceStringByCellColumns(current, 0, cursorCell, widthTerm); const endCell = cursorCell + pieceWidth; const endPrefix = sliceStringByCellColumns(current, 0, endCell, widthTerm); const overlapsWideEnd = pieceCellWidth(endPrefix, widthTerm) < Math.min(endCell, width); const suffix = sliceStringByCellColumns( current, endCell + (overlapsWideEnd ? 1 : 0), undefined, widthTerm, ); current = prefix + " ".repeat(cursorCell - pieceCellWidth(prefix, widthTerm)) + fitting + (overlapsWideEnd ? " " : "") + suffix; } } cursorCell += pieceWidth; currentCellWidth = Math.max(width, cursorCell); if (appending) { // Remember the grapheme this append left at the tail so a later chunk // continuing it (a split cluster) can be rejoined (see writeSpan). // An empty open grapheme would corrupt the join, so track null then. openGrapheme = !fitting ? null : isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting); openGraphemeCell = cursorCell; } cursor = appending ? current.length : isAsciiOnly(current) ? cursorCell : sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length; offset += fitting.length; } }; const saveTrackedCursor = () => { savedCursor = { row: screenRow, cell: cursorCell, originMode, autowrap }; }; /** * RIS (`ESC c`): xterm resets DECAWM/DECOM and the scroll margins, discards * the saved cursor, and homes it, so later output after a mode change * resolves the way the terminal resolves it. The transcript itself is a log * of printed output, so it is kept. */ const resetTrackedState = () => { scrollTopMargin = 1; scrollBottomMargin = Infinity; originMode = false; autowrap = true; savedCursor = null; // xterm clears the display before homing the cursor, so the open row's // screen state must not survive the reset even when the tracked cursor is // already on row 1: keeping `current` would let later output overwrite a // stale suffix (`LONG` + RIS + `X` would read `XONG`). Committing keeps // the printed text (the transcript is a log) and starts a fresh row. if (current) commitCurrentLine(); screenRow = 1; cursor = 0; cursorCell = 0; }; const restoreTrackedCursor = () => { if (!savedCursor) return; originMode = savedCursor.originMode; autowrap = savedCursor.autowrap; const savedCell = Math.min( maxChars - 1, savedCursor.cell, viewportCols > 0 ? viewportCols - 1 : savedCursor.cell, ); if (savedCursor.row !== screenRow && current) commitCurrentLine(); // A resize that removed the saved row leaves an obsolete target; xterm // clamps a restored cursor to the current viewport's bottom row. screenRow = viewportRows > 0 ? Math.min(savedCursor.row, viewportRows) : savedCursor.row; cursorCell = savedCell; cursor = savedCell === 0 ? 0 : isAsciiOnly(current) ? savedCell : sliceStringByCellColumns(current, 0, savedCell, widthTerm).length; }; const writeText = (text: string) => { let i = 0; while (i < text.length) { const ch = text[i]; if (ch === CONTROL_BOUNDARY) { // A stripped control (an SGR between display chunks, say): xterm // consumed a control here, resetting its preceding-join state, so a // later continuation must not rejoin the grapheme the tail holds. openGrapheme = null; i += 1; continue; } if (ch === ESC || ch === C1_CSI) { // Any control xterm parses resets its preceding-join state (see the // CONTROL_BOUNDARY comment), so a split cluster cannot rejoin across // a ride-through cursor or mode control either. openGrapheme = null; if (ch === ESC && text[i + 1] !== "[") { // Bare ESC finals ride through the stripper: DECSC ("ESC 7") saves // and DECRC ("ESC 8") restores the tracked cursor; RIS ("ESC c") // resets the tracked modes, margins, and cursor. if (text[i + 1] === "7") saveTrackedCursor(); else if (text[i + 1] === "8") restoreTrackedCursor(); else if (text[i + 1] === "c") resetTrackedState(); else if (text[i + 1] === "D" || text[i + 1] === "E" || text[i + 1] === "M") { const screenBottom = viewportRows > 0 ? viewportRows : 1_000_000; // The column these controls keep (NEL returns to the first one). const column = text[i + 1] === "E" ? 0 : Math.min( maxChars - 1, cursorCell, viewportCols > 0 ? viewportCols - 1 : cursorCell, ); if (text[i + 1] === "M") { // RI: up one row, clamped at the top margin. RI issued while // the cursor already sits at that margin makes xterm scroll // the region down instead of moving: a blank row opens at the // margin and the open row's text slides below it, so commit // the open row (the transcript keeps it) rather than letting // later output overwrite it in place. const topLimit = screenRow >= scrollTopMargin ? Math.min(scrollTopMargin, screenBottom) : 1; const nextRow = Math.max(topLimit, screenRow - 1); const scrollsAtMargin = nextRow === screenRow && screenRow >= scrollTopMargin && screenRow <= scrollBottomMargin; if ((nextRow !== screenRow || scrollsAtMargin) && current) { commitCurrentLine(); } screenRow = nextRow; } else { // IND / NEL: down one row, clamped like LF at the scroll // region's bottom margin. const bottomLimit = screenRow <= scrollBottomMargin ? Math.min(scrollBottomMargin, screenBottom) : screenBottom; screenRow = Math.min(bottomLimit, screenRow + 1); commitCurrentLine(); } // commitCurrentLine resets the tracked cursor; restore the column // these controls keep, the way the vertical CSI moves do. cursor = column === 0 ? 0 : isAsciiOnly(current) ? column : sliceStringByCellColumns(current, 0, column, widthTerm).length; cursorCell = column; } i += 2; continue; } const bodyStart = i + (ch === ESC ? 2 : 1); const end = consumeCsiBody(text, bodyStart)!; const command = text[end - 1]; const params = text.slice(bodyStart, end - 1).split(";"); if (command === "r") { // DECSTBM: xterm homes the cursor after (re)setting the scroll // margins, and relative row moves inside the region stop at its // bottom margin instead of the viewport's bottom row. A region whose // bottom is not below its top (after clamping both to the viewport) // is ignored entirely, margins and cursor alike. const viewportBottom = viewportRows > 0 ? viewportRows : 1_000_000; const top = Math.min(Math.max(1, Number(params[0]) || 1), viewportBottom); const bottom = Math.min(Number(params[1]) || viewportBottom, viewportBottom); if (bottom > top) { scrollTopMargin = top; scrollBottomMargin = bottom; // xterm homes the cursor at the origin-dependent home position: // row 1, or the new top margin while DECOM is active. const homeRow = originMode ? top : 1; if (screenRow !== homeRow && current) commitCurrentLine(); screenRow = homeRow; cursor = 0; cursorCell = 0; } i = end; continue; } if (command === "h" || command === "l") { // DECOM (CSI ?6h / ?6l): while set, CUP/HVP/VPA rows are relative to // and clamped within the scroll region. The terminal also homes the // cursor (to the origin-dependent home) on the mode change. // DECAWM (CSI ?7h / ?7l): while off, printable characters never wrap // to the next row; they overwrite the last column instead. // Private-mode sequences may combine modes in one control // (`CSI ?6;7l`); the leading "?" applies to every parameter, so each // one must be applied or combined controls would be dropped whole. if (params[0]?.startsWith("?")) { for (const param of params) { const mode = param.replace(/^\?/, ""); if (mode === "6") { originMode = command === "h"; const homeRow = originMode ? scrollTopMargin : 1; if (screenRow !== homeRow && current) commitCurrentLine(); screenRow = homeRow; cursor = 0; cursorCell = 0; } else if (mode === "7") { autowrap = command === "h"; } } } i = end; continue; } if (command === "s") { // SC: remember the cursor row and column so a later restore moves // back to it instead of output after it being concatenated onto the // row the stripper last saw. saveTrackedCursor(); i = end; continue; } if (command === "u") { restoreTrackedCursor(); i = end; continue; } if (command === "G") { // CHA: absolute column move on the current row. Keep placement // logical until text arrives, clamped like CUP's column. const column = Math.max(0, (Number(params[0]) || 1) - 1); const targetCell = Math.min( maxChars - 1, column, viewportCols > 0 ? viewportCols - 1 : column, ); cursor = targetCell === 0 ? 0 : isAsciiOnly(current) ? targetCell : sliceStringByCellColumns(current, 0, targetCell, widthTerm).length; cursorCell = targetCell; i = end; continue; } if (command === "C" || command === "D") { // CUF / CUB: relative horizontal moves stay on the same row, clamped // to the last column the way xterm clamps them. const move = Math.min(1_000_000, Math.max(1, Number(params[0]) || 1)); const maxCell = Math.min( maxChars - 1, viewportCols > 0 ? viewportCols - 1 : 1_000_000, ); // A deferred autowrap (or a DECAWM-off overwrite) parks the tracked // cursor one cell past the last column; xterm applies relative moves // from the last column it displays, so normalize the sentinel before // moving. The move also cancels the pending wrap, as in xterm. const baseCell = viewportCols > 0 && cursorCell >= viewportCols ? viewportCols - 1 : cursorCell; const targetCell = Math.max( 0, Math.min(maxCell, command === "C" ? baseCell + move : baseCell - move), ); cursor = targetCell === 0 ? 0 : isAsciiOnly(current) ? targetCell : sliceStringByCellColumns(current, 0, targetCell, widthTerm).length; cursorCell = targetCell; i = end; continue; } const amount = Math.min(1_000_000, Math.max(1, Number(params[0]) || 1)); // The terminal clamps cursor-row targets to the viewport's bottom row; // mirror that so moves past it stay same-row redraws. Inside a scroll // region, relative moves stop at the region's bottom margin. Without a // known viewport, keep the legacy 1,000,000 cap. const screenBottom = viewportRows > 0 ? viewportRows : 1_000_000; const bottomLimit = screenRow <= scrollBottomMargin ? Math.min(scrollBottomMargin, screenBottom) : screenBottom; const topLimit = screenRow >= scrollTopMargin ? Math.min(scrollTopMargin, screenBottom) : 1; const nextRow = command === "A" || command === "F" ? Math.max(topLimit, screenRow - amount) : command === "B" || command === "E" ? Math.min(bottomLimit, screenRow + amount) : originMode // Origin mode makes CUP/HVP/VPA rows relative to (and clamped // within) the scroll region's margins. ? Math.min(Math.min(scrollBottomMargin, screenBottom), scrollTopMargin + amount - 1) : Math.min(screenBottom, amount); const column = command === "H" || command === "f" ? Math.max(0, (Number(params[1]) || 1) - 1) : command === "E" || command === "F" ? 0 : cursorCell; if (nextRow !== screenRow && current) commitCurrentLine(); // CUP/HVP set both coordinates; vertical-only moves keep the column. // Keep placement logical until text arrives; cursor moves print no spaces. // Bound eventual padding by the transcript budget and, when known, the // viewport width: xterm clamps the column to the last one on screen, // while an unclamped column would fabricate wrapped rows. const targetCell = Math.min( maxChars - 1, column, viewportCols > 0 ? viewportCols - 1 : column, ); cursor = targetCell === 0 ? 0 : isAsciiOnly(current) ? targetCell : sliceStringByCellColumns(current, 0, targetCell, widthTerm).length; cursorCell = targetCell; screenRow = nextRow; i = end; continue; } if (ch === "\n") { // xterm scrolls at the viewport's bottom row (or the scroll region's // bottom margin) and leaves the cursor on that row; advancing past it // would turn a later same-row redraw into a row transition. const screenBottom = viewportRows > 0 ? viewportRows : 1_000_000; const bottomLimit = screenRow <= scrollBottomMargin ? Math.min(scrollBottomMargin, screenBottom) : screenBottom; screenRow = Math.min(bottomLimit, screenRow + 1); commitCurrentLine(); i += 1; continue; } if (ch === "\r") { cursor = 0; cursorCell = 0; i += 1; continue; } if (ch === "\b") { cursor = Math.max(0, cursor - 1); cursorCell = Math.max(0, cursorCell - 1); i += 1; continue; } if (ch === ERASE_TO_END_OF_LINE) { current = current.slice(0, cursor); // The erased suffix may have contained the open grapheme. openGrapheme = null; currentCellWidth = pieceCellWidth(current, widthTerm); i += 1; continue; } if (ch === ERASE_START_TO_CURSOR) { // Erase from the line start through the cursor, inclusive: blank the // intersected cells and keep the untouched suffix at its columns, the // way xterm fills the erased cells with blanks. The cursor stays put. openGrapheme = null; const throughCursor = Math.min(cursorCell + 1, currentCellWidth); if (throughCursor > 0) { // A grapheme straddling the erase boundary is erased in full, so the // blanked prefix extends to the next grapheme that starts after it. const suffixStart = firstGraphemeStartCellAtOrAfter(current, throughCursor, widthTerm); const suffix = suffixStart < currentCellWidth ? sliceStringByCellColumns(current, suffixStart, undefined, widthTerm) : ""; current = " ".repeat(suffixStart) + suffix; currentCellWidth = suffixStart + pieceCellWidth(suffix, widthTerm); cursor = isAsciiOnly(current) ? cursorCell : sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length; } i += 1; continue; } if (ch === ERASE_WHOLE_LINE) { // Erase the whole line: the transcript keeps the cursor's column, so a // later write re-pads the gap the way xterm renders the blanked cells. current = ""; cursor = 0; openGrapheme = null; currentCellWidth = 0; i += 1; continue; } if (ch === "\t") { // Expand to the next 8-column tab stop so row widths match how the // preview overlay renders the text; a literal tab renders at the tab // stop and would be clipped instead of wrapped onto a continuation row. const padding = TERMINAL_TAB_STOP_COLUMNS - (cursorCell % TERMINAL_TAB_STOP_COLUMNS); writeSpan(" ".repeat(padding)); i += 1; continue; } let end = i; while ( end < text.length && text[end] !== "\n" && text[end] !== "\r" && text[end] !== "\b" && text[end] !== "\t" && text[end] !== ESC && text[end] !== C1_CSI && text[end] !== ERASE_TO_END_OF_LINE && text[end] !== ERASE_START_TO_CURSOR && text[end] !== ERASE_WHOLE_LINE && text[end] !== CONTROL_BOUNDARY ) { end += 1; } writeSpan(text.slice(i, end)); i = end; } }; const getLines = (): readonly string[] => (current ? [...lines, current] : lines); const refreshPreviewCache = (cols: number) => { if (!cacheDirty && cacheCols === cols) return; cacheRows = buildPreviewRows( getLines(), current ? [...lineWrapFlags, currentStartsWrapped] : lineWrapFlags, cols, widthTerm, ); cacheCols = cols; cacheDirty = false; }; const history: TerminalOutputHistoryPreview = { append(chunk: string): void { if (!chunk) return; const { text, pending } = stripTerminalDisplayToPlainText(chunk, pendingEscape, true); pendingEscape = pending; if (!text) return; writeText(text); cacheDirty = true; }, clear(): void { lines = []; lineWrapFlags = []; currentStartsWrapped = false; current = ""; openGrapheme = null; currentCellWidth = 0; cursor = 0; cursorCell = 0; totalChars = 0; pendingEscape = ""; screenRow = 1; // The next xterm instance starts with a full-viewport scroll region. scrollTopMargin = 1; scrollBottomMargin = Infinity; originMode = false; autowrap = true; savedCursor = null; cacheDirty = true; }, getLines, getPreviewRowCount(cols: number): number { refreshPreviewCache(cols); return cacheRows.length; }, getPreviewRows({ cols, rows: visibleRows, top }): OutputHistoryPreviewWindow { const columnCount = Math.max(1, cols); const rowCount = Math.max(1, visibleRows); refreshPreviewCache(columnCount); const totalRows = cacheRows.length; const clampedTop = clampOutputHistoryPreviewTop(top, totalRows, rowCount); const window = cacheRows.slice(clampedTop, clampedTop + rowCount); while (window.length < rowCount) window.push({ isWrapped: false, text: "" }); return { rows: window, totalRows }; }, setViewportRows(rows: number): void { const nextRows = Math.max(0, Math.floor(rows)); if (nextRows !== viewportRows) { // xterm resets the scroll region to the full new viewport whenever the // terminal size changes; keep the tracked margins in step so relative // moves clamp to the new bounds instead of the previous ones. scrollTopMargin = 1; scrollBottomMargin = nextRows > 0 ? nextRows : Infinity; } viewportRows = nextRows; if (viewportRows > 0) { // xterm clamps its cursor as soon as the viewport shrinks; keep the // tracked row inside the new bounds so a later relative move is not // misread as a row transition. screenRow = Math.min(screenRow, viewportRows); } }, setWidthTerminal(term: WidthTerm | null): void { widthTerm = term ?? null; }, syncViewportAfterResize({ cols, rows, cursorX, cursorY, cursorLine, isWrapped }): void { history.setViewportRows(rows); history.setViewportCols(cols); // Xterm knows which committed rows reflowed and whether this buffer // supports reflow. Do not infer the new cursor from transcript rows: // they can include earlier redraws that no longer occupy the screen. // The alternate buffer can retain hidden cells and expose them again // when widened, unlike normal-buffer cursor-line truncation. Read the // visible cursor row rather than applying that truncation to both. const resizedLine = cursorLine.slice(0, maxChars); if (current !== resizedLine || currentStartsWrapped !== isWrapped) { current = resizedLine; currentStartsWrapped = isWrapped; currentCellWidth = pieceCellWidth(current, widthTerm); openGrapheme = null; cacheDirty = true; } screenRow = Math.max(1, Math.min(rows, cursorY + 1)); cursorCell = Math.max(0, Math.min(cols - 1, cursorX)); cursor = cursorCell === 0 ? 0 : isAsciiOnly(current) ? cursorCell : sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length; }, setViewportCols(cols: number): void { const nextCols = Math.max(0, Math.floor(cols)); if (nextCols === viewportCols) return; const narrowed = viewportCols > 0 && nextCols < viewportCols; // xterm resets its scroll region on any buffer resize, including a // width-only one where setViewportRows sees no row change; keep the // tracked margins in step so relative moves clamp to the live bounds // instead of a region set for the previous size. scrollTopMargin = 1; scrollBottomMargin = viewportRows > 0 ? viewportRows : Infinity; viewportCols = nextCols; if (viewportCols <= 0) return; if (narrowed && currentCellWidth > viewportCols) { // xterm trims the cursor row to the new width when the terminal // narrows (`reflowCursorLine` defaults to false, so the overflow is // discarded instead of re-wrapped into a continuation row); drop the // same characters from the tracked open line so later output lands on // the row xterm actually shows instead of retaining a phantom tail. current = sliceStringByCellColumns(current, 0, viewportCols, widthTerm); currentCellWidth = pieceCellWidth(current, widthTerm); // Hidden previews can skip intermediate widths; invalidate by content // change even when the next read reuses the cached column count. cacheDirty = true; // The grapheme the last append left open may have been trimmed away. if (openGrapheme !== null && openGraphemeCell > viewportCols) { openGrapheme = null; } } if (cursorCell > viewportCols - 1) { // xterm clamps its cursor to the new last column as soon as the // terminal narrows; keep the tracked column (and its UTF-16 index) in // step so the next printable span lands there instead of padding past // the viewport edge. cursorCell = viewportCols - 1; cursor = cursorCell === 0 ? 0 : isAsciiOnly(current) ? cursorCell : sliceStringByCellColumns(current, 0, cursorCell, widthTerm).length; } }, }; return history; };