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
@@ -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') {
@@ -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);
@@ -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>