Merge remote-tracking branch 'origin/master' into trajectory-history-source

This commit is contained in:
imccyu
2026-07-29 20:58:04 +08:00
409 changed files with 5432 additions and 2034 deletions
@@ -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: ddf2988799acf45357c3cc23a5517e59de59a60e
README.zh.md: d3147745424afe76a5ae1ca09e5f1f0534741b06
README.md: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910
+3 -1
View File
@@ -12,6 +12,8 @@ Approvals take over the composer through the chain this package declares: `Appro
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 a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. 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). 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. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card 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`/`openFile`) 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 plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) 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 standing 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.
@@ -33,7 +35,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
+3 -1
View File
@@ -10,6 +10,8 @@
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。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,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `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``openFile`),`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 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
@@ -33,7 +35,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行没有耗时区段**assistant `usage` 只携带 token 计数;耗时需要主机数据源。
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
@@ -10,6 +10,7 @@ import {
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'
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const singleFile = model.filePath !== undefined
return (
<ToolRow
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
terminal={terminal}
state={model.state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
@@ -175,9 +175,23 @@ 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;
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description
typography on a `CodeBlock` wrapper and change that body's spacing. */
.terminalDescription {
margin: 4px 0 0 22px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
@@ -1,14 +1,18 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or 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
// 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. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
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'
@@ -20,8 +24,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
@@ -52,17 +63,25 @@ export function ToolRow({
title,
summary,
body,
terminal,
state,
expandOnRowClick = false,
filePath,
onOpenFile,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet.
// args expand is off whether or not the open callback is wired yet. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = body !== null && !singleFile
const expandable = (body !== null && !singleFile) || 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 = () => {
@@ -137,9 +156,17 @@ export function ToolRow({
</>
)}
</div>
{open && (variant === 'code'
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{body}</div>)}
{/* The terminal presenter's description belongs ABOVE the card per the
render-intent contract, so an expanded terminal row keeps showing it
even though the collapsed summary is hidden while open. */}
{open && terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} 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,189 @@
/**
* 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 { resolveToolPath, 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 interface TerminalCardModel {
/**
* The props {@link TerminalBlock} draws. Held as a nested object so a render
* site spreads exactly the primitive's own surface and can never leak a
* neighbouring field into it.
*/
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
/**
* The call view's model-authored description, which the contract defines as
* rendering ABOVE the card (the card itself has no description slot). Absent
* when the presenter supplied none, or when the window dropped the call side;
* a row then keeps its args-derived summary.
*/
description: string | undefined
}
/**
* Resolve a terminal view's working directory the way the render-intent
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
* one joins under the session workspace, and an omitted one IS the session
* workspace. A pure presenter cannot see the session cwd, which is why this
* resolution belongs here rather than in the tool. Without a session cwd there
* is nothing to resolve against, so a relative path stays as authored and an
* omitted one stays absent (the prompt row then draws a bare `$`).
* @param viewCwd - the cwd the terminal call view carries, if any.
* @param sessionCwd - the session workspace root, if the caller knows it.
* @returns the working directory for the prompt label, or undefined.
*/
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
if (viewCwd === undefined || viewCwd === '') return sessionCwd
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
}
/**
* Collapse `.` and `..` segments so the prompt label names the directory the
* command actually ran in. The bash executor resolves the workdir before
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
* are preserved as authored (a Windows path keeps its backslashes) because this
* value is only ever displayed; a `..` that would climb past the root is
* dropped, which is what a filesystem does with it. A UNC path's `server` and
* `share` are part of its root, not poppable segments: Windows cannot climb
* above a share, so `\\\\server\\share` with a `..` stays there.
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
* @returns the same path with those segments resolved.
*/
function normalizeSegments(path: string): string {
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
// so they are split off here and neither is a segment `..` may pop. Its
// separator is fixed to a backslash, since a joined relative part may have
// introduced a forward slash that UNC syntax does not use.
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
if (unc !== null) {
// Both groups are mandatory in the pattern, so destructuring types them as
// strings without an assertion.
const [matched, server, share] = unc
const root = `\\\\${String(server)}\\${String(share)}`
// Rooted: what follows the share hangs off it, so a `..` at the top is
// dropped rather than kept — Windows cannot climb above a share.
const rest = collapse(path.slice(matched.length), true)
return rest === '' ? root : `${root}\\${rest}`
}
const backslashed = path.includes('\\') && !path.includes('/')
const separator = backslashed ? '\\' : '/'
const rooted = /^[/\\]/.test(path)
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
const leading = rooted ? separator : ''
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
}
/**
* Collapse the `.`/`..` segments of a path body against a known root state.
* @param body - the path after any drive letter or UNC root.
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
* the way a filesystem drops one; without a root the `..` is kept, since it
* stays meaningful against a cwd this function cannot see.
* @param separator - separator to rejoin with (default `/`).
* @returns the collapsed body, without leading or trailing separators.
*/
function collapse(body: string, rooted: boolean, separator = '/'): string {
const kept: string[] = []
for (const segment of body.split(/[/\\]/)) {
if (segment === '' || segment === '.') continue
if (segment === '..') {
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
else if (!rooted) kept.push(segment)
continue
}
kept.push(segment)
}
return kept.join(separator)
}
/**
* 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.
* @param sessionCwd - the session workspace root, which resolves an omitted or
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
* @returns the terminal-card props, or null for the generic path.
*/
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): 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 : {
description: call.description,
card: {
command: call.title,
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
output: undefined,
exitCode: undefined,
signal: undefined,
running: true,
},
}
}
const result = block.resultView?.card === 'terminal' ? block.resultView : null
if (result === null) return null
return {
description: call?.description,
card: {
// The result's title REPLACES the pending one when the tool supplies it
// (the presentation contract's replacement-title rule); the call title is
// what a result without one keeps.
command: result.title ?? call?.title ?? '',
// Only a PRESENT call view can mean "omitted the cwd, so use the
// workspace". When the window dropped the call head there is no cwd
// anywhere — the result view carries none — and the original call may
// well have used an explicit workdir, so the prompt draws a bare `$`
// rather than naming a directory this card cannot know.
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
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,17 @@
.code[data-error] {
color: var(--dsw-alias-state-error-primary);
}
/* Above the card, which is where the render-intent contract puts a terminal
call's description; the panel has no summary row to carry it. */
.terminalDescription {
margin: 0 0 6px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
/* 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
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
@@ -95,15 +110,11 @@ 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>
)}
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
</section>
</>
)}
@@ -112,6 +123,41 @@ 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}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @returns the Output section's body element.
*/
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) {
// The contract renders the presenter's description above the card, and the
// panel has no summary row to carry it, so it is drawn here.
return (
<>
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} 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 {
position: relative; /* sweep-glare overlay anchor */
@@ -3,10 +3,20 @@
// 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 and is not a details-panel target either (tool rows stopped being
// one), so its terminal body is resident rather than expand-gated as in
// ToolRow, and the card's own copy and expand controls are the row's only
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
// flow's tighter cap over the block's own default of 16 — and the block's
// internal expander keeps 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,24 +39,40 @@ 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 resident below it. The summary row is not a
* details-panel control (tool rows stopped being one), so the card's copy and
* expand controls are the row's only interactions.
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
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}
>
<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}
>
<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 />
{/* The terminal presenter's description is the contractual
above-card summary; it outranks the args-derived one. */}
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
</div>
{terminal !== null && (
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
)}
</div>
)
}
@@ -97,6 +97,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',
@@ -165,6 +170,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('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
const open = vi.fn()
const view = render(
@@ -0,0 +1,600 @@
// @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 }
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
function runStateOf(container: HTMLElement): string | null {
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
}
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({
description: 'List files',
card: {
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({
description: 'List files',
card: {
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
exitCode: 2, signal: undefined, running: false,
},
})
expect(terminalCardModel(settled({
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
}))?.card.signal).toBe('SIGTERM')
})
it('takes the result view\'s replacement title over the pending one', () => {
// The presentation contract defines a result title as REPLACING the pending
// title, so a tool that rewrites it at settle time must win here.
expect(terminalCardModel(settled({
callView: callTerminal({ title: 'pnpm run check' }),
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
}))?.card.command).toBe('pnpm run check --filter web')
// Without one, the call's title is what the card keeps.
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
})
it('resolves the cwd against the session workspace the way the bridge must', () => {
// Omitted workdir — the common bash call — IS the session workspace.
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
// A relative workdir joins under it.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
// An absolute one is used as-is.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/srv/other' }),
}), '/w/app')?.card.cwd).toBe('/srv/other')
// With no session cwd there is nothing to resolve against: a relative path
// stays as authored and an omitted one stays absent (a bare `$` prompt).
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}))?.card.cwd).toBe('packages/ui')
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
// The running arm resolves identically.
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
})
it('normalizes a relative workdir so the label names the directory actually used', () => {
// The bash executor resolves the workdir before running, so `..` against
// /w/app runs in /w — the card must say `w`, not `..`.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '/w/app')?.card.cwd).toBe('/w')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '.' }),
}), '/w/app')?.card.cwd).toBe('/w/app')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../sibling' }),
}), '/w/app')?.card.cwd).toBe('/w/sibling')
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: './nested/../other' }),
}), '/w/app')?.card.cwd).toBe('/w/app/other')
// A `..` that would climb past the root is dropped, as a filesystem does.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../../..' }),
}), '/w')?.card.cwd).toBe('/')
// An absolute path carrying segments normalizes too.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/srv/./app/../other' }),
}), '/w/app')?.card.cwd).toBe('/srv/other')
// A Windows path keeps its separators.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
}), '/w')?.card.cwd).toBe('C:\\ws')
// Without a session cwd a relative `..` has nothing to resolve against, so
// it survives as authored rather than being silently dropped.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../elsewhere' }),
}))?.card.cwd).toBe('../elsewhere')
})
it('keeps a UNC server and share as an unpoppable root', () => {
// Windows cannot climb above a share, so `..` from the share root stays put.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
// Below the share it pops normally, keeping the UNC separators.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '..' }),
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
// Several `..` cannot escape the root either.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '../../..' }),
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
})
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
// A truncated call carries no cwd anywhere: the result view has none, and
// the original call may have used an explicit workdir. Falling back to the
// session workspace here would name a directory the card cannot know.
expect(terminalCardModel(settled({
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
}), '/w/app')?.card.cwd).toBeUndefined()
// A present call view that omits its cwd still means the workspace.
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
})
it('carries the call view\'s description, which the contract renders above the card', () => {
expect(terminalCardModel(settled())?.description).toBe('List files')
expect(terminalCardModel(running())?.description).toBe('List files')
// A presenter that supplies none, and a window-truncated call side, both
// leave it absent so the row keeps its args-derived summary.
expect(terminalCardModel(settled({
callView: { card: 'terminal', title: 'ls' },
}))?.description).toBeUndefined()
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
})
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' }),
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
expect(terminalCardModel(settled(truncated))?.card).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, openFile: 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('renders a multi-line command as one prompt row per line', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ title: 'ls -la\necho done' }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
// Still one dot for the call, on the first row.
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
})
it('the fallback row shows the presenter description, not the args summary', () => {
// Any terminal-declaring tool without its own keyed row lands here, so the
// contract's above-card description has to win at this render site as well.
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
it('keeps the presenter description visible once the terminal card is expanded', () => {
// The contract puts the description ABOVE the card. The collapsed summary is
// hidden while a row is open, so an expanded terminal row has to draw it
// itself or the description would only ever be visible collapsed.
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
expect(view.getByText('Terminal 3')).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()
// The card states its own run state: a running command reads as running
// even though it has no output yet to distinguish it from an empty settle.
expect(runStateOf(view.container)).toBe('ongoing')
})
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, waitingApproval: false, updatedAt: 0 } },
current: undefined,
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the command output under the summary row, without an expand gesture', () => {
const view = render(<BashRow {...rowProps(settled())} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
// The card's controls are the row's only interactions: a bash row is not a
// path link and no longer a details-panel target, so nothing here navigates.
expect(view.container.querySelector('[data-clickable]')).toBeNull()
expect(view.getByText('复制')).toBeTruthy()
})
// The row's leading StateDot and the card's run-state dot describe the same
// command, so a running row whose card claimed 'done' would be a contradiction
// the reader sees on one line.
it('agrees with the summary row about the run state', () => {
const runningView = render(<BashRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
expect(runStateOf(runningView.container)).toBe('ongoing')
cleanup()
const settledView = render(<BashRow {...rowProps(settled())} />)
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
expect(runStateOf(settledView.container)).toBe('done')
})
it('shows the terminal presenter\'s description instead of the args summary', () => {
// `terminal_send`-style presenters author a description the args do not
// repeat; the contract puts it above the card, which is this row's summary.
const view = render(<BashRow {...rowProps(settled({
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
})
it('keeps the args-derived summary when the presenter authored no description', () => {
const view = render(<BashRow {...rowProps(settled({
callView: { card: 'terminal', title: 'ls -la' },
}))} />)
expect(view.getByText('List files')).toBeTruthy()
})
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, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
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: () => {} }}
useProjection={(() => undefined)}
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: [], 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' }
// The panel never unmounts between selections, so per-call view state has to
// be keyed off the selected call or it leaks into the next one.
it('resets the card\'s expand state when the selected call changes', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
}), target)
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
// A second call, selected without unmounting the panel, starts collapsed.
cleanup()
const second = mount(snapshot({
nodes: [settled({
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
})],
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
})
it('renders the presenter description above the card', () => {
const view = mount(snapshot({
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
}), target)
const description = view.getByText('Terminal 3')
const card = view.container.querySelector('[data-terminal]')
expect(card).not.toBeNull()
// Above, not below: document order is what places it as the card's heading.
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
// No workdir in the call view: the prompt label is the workspace basename.
expect(view.getByText('app')).toBeTruthy()
})
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()
expect(runStateOf(view.container)).toBe('ongoing')
})
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')
})
// The panel resolves a sub-dispatch through the same material as a native
// call, so a sub-call that DID carry terminal views would render the card.
// The shipped wire cannot produce that yet: `session.ts` folds
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
// pins the resolution path with views injected directly, and the arm below
// pins what the shipped path actually shows today.
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
}), target)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
}), target)
// No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping).
expect(view.container.querySelector('[data-terminal]')).toBeNull()
const output = view.getByText('Output').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
})
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: () => {} }}
useProjection={(() => undefined)}
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()
})
})