feat(web): render bash tool output as a terminal card

The bash tool already declares the `card: 'terminal'` render intent for
both its call and its result, and host/connection/runtime already deliver
it to the browser as callView/resultView. The Web client ignored it:
rows derived from raw args, and the details panel flattened every tool's
content into one soft-wrapping `<pre>`. Column-aligned output folded into
a paragraph and a long listing stretched the panel without bound.

`TerminalBlock` (ui-primitives) renders a command as a terminal surface:
a shortened-cwd prompt line, output at `white-space: pre` in a
horizontally scrolling box, a head/tail height cap with an expand
control, an exit-code/signal status pill, and a copy control for the raw
output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*`
theme tokens, with literal rgb kept for values the design system has no
token for. Geometry and fonts mirror CodeBlock; the clipboard write both
need moved into a package-internal `clipboard.ts`.

Both Web render sites for a bash call consume the intent through one
derivation (`terminal-card-model.ts`), so they cannot disagree about a
command, its cwd, or its exit status: the keyed BashRow carries the card
resident below its summary row, and the render-site fallback row keeps it
behind its existing expand control. Rows cap at 8 lines against the
panel's 16.

Inline output in the chat row reverses this package's stated
no-inline-output convention, on the owner's explicit decision; the Agent
Note records the reversal and its bound.

Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring
specs at every render site, a built-client-graph snapshot covering both
chat-row shapes, and a real-browser e2e asserting the no-wrap layout and
the page's own Clipboard API.
This commit is contained in:
Chinesezjc
2026-07-28 14:58:06 +08:00
parent 3f97b88ff9
commit 5081697aaf
40 changed files with 2218 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: c9c70f29804ac4e6783486595460bf07499e1dff
README.zh.md: 254fc5ba5aef553fd447338353a0c5311ddbd98a

View File

@@ -2,12 +2,16 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## 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).
## Model Experience
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
@@ -21,3 +25,4 @@ 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.
- **`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

@@ -2,12 +2,16 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 TerminalBlock。契约:api-contracts v3 §8。
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 终端输出
`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)。
## 模型体验
无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
@@ -21,3 +25,4 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,而光标移动、清屏和备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。

View File

@@ -21,6 +21,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@shikijs/langs": "^4.3.1",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -12,7 +12,9 @@ import css from './Pill.module.css'
*/
export function Pill({ active = false, className, children, onClick, ...rest }: {
active?: boolean
className?: string
// `| undefined` so a caller can forward an optional class straight through
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
className?: string | undefined
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>) {
if (!onClick) {

View File

@@ -0,0 +1,101 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
markdown code-block font) so a terminal card and a fenced code block read as
one family. The one deliberate divergence: output keeps `white-space: pre`
and scrolls horizontally, because folding a column-aligned command's output
destroys its alignment. */
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
}
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
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. */
.prompt {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
}
.cwd {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
flex: none;
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.output {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
.line {
min-height: var(--dsl-terminal-line-height);
white-space: pre;
}
.expand {
display: block;
width: 100%;
padding: 0;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
.empty {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,170 @@
// 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:
// 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.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
import { writeClipboard } from './clipboard.ts'
import { Pill } from './Pill.tsx'
import css from './TerminalBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches the
* TUI transcript's default tool-output budget so both front ends cut a long
* command's output at the same place.
*/
export const DEFAULT_TERMINAL_MAX_LINES = 16
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
/** Working directory for the prompt label; absent renders a plain `$`. */
cwd?: string | undefined
/** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
home?: string | undefined
/** The command's output text; may contain ANSI escape sequences. */
output?: string | undefined
/** Settled exit code; a non-zero value renders the status pill. */
exitCode?: number | undefined
/** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Prompt label for a working directory: `~` for the home directory itself,
* otherwise the path's last segment (both separators accepted, trailing
* separators ignored), falling back to the path itself when it has no
* segment.
* @param cwd - the working directory path.
* @param home - absolute home directory, when the caller knows it.
* @returns the prompt label.
*/
function promptLabel(cwd: string, home: string | undefined): string {
const trimmed = cwd.replace(/[/\\]+$/, '')
if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
const segment = trimmed.split(/[/\\]/).pop()
return segment === undefined || segment === '' ? cwd : segment
}
/**
* Status pill text for a settled command, or undefined when the command
* settled cleanly (exit 0, no signal) and needs no pill — the same
* distinction the bash tool's own exit-status markers draw.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the pill text, or undefined for a clean exit.
*/
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
if (signal !== undefined) return `信号 ${signal}`
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
return undefined
}
/**
* Render one parsed output line. Runs without SGR state render as bare text,
* so uncolored output carries no span wrappers.
* @param line - the line's styled runs.
* @returns the line's children.
*/
function renderLine(line: AnsiLine) {
return line.map((span, index) => span.style === undefined
? span.text
: <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a shell command as a terminal surface.
* @param props - see {@link TerminalBlockProps}.
* @returns the terminal block element.
*/
export function TerminalBlock({
command,
cwd,
home,
output,
exitCode,
signal,
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
className,
}: TerminalBlockProps) {
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The copy control
// still copies `text` untouched.
const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [text])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The raw output, never the rendered tree: the prompt line and the status
// pill are chrome the user did not run.
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, text])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const empty = text.trim() === ''
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
// command's head and tail slices agree between the two front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
return (
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
<div className={css.header}>
<div className={css.prompt}>
<span className={css.cwd}>{cwd === undefined ? '$' : promptLabel(cwd, home)}</span>
<span className={css.command}>{command}</span>
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}>无输出</div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 行`}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,153 @@
// ANSI model behind TerminalBlock: anser splits the SGR runs, this module
// resolves each run's colors and decorations into a plain style record and
// folds the runs into per-line span arrays so a height cap can slice whole
// lines. Sequences anser does not turn into color (OSC, cursor movement,
// other C0 controls) are removed before parsing so they never reach the DOM
// as literal characters.
import Anser from 'anser'
import type { CSSProperties } from 'react'
/**
* The subset of one anser JSON chunk this module reads. anser's own types
* declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run
* that sets no color, so the null is spelled out here.
*/
interface AnsiChunk {
/** Run text with its SGR codes already removed. */
content: string
/** Foreground as an `r, g, b` triple, or null when the run sets none. */
fg: string | null
/** Background as an `r, g, b` triple, or null when the run sets none. */
bg: string | null
/** SGR attributes in effect for the run, in the order they were declared. */
decorations: readonly string[]
}
/** One run of terminal text; `style` is undefined for text that carries no SGR state. */
export interface AnsiSpan {
/** The run's plain text, free of escape sequences and newlines. */
text: string
/** Resolved inline style, or undefined when the run needs no wrapper. */
style: CSSProperties | undefined
}
/** The spans of one output line, in order. */
export type AnsiLine = readonly AnsiSpan[]
/**
* The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple
* anser emits for them, mapped onto the theme tokens that carry the same
* semantic. Black and white both resolve to the primary label color so text
* stays legible under either theme instead of matching the surface it sits
* on; bright black takes the tertiary label color (the muted-gray role).
* Magenta and cyan have no token equivalent in this design system and fall
* through to anser's literal rgb, as do all 256-palette and truecolor values.
*/
const TOKEN_BY_BASIC_RGB: Record<string, string> = {
'0,0,0': 'var(--dsw-alias-label-primary)',
'255,255,255': 'var(--dsw-alias-label-primary)',
'85,85,85': 'var(--dsw-alias-label-tertiary)',
'187,0,0': 'var(--dsw-alias-state-error-primary)',
'255,85,85': 'var(--dsw-alias-state-error-secondary)',
'0,187,0': 'var(--dsw-alias-state-success-primary)',
'0,255,0': 'var(--dsw-alias-state-success-secondary)',
'187,187,0': 'var(--dsw-alias-state-warn-primary)',
'255,255,85': 'var(--dsw-alias-state-warn-secondary)',
'0,0,187': 'var(--dsw-alias-state-business-primary)',
'85,85,255': 'var(--dsw-static-blue-400)',
}
/**
* CSS for each SGR attribute anser reports. `blink` is deliberately absent —
* animated text is not reproduced. `reverse` never arrives here: anser
* consumes it by swapping the run's foreground and background. Underline and
* strikethrough share `textDecoration`, so in a run declaring both, the
* later declaration wins.
*/
const STYLE_BY_DECORATION: Record<string, CSSProperties | undefined> = {
bold: { fontWeight: 700 },
dim: { opacity: 0.7 },
italic: { fontStyle: 'italic' },
underline: { textDecoration: 'underline' },
strikethrough: { textDecoration: 'line-through' },
hidden: { visibility: 'hidden' },
}
/** OSC strings (window title, hyperlinks), with or without their terminator. */
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
/**
* Apply carriage-return redraws: within a line, only the text after the last
* `\r` survives, which is what a terminal shows for progress output. A `\r`
* that only terminates a CRLF line is dropped first so those lines keep
* their text. SGR codes preceding a dropped redraw are dropped with it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line reduced to its final redraw.
*/
function applyCarriageReturns(text: string): string {
return text.split('\n').map((raw) => {
const line = raw.replace(/\r+$/, '')
return line.slice(line.lastIndexOf('\r') + 1)
}).join('\n')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout.
* @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, '')
}
/**
* Resolve one run's colors and decorations.
* @param chunk - the anser chunk to style.
* @returns the run's inline style, or undefined when it carries no SGR state.
*/
function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
const style: CSSProperties = {}
const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
if (background !== undefined) style.backgroundColor = background
if (chunk.fg !== null) {
const literal = `rgb(${chunk.fg})`
// A run that paints its own background keeps anser's literal pair so the
// authored foreground/background contrast survives; a foreground-only run
// maps onto a theme token, which adapts to light and dark surfaces.
style.color = background === undefined
? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
: literal
}
for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
return Object.keys(style).length === 0 ? undefined : style
}
/**
* Parse command output into styled spans grouped by line.
* @param text - raw output text, which may contain ANSI escape sequences.
* @returns one entry per output line (always at least one, possibly empty).
*/
export function parseAnsiLines(text: string): AnsiLine[] {
let current: AnsiSpan[] = []
const lines: AnsiSpan[][] = [current]
for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
const style = resolveStyle(chunk)
for (const [index, part] of chunk.content.split('\n').entries()) {
if (index > 0) {
current = []
lines.push(current)
}
if (part !== '') current.push({ text: part, style })
}
}
return lines
}

View File

@@ -0,0 +1,48 @@
// Package-internal clipboard write, shared by every copy control in this
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
// public surface: consumers get the components, not the host detection.
/**
* Write text to the host clipboard, preferring the async Clipboard API and
* falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
* that omit it.
* @param text - the exact text to place on the clipboard.
* @returns true only when the host accepted the write.
*/
export async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}

View File

@@ -17,6 +17,8 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'

View File

@@ -6,6 +6,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -18,45 +19,6 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
// execCommand('copy') is the only clipboard fallback where the async API
// is missing; deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
/* eslint-enable @typescript-eslint/no-deprecated */
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])

View File

@@ -0,0 +1,188 @@
// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
// resolved into inline styles and folded into per-line span arrays, with every
// escape and control character that carries no color removed first. The DOM
// side of the same model (which runs get a span wrapper) is in
// terminal-block.spec.tsx.
import { describe, expect, it } from 'vitest'
import { parseAnsiLines } from '../src/ansi.ts'
const ESC = '\u001b'
/** Paint `text` with the SGR `codes`, then reset. */
function sgr(codes: string, text: string): string {
return `${ESC}[${codes}m${text}${ESC}[0m`
}
/** The single span of a single-line, single-run parse. */
function onlySpan(text: string) {
const lines = parseAnsiLines(text)
expect(lines).toHaveLength(1)
expect(lines[0]).toHaveLength(1)
return lines[0]![0]!
}
describe('parseAnsiLines: text without SGR state', () => {
it('leaves plain text as one unstyled span', () => {
expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
})
it('returns exactly one empty line for empty input', () => {
expect(parseAnsiLines('')).toEqual([[]])
})
it('splits a multi-line run and drops the empty line between two blocks', () => {
expect(parseAnsiLines('a\n\nb')).toEqual([
[{ text: 'a', style: undefined }],
[],
[{ text: 'b', style: undefined }],
])
})
it('keeps tabs, which the terminal surface needs for column layout', () => {
expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
})
})
describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
it.each<[string, string, string]>([
['30', 'black', 'var(--dsw-alias-label-primary)'],
['37', 'white', 'var(--dsw-alias-label-primary)'],
['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
['31', 'red', 'var(--dsw-alias-state-error-primary)'],
['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
['32', 'green', 'var(--dsw-alias-state-success-primary)'],
['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
['94', 'bright blue', 'var(--dsw-static-blue-400)'],
])('SGR %s (%s) resolves to %s', (code, _name, token) => {
expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
})
})
describe('parseAnsiLines: colors with no token equivalent', () => {
it.each<[string, string, string]>([
['35', 'magenta', 'rgb(187, 0, 187)'],
['36', 'cyan', 'rgb(0, 187, 187)'],
['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
})
})
describe('parseAnsiLines: backgrounds', () => {
it('sets backgroundColor for a background-only run', () => {
expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
})
it('keeps the literal foreground when the run paints its own background', () => {
expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
backgroundColor: 'rgb(187, 0, 0)',
color: 'rgb(255,255,255)',
})
})
it('renders reverse video as the swapped pair anser reports', () => {
expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
backgroundColor: 'rgb(187, 0, 0)',
color: 'rgb(0, 0, 0)',
})
})
})
describe('parseAnsiLines: decorations', () => {
it.each<[string, string, Record<string, unknown>]>([
['1', 'bold', { fontWeight: 700 }],
['2', 'dim', { opacity: 0.7 }],
['3', 'italic', { fontStyle: 'italic' }],
['4', 'underline', { textDecoration: 'underline' }],
['9', 'strikethrough', { textDecoration: 'line-through' }],
['8', 'hidden', { visibility: 'hidden' }],
])('SGR %s (%s) resolves to %o', (code, _name, style) => {
expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
})
it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
})
it('combines a color with several decorations in one style', () => {
expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
color: 'var(--dsw-alias-state-error-primary)',
fontWeight: 700,
fontStyle: 'italic',
})
})
it('reproduces no animation for blink, leaving the run unstyled', () => {
expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
})
})
describe('parseAnsiLines: sequences that carry no color', () => {
it('removes an OSC string with its BEL terminator', () => {
expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
})
it('removes an OSC string terminated by ST', () => {
expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
})
it('removes non-CSI escapes such as charset selection and reset', () => {
expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
})
it('removes inert C0 controls', () => {
expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
})
it('keeps CSI sequences that only move the cursor out of the text', () => {
expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
})
})
describe('parseAnsiLines: carriage returns', () => {
it('keeps only the last redraw of a line', () => {
expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
})
it('drops the SGR codes that preceded a discarded redraw', () => {
expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined })
})
it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
[{ text: 'a', style: undefined }],
[{ text: 'b', style: undefined }],
[],
])
})
it('applies the redraw per line, not across the whole text', () => {
expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
[{ text: 'two', style: undefined }],
[{ text: 'three', style: undefined }],
])
})
})
describe('parseAnsiLines: runs spanning lines', () => {
it('carries one run\'s style onto every line it covers', () => {
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
[{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
])
})
it('keeps several runs of one line in order', () => {
expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
{ text: 'plain', style: undefined },
{ text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
{ text: 'tail', style: undefined },
]])
})
})

View File

@@ -0,0 +1,315 @@
// @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,
// 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
// resolution of ANSI runs into styles is pinned in ansi.spec.ts, so only its
// DOM consequence (which runs get a span wrapper) is asserted here.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts'
import { writeClipboard } from '../src/clipboard.ts'
const ESC = '\u001b'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered output rows, one string per visible line (CSS-module class prefix). */
function outputLines(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** `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')
}
describe('TerminalBlock prompt label', () => {
it('collapses the home directory itself to ~', () => {
render(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me" />)
expect(screen.getByText('~')).toBeTruthy()
})
it('shows only the last segment below home', () => {
render(<TerminalBlock command="ls" cwd="/Users/me/Documents" home="/Users/me" />)
expect(screen.getByText('Documents')).toBeTruthy()
})
it('ignores trailing separators on both the cwd and home', () => {
const view = render(<TerminalBlock command="ls" cwd="/Users/me/" home="/Users/me" />)
expect(view.getByText('~')).toBeTruthy()
view.rerender(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me/" />)
expect(view.getByText('~')).toBeTruthy()
})
it('drops trailing separators before taking the last segment', () => {
render(<TerminalBlock command="ls" cwd="/Users/me/Documents///" home="/Users/me" />)
expect(screen.getByText('Documents')).toBeTruthy()
})
it('takes the last segment when no home is known', () => {
render(<TerminalBlock command="ls" cwd="C:\\Users\\me\\Projects" />)
expect(screen.getByText('Projects')).toBeTruthy()
})
it('collapses a backslash home path to ~', () => {
render(<TerminalBlock command="ls" cwd="C:\\Users\\me" home="C:\\Users\\me" />)
expect(screen.getByText('~')).toBeTruthy()
})
it('falls back to the raw path when it has no segment', () => {
render(<TerminalBlock command="ls" cwd="/" home="/Users/me" />)
expect(screen.getByText('/')).toBeTruthy()
})
it('renders a plain $ with no cwd', () => {
render(<TerminalBlock command="ls" />)
expect(screen.getByText('$')).toBeTruthy()
})
it('renders the command verbatim after the label', () => {
render(<TerminalBlock command="git log --oneline | head -3" cwd="/Users/me/app" />)
expect(screen.getByText('git log --oneline | head -3')).toBeTruthy()
})
})
describe('TerminalBlock states', () => {
it('running shows the command line only: no output, no placeholder, no copy', () => {
const view = render(<TerminalBlock command="sleep 5" running output="partial" />)
expect(view.getByText('sleep 5')).toBeTruthy()
expect(view.queryByText('partial')).toBeNull()
expect(view.queryByText('无输出')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
})
it('running still shows a settled-looking status pill when one is supplied', () => {
render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
expect(screen.getByText('信号 SIGINT')).toBeTruthy()
})
it('settled with whitespace-only output shows the dimmed placeholder', () => {
const view = render(<TerminalBlock command="true" output={' \n '} exitCode={0} />)
expect(view.getByText('无输出')).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('settled with absent output shows the placeholder', () => {
render(<TerminalBlock command="true" exitCode={0} />)
expect(screen.getByText('无输出')).toBeTruthy()
})
it('settled with an empty string shows the placeholder', () => {
render(<TerminalBlock command="true" output="" exitCode={0} />)
expect(screen.getByText('无输出')).toBeTruthy()
})
it('merges className onto the wrapper', () => {
const view = render(<TerminalBlock command="ls" className="x" output="a" />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false)
})
it('drops the output text terminator instead of drawing a blank line', () => {
const view = render(<TerminalBlock command="ls" output={'a\nb\n'} />)
expect(outputLines(view.container)).toEqual(['a', 'b'])
})
it('keeps a genuinely blank final line when the output ends with two newlines', () => {
const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
expect(outputLines(view.container)).toEqual(['a', 'b', ''])
})
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]')
expect(span?.textContent).toBe('bad')
expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary')
expect(outputLines(view.container)).toEqual(['bad ok'])
})
it('renders uncolored output with no span wrappers at all', () => {
const view = render(<TerminalBlock command="ls" output={'plain one\nplain two\n'} />)
expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0)
})
})
describe('TerminalBlock status pill', () => {
it('renders no pill for a clean exit', () => {
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
expect(view.queryByText(/退出码|信号/u)).toBeNull()
})
it('renders no pill while the exit status is unknown', () => {
const view = render(<TerminalBlock command="ls" output="a" />)
expect(view.queryByText(/退出码|信号/u)).toBeNull()
})
it('renders the exit-code pill for a non-zero exit', () => {
render(<TerminalBlock command="false" output="a" exitCode={1} />)
expect(screen.getByText('退出码 1')).toBeTruthy()
})
it('renders the signal pill, which outranks the exit code', () => {
render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
expect(screen.getByText('信号 SIGKILL')).toBeTruthy()
expect(screen.queryByText(/退出码/u)).toBeNull()
})
})
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} />)
expect(outputLines(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('does not count the output terminator against the cap', () => {
const view = render(<TerminalBlock command="ls" output={`${body(4)}\n`} maxLines={4} />)
expect(outputLines(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<TerminalBlock command="ls" output={body(10)} maxLines={4} />)
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
const toggle = view.getByRole('button', { name: '展开其余 6 行输出' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 行')
fireEvent.click(toggle)
expect(outputLines(view.container)).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起输出' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<TerminalBlock command="ls" output={body(5)} maxLines={1} />)
expect(outputLines(view.container)).toEqual(['line 1'])
expect(view.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
const view = render(<TerminalBlock command="ls" output={body(DEFAULT_TERMINAL_MAX_LINES + 1)} />)
expect(outputLines(view.container)).toHaveLength(DEFAULT_TERMINAL_MAX_LINES)
expect(view.getByRole('button', { name: '展开其余 1 行输出' })).toBeTruthy()
})
})
describe('TerminalBlock copy', () => {
it('copies the raw output, never the prompt line or the pill', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const output = `${ESC}[31mbad${ESC}[39m\n`
render(<TerminalBlock command="make" cwd="/Users/me/app" output={output} exitCode={2} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
// Escape codes, the newline terminator, and nothing of the chrome around them.
expect(writeText).toHaveBeenCalledWith(output)
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('copies the whole output while the height cap hides its middle', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const output = `${body(10)}\n`
render(<TerminalBlock command="ls" output={output} maxLines={4} exitCode={0} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith(output)
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when the host refuses the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<TerminalBlock command="ls" output="a" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
})
describe('writeClipboard', () => {
it('reports true after the async Clipboard API accepts the exact text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
await expect(writeClipboard('payload')).resolves.toBe(true)
expect(writeText).toHaveBeenCalledWith('payload')
})
it('reports false when the Clipboard API rejects', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
await expect(writeClipboard('payload')).resolves.toBe(false)
})
it('selects a detached textarea for the execCommand fallback and removes it after', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
let selected: string | undefined
const exec = vi.fn(() => {
selected = document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value
return true
})
Object.defineProperty(document, 'execCommand', { configurable: true, value: exec })
await expect(writeClipboard('payload')).resolves.toBe(true)
expect(exec).toHaveBeenCalledWith('copy')
expect(selected).toBe('payload')
expect(document.querySelector('textarea')).toBeNull()
})
it('reports execCommand\'s own refusal verbatim', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn(() => false) })
await expect(writeClipboard('payload')).resolves.toBe(false)
})
it('reports false and still removes the textarea when execCommand throws', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
await expect(writeClipboard('payload')).resolves.toBe(false)
expect(document.querySelector('textarea')).toBeNull()
})
it('reports false on a host with neither clipboard path', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
await expect(writeClipboard('payload')).resolves.toBe(false)
})
it('reports false when navigator.clipboard exists without writeText', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {} })
Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
await expect(writeClipboard('payload')).resolves.toBe(false)
})
})