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

@@ -284,8 +284,10 @@ function buildAlphaLog(): SessionEvent[] {
]
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
// whose prompt label is its last segment, and a non-zero exit recovered from
// the trailing marker the bash tool appends. Named `bash`, so it also covers
// whose prompt label is its last segment, and a non-zero exit authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
// `[exit code: N]` marker, since the real presenter consumes that one out of
// the body. Named `bash`, so it also covers
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
// row) — the two chat-row shapes the terminal card renders in.
//

View File

@@ -182,14 +182,16 @@ button.leading {
also replaces each primitive's own standalone vertical spacing with the
flow's row rhythm. */
.codeBody,
/* Indented to the terminal body's own column, so the description reads as the
card's heading rather than as another summary row. */
.terminalBody {
margin: 4px 0 4px 22px;
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description
typography on a `CodeBlock` wrapper and change that body's spacing. */
.terminalDescription {
margin: 4px 0 0 22px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
.terminalBody {
margin: 4px 0 4px 22px;
}

View File

@@ -93,8 +93,6 @@
color: var(--dsw-alias-state-error-primary);
}
/* The terminal card sits directly under its section label, so it drops the
primitive's standalone vertical margin; the section owns the spacing. */
/* Above the card, which is where the render-intent contract puts a terminal
call's description; the panel has no summary row to carry it. */
.terminalDescription {
@@ -103,6 +101,8 @@
font: var(--dsw-font-xs-13);
}
/* The terminal card sits directly under its section label, so it drops the
primitive's standalone vertical margin; the section owns the spacing. */
.terminal {
margin: 0;
}

View File

@@ -6,10 +6,12 @@
//
// A bash call declares the terminal render intent, so this row also renders
// the command's own output through TerminalBlock. This row has no expand
// control (a click goes to the details panel), so its terminal body is
// resident rather than expand-gated as in ToolRow; the block's own height cap
// (CHAT_TERMINAL_MAX_LINES) and internal expander keep a long output from
// taking over the message flow.
// control and is not a details-panel target either (tool rows stopped being
// one), so its terminal body is resident rather than expand-gated as in
// ToolRow, and the card's own copy and expand controls are the row's only
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
// flow's tighter cap over the block's own default of 16 — and the block's
// internal expander keeps a long output from taking over the message flow.
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'

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', ''])