Merge remote-tracking branch 'origin/master' into feat/web-terminal-card
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -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: 9e5384f84c3714d327b7b4ceaba8fb0a2cd67e7b
|
||||
README.zh.md: 9f2a362e4a1e03bffde1d7b218bf94ed41ffd168
|
||||
README.md: 5d71aa920707462f953ed4eb5572b0530b8d0ed2
|
||||
README.zh.md: 7c59ed3d3bacbac06a0123e6ff93023a1bcbd028
|
||||
|
||||
@@ -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, 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 to the left of the card surface. 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. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; 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, 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 to the left of the card surface. 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 redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; 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
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 终端输出
|
||||
|
||||
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 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` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义的控制符;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -75,12 +75,15 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
|
||||
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
|
||||
continuation. Both hold the single row and the ellipsis. */
|
||||
.command {
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -80,8 +80,12 @@ const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
|
||||
/** Escape sequences other than CSI: charset selection, single-shift, reset. */
|
||||
const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
|
||||
|
||||
/** C0 controls with no display meaning here; tab, newline and ESC survive for layout and anser's CSI split. */
|
||||
const INERT_CONTROL = /[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]/g
|
||||
/**
|
||||
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
|
||||
* survive: the first two for layout, backspace for its overwrite, ESC for
|
||||
* anser's CSI split.
|
||||
*/
|
||||
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
|
||||
|
||||
/**
|
||||
* Apply carriage-return redraws: within a line, only the text after the last
|
||||
@@ -98,15 +102,40 @@ function applyCarriageReturns(text: string): string {
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply backspaces as the cursor-left-then-overwrite a terminal performs, so
|
||||
* `abc` followed by two backspaces and `XY` reads `aXY` instead of keeping the
|
||||
* characters it overwrote. Progress meters and captured PTY output use
|
||||
* backspace this way. Resolved per line, so a backspace neither eats the
|
||||
* newline before it nor reaches into the previous line's tail; one at a line
|
||||
* start has nothing to erase.
|
||||
* @param text - output text, already reduced to its carriage-return redraws.
|
||||
* @returns the text with each backspace resolved against the character before it.
|
||||
*/
|
||||
function applyBackspaces(text: string): string {
|
||||
if (!text.includes('\u0008')) return text
|
||||
return text.split('\n').map((line) => {
|
||||
const kept: string[] = []
|
||||
for (const char of line) {
|
||||
if (char === '\u0008') kept.pop()
|
||||
else kept.push(char)
|
||||
}
|
||||
return kept.join('')
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every escape sequence and control character that carries no color,
|
||||
* leaving CSI sequences for anser and `\n`/`\t` for layout.
|
||||
* leaving CSI sequences for anser and `\n`/`\t` for layout. Carriage-return
|
||||
* redraws and backspace overwrites resolve first: both are cursor movements
|
||||
* whose effect on the visible text must land before the characters that
|
||||
* expressed them are dropped.
|
||||
* @param text - raw command output.
|
||||
* @returns text whose only remaining escapes are CSI sequences.
|
||||
*/
|
||||
function sanitize(text: string): string {
|
||||
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
|
||||
return applyCarriageReturns(escaped).replace(INERT_CONTROL, '')
|
||||
return applyBackspaces(applyCarriageReturns(escaped)).replace(INERT_CONTROL, '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseAnsiLines } from '../src/ansi.ts'
|
||||
|
||||
const ESC = '\u001b'
|
||||
const BS = '\u0008'
|
||||
|
||||
/** Paint `text` with the SGR `codes`, then reset. */
|
||||
function sgr(codes: string, text: string): string {
|
||||
@@ -170,6 +171,30 @@ describe('parseAnsiLines: carriage returns', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: backspaces', () => {
|
||||
it('applies a backspace as the overwrite a terminal draws', () => {
|
||||
// `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
|
||||
expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
|
||||
})
|
||||
|
||||
it('stops at the line start instead of eating the newline before it', () => {
|
||||
expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
|
||||
[{ text: 'ab', style: undefined }],
|
||||
[{ text: 'cd', style: undefined }],
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the overwrite after a carriage-return redraw, not before', () => {
|
||||
// The redraw wins first; the backspace then erases inside what survived.
|
||||
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })
|
||||
})
|
||||
|
||||
it('keeps the run\'s style while erasing its own characters', () => {
|
||||
expect(onlySpan(sgr('31', `bad${BS}${BS}${BS}ok`)))
|
||||
.toEqual({ text: 'ok', style: { color: 'var(--dsw-alias-state-error-primary)' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: runs spanning lines', () => {
|
||||
it('carries one run\'s style onto every line it covers', () => {
|
||||
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
|
||||
|
||||
@@ -217,6 +217,18 @@ describe('TerminalBlock run-state dot', () => {
|
||||
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
|
||||
})
|
||||
|
||||
// A heredoc or an editor-authored command commonly ends in a newline; that
|
||||
// terminator is not a further, empty command to draw a row for.
|
||||
it('drops a trailing newline instead of drawing an empty final row', () => {
|
||||
const view = render(<TerminalBlock command={'echo one\necho two\n'} output="a" exitCode={0} />)
|
||||
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
|
||||
})
|
||||
|
||||
it('keeps a genuinely blank command line when the command ends with two newlines', () => {
|
||||
const view = render(<TerminalBlock command={'echo one\n\n'} output="a" exitCode={0} />)
|
||||
expect(promptRows(view.container)).toEqual(['$echo one', '$'])
|
||||
})
|
||||
|
||||
// The exit status the view carries is the whole call's — bash reports no
|
||||
// per-command status — so exactly one dot and one label are correct however
|
||||
// many lines the command spans. A dot per row would assert, of a line that
|
||||
|
||||
Reference in New Issue
Block a user