fix(web): replay cursor movements the way a terminal paints them
Carriage return and backspace only MOVE the cursor; neither erases. Both of my earlier approximations were wrong, and I checked each case against a real terminal rather than reasoning about it: `100%\rOK` shows `OK0%`, not `OK` — the redraw is shorter than the frame beneath it, so the tail stands. `abc\b` still shows `abc`, not `ab` — a trailing backspace has nothing to overwrite. `\x1b[31mgone\rkept` paints `kept` RED, because a carriage return does not reset the graphic state, which one of my own tests had asserted the opposite of. Both now replay into a per-line column buffer with SGR state stamped per column, as a terminal stores it per cell. That gives the partial-overwrite case its real result too: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red, since `ok` reached only two of the three cells. The presenter description now also renders at every site. An expanded row draws it itself — the collapsed summary is hidden while open, so otherwise the description was visible only collapsed, the opposite of "above the card" — and the details panel draws it above the card as well. Three of my own tests encoded the wrong semantics and were corrected with their behavior, and the emit loop's gap-filling arm was removed as unreachable: `\r` and backspace only move left, so no column can be unwritten.
This commit is contained in:
@@ -82,95 +82,99 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
|
||||
|
||||
/**
|
||||
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
|
||||
* survive: the first two for layout, backspace for its overwrite, ESC for
|
||||
* anser's CSI split.
|
||||
* survive: the first two for layout, backspace for the cursor replay, ESC
|
||||
* for anser's CSI split.
|
||||
*/
|
||||
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
|
||||
|
||||
/**
|
||||
* Apply carriage-return redraws: within a line, only the text after the last
|
||||
* `\r` survives, which is what a terminal shows for progress output. A `\r`
|
||||
* that only terminates a CRLF line is dropped first so those lines keep
|
||||
* their text. SGR codes preceding a dropped redraw are dropped with it.
|
||||
* @param text - output text, already free of OSC and non-CSI escapes.
|
||||
* @returns the text with each line reduced to its final redraw.
|
||||
*/
|
||||
function applyCarriageReturns(text: string): string {
|
||||
return text.split('\n').map((raw) => {
|
||||
const line = raw.replace(/\r+$/, '')
|
||||
return line.slice(line.lastIndexOf('\r') + 1)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply backspaces as the cursor-left-then-overwrite a terminal performs, so
|
||||
* `abc` followed by two backspaces and `XY` reads `aXY` instead of keeping the
|
||||
* characters it overwrote. Progress meters and captured PTY output use
|
||||
* backspace this way. Resolved per line, so a backspace neither eats the
|
||||
* newline before it nor reaches into the previous line's tail; one at a line
|
||||
* start has nothing to erase.
|
||||
* @param text - output text, already reduced to its carriage-return redraws.
|
||||
* @returns the text with each backspace resolved against the character before it.
|
||||
*/
|
||||
function applyBackspaces(text: string): string {
|
||||
if (!text.includes('\u0008')) return text
|
||||
return text.split('\n').map(applyBackspacesToLine).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* One line's backspaces, resolved over VISIBLE characters only. A CSI sequence
|
||||
* moves no cursor, so it must survive intact: erasing its bytes would corrupt
|
||||
* the sequence and repaint the rest of the output with whatever the mangled
|
||||
* remainder parses as. The sequences are therefore held as indivisible units
|
||||
* that a backspace steps over on its way to the last printed character, and a
|
||||
* unit already erased stays erased so a run's own color still applies to what
|
||||
* remains of it.
|
||||
* 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
|
||||
* erases anything — so what a reader sees is whatever each column last had
|
||||
* written to it. That distinction is the whole point of doing this as a buffer
|
||||
* rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
|
||||
* shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
|
||||
* because nothing ever overwrote the `c`.
|
||||
*
|
||||
* A CSI sequence occupies no column; it changes the state that the NEXT writes
|
||||
* are stamped with, which is how a terminal stores color per cell. `red bad`
|
||||
* then three backspaces then `ok` therefore shows `okd` with the `d` still red:
|
||||
* `ok` overwrote two cells and the third kept the state it was written with.
|
||||
* The columns are re-emitted as runs, so anser sees that same styling.
|
||||
* @param line - one output line, still carrying its CSI sequences.
|
||||
* @returns the line with each backspace applied to the character before it.
|
||||
* @returns the line as the terminal would have it after every movement.
|
||||
*/
|
||||
function applyBackspacesToLine(line: string): string {
|
||||
if (!line.includes('\u0008')) return line
|
||||
const units: { text: string; visible: boolean }[] = []
|
||||
// Same shape anser splits on: CSI ... final byte. Matched here so a sequence
|
||||
// is one unit rather than a run of erasable characters.
|
||||
function replayLine(line: string): string {
|
||||
// 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 }[] = []
|
||||
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.
|
||||
let sgr = ''
|
||||
let at = 0
|
||||
|
||||
const consume = (chunk: string): void => {
|
||||
for (const char of chunk) {
|
||||
if (char === '\r') { cursor = 0; continue }
|
||||
if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
|
||||
columns[cursor] = { sgr, char }
|
||||
cursor++
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of line.matchAll(csi)) {
|
||||
for (const char of line.slice(at, match.index)) units.push({ text: char, visible: true })
|
||||
units.push({ text: match[0], visible: false })
|
||||
consume(line.slice(at, match.index))
|
||||
// A reset clears the accumulated state; anything else adds to it.
|
||||
sgr = /^\u001b\[0?m$/.test(match[0]) ? '' : sgr + match[0]
|
||||
at = match.index + match[0].length
|
||||
}
|
||||
for (const char of line.slice(at)) units.push({ text: char, visible: true })
|
||||
consume(line.slice(at))
|
||||
|
||||
const kept: { text: string; visible: boolean }[] = []
|
||||
for (const unit of units) {
|
||||
if (unit.visible && unit.text === '\u0008') {
|
||||
// Walk back past any escapes to the last printed character and drop it,
|
||||
// keeping those escapes so the surviving text stays styled as authored.
|
||||
for (let index = kept.length - 1; index >= 0; index--) {
|
||||
if (kept[index]?.visible !== true) continue
|
||||
kept.splice(index, 1)
|
||||
break
|
||||
}
|
||||
continue
|
||||
// Re-emit the columns, opening a run only where its SGR state changes and
|
||||
// closing the previous one, so anser sees the same styling a terminal shows.
|
||||
// No index can be missing: `\r` and backspace only move the cursor LEFT, so
|
||||
// every column up to the furthest write has been written at least once.
|
||||
let out = ''
|
||||
let active = ''
|
||||
for (const column of columns) {
|
||||
if (column.sgr !== active) {
|
||||
if (active !== '') out += '\u001b[0m'
|
||||
out += column.sgr
|
||||
active = column.sgr
|
||||
}
|
||||
kept.push(unit)
|
||||
out += column.char
|
||||
}
|
||||
return kept.map(unit => unit.text).join('')
|
||||
return active === '' ? out : `${out}\u001b[0m`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* themselves.
|
||||
* @param text - output text, already free of OSC and non-CSI escapes.
|
||||
* @returns the text with each line painted as the terminal would.
|
||||
*/
|
||||
function applyCursorMovements(text: string): string {
|
||||
return text.split('\n')
|
||||
.map(raw => raw.replace(/\r+$/, ''))
|
||||
.map(line => (/[\r\u0008]/.test(line) ? replayLine(line) : line))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every escape sequence and control character that carries no color,
|
||||
* leaving CSI sequences for anser and `\n`/`\t` for layout. Carriage-return
|
||||
* redraws and backspace overwrites resolve first: both are cursor movements
|
||||
* whose effect on the visible text must land before the characters that
|
||||
* expressed them are dropped.
|
||||
* leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
|
||||
* (carriage return, backspace) replay first, since their effect on the visible
|
||||
* text must land before the characters that expressed them are dropped.
|
||||
* @param text - raw command output.
|
||||
* @returns text whose only remaining escapes are CSI sequences.
|
||||
*/
|
||||
function sanitize(text: string): string {
|
||||
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
|
||||
return applyBackspaces(applyCarriageReturns(escaped)).replace(INERT_CONTROL, '')
|
||||
return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user