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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
101
packages/client/ui-primitives/src/TerminalBlock.module.css
Normal file
101
packages/client/ui-primitives/src/TerminalBlock.module.css
Normal 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);
|
||||
}
|
||||
170
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal file
170
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
packages/client/ui-primitives/src/ansi.ts
Normal file
153
packages/client/ui-primitives/src/ansi.ts
Normal 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
|
||||
}
|
||||
48
packages/client/ui-primitives/src/clipboard.ts
Normal file
48
packages/client/ui-primitives/src/clipboard.ts
Normal 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 */
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
|
||||
188
packages/client/ui-primitives/tests/ansi.spec.ts
Normal file
188
packages/client/ui-primitives/tests/ansi.spec.ts
Normal 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 },
|
||||
]])
|
||||
})
|
||||
})
|
||||
315
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal file
315
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user