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:
@@ -46,6 +46,51 @@ const MARKDOWN_FIXTURE = [
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
/**
|
||||
* SGR wrapper for the terminal output sample below: authoring the escapes as
|
||||
* `\u001b` keeps literal control bytes out of this source file.
|
||||
* @param code - the SGR parameter (an ANSI color or attribute number).
|
||||
* @param body - the text the attribute applies to.
|
||||
* @returns the body wrapped in the attribute and a reset.
|
||||
*/
|
||||
function sgr(code: number, body: string): string {
|
||||
return `\u001b[${code}m${body}\u001b[0m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 66, authored to carry every feature
|
||||
* the terminal card draws that turn 60's three plain lines cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
|
||||
* height cap collapses the middle, and the trailing `[exit code: N]` marker the
|
||||
* bash tool appends, from which the exit pill is recovered.
|
||||
*/
|
||||
const TERMINAL_OUTPUT_FIXTURE = [
|
||||
sgr(1, 'Running 4 checks'),
|
||||
`${sgr(32, '\u2713')} typecheck 1.82s`,
|
||||
`${sgr(32, '\u2713')} lint 0.94s`,
|
||||
`${sgr(32, '\u2713')} duplication 2.10s`,
|
||||
`${sgr(31, '\u2717')} unit 8.41s`,
|
||||
'',
|
||||
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
|
||||
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
|
||||
' expected 16 lines, received 24',
|
||||
'',
|
||||
'NAME LINES BRANCHES FUNCTIONS UNCOVERED',
|
||||
'TerminalBlock.tsx 100% 100% 100% -',
|
||||
'ansi.ts 100% 100% 100% -',
|
||||
'clipboard.ts 100% 100% 100% -',
|
||||
'CodeBlock.tsx 98.4% 96.2% 100% 41-43',
|
||||
'highlight.ts 100% 100% 100% -',
|
||||
'Pill.tsx 100% 100% 100% -',
|
||||
'StateDot.tsx 100% 100% 100% -',
|
||||
'markdown/Markdown.tsx 100% 100% 100% -',
|
||||
'',
|
||||
sgr(31, '1 of 4 checks failed'),
|
||||
'[exit code: 1]',
|
||||
].join('\n')
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -124,7 +169,8 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
}
|
||||
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
|
||||
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
|
||||
// stays presenter-less as the unknown fallback.
|
||||
// stays presenter-less as the unknown fallback. Turn 66 is the second terminal sample, carrying
|
||||
// what turn 60's three plain lines cannot (see TERMINAL_OUTPUT_FIXTURE) through the keyed row.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
@@ -201,6 +247,13 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const callIndex = events.length - 4
|
||||
const callTime = events[callIndex]?.time as number
|
||||
events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } })
|
||||
// Turn 66: the terminal sample turn 60's three clean lines cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested
|
||||
// cwd whose prompt label is its last segment, and a non-zero exit recovered
|
||||
// from the trailing marker the bash tool appends. Named `bash`, so it also
|
||||
// covers the keyed toolview row (turn 60's `fx-bash` covers the render-site
|
||||
// fallback row) — the two chat-row shapes the terminal card renders in.
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
events.forEach((e, i) => { e.seq = i })
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
@@ -219,7 +272,11 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
return undefined
|
||||
}
|
||||
switch (name) {
|
||||
// Both names present the same terminal card: `fx-bash` lands on the
|
||||
// render-site fallback row, `bash` on the keyed BashRow registration, so
|
||||
// the two chat-row shapes of one render intent are both reachable.
|
||||
case 'fx-bash':
|
||||
case 'bash':
|
||||
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
|
||||
case 'fx-write':
|
||||
return {
|
||||
@@ -235,12 +292,24 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the exit status from a trailing `[exit code: N]` marker, mirroring
|
||||
* the real bash tool's `parseExitStatus` (this package must not depend on a
|
||||
* tool package, so the marker contract is re-read rather than imported).
|
||||
* @param text - the rendered result text.
|
||||
* @returns the recovered exit code (0 when the marker is absent).
|
||||
*/
|
||||
function fixtureExitCode(text: string): number {
|
||||
const marker = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
return marker?.[1] === undefined ? 0 : Number(marker[1])
|
||||
}
|
||||
|
||||
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
|
||||
const call = presentCall(name, argsRaw)
|
||||
if (call === undefined) return undefined
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
return { card: 'terminal', output: resultText, exitCode: 0 }
|
||||
return { card: 'terminal', output: resultText, exitCode: fixtureExitCode(resultText) }
|
||||
case 'diff':
|
||||
return { card: 'diff', diffs: call.diffs }
|
||||
case 'generic':
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739
|
||||
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
|
||||
README.md: 1c6912b05e259fa1f4a7096c3a2b82f9f67f5527
|
||||
README.zh.md: 157bfafd3a1157420acbb73861cd40d759228743
|
||||
|
||||
@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
@@ -35,6 +36,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
terminal={terminalCardModel(block)}
|
||||
state={model.state}
|
||||
onOpenDetails={openDetails}
|
||||
/>
|
||||
|
||||
@@ -102,9 +102,13 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// component-local view state; row click hands the selection off to the owner.
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. The details
|
||||
// panel remains the full-height reading surface for the same call. Expand
|
||||
// state is component-local view state; row click hands the selection off to
|
||||
// the owner.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -19,8 +25,15 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = not expandable (leading slot never toggles). */
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
body: string | null
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -46,12 +59,18 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
onOpenDetails,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = body !== null
|
||||
const terminalBody = terminal ?? null
|
||||
const expandable = body !== null || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -99,9 +118,11 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the bash tool declares arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export type TerminalCardModel = Pick<
|
||||
TerminalBlockProps,
|
||||
'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'
|
||||
>
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
command: call.title,
|
||||
cwd: call.cwd,
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
command: call?.title ?? result.title ?? '',
|
||||
cwd: call?.cwd,
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -92,3 +92,9 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The terminal card sits directly under its section label, so it drops the
|
||||
primitive's standalone vertical margin; the section owns the spacing. */
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,59 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
/**
|
||||
* Selected call material: the call's display name and args plus the frozen
|
||||
* block slice it came from. `block` is a snapshot-cached reference, so the
|
||||
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
||||
* running split is read off it with the `'kind' in block` discrimination
|
||||
* instead of duplicated as flags.
|
||||
*/
|
||||
interface CallMaterial {
|
||||
name: string
|
||||
argsRaw: string | null
|
||||
result: ToolResultNode | null
|
||||
running: boolean
|
||||
block: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
||||
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
||||
}
|
||||
|
||||
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
||||
function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
|
||||
}
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -95,15 +107,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
<OutputBody material={material} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -112,6 +116,29 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material }: { material: CallMaterial }) {
|
||||
const terminal = terminalCardModel(material.block)
|
||||
if (terminal !== null) return <TerminalBlock {...terminal} className={css.terminal} />
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control (a click goes to the details panel), so its terminal body is
|
||||
// resident rather than expand-gated as in ToolRow; the block's own height cap
|
||||
// (CHAT_TERMINAL_MAX_LINES) and internal expander keep a long output from
|
||||
// taking over the message flow.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
@@ -29,26 +37,37 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card below it. The summary row keeps its own click target
|
||||
* (the details handoff); the terminal card sits outside that row, so its copy
|
||||
* and expand controls do not open the details panel.
|
||||
*/
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const terminal = terminalCardModel(block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -139,6 +144,22 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
|
||||
359
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
359
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
// @vitest-environment jsdom
|
||||
// The terminal render intent on the web side: the pure terminalCardModel
|
||||
// derivation over callView/resultView, and both conversation render sites that
|
||||
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
|
||||
// and the details panel's Output section.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Match an output line with its interior whitespace intact: the column
|
||||
* alignment this card exists to preserve is exactly what the default
|
||||
* whitespace-collapsing matcher would hide.
|
||||
*/
|
||||
const RAW = { normalizer: (text: string) => text }
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
}))).toEqual({
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, openDetails = vi.fn()): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails,
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const openDetails = vi.fn()
|
||||
const view = render(<BashRow {...rowProps(settled(), openDetails)} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The terminal card sits outside the row's click target: copying does not
|
||||
// open the details panel.
|
||||
fireEvent.click(view.getByText('复制'))
|
||||
expect(openDetails).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(view.getByText('row-0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
const pre = view.container.querySelector('pre[data-error]')
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
it('a run_code sub-dispatch resolves to its own terminal card', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
|
||||
settled({ callId: 'elsewhere' }),
|
||||
],
|
||||
runningCalls: [running({ callId: 'also-elsewhere' })],
|
||||
}), target)
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no selection at all renders the guidance line and the default title', () => {
|
||||
const view = mount(snapshot(), null)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a step selection without a callId renders the guidance line too', () => {
|
||||
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the close button reaches closeDetails', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
const closeDetails = vi.fn()
|
||||
const snap = snapshot()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -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