fix(web): bound the SGR state and follow real terminal widths
Nine findings, one critical. Terminal cases verified in a real terminal first. CRITICAL: cells held the accumulated SGR history, so every state boundary re-emitted the whole chain — output switching color without a full reset emitted O(n^2) characters. Measured: 3200 such cells produced 25 MB, and the reviewer's ~90 KB alternating-color case is well under bash's own output cap. State is now a normalized record (foreground, background, attribute set) with one canonical sequence per boundary, so the emitted text is linear in cells; the 90 KB case parses in 36 ms. That also makes the attribute closers every chalk-based tool writes actually close: `\x1b[1mbold\x1b[22mplain` leaves the following write PLAIN, which a real terminal confirms. Width follows emoji presentation, not the U+2600-U+27BF block: `A✓B` redrawn with `XY` shows `XYB`, so the check every progress line writes is ONE column. Taking the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing. `line\n\x1b[0m` does not end in a newline as a string yet its last parsed line holds nothing visible, so the terminator check now reads the parsed lines — it had added a blank row and inflated the collapse count. A line with no cursor movement no longer builds a column buffer at all; only its SGR is folded, so an `ls -R` or a 5k-line log allocates nothing per character. The `.terminalDescription` rule had been inserted into an existing grouped selector, silently giving `.codeBody` description typography and changing its bottom margin from 4px to 0 — a pre-existing surface this PR does not own. Split out, `.codeBody`'s margin restored. Three comments contradicted their code: the fixture's exit-marker line (still claiming recovery from a marker deliberately removed), `bash-sample`'s header (still routing a click to the details panel, and calling the consumer's cap the block's own), and a DetailsPanel comment stacked above the wrong rule. The ui-primitives README documented only the CR/BS half of the replay, so a reader would expect `OK0%` where `100%\r\x1b[KOK` renders `OK`.
This commit is contained in:
@@ -126,9 +126,19 @@ export function TerminalBlock({
|
||||
}: TerminalBlockProps) {
|
||||
const text = output ?? ''
|
||||
// A command's output ends with a newline; that terminator is not an extra
|
||||
// blank line to draw or to count against the height cap. The copy control
|
||||
// still copies `text` untouched.
|
||||
const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [text])
|
||||
// blank line to draw or to count against the height cap. The check runs on the
|
||||
// PARSED lines rather than on the raw text, because a reset after the final
|
||||
// newline (`line\n\x1b[0m`) leaves the string not ending in one while still
|
||||
// producing a last line with nothing visible in it. A genuinely blank final
|
||||
// line — the double newline — survives, since it has a real empty line before
|
||||
// the terminator. The copy control still copies `text` untouched.
|
||||
const lines = useMemo(() => {
|
||||
const parsed = parseAnsiLines(text)
|
||||
const last = parsed[parsed.length - 1]
|
||||
const terminated = parsed.length > 1 && last !== undefined
|
||||
&& last.every(span => span.text === '')
|
||||
return terminated ? parsed.slice(0, -1) : parsed
|
||||
}, [text])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
|
||||
*/
|
||||
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
|
||||
|
||||
/** SGR sequences alone, for folding state through a line that needs no replay. */
|
||||
const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
|
||||
|
||||
/** Terminal tab stop width; a tab advances to the next multiple of this. */
|
||||
const TAB_WIDTH = 8
|
||||
|
||||
@@ -107,12 +110,18 @@ const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
|
||||
|
||||
/**
|
||||
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
|
||||
* CJK punctuation, and the emoji/symbol blocks a command's output realistically
|
||||
* carries.
|
||||
* CJK punctuation, and characters with emoji presentation. Text-presentation
|
||||
* symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
|
||||
* must stay out of this set.
|
||||
*/
|
||||
const WIDE_CHAR = new RegExp(
|
||||
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
|
||||
+ '|[\\u{1f300}-\\u{1faff}\\u{2600}-\\u{27bf}\\uff01-\\uff60\\u3000-\\u303e]',
|
||||
// Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
|
||||
// width — `\u2713` (the check every progress line writes, this fixture
|
||||
// included) advances one column, verified against a real terminal, so taking
|
||||
// the whole block as wide misaligned exactly the output this card exists for.
|
||||
+ '|\\p{Emoji_Presentation}'
|
||||
+ '|[\\uff01-\\uff60\\u3000-\\u303e]',
|
||||
'u',
|
||||
)
|
||||
|
||||
@@ -129,6 +138,87 @@ function isWide(char: string): boolean {
|
||||
return WIDE_CHAR.test(char)
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell's graphic state, normalized. Held as fields rather than as the raw
|
||||
* sequence history because a terminal tracks CURRENT state, not a transcript:
|
||||
* accumulating sequences made each state boundary re-emit the whole chain, so
|
||||
* output that switches color without a full reset emitted O(n^2) characters
|
||||
* (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
|
||||
* the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
|
||||
* `24`, `27`, `29` — actually close their attribute instead of appending to it.
|
||||
*/
|
||||
interface SgrState {
|
||||
fg: string
|
||||
bg: string
|
||||
/** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
|
||||
attrs: readonly string[]
|
||||
}
|
||||
|
||||
/** The default state: no color, no attributes. */
|
||||
const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
|
||||
|
||||
/** Attribute closers, mapped to the opener parameters each one turns off. */
|
||||
const ATTR_CLOSERS: Record<string, readonly string[]> = {
|
||||
22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one SGR sequence's parameters into the state it produces.
|
||||
* @param state - state in force before the sequence.
|
||||
* @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
|
||||
* @returns the state the sequence leaves in force.
|
||||
*/
|
||||
function foldSgr(state: SgrState, params: string): SgrState {
|
||||
const codes = params === '' ? ['0'] : params.split(';')
|
||||
let next = state
|
||||
for (let index = 0; index < codes.length; index++) {
|
||||
const code = String(codes[index])
|
||||
if (code === '' || code === '0') { next = SGR_NONE; continue }
|
||||
// Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
|
||||
// consume their own arguments, so they are taken whole.
|
||||
if (code === '38' || code === '48') {
|
||||
const kind = codes[index + 1] ?? ''
|
||||
const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
|
||||
const value = codes.slice(index, index + span + 1).join(';')
|
||||
next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
|
||||
index += span
|
||||
continue
|
||||
}
|
||||
const closes = ATTR_CLOSERS[code]
|
||||
if (closes !== undefined) {
|
||||
next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
|
||||
continue
|
||||
}
|
||||
const numeric = Number(code)
|
||||
if (code === '39') { next = { ...next, fg: '' }; continue }
|
||||
if (code === '49') { next = { ...next, bg: '' }; continue }
|
||||
if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
|
||||
if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
|
||||
if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a state as the one canonical sequence that establishes it from the
|
||||
* default, so a boundary emits a bounded string no matter how the state was
|
||||
* reached.
|
||||
* @param state - the state to open.
|
||||
* @returns the SGR sequence, or the empty string for the default state.
|
||||
*/
|
||||
function openSgr(state: SgrState): string {
|
||||
const codes = [...state.attrs]
|
||||
if (state.fg !== '') codes.push(state.fg)
|
||||
if (state.bg !== '') codes.push(state.bg)
|
||||
return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
|
||||
}
|
||||
|
||||
/** Whether two states are the same, so a boundary is only emitted on a change. */
|
||||
function sameSgr(a: SgrState, b: SgrState): boolean {
|
||||
return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
|
||||
&& a.attrs.every((attr, index) => attr === b.attrs[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one line's cursor movements the way a terminal paints it, into a
|
||||
* column buffer. Carriage return and backspace only MOVE the cursor — neither
|
||||
@@ -149,19 +239,29 @@ function isWide(char: string): boolean {
|
||||
* @returns the line as the terminal would have it after every movement, plus the
|
||||
* SGR state at its end for the next line to enter with.
|
||||
*/
|
||||
function replayLine(line: string, entrySgr: string): { text: string; sgr: string } {
|
||||
function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
|
||||
// Same shape anser splits on, so a sequence is one unit here as well.
|
||||
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
|
||||
/** Per column: the SGR state in force when it was written, and its character. */
|
||||
const columns: ({ sgr: string; char: string; spacer?: boolean } | undefined)[] = []
|
||||
/** Per column: the state in force when it was written, and its character. */
|
||||
const columns: (Cell | undefined)[] = []
|
||||
let cursor = 0
|
||||
// SGR state accumulates as the line is scanned, exactly as a terminal tracks
|
||||
// it: each cell is stamped with whatever was in force at the moment of the
|
||||
// write, so a later redraw cannot restyle the cells it does not reach. It
|
||||
// enters carrying the previous line's state, since a newline does not reset it.
|
||||
// State is tracked exactly as a terminal tracks it: each cell is stamped with
|
||||
// whatever was in force at the moment of the write, so a later redraw cannot
|
||||
// restyle the cells it does not reach. It enters carrying the previous line's
|
||||
// state, since a newline does not reset it.
|
||||
let sgr = entrySgr
|
||||
let at = 0
|
||||
|
||||
/** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
|
||||
const clear = (index: number, fill: string): void => {
|
||||
const cell = columns[index]
|
||||
if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
|
||||
else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
|
||||
columns[index + 1] = { sgr, char: fill }
|
||||
}
|
||||
columns[index] = { sgr, char: fill }
|
||||
}
|
||||
|
||||
const consume = (chunk: string): void => {
|
||||
for (const char of chunk) {
|
||||
if (char === '\r') { cursor = 0; continue }
|
||||
@@ -176,13 +276,16 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
||||
}
|
||||
if (ZERO_WIDTH.test(char)) {
|
||||
// No column of its own: it attaches to the cell already written, so a
|
||||
// redraw that covers that cell covers the mark with it.
|
||||
// With no cell to attach to (line start, or straight after a redraw to
|
||||
// column 0) a terminal shows nothing rather than a lone accent.
|
||||
// redraw that covers that cell covers the mark with it. With no cell to
|
||||
// attach to (line start, or straight after a redraw to column 0) a
|
||||
// terminal shows nothing rather than a lone accent.
|
||||
const base = cursor > 0 ? columns[cursor - 1] : undefined
|
||||
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
|
||||
continue
|
||||
}
|
||||
// Writing over either half of a wide pair blanks the other half, since a
|
||||
// terminal cannot leave one cell of a two-cell glyph standing.
|
||||
clear(cursor, ' ')
|
||||
columns[cursor] = { sgr, char }
|
||||
cursor++
|
||||
// A wide character occupies two columns; the trailing one is a spacer,
|
||||
@@ -205,31 +308,31 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
||||
// standing, which is text the terminal never showed. `1` blanks from the
|
||||
// line start THROUGH the cursor column (inclusive, per the CSI spec)
|
||||
// rather than dropping those cells, since the cursor does not move and a
|
||||
// later write can still land past them.
|
||||
// Only the FIRST parameter selects the mode; a terminal ignores the rest
|
||||
// (`1;2K` erases exactly as `1K` does — verified against a real terminal).
|
||||
// later write can still land past them. Only the FIRST parameter selects
|
||||
// the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
|
||||
const mode = String(params.split(';')[0])
|
||||
if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' }
|
||||
if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
|
||||
else columns.length = mode === '2' ? 0 : cursor
|
||||
continue
|
||||
}
|
||||
// Only SGR carries graphic state; every other final byte is a cursor or
|
||||
// erase action that must not be accumulated into a cell's style.
|
||||
// erase action that must not affect a cell's style.
|
||||
if (final !== 'm') continue
|
||||
sgr = /^0?$/.test(params) ? '' : sgr + match[0]
|
||||
sgr = foldSgr(sgr, params)
|
||||
}
|
||||
consume(line.slice(at))
|
||||
|
||||
// Re-emit the columns, opening a run only where its SGR state changes, so
|
||||
// anser sees the same styling a terminal shows. A `\x1b[2K` can leave holes
|
||||
// before the cursor, which a terminal paints as blanks.
|
||||
// Re-emit the columns, opening a run only where its state changes, so anser
|
||||
// sees the same styling a terminal shows. Each boundary emits ONE canonical
|
||||
// sequence for the state it opens, which is what keeps the output linear in
|
||||
// the number of cells however the state was reached.
|
||||
let out = ''
|
||||
let active = entrySgr
|
||||
for (let index = 0; index < columns.length; index++) {
|
||||
const column = columns[index] ?? { sgr: '', char: ' ' }
|
||||
if (column.sgr !== active) {
|
||||
if (active !== '') out += '\u001b[0m'
|
||||
out += column.sgr
|
||||
const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
|
||||
if (!sameSgr(column.sgr, active)) {
|
||||
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
|
||||
out += openSgr(column.sgr)
|
||||
active = column.sgr
|
||||
}
|
||||
// A spacer still holds its column. While its lead cell survives, the wide
|
||||
@@ -243,13 +346,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
||||
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
|
||||
// no cell yet still ends the run, and it has to reach both the DOM and the
|
||||
// next line. Without this a line ending in a reset leaked its color onward.
|
||||
if (active !== sgr) {
|
||||
if (active !== '') out += '\u001b[0m'
|
||||
out += sgr
|
||||
if (!sameSgr(active, sgr)) {
|
||||
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
|
||||
out += openSgr(sgr)
|
||||
}
|
||||
return { text: out, sgr }
|
||||
}
|
||||
|
||||
/** One replayed column: the state it was written with, and its character. */
|
||||
interface Cell {
|
||||
sgr: SgrState
|
||||
char: string
|
||||
/** The trailing half of a wide character's two-column pair. */
|
||||
spacer?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
|
||||
* is dropped first, so those lines keep their text instead of being redrawn onto
|
||||
@@ -260,18 +371,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
||||
*/
|
||||
function applyCursorMovements(text: string): string {
|
||||
const replayed: string[] = []
|
||||
let sgr = ''
|
||||
let sgr = SGR_NONE
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.replace(/\r+$/, '')
|
||||
// A line with no cursor movement or erase needs no replay — its tabs stay
|
||||
// literal for `white-space: pre` to lay out — but its own SGR still has to
|
||||
// be tracked so a later line that DOES replay enters with the right state.
|
||||
// Tabs only need column arithmetic where a redraw can land on them, which is
|
||||
// exactly the replayed case. An erase counts: `\x1b[1K` blanks columns even
|
||||
// with no `\r` beside it.
|
||||
const result = replayLine(line, sgr)
|
||||
replayed.push(NEEDS_REPLAY.test(line) ? result.text : line)
|
||||
sgr = result.sgr
|
||||
if (NEEDS_REPLAY.test(line)) {
|
||||
const result = replayLine(line, sgr)
|
||||
replayed.push(result.text)
|
||||
sgr = result.sgr
|
||||
continue
|
||||
}
|
||||
// No cursor movement: the line needs no column buffer, and painting one
|
||||
// would allocate a cell per character of output this card never redraws —
|
||||
// an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
|
||||
// line that DOES replay enters with the right state.
|
||||
replayed.push(line)
|
||||
for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
|
||||
}
|
||||
return replayed.join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user