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:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', ''])
|
||||
|
||||
Reference in New Issue
Block a user