Merge remote-tracking branch 'origin/master' into trajectory-history-source
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 通道。
|
||||
浏览器侧订阅系统 SSE(Server-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 fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,9 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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: d283cf19572f4888d17884472ea0d2272109de7f
|
||||
README.zh.md: e7006a5ccbbc128b8d272199851057732b76ada3
|
||||
README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host 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 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host 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` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-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。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
@@ -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)所记录的问题。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: ddf2988799acf45357c3cc23a5517e59de59a60e
|
||||
README.zh.md: d3147745424afe76a5ae1ca09e5f1f0534741b06
|
||||
README.md: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
|
||||
README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
@@ -33,7 +35,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
|
||||
@@ -175,9 +175,23 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -20,8 +24,15 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = not expandable (leading slot never toggles). */
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
body: string | null
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -52,17 +63,25 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet.
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -137,9 +156,17 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the bash tool declares arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
*/
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.` and `..` segments so the prompt label names the directory the
|
||||
* command actually ran in. The bash executor resolves the workdir before
|
||||
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
|
||||
* are preserved as authored (a Windows path keeps its backslashes) because this
|
||||
* value is only ever displayed; a `..` that would climb past the root is
|
||||
* dropped, which is what a filesystem does with it. A UNC path's `server` and
|
||||
* `share` are part of its root, not poppable segments: Windows cannot climb
|
||||
* above a share, so `\\\\server\\share` with a `..` stays there.
|
||||
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
|
||||
* @returns the same path with those segments resolved.
|
||||
*/
|
||||
function normalizeSegments(path: string): string {
|
||||
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
|
||||
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
|
||||
// so they are split off here and neither is a segment `..` may pop. Its
|
||||
// separator is fixed to a backslash, since a joined relative part may have
|
||||
// introduced a forward slash that UNC syntax does not use.
|
||||
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
|
||||
if (unc !== null) {
|
||||
// Both groups are mandatory in the pattern, so destructuring types them as
|
||||
// strings without an assertion.
|
||||
const [matched, server, share] = unc
|
||||
const root = `\\\\${String(server)}\\${String(share)}`
|
||||
// Rooted: what follows the share hangs off it, so a `..` at the top is
|
||||
// dropped rather than kept — Windows cannot climb above a share.
|
||||
const rest = collapse(path.slice(matched.length), true)
|
||||
return rest === '' ? root : `${root}\\${rest}`
|
||||
}
|
||||
const backslashed = path.includes('\\') && !path.includes('/')
|
||||
const separator = backslashed ? '\\' : '/'
|
||||
const rooted = /^[/\\]/.test(path)
|
||||
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
|
||||
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
|
||||
const leading = rooted ? separator : ''
|
||||
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the `.`/`..` segments of a path body against a known root state.
|
||||
* @param body - the path after any drive letter or UNC root.
|
||||
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
|
||||
* the way a filesystem drops one; without a root the `..` is kept, since it
|
||||
* stays meaningful against a cwd this function cannot see.
|
||||
* @param separator - separator to rejoin with (default `/`).
|
||||
* @returns the collapsed body, without leading or trailing separators.
|
||||
*/
|
||||
function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
const kept: string[] = []
|
||||
for (const segment of body.split(/[/\\]/)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
|
||||
else if (!rooted) kept.push(segment)
|
||||
continue
|
||||
}
|
||||
kept.push(segment)
|
||||
}
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
description: call?.description,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -92,3 +92,17 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The terminal card sits directly under its section label, so it drops the
|
||||
primitive's standalone vertical margin; the section owns the spacing. */
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,59 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
/**
|
||||
* Selected call material: the call's display name and args plus the frozen
|
||||
* block slice it came from. `block` is a snapshot-cached reference, so the
|
||||
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
||||
* running split is read off it with the `'kind' in block` discrimination
|
||||
* instead of duplicated as flags.
|
||||
*/
|
||||
interface CallMaterial {
|
||||
name: string
|
||||
argsRaw: string | null
|
||||
result: ToolResultNode | null
|
||||
running: boolean
|
||||
block: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
||||
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
||||
}
|
||||
|
||||
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
||||
function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
|
||||
}
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
{/* Keyed by the selected call: the body owns per-call view
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -112,6 +123,41 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} className={css.terminal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control and is not a details-panel target either (tool rows stopped being
|
||||
// one), so its terminal body is resident rather than expand-gated as in
|
||||
// ToolRow, and the card's own copy and expand controls are the row's only
|
||||
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
||||
// flow's tighter cap over the block's own default of 16 — and the block's
|
||||
// internal expander keeps a long output from taking over the message flow.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
@@ -29,24 +39,40 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; it outranks the args-derived one. */}
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -165,6 +170,22 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。
|
||||
切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择纳入会话创建的通道(host 的 `targetFor` 中的种子顺序说明了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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-primitives/README.md
|
||||
README.md: 99d5f8b238ce78d83de0b5247d194b308dd6fac0
|
||||
README.zh.md: 468aa75e88580d6ed5e78666ad34c18b22a85dd3
|
||||
README.md: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
|
||||
README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), and the read-only JsonTree inspector. Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, and 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. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. 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.
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及只读 JsonTree 检查器。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器,以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 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 +24,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。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"anser": "^2.3.5",
|
||||
"clsx": "^2.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
|
||||
@@ -12,7 +12,9 @@ import css from './Pill.module.css'
|
||||
*/
|
||||
export function Pill({ active = false, className, children, onClick, ...rest }: {
|
||||
active?: boolean
|
||||
className?: string
|
||||
// `| undefined` so a caller can forward an optional class straight through
|
||||
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
|
||||
className?: string | undefined
|
||||
children?: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
if (!onClick) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
152
packages/client/ui-primitives/src/TerminalBlock.module.css
Normal file
152
packages/client/ui-primitives/src/TerminalBlock.module.css
Normal 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);
|
||||
}
|
||||
237
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal file
237
packages/client/ui-primitives/src/TerminalBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
447
packages/client/ui-primitives/src/ansi.ts
Normal file
447
packages/client/ui-primitives/src/ansi.ts
Normal 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
|
||||
}
|
||||
48
packages/client/ui-primitives/src/clipboard.ts
Normal file
48
packages/client/ui-primitives/src/clipboard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package-internal clipboard write, shared by every copy control in this
|
||||
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
|
||||
// public surface: consumers get the components, not the host detection.
|
||||
|
||||
/**
|
||||
* Write text to the host clipboard, preferring the async Clipboard API and
|
||||
* falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
|
||||
* that omit it.
|
||||
* @param text - the exact text to place on the clipboard.
|
||||
* @returns true only when the host accepted the write.
|
||||
*/
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Denied permissions / iframe policy — do not claim success.
|
||||
return false
|
||||
}
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing; deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return false
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
return exec('copy')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
el.remove()
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from '../clipboard.ts'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
|
||||
@@ -18,45 +19,6 @@ export interface CodeBlockProps {
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** @returns true only when the host accepted the write. */
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Denied permissions / iframe policy — do not claim success.
|
||||
return false
|
||||
}
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing; deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return false
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
return exec('copy')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
el.remove()
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
|
||||
513
packages/client/ui-primitives/tests/ansi.spec.ts
Normal file
513
packages/client/ui-primitives/tests/ansi.spec.ts
Normal 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 },
|
||||
]])
|
||||
})
|
||||
})
|
||||
430
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal file
430
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器半侧:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话恒为 agent-backed,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 出生的 `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-flight;scope 创建时的 `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 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
@@ -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 是台账事项)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,零 React/DOM/cordis;`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 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。
|
||||
- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill(技能)/subagent 时可以接受,业务 source 加入后需重新审视。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 hook;hook 绑定属于渲染机制这一侧的 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 声明」。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../../.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)。
|
||||
|
||||
@@ -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-trajectory/README.md
|
||||
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
|
||||
README.zh.md: e7544499065893142025f8138a89177b25fbdb99
|
||||
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
|
||||
|
||||
@@ -10,8 +10,8 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **进行中的 Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
- **进行中时,Time 保持空白**:`partial`/`runningCalls` 行会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 位于 runtime(store 已迁移);业务插件只依赖 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,也没有逐配置项惰性加载;渐进式渲染能力将在其独立项目中恢复。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 验收项。
|
||||
|
||||
Reference in New Issue
Block a user