feat(web): state the run state on the terminal card's prompt line

The terminal card showed no run state: a running command and a settled
command that produced no output rendered the same prompt line, so whether
a command was still running had to be inferred from the absence of output.

Lead the prompt line with a StateDot in three of its states — the spinning
ring while running, red for the same exit status that renders the status
pill, green for a clean settle. That is the same indicator a tool row's
leading icon carries, so a row and its own card cannot disagree about one
command; the row/card agreement is pinned in the ui-conversation spec.
StateDot is aria-hidden, so a visually hidden text label rides beside it,
which is what the refreshed aria goldens now record.

The e2e adds what jsdom cannot compute: the dot's color resolves to the
green success token through the real theme stylesheet, and the dot precedes
the prompt label in document order.
This commit is contained in:
Chinesezjc
2026-07-28 16:41:04 +08:00
parent 540cb59585
commit f4c243c75f
18 changed files with 184 additions and 27 deletions

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: c9c70f29804ac4e6783486595460bf07499e1dff
README.zh.md: 254fc5ba5aef553fd447338353a0c5311ddbd98a
README.md: b0387debbecb713b1e4af2b1497e81ddda083a51
README.zh.md: d04a34951422c9cdd02421759a4e29672b1f7a54

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 (shortened `cwd` label plus 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. 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: 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 spinning ring 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).
## Model Experience

View File

@@ -10,7 +10,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签加命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。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` 标签之前是一枚运行状态 `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)。
## 模型体验

View File

@@ -19,8 +19,8 @@ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
*/
export function StateDot({ state, size = 10, className }: {
state: StateDotState
size?: number
className?: string
size?: number | undefined
className?: string | undefined
}) {
const gradientId = useId()
if (state === 'ongoing') {

View File

@@ -36,6 +36,23 @@
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. */
.runState {
flex: none;
align-self: center;
}
/* The dot is aria-hidden; this is its text label for assistive technology. */
.runStateLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cwd {
flex: none;
color: var(--dsw-alias-label-tertiary);

View File

@@ -1,6 +1,6 @@
// TerminalBlock: the terminal surface for a shell command and its output —
// prompt line (shortened cwd + command), ANSI-colored output, settled exit
// status, and a copy control for the raw output. Output never soft-wraps:
// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
// settled exit status, and a copy control for the raw output. Output never soft-wraps:
// column-aligned output (ls, tables, box drawing) keeps its alignment and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; ANSI parsing lives in ansi.ts.
@@ -10,6 +10,7 @@ import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { Pill } from './Pill.tsx'
import { StateDot, type StateDotState } from './StateDot.tsx'
import css from './TerminalBlock.module.css'
/**
@@ -70,6 +71,31 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s
return undefined
}
/**
* Run-state indicator for the command, shown at the head of the prompt line so
* the card states whether the command is still running without the reader
* having to infer it from the presence of output. Three of {@link StateDotState}'s
* four states are reachable: the spinning ring while running (the same
* indicator a running tool row's leading icon uses, so the row and its card
* never disagree), green for a clean settle, red for a signal or a non-zero
* exit — the same status distinction {@link statusText} draws for the pill. A
* settled command whose exit status never reached the view counts as a clean
* settle: the view says it finished and says nothing went wrong.
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
}
/**
* Render one parsed output line. Runs without SGR state render as bare text,
* so uncolored output carries no span wrappers.
@@ -120,6 +146,7 @@ export function TerminalBlock({
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
const empty = text.trim() === ''
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
@@ -132,6 +159,8 @@ 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>
</div>

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
// arms, the exit-status pill, the head/tail height cap and its expand control,
// arms, the prompt line's run-state dot, the exit-status pill, the head/tail height cap and its expand control,
// and the copy control writing the raw output on both the accepted and the
// refused clipboard paths. writeClipboard's own return contract is pinned here
// too, since it is the seam both copy controls in this package share; the
@@ -25,6 +25,15 @@ function outputLines(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
const dot = container.querySelector('[class*="_runState_"][data-state]')
return {
state: dot?.getAttribute('data-state') ?? null,
label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
}
}
/** `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')
@@ -128,7 +137,8 @@ describe('TerminalBlock states', () => {
it('renders ANSI runs as styled spans and plain text bare', () => {
const view = render(<TerminalBlock command="ls" output={`${ESC}[31mbad${ESC}[39m ok`} />)
const span = view.container.querySelector('span[style]')
// Scoped to a line: the prompt line's run-state dot is a styled span too.
const span = view.container.querySelector('[class^="_line_"] span[style]')
expect(span?.textContent).toBe('bad')
expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary')
expect(outputLines(view.container)).toEqual(['bad ok'])
@@ -163,6 +173,46 @@ describe('TerminalBlock status pill', () => {
})
})
describe('TerminalBlock run-state dot', () => {
it('shows the spinning ring and its running label while the command runs', () => {
const view = render(<TerminalBlock command="sleep 5" running />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
it('shows the done dot for a clean settled exit', () => {
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('counts a settled command with no exit status as a clean settle', () => {
const view = render(<TerminalBlock command="ls" output="a" />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('shows the error dot for a non-zero exit', () => {
const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
it('shows the error dot for a signal, whatever the exit code says', () => {
const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
// The dot precedes the prompt label, which is what makes it read as the
// 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'])
})
it('keeps the running dot even while a settled-looking status pill is supplied', () => {
const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
})
describe('TerminalBlock height cap', () => {
it('renders every line and no expand control under the cap', () => {
const view = render(<TerminalBlock command="ls" output={body(4)} maxLines={4} />)