Merge remote-tracking branch 'origin/master' into worktree-renameweb

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.zh.md
#	packages/session-title/session-title/README.i18n.yaml
#	packages/session-title/session-title/README.zh.md
This commit is contained in:
imccyu
2026-07-29 20:58:22 +08:00
410 changed files with 5440 additions and 2041 deletions

View File

@@ -80,6 +80,62 @@ 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 65, authored to carry every feature
* the terminal card draws that turn 60's two prompt rows 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. The exit status is authored separately in
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
* terminal card shows the exit as its own pill and leaving the marker in would
* render it twice (packages/bash/tool-bash/src/render.ts).
*/
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'),
].join('\n')
/**
* Exit status for each terminal sample, keyed by its output text. Authored
* alongside the sample rather than parsed back out of its trailing marker,
* which is the bash tool's own job and not something to reimplement here.
*/
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
// A two-line command, so the fixture covers the terminal card's one-row-per-
// command-line prompt (and that the card still marks the call exactly once).
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
// Turn 65: the terminal sample turn 60's two clean prompt rows 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 authored beside
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
// `[exit code: N]` marker, since the real presenter consumes that one out of
// the body. 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.
//
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
// next `turn/start`, so a turn appended after it would leave the dock's plan
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -250,7 +322,10 @@ 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.
case 'fx-bash':
case 'bash':
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
case 'fx-write':
return {
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
if (call === undefined) return undefined
switch (call.card) {
case 'terminal':
return { card: 'terminal', output: resultText, exitCode: 0 }
// The sample's own exit status, authored beside it: re-parsing the
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
// which this client-side fixture cannot import.
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
case 'diff':
return { card: 'diff', diffs: call.diffs }
case 'generic':

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR无需 builder→host 通道。
浏览器侧订阅系统 SSEServer-Sent Events通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
## 模型体验
@@ -12,10 +12,10 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiberSession 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容rev 刷新会随重新连接握手机制落地
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiberSession 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容rev 刷新将在重新连接握手机制中实现

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: 9015af2b44a33771b06863ace139fe97695df616
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf

View File

@@ -10,9 +10,9 @@ locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change`分区会保留已渲染文本,直到重新挂载。
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change`界面区域会保留已渲染文本,直到重新挂载。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
客户端模块系统Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项fiber 生命周期、inject 等待、update/refresh并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
客户端模块系统Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项fiber 生命周期、inject 等待、update/refresh并把该包package`ClientModuleLoader` 作为其 `internal` seam 注入vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;图行`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取HMR hook
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子
## 模型体验
@@ -18,5 +18,5 @@
## 已知限制与暂缓事项
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表接口loadCache/edges/invalidate按通用模块图塑形因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只逐记录清点自身拥有的样式标签 id。
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点接口loadCache/edges/invalidate按通用模块图塑形因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只在每条记录中登记其拥有的样式标签 id。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220
README.zh.md: 0aba4674d2deb2cfc93712becf1626a0db4241b2
README.zh.md: 5531cd4724df2c63a9a5c6d1923b52c1f578db10

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create`瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾`projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端会话一律由 Host 创建(一次 `session.create`时产生 Session、agent(智能体)和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录末尾的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次**受理成功**`prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受`prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
## Code Mode 子调用索引
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话模型选择
@@ -30,14 +30,14 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型体验
无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
#### KV Cache 影响
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
更改目标可能改变提供方侧的缓存复用,或使其失效;该包package本身不会改变提示词前缀。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配空状态 P0 事故复盘
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber dispose资源释放 → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 事故复盘postmortem所记录的问题

View File

@@ -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: e2148cfca658196540e3800912dccd0568ae8d0e
README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9
README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f
README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f

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.

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 scopekey 空间在运行时开放);其渲染点逐行通过 `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']` 作为加载顺序 seamapply 在聊天注册后挂载 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。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。

View File

@@ -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}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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,
},
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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[] = []

View File

@@ -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 */

View File

@@ -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>
)
}

View File

@@ -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(

View File

@@ -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()
})
})

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85
README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4

View File

@@ -2,20 +2,20 @@
[English](README.md) | 中文
模型选择插件(浏览器侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、坑位注入面类型。
## Model Experience
## 模型体验
间接影响,经两个入口共同提交的 `session.selectModel` RPCHost 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
间接影响,经两个入口共同提交的 `session.selectModel` RPCHost 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
#### KV Cache effect
#### KV Cache 影响
切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。
切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
## Known Limitations and Deferred Work
## 已知限制与暂缓事项
- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择入会话创建的通道host `targetFor` 的种子序注释记录了该层未来的落点)。
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择入会话创建的通道host `targetFor` 的种子顺序说明了该层未来的落点)。
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 13f51d5338affd65d0705cec6a3b4ef78a534f0f
README.zh.md: 466505beb27c729246afe04e6378235b91d072cf
README.zh.md: 90f4eb5959e105178844c7bd5b07596aff81e706

View File

@@ -10,7 +10,7 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
README.zh.md: 567881e8ca7d5e8017f82884cd638f08b13fc7e7

View File

@@ -2,12 +2,16 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), 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. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; 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,5 @@ 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.
- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

View File

@@ -2,15 +2,19 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族MessageText/MarkdownText/JsonBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Inputmarkdown 家族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
`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` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 模型体验
无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
无。该包package在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -21,3 +25,5 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**尚未实现已交付的四种状态done/warning/ongoing/error构成完整的 P-I 表层。
- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale``TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

View File

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

View File

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

View File

@@ -23,8 +23,8 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [
*/
export function StateDot({ state, size = 10, className }: {
state: StateDotState
size?: number
className?: string
size?: number | undefined
className?: string | undefined
}) {
if (state === 'ongoing') {
return (

View File

@@ -0,0 +1,152 @@
/* 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;
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
position: relative;
margin: 16px 0;
/* The gutter is the card's OWN padding, not a margin: every consumer rewrites
`margin` wholesale (each render site sets its own indent), which silently
cancelled the reservation and let the dot fall outside the card into a
container that clips it. Owning the reservation here keeps the invariant
with the component that depends on it. */
padding-left: var(--dsl-terminal-gutter);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
}
/* One row per command line. The prompt column is the only element allowed to
shrink; the status pill and the copy control keep their intrinsic width. */
.prompt {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
}
.promptLine {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
line-height: var(--dsl-terminal-line-height);
}
/* Out of flow inside the card's own gutter padding, so the reservation and the
dot move together and no consumer margin can pull them apart; the dot neither
indents its command nor depends on the command's text metrics to line up.
Centered against the row's line box, not the code font's baseline. */
.runState {
position: absolute;
left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
top: 50%;
transform: translateY(-50%);
}
/* The dot is aria-hidden; this is its text label for assistive technology. */
.runStateLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cwd {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
continuation. Both hold the single row and the ellipsis. */
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.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 12px 0;
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 12px 0;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,237 @@
// TerminalBlock: the terminal surface for a shell command and its output —
// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
// settled exit status, and a copy control for the raw output. Output never soft-wraps:
// column-aligned output (ls, tables, box drawing) keeps its alignment and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; ANSI parsing lives in ansi.ts.
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 { StateDot, type StateDotState } from './StateDot.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
}
/**
* Run-state indicator for the command, shown at the head of the prompt line so
* the card states whether the command is still running without the reader
* having to infer it from the presence of output. Three of {@link StateDotState}'s
* four states are reachable: the running chase (the same
* indicator a running tool row's leading icon uses, so the row and its card
* never disagree), green for a clean settle, red for a signal or a non-zero
* exit — the same status distinction {@link statusText} draws for the pill. A
* settled command whose exit status never reached the view counts as a clean
* settle: the view says it finished and says nothing went wrong.
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
}
/**
* Render one parsed output line. Runs without SGR state render as bare text,
* so uncolored output carries no span wrappers.
* @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 check runs on the
// PARSED lines rather than on the raw text, because a reset after the final
// newline (`line\n\x1b[0m`) leaves the string not ending in one while still
// producing a last line with nothing visible in it. A genuinely blank final
// line — the double newline — survives, since it has a real empty line before
// the terminator. The copy control still copies `text` untouched.
const lines = useMemo(() => {
const parsed = parseAnsiLines(text)
const last = parsed[parsed.length - 1]
const terminated = parsed.length > 1 && last !== undefined
&& last.every(span => span.text === '')
return terminated ? parsed.slice(0, -1) : parsed
}, [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 state = runState(running, exitCode, signal)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
const commandLines = useMemo(() => {
const body = command.endsWith('\n') ? command.slice(0, -1) : command
return body.split('\n')
}, [command])
// Read from the parsed lines the card actually renders, not from the raw text:
// output that is only escapes or control bytes (a lone reset, an OSC title, an
// erase) survives `text.trim()` yet parses to nothing visible. Judging it on
// the raw text drew an output box of blank rows plus a copy control for
// invisible bytes, and hid the placeholder that belongs there.
const empty = lines.every(line => line.every(span => span.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.runStateLabel}>{state.label}</span>
{commandLines.map((line, index) => (
<div key={index} className={css.promptLine}>
{/* One dot for the card, on the first row: the exit status the
view carries is the whole call's, and bash reports no
per-command status, so a dot per row would assert a
per-line outcome nothing here knows. */}
{index === 0 && <StateDot state={state.state} className={css.runState} />}
{/* The cwd labels the CALL, so only its first row carries it. The
view knows one working directory — where the call started —
and a later line may well run somewhere else (a `cd` in the
command is enough), so repeating the label down the rows would
assert a directory per line that nothing here knows. Later
rows keep a bare `$` to stay aligned as prompts. */}
<span className={css.cwd}>
{index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
</span>
<span className={css.command}>{line}</span>
</div>
))}
</div>
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}></div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
<div key={index} className={css.line}>{renderLine(line)}</div>
))}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,447 @@
// 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, backspace and ESC
* survive: the first two for layout, backspace for the cursor replay, ESC
* for anser's CSI split.
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Lines whose cursor movements have to be replayed: a carriage return, a
* backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
* `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
* form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
*/
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
/** SGR sequences alone, for folding state through a line that needs no replay. */
const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
/** Terminal tab stop width; a tab advances to the next multiple of this. */
const TAB_WIDTH = 8
/**
* Combining marks and other zero-width code points: a terminal advances no
* column for them, so `e` + U+0301 occupies one cell and a two-column redraw
* covers both code points.
*/
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
/**
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
* CJK punctuation, and characters with emoji presentation. Text-presentation
* symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
* must stay out of this set.
*/
const WIDE_CHAR = new RegExp(
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
// Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
// width — `\u2713` (the check every progress line writes, this fixture
// included) advances one column, verified against a real terminal, so taking
// the whole block as wide misaligned exactly the output this card exists for.
+ '|\\p{Emoji_Presentation}'
+ '|[\\uff01-\\uff60\\u3000-\\u303e]',
'u',
)
/**
* Whether a character occupies two terminal columns (CJK, fullwidth forms,
* emoji). Covers the ranges a command's output realistically carries; a
* narrower guess would misalign the columns this card exists to preserve.
* @param char - one character from the output.
* @returns true when the terminal advances two columns for it.
*/
function isWide(char: string): boolean {
const code = char.codePointAt(0)
if (code === undefined || code < 0x1100) return false
return WIDE_CHAR.test(char)
}
/**
* A cell's graphic state, normalized. Held as fields rather than as the raw
* sequence history because a terminal tracks CURRENT state, not a transcript:
* accumulating sequences made each state boundary re-emit the whole chain, so
* output that switches color without a full reset emitted O(n^2) characters
* (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
* the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
* `24`, `27`, `29` — actually close their attribute instead of appending to it.
*/
interface SgrState {
fg: string
bg: string
/** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
attrs: readonly string[]
}
/** The default state: no color, no attributes. */
const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
/** Attribute closers, mapped to the opener parameters each one turns off. */
const ATTR_CLOSERS: Record<string, readonly string[]> = {
22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
}
/**
* Fold one SGR sequence's parameters into the state it produces.
* @param state - state in force before the sequence.
* @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
* @returns the state the sequence leaves in force.
*/
function foldSgr(state: SgrState, params: string): SgrState {
const codes = params === '' ? ['0'] : params.split(';')
let next = state
for (let index = 0; index < codes.length; index++) {
const code = String(codes[index])
if (code === '' || code === '0') { next = SGR_NONE; continue }
// Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
// consume their own arguments, so they are taken whole.
if (code === '38' || code === '48') {
const kind = codes[index + 1] ?? ''
const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
const value = codes.slice(index, index + span + 1).join(';')
next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
index += span
continue
}
const closes = ATTR_CLOSERS[code]
if (closes !== undefined) {
next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
continue
}
const numeric = Number(code)
if (code === '39') { next = { ...next, fg: '' }; continue }
if (code === '49') { next = { ...next, bg: '' }; continue }
if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
}
return next
}
/**
* Render a state as the one canonical sequence that establishes it from the
* default, so a boundary emits a bounded string no matter how the state was
* reached.
* @param state - the state to open.
* @returns the SGR sequence, or the empty string for the default state.
*/
function openSgr(state: SgrState): string {
const codes = [...state.attrs]
if (state.fg !== '') codes.push(state.fg)
if (state.bg !== '') codes.push(state.bg)
return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
}
/** Whether two states are the same, so a boundary is only emitted on a change. */
function sameSgr(a: SgrState, b: SgrState): boolean {
return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
&& a.attrs.every((attr, index) => attr === b.attrs[index])
}
/**
* Replay one line's cursor movements the way a terminal paints it, into a
* column buffer. Carriage return and backspace only MOVE the cursor — neither
* erases anything — so what a reader sees is whatever each column last had
* written to it. That distinction is the whole point of doing this as a buffer
* rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
* shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
* because nothing ever overwrote the `c`.
*
* A CSI sequence occupies no column; it changes the state that the NEXT writes
* are stamped with, which is how a terminal stores color per cell. `red bad`
* then three backspaces then `ok` therefore shows `okd` with the `d` still red:
* `ok` overwrote two cells and the third kept the state it was written with.
* The columns are re-emitted as runs, so anser sees that same styling.
* @param line - one output line, still carrying its CSI sequences.
* @param entrySgr - SGR state in force when the line begins, since a newline
* does not reset it.
* @returns the line as the terminal would have it after every movement, plus the
* SGR state at its end for the next line to enter with.
*/
function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
// Same shape anser splits on, so a sequence is one unit here as well.
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
/** Per column: the state in force when it was written, and its character. */
const columns: (Cell | undefined)[] = []
let cursor = 0
// State is tracked exactly as a terminal tracks it: each cell is stamped with
// whatever was in force at the moment of the write, so a later redraw cannot
// restyle the cells it does not reach. It enters carrying the previous line's
// state, since a newline does not reset it.
let sgr = entrySgr
let at = 0
/** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
const clear = (index: number, fill: string): void => {
const cell = columns[index]
if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
columns[index + 1] = { sgr, char: fill }
}
columns[index] = { sgr, char: fill }
}
const consume = (chunk: string): void => {
for (const char of chunk) {
if (char === '\r') { cursor = 0; continue }
if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
if (char === '\t') {
// A tab advances to the next 8-column stop, leaving the cells it skips
// as they were — which is how a redraw can leave a tabbed column
// standing. Column alignment is the whole point of this card.
const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
continue
}
if (ZERO_WIDTH.test(char)) {
// No column of its own: it attaches to the cell already written, so a
// redraw that covers that cell covers the mark with it. With no cell to
// attach to (line start, or straight after a redraw to column 0) a
// terminal shows nothing rather than a lone accent.
const base = cursor > 0 ? columns[cursor - 1] : undefined
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
continue
}
// Writing over either half of a wide pair blanks the other half, since a
// terminal cannot leave one cell of a two-cell glyph standing.
clear(cursor, ' ')
columns[cursor] = { sgr, char }
cursor++
// A wide character occupies two columns; the trailing one is a spacer,
// marked so that overwriting the lead cell leaves a blank behind instead
// of closing the gap and shifting everything after it left.
if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
}
}
for (const match of line.matchAll(csi)) {
consume(line.slice(at, match.index))
at = match.index + match[0].length
// Both groups are mandatory in the pattern, so destructuring types them as
// strings without a fallback that could never run.
const params = String(match[1])
const final = String(match[2])
if (final === 'K') {
// Erase in line: the fixed companion of `\r` in every spinner and progress
// bar. Without it a shorter redraw leaves the previous frame's tail
// standing, which is text the terminal never showed. `1` blanks from the
// line start THROUGH the cursor column (inclusive, per the CSI spec)
// rather than dropping those cells, since the cursor does not move and a
// later write can still land past them. Only the FIRST parameter selects
// the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
const mode = String(params.split(';')[0])
if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
else columns.length = mode === '2' ? 0 : cursor
continue
}
// Only SGR carries graphic state; every other final byte is a cursor or
// erase action that must not affect a cell's style.
if (final !== 'm') continue
sgr = foldSgr(sgr, params)
}
consume(line.slice(at))
// Re-emit the columns, opening a run only where its state changes, so anser
// sees the same styling a terminal shows. Each boundary emits ONE canonical
// sequence for the state it opens, which is what keeps the output linear in
// the number of cells however the state was reached.
let out = ''
let active = entrySgr
for (let index = 0; index < columns.length; index++) {
const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
if (!sameSgr(column.sgr, active)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(column.sgr)
active = column.sgr
}
// A spacer still holds its column. While its lead cell survives, the wide
// glyph spans both and the spacer emits nothing; once a later write replaced
// that lead, the terminal blanks the spacer instead of closing the gap, so
// emitting nothing would shift everything after it one column left.
const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
out += column.spacer === true && !leadIntact ? ' ' : column.char
}
// Converge to the state the SCAN ended in, not the last written cell's: a
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
// no cell yet still ends the run, and it has to reach both the DOM and the
// next line. Without this a line ending in a reset leaked its color onward.
if (!sameSgr(active, sgr)) {
if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
out += openSgr(sgr)
}
return { text: out, sgr }
}
/** One replayed column: the state it was written with, and its character. */
interface Cell {
sgr: SgrState
char: string
/** The trailing half of a wide character's two-column pair. */
spacer?: boolean
}
/**
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
* is dropped first, so those lines keep their text instead of being redrawn onto
* themselves. SGR state threads across lines: a newline does not reset it, so a
* run opened before a redraw still colors the lines after it.
* @param text - output text, already free of OSC and non-CSI escapes.
* @returns the text with each line painted as the terminal would.
*/
function applyCursorMovements(text: string): string {
const replayed: string[] = []
let sgr = SGR_NONE
for (const raw of text.split('\n')) {
const line = raw.replace(/\r+$/, '')
if (NEEDS_REPLAY.test(line)) {
const result = replayLine(line, sgr)
replayed.push(result.text)
sgr = result.sgr
continue
}
// No cursor movement: the line needs no column buffer, and painting one
// would allocate a cell per character of output this card never redraws —
// an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
// line that DOES replay enters with the right state.
replayed.push(line)
for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
}
return replayed.join('\n')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
* (carriage return, backspace) replay first, since their effect on the visible
* text must land before the characters that expressed them are dropped.
* @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 applyCursorMovements(escaped).replace(INERT_CONTROL, '')
}
/**
* Resolve one run's colors and decorations.
* @param chunk - the anser chunk to style.
* @returns the run's inline style, or undefined when it carries no SGR state.
*/
function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
const style: CSSProperties = {}
const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
if (background !== undefined) style.backgroundColor = background
if (chunk.fg !== null) {
const literal = `rgb(${chunk.fg})`
// A run that paints its own background keeps anser's literal pair so the
// authored foreground/background contrast survives; a foreground-only run
// maps onto a theme token, which adapts to light and dark surfaces.
style.color = background === undefined
? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
: literal
}
for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
return Object.keys(style).length === 0 ? undefined : style
}
/**
* Parse command output into styled spans grouped by line.
* @param text - raw output text, which may contain ANSI escape sequences.
* @returns one entry per output line (always at least one, possibly empty).
*/
export function parseAnsiLines(text: string): AnsiLine[] {
let current: AnsiSpan[] = []
const lines: AnsiSpan[][] = [current]
for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
const style = resolveStyle(chunk)
for (const [index, part] of chunk.content.split('\n').entries()) {
if (index > 0) {
current = []
lines.push(current)
}
if (part !== '') current.push({ text: part, style })
}
}
return lines
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,513 @@
// 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'
const BS = '\u0008'
/** A combining acute accent: zero-width, so it takes no terminal column. */
const ACCENT = '\u0301'
/** 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('leaves the tail of a longer frame standing under a shorter redraw', () => {
// Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
// return only moves the cursor, so the two columns the redraw never reaches
// still hold the frame beneath — truncating to the last `\r` would lose them.
expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
})
it('clamps a backspace run at the line start rather than going negative', () => {
// More backspaces than characters: the cursor stops at column 0, so the
// following write simply overwrites from there.
expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
})
it('keeps SGR state in force across a redraw, as a terminal does', () => {
// Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
// A carriage return moves the cursor; it does not reset the graphic state,
// so the redraw inherits the color the discarded frame was written with.
expect(onlySpan(`${ESC}[31mgone\rkept`))
.toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
})
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: backspaces', () => {
it('applies a backspace as the overwrite a terminal draws', () => {
// `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
})
it('stops at the line start instead of eating the newline before it', () => {
expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
[{ text: 'ab', style: undefined }],
[{ text: 'cd', style: undefined }],
])
})
it('treats a trailing backspace as a cursor move, not a delete', () => {
// Verified against a real terminal: `abc\b` still shows `abc`. Only a later
// write overwrites; a backspace with nothing after it erases nothing.
expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
// Same at a line boundary: the newline ends the line before any overwrite.
expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
[{ text: 'abc', style: undefined }],
[{ text: 'def', style: undefined }],
])
})
it('steps over an SGR sequence instead of erasing its bytes', () => {
// `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
// corrupt it and repaint the rest of the line with whatever the remainder
// parses as. The visible result is `aXY`, still red, with the reset intact.
expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
{ text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
{ text: 'XY', style: undefined },
]])
})
it('erases across a style boundary without dropping the styles between', () => {
// The backspace reaches back past the reset to the last printed character.
expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
{ text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
{ text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
it('replays a redraw and a trailing backspace as pure cursor moves', () => {
// Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
// repaints all three columns and the trailing backspace only moves the
// cursor left — nothing overwrites the `w`, so nothing is lost.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
})
it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
// Verified against a real terminal: red `bad`, three backspaces, then `ok`
// shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
// the three columns, so the untouched `d` keeps the run's red.
expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
{ text: 'ok', style: undefined },
{ text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
})
describe('parseAnsiLines: erase and column arithmetic', () => {
it('erases the rest of the line, the fixed companion of a redraw', () => {
// Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
// progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
// stands and the card shows text the terminal never displayed.
expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
// The parameterless form and `0` are the same erase.
expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
})
it('erases the whole line for the 2K form and to the cursor for 1K', () => {
expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
// 1K clears left of the cursor without moving it, so those columns read as
// blanks — verified in a real terminal, which shows ` |` for this input.
expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
})
it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
// 2K clears the line but leaves the cursor where it was, so writing there
// leaves the columns before it unwritten — blanks, as a terminal shows.
expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined })
})
it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
// Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at
// column 8, which a two-character redraw cannot reach. Counting the tab as
// one column would have produced `XYb` and destroyed the alignment.
expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined })
})
it('counts a wide character as the two columns a terminal advances', () => {
// `中` occupies two cells, so a two-character redraw covers exactly it.
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
})
it('does not accumulate a cursor or erase sequence into a cell style', () => {
// Only SGR carries graphic state. An erase folded into the style string
// would grow it per redraw and emit boundaries anser has to discard.
expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
{ text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
]])
})
})
describe('parseAnsiLines: line-end state and column widths', () => {
it('closes a run whose reset lands after the last written cell', () => {
// Verified in a real terminal: `\x1b[32mdone\rok\x1b[0m` then `plain` shows
// `okne` GREEN and `plain` in the DEFAULT color. The reset changes no cell,
// so returning the last cell's state leaked green onto every later line —
// and this exact shape (`\r\x1b[K\x1b[32m✓ built\x1b[0m`) is what every
// build tool writes.
expect(parseAnsiLines(`${ESC}[32mdone\rok${ESC}[0m\nplain`)).toEqual([
[{ text: 'okne', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'plain', style: undefined }],
])
})
it('erases through the cursor column for 1K, not up to it', () => {
// Verified in a real terminal: `abcd\b\x1b[1K|` shows ` |` — the `d` under
// the cursor is erased too, which the CSI spec calls inclusive.
expect(onlySpan(`abcd${BS}${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
})
it('gives a combining mark no column of its own', () => {
// Verified in a real terminal: `é` (e + U+0301) then `x`, redrawn with `YZ`,
// shows `YZ`. Counting the mark as a column left the `x` standing.
expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
})
it('drops a combining mark left with no cell to attach to by a redraw', () => {
// Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`.
// The redraw puts the cursor at column 0, so the mark has no preceding cell
// and the terminal shows nothing for it rather than a lone accent.
expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined })
// A mark with no movement on its line never reaches the replay at all: it
// is width business, not a cursor move, so it stays as authored.
expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined })
})
it('carries a colour opened after the last write onto the next line', () => {
// The mirror of the reset case, verified in a real terminal: `ab` CR `X` then
// `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red.
// The scan ends styled while the last cell is not, so the convergence has to
// open the run at the line end for it to reach the following line.
expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([
[{ text: 'Xb', style: undefined }],
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
])
})
it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
// Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
// glyph's second cell becomes a blank rather than closing the gap, so the
// `x` keeps column 3.
expect(onlySpan('中x\rA')).toEqual({ text: 'A x', style: undefined })
// Covering both of its columns leaves no spacer behind.
expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
})
it('replays an erase whose parameters carry a semicolon', () => {
// The replay guard has to match the same CSI shape the parser accepts, or a
// form like `\x1b[1;2K` skips the replay and its erase never happens.
expect(onlySpan(`abcd${ESC}[1;2K|`)).toEqual({ text: ' |', style: undefined })
})
})
describe('parseAnsiLines: bounded state and true widths', () => {
it('emits one canonical sequence per boundary however the state was reached', () => {
// Colors that never fully reset used to accumulate raw sequence history per
// cell, so every boundary re-emitted the whole chain: 3200 such cells
// produced 25 MB and eventually a RangeError. The state is normalized now,
// so the emitted text stays linear in the number of cells.
let input = ''
for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
})
it('closes an attribute with its closer instead of appending to the state', () => {
// `1` then `22` is bold then not-bold, which every chalk-based tool writes;
// appending both left the cell bold and grew the chain.
// Verified in a real terminal: the `22` closes the bold, so the `x` written
// after the redraw is PLAIN. Appending both left it bold and grew the chain.
expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
{ text: 'x', style: undefined },
]])
expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
{ text: 'A', style: { fontWeight: 700 } },
{ text: 'B', style: undefined },
]])
})
it('folds extended colors, backgrounds and every attribute closer', () => {
// The 256-palette and truecolor forms consume their own arguments, so the
// fold has to take them whole rather than as separate codes.
expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(255, 135, 0)' } },
]])
expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'rgb(10, 20, 30)' } },
]])
// A background survives the same way, and `49` closes it.
expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: undefined },
]])
// Each closer drops only its own attribute: `4` underline closed by `24`
// while the italic opened before it stays in force.
expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { fontStyle: 'italic' } },
]])
// `39` closes a foreground without touching the background.
expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
]])
})
it('folds the remaining SGR shapes the model has to carry', () => {
// A 48-background in extended form, so the `48` arm and the `2`-span both run.
expect(parseAnsiLines(`${ESC}[48;2;1;2;3mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { backgroundColor: 'rgb(1, 2, 3)' } },
]])
// A bright foreground and a bright background, the 90-97 / 100-107 arms.
expect(parseAnsiLines(`${ESC}[91mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { color: 'var(--dsw-alias-state-error-secondary)' } },
]])
expect(parseAnsiLines(`${ESC}[101mA\r${ESC}[KB`)).toEqual([[
{ text: 'B', style: { backgroundColor: 'rgb(255, 85, 85)' } },
]])
// An extended form with no recognized kind byte consumes nothing extra.
expect(parseAnsiLines(`${ESC}[38mA\r${ESC}[KB`)).toEqual([[{ text: 'B', style: undefined }]])
// Re-opening an attribute already in force does not duplicate it, and a bare
// `\x1b[m` resets exactly as `\x1b[0m` does.
expect(parseAnsiLines(`${ESC}[1m${ESC}[1mA${ESC}[mB\r${ESC}[KC`)).toEqual([[
{ text: 'C', style: undefined },
]])
})
it('treats a text-presentation symbol as one column', () => {
// Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
// check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
// misaligned exactly the progress output this card exists to show.
expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
// An emoji-presentation character is two, so the same redraw leaves a blank.
expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
})
it('clears a wide pair from either side, including through an erase', () => {
// Verified in a real terminal (`A x`): the redraw puts the cursor at column
// 0, the backspace clamps there, and writing `A` over the wide lead blanks
// its spacer rather than letting the `x` slide left.
expect(onlySpan(`\u4e2dx\r${BS}A`)).toEqual({ text: 'A x', style: undefined })
// An erase reaching the lead blanks its spacer through the same helper.
// Verified in a real terminal (` |`): 1K blanks through the cursor column,
// so the wide glyph's two cells and the `x` all become blanks.
expect(onlySpan(`\u4e2dx${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
})
it('clears the lead when the write lands on the spacer itself', () => {
// Two backspaces from after `中x` stop ON the wide glyph's second cell;
// writing there blanks the lead through the spacer side of the pair clear,
// so the glyph cannot survive as half a character.
expect(onlySpan(`中x${BS}${BS}A`)).toEqual({ text: ' Ax', style: undefined })
})
it('keeps a surviving spacer as a blank when its lead was replaced by a spacer', () => {
// `好` written over the first glyph's spacer puts its own spacer on the
// second glyph's lead cell — a write that goes down without a pair clear.
// The second glyph's spacer survives with a dead lead and must emit a
// blank, or everything after it shifts one column left.
expect(onlySpan(`中中${BS}${BS}${BS}`)).toEqual({ text: ' 好 ', style: undefined })
})
it('blanks both halves of a wide pair when either is overwritten', () => {
// A terminal cannot leave one cell of a two-cell glyph standing, so writing
// over the spacer clears the lead as well.
// Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
// the second glyph — writing the lead cell blanks its spacer, so the column
// stays occupied rather than collapsing.
expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
})
})
describe('parseAnsiLines: SGR across lines', () => {
it('carries active state past a newline, as a terminal does', () => {
// Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
// A newline does not reset the graphic state, so a replayed line must hand
// its state to the next one instead of closing it off.
expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
[{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
[{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
])
})
it('tracks state through a line that needs no replay', () => {
// The middle line has no movement, so it is not replayed — but its own SGR
// still has to reach the line after it.
expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
[{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
])
})
})
describe('parseAnsiLines: runs spanning lines', () => {
it('carries one run\'s style onto every line it covers', () => {
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
[{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
])
})
it('keeps several runs of one line in order', () => {
expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
{ text: 'plain', style: undefined },
{ text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
{ text: 'tail', style: undefined },
]])
})
})

View File

@@ -0,0 +1,430 @@
// @vitest-environment jsdom
// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
// arms, the prompt line's run-state dot, 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 ?? '')
}
/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
const dot = container.querySelector('[class*="_runState_"][data-state]')
return {
state: dot?.getAttribute('data-state') ?? null,
label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
}
}
/** The prompt rows as `<label><command>`, one per command line (the visual gap is CSS). */
function promptRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_promptLine_"]')].map(row => (row.textContent ?? '').trim())
}
/** `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('treats output that renders nothing visible as empty', () => {
// A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse
// to nothing. Judging emptiness on the raw text drew a box of blank rows
// plus a copy control for invisible bytes, and hid the placeholder.
const view = render(<TerminalBlock command="true" output={`${ESC}[0m`} exitCode={0} />)
expect(view.getByText('无输出')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
view.rerender(<TerminalBlock command="true" output={`${ESC}]0;title${ESC}\\`} exitCode={0} />)
expect(view.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('drops the output terminator even when a reset follows the final newline', () => {
// `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
// line holds nothing visible — a common shape, since tools close their color
// after the last line. Judging the terminator on the raw text added a blank
// row and inflated both the card height and the collapse count.
const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
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`} />)
// Scoped to a line: the prompt line's run-state dot is a styled span too.
const span = view.container.querySelector('[class^="_line_"] 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 run-state dot', () => {
it('shows the running chase and its running label while the command runs', () => {
const view = render(<TerminalBlock command="sleep 5" running />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
it('shows the done dot for a clean settled exit', () => {
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('counts a settled command with no exit status as a clean settle', () => {
const view = render(<TerminalBlock command="ls" output="a" />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('shows the error dot for a non-zero exit', () => {
const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
it('shows the error dot for a signal, whatever the exit code says', () => {
const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
// The dot precedes the prompt label, which is what makes it read as the
// state OF this command rather than of the card's chrome.
it('places the dot ahead of the prompt label and the command', () => {
const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
const row = view.container.querySelector('[class^="_promptLine_"]')
expect([...row!.children].map(node => node.textContent)).toEqual(['', 'app', 'ls'])
})
// The cwd labels the call, not each line: a `cd` in the command moves later
// lines elsewhere, so repeating the label would state a directory per line
// that the view does not know.
it('labels only the first row with the cwd, leaving later rows a bare $', () => {
const view = render(<TerminalBlock command={'cd ~\nls'} cwd="/srv/app" output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['appcd ~', '$ls'])
})
it('gives a multi-line command one row per line', () => {
const view = render(<TerminalBlock command={'echo one\necho two'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
})
// A heredoc or an editor-authored command commonly ends in a newline; that
// terminator is not a further, empty command to draw a row for.
it('drops a trailing newline instead of drawing an empty final row', () => {
const view = render(<TerminalBlock command={'echo one\necho two\n'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
})
it('keeps a genuinely blank command line when the command ends with two newlines', () => {
const view = render(<TerminalBlock command={'echo one\n\n'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$'])
})
// The exit status the view carries is the whole call's — bash reports no
// per-command status — so exactly one dot and one label are correct however
// many lines the command spans. A dot per row would assert, of a line that
// succeeded inside a failing call, that the line itself failed.
it('marks the call once, on the first row, never per line', () => {
const view = render(<TerminalBlock command={'true\nfalse\ntrue'} output="x" exitCode={1} />)
expect(view.container.querySelectorAll('[class*="_runState_"][data-state]')).toHaveLength(1)
expect(view.container.querySelectorAll('[class^="_runStateLabel_"]')).toHaveLength(1)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect(rows[0]!.querySelector('[data-state]')).not.toBeNull()
expect(rows[1]!.querySelector('[data-state]')).toBeNull()
expect(rows[2]!.querySelector('[data-state]')).toBeNull()
})
it('keeps the running dot even while a settled-looking status pill is supplied', () => {
const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
})
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)
})
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
README.md: bb99f9b37927eec57650aa4025deb043b369c78e
README.zh.md: fce11e2cf44fe6c1debe850df644b0114dbde5e3
README.zh.md: 64b207aadfbcd7d25c005b3dcd5358013d53c173

View File

@@ -10,7 +10,7 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
README.zh.md: 1ef636dbd00c894c8312ab1fbfa9a96af45956f0
README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的 Session 显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
New Session 会启动运行时的页面局部前端 Session Intent真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端 Session 为目标。Workspace Intent 不会出现在侧边栏中。
New Session 会启动运行时的页面局部前端 Session Intent真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions``useWorkspaces` hook、已声明的 `sidebar.workspace``sidebar.settings` 子 slot以及注入的 `startSession``open` 和侧边栏切换回调。这里没有插件 store`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions``useWorkspaces` 钩子、已声明的 `sidebar.workspace``sidebar.settings` 子 slot以及注入的 `startSession``open` 和侧边栏切换回调。这里没有插件 store`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot并共享其栏状态`wide`ui-settings 在此注册触发行和设置面板。
@@ -18,10 +18,10 @@ New Session 会启动运行时的页面局部前端 Session Intent真实 Work
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **状态点只有两种实时数据状态running/none**done/error/amber 数据源随 P-II 审批与通知到来;四色原语已经接线
- **状态点只有两种实时数据状态running/none**done/error/amber 数据源随 P-II 审批与通知功能一并提供;四色原语已接入
- **分组选单只提供按 Workspace 分组**Update/Status 分组策略只有图稿而没有规范,暂缓实现。
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达主
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
README.zh.md: 368171a43ef3a449049542cd227459f82ec43086
README.zh.md: ed582128246a62297f555f8abe09f427cb9d256a

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill技能引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backedhost 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flightscope 出生`warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
skill技能引用 source 的浏览器:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flightscope 创建时`warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。
@@ -12,20 +12,20 @@ skill技能引用 source 的浏览器半侧:把 `/` 触发的 `skill` so
### 用户提示词中的 skill 引用文本
#### 模型所见
#### 模型看到的内容
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21纯文本`<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且不确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
#### Token 影响
有条件且极小:只有 pick或手动键入相同文本会把引用的字符加进那一条用户消息。浏览菜单和候选拉取增加零模型 token。
有条件且极小:只有 pick或手动键入相同文本会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包package绝不改写较早的请求 token。
## 已知限制与暂缓事项
- **skill 加载不确定**:引用是协作线索,不是保证;模型可能忽略它。命中率被证明不足时的返工路径host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;wire 上的文本形不会改变。
- **首次击键可能与预热竞速**scope 出生的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
- **文本即真身**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。
- **skill 加载不确定**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形不会改变。
- **首次击键可能与预热竞速**scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
README.zh.md: 20770f37f33c4a8a94486b116856b41bedec913e

View File

@@ -2,25 +2,25 @@
[English](README.md) | 中文
输入触发线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`会话领域的接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
输入触发流水线插件:光标处的 `/``@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster并按会话 scope`sessionOf`)各解析一个 `SlashController`对话接线层在 controller 上驱动 `track``arbitrate``onSpace``adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace``matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`T2是纯内核——`detectTrigger``menuReduce``seedGroups``MENU_CLOSED``exactMatch`,零 ReactDOMcordis`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类会话 scope菜单关闭期间渲染 null。该 slot 由 ui-conversation 的组合器条目拥有锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`因为依赖方向ui-conversation → ui-slash不允许反向的类型导入。combobox 模式:焦点始终留在 textarea行在 mousedown 时完成 pick高亮由 `aria-activedescendant` 承载。
`/client` 导出表层是插件主体(`apply``inject`)、`SlashService``MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
## 模型体验
无。触发线只是浏览器呈现——pick 产出 `CommandClaim``ReferenceInsert` 数据,其模型可见后果(host 命令执行;插入的引用文本随普通提示词发送)由消费方的 host 包与输入状态机包拥有。
无。触发流水线只是浏览器呈现——pick 产出 `CommandClaim``ReferenceInsert` 数据,其模型可见后果(宿主命令执行;插入的引用文本随普通提示词发送)由消费方的 host 包与输入状态机包拥有。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
- **`SlashCandidate.icon` 以文本渲染**MenuView 把该字符串原样放进图标位;接到设计系统图标枚举iconFile 五变体家族)的接线等该枚举交付后落地
- **`SlashCandidate.icon` 以文本渲染**MenuView 把该字符串原样放进图标位;设计系统图标枚举iconFile 五变体家族)的接入将在该枚举交付后完成
- **overlay 的 SlotMap 合并归属与 slot 所有权分离**`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义锚点、children 声明、生命周期)留在 ui-conversation依赖方向ui-conversation → ui-slash迫使这一拆分未来依赖关系调整时应重新审视。
- **菜单组顺序即注册顺序**source 之间没有显式排序 seamroster 还是 commandskillsubagent 时可以接受,业务 source 加入后需重新审视。
- **菜单组顺序即注册顺序**source 之间没有显式排序 seamroster 还是 commandskill(技能)subagent 时可以接受,业务 source 加入后需重新审视。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
README.md: ed6f052b3a47e08d693928b6763e32427b829467
README.zh.md: 8f15352d09a33a862203507ac89841da91a43e59
README.zh.md: 17c3cbb28defe0c9bc66df417976984be3955b53

View File

@@ -2,24 +2,24 @@
[English](README.md) | 中文
Slot 注册表纯核心、slot 终端设计SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。React 类型仅在运行时使用,该包不依赖 React也不依赖 cordis。
Slot 注册表纯核心、slot 终端设计SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型该包package不依赖 React也不依赖 Cordis。
一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot声明 = 渲染授权 = 运行时规范三者共用一张表、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生:
| share | 类型 | 来源 |
|---|---|---|
| runtime | `PropsRuntime<K>` | SlotMap 配置项`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
| runtime | `PropsRuntime<K>` | SlotMap 条目`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
| child render | `PropsRenderSlots<S>` | register 调用的 `children` key 集合(静态缩窄的 `renderSlot` |
| store | `PropsStore<H>` | 已声明 handle`useStore` selector hook + 移除 draft 的 `actions` |
| business | `I` | 从 `inject` factory 返回值推断 |
chain-kind slot 会反转键控路由:配置项自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其配置项,并成为组件的 `matched` prop全部返回 null 时则使用 owner 的 `renderSlotChain` fallback`ChainRenderOpts`)。
chain-kind slot 会反转键控路由:条目自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其条目,并成为组件的 `matched` prop全部返回 null 时则使用 owner 的 `renderSlotChain` fallback`ChainRenderOpts`)。
标准工具包接口(`SessionStandardProps``GlobalStandardProps`)在这里声明为空,由 runtime 包合并(与 SlotMap key 相同的 declare-merge 模式。renderer 会把运行时 Session 和 Workspace observable source 绑定为 selector hook。Inject factory 参数从声明派生(`InjectParams`Session slot 获得 `sessionId`;声明 store 时追加 baked `actions`;没有其他参数,数据访问位于 apply 闭包的 ctx 中。
store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source`getSnapshot``subscribe`),绝不携带 React hookhook 绑定属于渲染机制这一侧的 seam只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
`SlotCore` 在构造时播种先验的 `'root'` slot并强制执行加载时验证注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。配置项的 disposer 会递归折叠其声明的子 slot账本行、贡献和 store 挂载都沿同一生命周期轴消失`renderer.ts` 携带安装 seam`SlotRenderer``SlotRendererHost`)以及 `StaleAuthorizationError``SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
`SlotCore` 在构造时预置 `'root'` slot并强制执行加载时验证注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot账本行、贡献和 store 挂载都会随同一生命周期结束而移除`renderer.ts` 携带安装 seam`SlotRenderer``SlotRendererHost`)以及 `StaleAuthorizationError``SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
## 模型体验
@@ -31,5 +31,5 @@ store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 st
## 已知限制与暂缓事项
- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用配置项→记录反向引用改进。
- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用条目→记录反向引用改进。
- **`__renders` 幻象锚点在 `PropsRenderSlots` 上可见**:这是与类型链设计的 `__accepts` 相同且已接受的噪声;泛型方法签名在 key 联合之间比较宽松,因此必须依靠逆变标记强制执行「组件 key 集合 ⊆ children 声明」。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: 7a70add139eae7bc507469b4fe7170359efdec31
README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a
README.zh.md: 4ff79780fd33a47a0a45695ab9feda15cd1763cd

View File

@@ -4,15 +4,15 @@
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running``displayTitle` 包含 querypick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace``matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering中途引导、恢复已 dispose 的子会话)是未来的业务工作。
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering中途引导、恢复已 dispose(资源释放)的子会话)是未来的业务工作。
`/client` 导出表层只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
`/client` 导出内容只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
## 模型体验
### 用户提示词中的 subagent label 文本
#### 模型所见
#### 模型看到的内容
被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
@@ -26,6 +26,6 @@ subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source
## 已知限制与暂缓事项
- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话 steering发消息以及是否允许恢复已 dispose 的子会话),待台账中它自己的设计决策。
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现roster 只含 scope 所指会话的直接子会话不含孙辈,不含跨会话 agent
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可接受;消费功能必须绑定到会话 id。
- **`@` 消费语义尚未构建**:引用只是不具消费语义的纯文本;将其接入对指名子会话进行 steering消息的机制(以及是否允许恢复已 dispose 的子会话),仍有待台账中的专门设计决策。
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现roster 只含 scope 所指会话的直接子会话不含孙辈,不含跨会话 agent(智能体)。
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用仍是不具消费语义的纯文本时可接受;消费功能必须绑定到会话 id。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: a1ff7d840dae86f5da98de1208ecda3b8b62026b
README.zh.md: 49b52bcb1e07527e98c602086404228c5513091a
README.zh.md: 2e034f3173baabb3eea6b4ae670d5070c95480be

View File

@@ -6,9 +6,9 @@
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
滚动条重新绑定契约:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。抬升表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。
滚动条重新绑定契约:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。
两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性只要取非 `auto`Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性WebKit 系引擎走伪元素hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto`Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性WebKit 系引擎走伪元素hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
## 模型体验
@@ -16,9 +16,9 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。
- **token 样式表是颜色的唯一权威**不会追加 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);应使用最接近的语义 token裁定于 2026-07-22
- **token 样式表是颜色的唯一权威来源**会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token裁定于 2026-07-22

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697
README.zh.md: 056827d0110b4360791f1ae08c9bf6f62c00f049

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
轨迹轮次列表 chrome(吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
轨迹轮次列表界面框架(吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall(瀑布式事件)占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验
@@ -10,8 +10,8 @@
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
无;该包package既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **进行中Time 保持空白**`partial``runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式只在本地生效(未连接到聊天详情);锚点深链接仍暂缓实现。
- **进行中时,Time 保持空白**`partial``runningCalls` 行在实时钟策略落地前渲染为 `—`;选中样式仅在当前视图内部生效(未连接到聊天详情);锚点深链接仍暂缓实现。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/web-react/README.md
README.md: 7cc80f22bd5527838288d11c819e81b7ec4d17c4
README.zh.md: ec7143db9a1fe97145a07a0a1fde45c9268c2425
README.zh.md: 855417ff357b78b05fa54946c6cf84fafaa5180e

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
slot 终端设计的外壳侧 React 胶水createSlotRenderer外壳安装到运行时 SlotsService 的 SlotRenderer 实现、SessionProvider框架接线的 render prop也作为标准 seat 注入到声明会话 scope 子的配置项、bindSnapshotSelector唯一的 hook 构造器:主机与引擎只传递裸 observable source每个 hook 在此绑定,并按 source 缓存、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector只挂载被选中的配置项其 select 返回值以 `matched` 加入 props`renderSlotChain` binding `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于 runtimestore 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包。
slot 终端设计的外壳侧 React 胶水createSlotRenderer外壳安装到运行时 SlotsService 的 SlotRenderer 实现、SessionProvider框架接的 render prop也作为标准 seat 注入到声明会话 scope 子 slot 的配置项、bindSnapshotSelector唯一的钩子构造器:主机与引擎只传递裸 observable source每个钩子在此绑定,并按 source 缓存、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector只挂载被选中的配置项其 select 返回值以 `matched` 加入 props`renderSlotChain` 绑定`renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于运行时store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包package
## 模型体验
@@ -14,6 +14,6 @@ slot 终端设计的外壳侧 React 胶水createSlotRenderer外壳安装
## 已知限制与暂缓事项
- **persist 中间件会损坏原始值状态 store**:保存时它会对状态执行对象展开,因此 `SnapshotStore<string>` 往返后会变成字符 map;引擎改为自行实现持久化(见 `attachPersistence`)。
- **persist 中间件会损坏原始值状态 store**:保存时它会对状态执行对象展开,因此 `SnapshotStore<string>` 往返后会变成字符映射;引擎改为自行实现持久化(见 `attachPersistence`)。
- **`UseSession` 有意保持宽泛(`object` 快照)**依赖方向runtime → web-react绝不反向使真实 `ConversationSnapshot` 类型不可访问;会话 slot 消费方在其边界处缩窄一次。
- **renderSlot 是唯一的 P-I 形式**:没有 Suspense也没有逐配置项惰性加载渐进式渲染表层会随其独立项目回归
- **renderSlot 是唯一的 P-I 形式**:没有 Suspense也没有逐配置项惰性加载渐进式渲染能力将在其独立项目中恢复

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/client/web/README.md
README.md: a4325f5dbe8ddd9bbe77086eb16fdb7aed9adc83
README.zh.md: 9c1eab795145cf0a1b69528bc9822a26ed226023
README.zh.md: d4cb7a43da4e9c84a401ee0a1ac8c30d4816926e

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动web2挂载整个客户端。第一阶段模块表层):构建客户端模块系统(`@deepseek-ai/dsh-client-modules`),以主机推送的配置项图(`window.__DSH_BOOT__`)为基础,并行预`immediately` 层级;执行组合包只会注册 factory。第二阶段插件表层):挂载 vendored cordis Loader并把模块系统作为其 `internal` seam 注入;为每一行图数据创建一个 loader 配置项,另创建外壳自身的 app-shell 组装配置项tree.import 会物化各模块);以 settle 作为 AppRoot 的门禁loader 完全停稳 + 每个配置项 fiber 都为 ACTIVE → 一次切换显示完整 UI。组合完全由主机图决定花名册和 immediately 层级都位于负责组合的应用中;外壳不作任何组合决策。
Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动web2挂载整个客户端。第一阶段模块):构建客户端模块系统(`@deepseek-ai/dsh-client-modules`),以主机推送的配置项图(`window.__DSH_BOOT__`)为基础,并行预取 `immediately` 层级;执行组合包只会注册 factory。第二阶段插件):挂载仓库内置的 Cordis Loader并把模块系统作为其 `internal` seam 注入;为每一行图数据创建一个 loader 配置项,另创建外壳自身的 app-shell 组装配置项tree.import 会物化各模块);以 settle 作为 AppRoot 的门禁loader 完全停稳 + 每个配置项 fiber 都为 ACTIVE → 一次切换显示完整 UI。组合完全由主机图决定花名册和 immediately 层级都位于负责组合的应用中;外壳不作任何组合决策。
外壳自给自足web2 硬性规则):内核不对任何插件包执行值导入;启动状态 store 与信号在这里手写(`loader-status.ts`),因此插件失败时(尤其在失败时)加载页面仍能工作。app-shell 组装(`@deepseek-ai/dsh-client-app-shell`,由外壳拥有、背后没有 npm 包的伪配置项)是唯一通过 `registerStatic` 注册的模块;它与任何插件一样,通过 inject 等待 slots/sessions/layout。
外壳自给自足web2 硬性规则):内核不对任何插件包package执行值导入;启动状态 store 与信号在这里手写(`loader-status.ts`),因此即使插件失败,加载页面仍能工作,而此时这一点尤其重要。app-shell 组装(`@deepseek-ai/dsh-client-app-shell`,由外壳拥有、背后没有 npm 包的伪配置项)是唯一通过 `registerStatic` 注册的模块;它与任何插件一样,通过 inject 等待 slots/sessions/layout。
`PLATFORM_MODULES`src/platform.ts是共享模块表层的唯一真源种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
可选 `seams` 参数会转发模块系统的 `fetchBundle``executeBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于测试环境,因为此类环境中的 `<script>` 执行无法到达页面上下文jsdom
可选 `seams` 参数会转发模块系统的 `fetchBundle``executeBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于 `<script>` 执行无法到达页面上下文的测试环境jsdom
外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。
## 模型体验
无。配置项外壳负责启动浏览器插件树;这里没有任何内容进入模型请求。
无。入口外壳负责启动浏览器插件树;这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -22,5 +22,5 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动w
## 已知限制与暂缓事项
- **有意采用一次性渲染**UI 等待启动 settle只要一个配置项失败加载页面就会保留并高声逐项报告,不提供部分可用性(渐进式渲染会随其独立项目回归)。
- **有意采用一次性渲染**UI 等待启动 settle只要一个配置项失败加载页面就会保留并逐项显示醒目的报告,不提供部分可用性(渐进式渲染将作为独立项目恢复)。
- **窄窗口验收暂缓**ui-layout 已实现让步链,但外壳级窄视口演练是 P-II 验收项。