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:
Chinesezjc
2026-07-29 16:42:10 +08:00
parent 4145cd7075
commit fdcb45b619
14 changed files with 274 additions and 66 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: fda45aaea8e6ad640c0002cf34e654d49e63582a
README.zh.md: 94e99b838e102266fb3b5868d3ceb06ae0633b15
README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
README.zh.md: 51d3c7a3f3dd46e487a3031cfa1f3d93bace336a

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage return and backspace replay into a per-line column buffer before inert controls are stripped, since both only move the cursor (so `100%` + CR + `OK` shows `OK0%`), with SGR state stamped per column as a terminal stores it per cell; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Model Experience
@@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, while cursor movement, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

View File

@@ -10,7 +10,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span回车与退格在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为两者都只移动光标(所以 `100%` 加回车再加 `OK` 显示为 `OK0%`),且 SGR 状态按列打戳,与终端按单元格存储颜色一致;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 模型体验
@@ -26,4 +26,4 @@
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**尚未实现已交付的四种状态done/warning/ongoing/error构成完整的 P-I 表层。
- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale``TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,而光标移动、清屏备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

View File

@@ -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)

View File

@@ -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')
}

View File

@@ -357,6 +357,75 @@ describe('parseAnsiLines: line-end state and column widths', () => {
})
})
describe('parseAnsiLines: bounded state and true widths', () => {
it('emits one canonical sequence per boundary however the state was reached', () => {
// Colors that never fully reset used to accumulate raw sequence history per
// cell, so every boundary re-emitted the whole chain: 3200 such cells
// produced 25 MB and eventually a RangeError. The state is normalized now,
// so the emitted text stays linear in the number of cells.
let input = ''
for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
})
it('closes an attribute with its closer instead of appending to the state', () => {
// `1` then `22` is bold then not-bold, which every chalk-based tool writes;
// appending both left the cell bold and grew the chain.
// Verified in a real terminal: the `22` closes the bold, so the `x` written
// after the redraw is PLAIN. Appending both left it bold and grew the chain.
expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
{ text: 'x', style: undefined },
]])
expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
{ text: 'A', style: { fontWeight: 700 } },
{ text: 'B', style: undefined },
]])
})
it('folds extended colors, backgrounds and every attribute closer', () => {
// The 256-palette and truecolor forms consume their own arguments, so the
// fold has to take them whole rather than as separate codes.
expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(255, 135, 0)' } },
]])
expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(10, 20, 30)' } },
]])
// A background survives the same way, and `49` closes it.
expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: undefined },
]])
// Each closer drops only its own attribute: `4` underline closed by `24`
// while the italic opened before it stays in force.
expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { fontStyle: 'italic' } },
]])
// `39` closes a foreground without touching the background.
expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
]])
})
it('treats a text-presentation symbol as one column', () => {
// Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
// check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
// misaligned exactly the progress output this card exists to show.
expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
// An emoji-presentation character is two, so the same redraw leaves a blank.
expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
})
it('blanks both halves of a wide pair when either is overwritten', () => {
// A terminal cannot leave one cell of a two-cell glyph standing, so writing
// over the spacer clears the lead as well.
// Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
// the second glyph — writing the lead cell blanks its spacer, so the column
// stays occupied rather than collapsing.
expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
})
})
describe('parseAnsiLines: SGR across lines', () => {
it('carries active state past a newline, as a terminal does', () => {
// Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.

View File

@@ -146,6 +146,15 @@ describe('TerminalBlock states', () => {
expect(outputLines(view.container)).toEqual(['a', 'b'])
})
it('drops the output terminator even when a reset follows the final newline', () => {
// `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
// line holds nothing visible — a common shape, since tools close their color
// after the last line. Judging the terminator on the raw text added a blank
// row and inflated both the card height and the collapse count.
const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
expect(outputLines(view.container)).toEqual(['a', 'b'])
})
it('keeps a genuinely blank final line when the output ends with two newlines', () => {
const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
expect(outputLines(view.container)).toEqual(['a', 'b', ''])