Files
NetMesh/components/terminal/runtime/terminalOutputHistory.ts

1313 lines
56 KiB
TypeScript
Raw Permalink Normal View History

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<Parameters<typeof stringCellWidth>[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;
};