feat(web): give a multi-line command one prompt row per line

A `command` carrying two shell commands on two lines rendered as one row:
`.command` had `white-space: nowrap`, so the two collapsed into a single
ellipsized line that read as one command with stray arguments.

Render one prompt row per command line, and move the run-state dot out of
flow into a gutter reserved to the left of the card surface, so it neither
indents its command nor depends on the command's text metrics to line up.

The dot stays exactly one per card, on the first row. The exit status the
view carries is the whole call's and bash reports no per-command status, so
a dot per line would assert, of a line that succeeded inside a failing call,
that the line itself failed. The single visually hidden label keeps the same
scope, since one label per row would read to assistive technology as several
distinct outcomes.

Fixture turn 60's command becomes two lines, so the built-bundle snapshot
pins the layout and its dot distribution (`dotsPerPromptRow: [1, 0]`), and
the e2e adds that the dot starts left of the card surface — geometry jsdom
cannot compute. Both READMEs now also record that this package's
user-facing copy is inline Chinese, since zero-cordis atoms have no route to
`ctx.locale`; extracting it belongs to the repo-wide localization work.
This commit is contained in:
Chinesezjc
2026-07-28 18:48:57 +08:00
parent 9d2f7f4362
commit a00678a444
13 changed files with 140 additions and 35 deletions

View File

@@ -185,7 +185,9 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')

View File

@@ -137,6 +137,17 @@ describe('chat row terminal body', () => {
expect(view.getByText('line-5')).toBeTruthy()
})
it('renders a multi-line command as one prompt row per line', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ title: 'ls -la\necho done' }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
// Still one dot for the call, on the first row.
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
})
it('a running terminal call expands to the prompt line with no output yet', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
fireEvent.click(view.container.querySelector('button')!)

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: bc880333157f5cb790cbaf854b01aaf8ea6c1cc9
README.zh.md: 6593e4518d09eaccee7e55874b1162943c5eb3bd
README.md: 9e5384f84c3714d327b7b4ceaba8fb0a2cd67e7b
README.zh.md: 9f2a362e4a1e03bffde1d7b218bf94ed41ffd168

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: a prompt line (a run-state `StateDot` ahead of the shortened `cwd` label, then the command), 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. The dot 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 a visually hidden text label because `StateDot` is `aria-hidden`. 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. 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).
## Model Experience
@@ -25,4 +25,5 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **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.

View File

@@ -10,7 +10,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签之前是一枚运行状态 `StateDot`,其后是命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。该状态点用到 `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`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。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)。
## 模型体验
@@ -25,4 +25,5 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **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。

View File

@@ -7,17 +7,23 @@
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* Reserved strip to the left of the card for the per-line run-state dots.
The dots sit outside the card surface, so a reader scans command state
down one column without the dots competing with the commands themselves. */
--dsl-terminal-gutter: 30px;
position: relative;
margin: 16px 0;
margin: 16px 0 16px var(--dsl-terminal-gutter);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
.header {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
@@ -25,22 +31,33 @@
border-top-right-radius: var(--dsl-terminal-radius);
}
/* The prompt row is the only element allowed to shrink; the status pill and
the copy control keep their intrinsic width. */
/* One row per command line. The prompt column is the only element allowed to
shrink; the status pill and the copy control keep their intrinsic width. */
.prompt {
display: flex;
align-items: baseline;
gap: 8px;
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
}
/* The dot sits on the prompt row's baseline box, which is a code-font line, so
it is centered against that line's box rather than sitting on the baseline. */
.promptLine {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
line-height: var(--dsl-terminal-line-height);
}
/* Out of flow in the gutter, so a dot neither indents its command nor depends
on the command's own text metrics to line up with it. Centered against the
row's line box rather than sitting on the code font's baseline. */
.runState {
flex: none;
align-self: center;
position: absolute;
left: calc(-1 * var(--dsl-terminal-gutter));
top: 50%;
transform: translateY(-50%);
}
/* The dot is aria-hidden; this is its text label for assistive technology. */

View File

@@ -147,6 +147,13 @@ export function TerminalBlock({
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
const commandLines = useMemo(() => {
const body = command.endsWith('\n') ? command.slice(0, -1) : command
return body.split('\n')
}, [command])
const empty = text.trim() === ''
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
@@ -159,10 +166,18 @@ export function TerminalBlock({
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
<div className={css.header}>
<div className={css.prompt}>
<StateDot state={state.state} className={css.runState} />
<span className={css.runStateLabel}>{state.label}</span>
<span className={css.cwd}>{cwd === undefined ? '$' : promptLabel(cwd, home)}</span>
<span className={css.command}>{command}</span>
{commandLines.map((line, index) => (
<div key={index} className={css.promptLine}>
{/* One dot for the card, on the first row: the exit status the
view carries is the whole call's, and bash reports no
per-command status, so a dot per row would assert a
per-line outcome nothing here knows. */}
{index === 0 && <StateDot state={state.state} className={css.runState} />}
<span className={css.cwd}>{cwd === undefined ? '$' : promptLabel(cwd, home)}</span>
<span className={css.command}>{line}</span>
</div>
))}
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (

View File

@@ -34,6 +34,11 @@ function runStateOf(container: HTMLElement): { state: string | null; label: stri
}
}
/** The prompt rows as `<label><command>`, one per command line (the visual gap is CSS). */
function promptRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_promptLine_"]')].map(row => (row.textContent ?? '').trim())
}
/** `count` numbered output lines, without the terminating newline. */
function body(count: number): string {
return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n')
@@ -203,8 +208,28 @@ describe('TerminalBlock run-state dot', () => {
// state OF this command rather than of the card's chrome.
it('places the dot ahead of the prompt label and the command', () => {
const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
const prompt = view.container.querySelector('[class^="_prompt_"]')
expect([...prompt!.children].map(node => node.textContent)).toEqual(['', '已完成', 'app', 'ls'])
const row = view.container.querySelector('[class^="_promptLine_"]')
expect([...row!.children].map(node => node.textContent)).toEqual(['', 'app', 'ls'])
})
it('gives a multi-line command one row per line', () => {
const view = render(<TerminalBlock command={'echo one\necho two'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
})
// 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
// succeeded inside a failing call, that the line itself failed.
it('marks the call once, on the first row, never per line', () => {
const view = render(<TerminalBlock command={'true\nfalse\ntrue'} output="x" exitCode={1} />)
expect(view.container.querySelectorAll('[class*="_runState_"][data-state]')).toHaveLength(1)
expect(view.container.querySelectorAll('[class^="_runStateLabel_"]')).toHaveLength(1)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect(rows[0]!.querySelector('[data-state]')).not.toBeNull()
expect(rows[1]!.querySelector('[data-state]')).toBeNull()
expect(rows[2]!.querySelector('[data-state]')).toBeNull()
})
it('keeps the running dot even while a settled-looking status pill is supplied', () => {