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:
@@ -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 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
|
||||
2026-07-28-web-terminal-card.md: 0c10e867afe6a9cc7085206709ae94df54bc4ed0
|
||||
2026-07-28-web-terminal-card.zh.md: e680f34bc5852430e00a5970389be0280b086f3b
|
||||
2026-07-28-web-terminal-card.md: 7a4672e110cb4333df8d7a29a772de8bf2717d84
|
||||
2026-07-28-web-terminal-card.zh.md: df49ef45e000a464715635ca32e42848994fc7ff
|
||||
@@ -16,7 +16,8 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/
|
||||
|
||||
The component's contract:
|
||||
|
||||
- **Prompt line.** A run-state dot, then a shortened cwd label, then the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. The dot is `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. It leads the line because the first question a reader has about a shell command is whether it is still running, and without the dot that had to be inferred from the absence of output — which a settled command producing no output also looks like. `StateDot` is `aria-hidden`, so a visually hidden text label rides beside it.
|
||||
- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command.
|
||||
- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: 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 text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
|
||||
- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
|
||||
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
|
||||
- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output.
|
||||
@@ -50,13 +51,13 @@ Inline rendering is licensed for the terminal intent alone. A future intent that
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly.
|
||||
`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly.
|
||||
|
||||
`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files.
|
||||
|
||||
`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker.
|
||||
`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`); that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker.
|
||||
|
||||
`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet), and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one.
|
||||
`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and starts to the left of the card surface itself, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
|
||||
|
||||
该组件的契约:
|
||||
|
||||
- **提示符行。** 一枚运行状态点,其后是缩短的 cwd 标签,再原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。状态点是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。它位于行首,因为读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有该状态点时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。`StateDot` 是 `aria-hidden`,因此其旁伴随一处视觉隐藏的文本标签。
|
||||
- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。
|
||||
- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
|
||||
- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
|
||||
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
|
||||
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。
|
||||
@@ -50,13 +51,13 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。
|
||||
`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。
|
||||
|
||||
`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
|
||||
|
||||
`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。
|
||||
`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`);该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。
|
||||
|
||||
`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值);复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。
|
||||
`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且其起点位于卡片表面本身的左侧;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -222,16 +222,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
state: node.getAttribute('data-state'),
|
||||
color: getComputedStyle(node as HTMLElement).color,
|
||||
success,
|
||||
label: node.parentElement?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
||||
// One label per card (the state is the call's), so it hangs off the
|
||||
// prompt column rather than the row the dot sits in.
|
||||
label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
||||
// The dot precedes the prompt label in document order, which is what
|
||||
// puts it to the left of the `$`.
|
||||
beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
|
||||
=== Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
// The dot is out of flow in the card's left gutter, so it starts to the
|
||||
// left of the card surface itself — the geometry jsdom cannot compute.
|
||||
leftOfCard: (node as HTMLElement).getBoundingClientRect().left
|
||||
< node.closest('[data-terminal]')!.getBoundingClientRect().left,
|
||||
}
|
||||
})
|
||||
expect(dot.state).toBe('done')
|
||||
expect(dot.label).toBe('已完成')
|
||||
expect(dot.beforePrompt).toBe(true)
|
||||
expect(dot.leftOfCard).toBe(true)
|
||||
// Resolved through the theme token, not a literal hex in the component.
|
||||
expect(dot.success).toMatch(/^rgb/)
|
||||
expect(dot.color).toBe(dot.success)
|
||||
|
||||
@@ -111,7 +111,14 @@ function readCard(card: Element) {
|
||||
const status = card.querySelector('[class*="_status_"]')
|
||||
const expander = card.querySelector('button[aria-expanded]')
|
||||
return {
|
||||
prompt: `${card.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${card.querySelector('[class*="_command_"]')?.textContent ?? ''}`,
|
||||
// One entry per command line: a multi-line command is one row per line.
|
||||
prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
||||
`${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
|
||||
// Dots per prompt row: exactly one, on the first row — the exit status the
|
||||
// view carries is the whole call's, so a dot per line would assert a
|
||||
// per-line outcome bash does not report.
|
||||
dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
|
||||
row.querySelectorAll('[data-state]').length),
|
||||
status: status === null ? null : status.textContent,
|
||||
copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
|
||||
lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
|
||||
@@ -187,6 +194,9 @@ it('renders the keyed bash row with a resident terminal card', async () => {
|
||||
"color: var(--dsw-alias-state-error-primary);",
|
||||
],
|
||||
"copy": "复制",
|
||||
"dotsPerPromptRow": [
|
||||
1,
|
||||
],
|
||||
"expander": {
|
||||
"expanded": "false",
|
||||
"label": "展开其余 14 行输出",
|
||||
@@ -202,7 +212,9 @@ it('renders the keyed bash row with a resident terminal card', async () => {
|
||||
"1 of 4 checks failed",
|
||||
"[exit code: 1]",
|
||||
],
|
||||
"prompt": "nested pnpm run check",
|
||||
"prompt": [
|
||||
"nested pnpm run check",
|
||||
],
|
||||
"runState": "error",
|
||||
"runStateLabel": "失败",
|
||||
"status": "退出码 1",
|
||||
@@ -229,13 +241,20 @@ it('the fallback row reaches the same card through its expand control', async ()
|
||||
{
|
||||
"colors": [],
|
||||
"copy": "复制",
|
||||
"dotsPerPromptRow": [
|
||||
1,
|
||||
0,
|
||||
],
|
||||
"expander": null,
|
||||
"lines": [
|
||||
"total 2",
|
||||
"drwxr-xr-x fixture",
|
||||
"-rw-r--r-- demo.txt",
|
||||
],
|
||||
"prompt": "fixture ls -la",
|
||||
"prompt": [
|
||||
"fixture ls -la",
|
||||
"fixture echo done",
|
||||
],
|
||||
"runState": "done",
|
||||
"runStateLabel": "已完成",
|
||||
"status": null,
|
||||
@@ -302,6 +321,9 @@ it('the details panel Output section renders the same call at full height', asyn
|
||||
"color: var(--dsw-alias-label-tertiary);",
|
||||
],
|
||||
"copy": "复制",
|
||||
"dotsPerPromptRow": [
|
||||
1,
|
||||
],
|
||||
"expander": {
|
||||
"expanded": "false",
|
||||
"label": "展开其余 6 行输出",
|
||||
@@ -326,7 +348,9 @@ it('the details panel Output section renders the same call at full height', asyn
|
||||
"[exit code: 1]",
|
||||
],
|
||||
"panelLines": 16,
|
||||
"prompt": "nested pnpm run check",
|
||||
"prompt": [
|
||||
"nested pnpm run check",
|
||||
],
|
||||
"runState": "error",
|
||||
"runStateLabel": "失败",
|
||||
"status": "退出码 1",
|
||||
|
||||
@@ -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"}', '已写入')
|
||||
|
||||
@@ -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')!)
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user