<p align="center">
  <a href="https://dshfind.com/zh/plugins/huanlinoto/dsh-plugin-merge-tool-calls"><img src="https://dshfind.com/api/card/huanlinoto/dsh-plugin-merge-tool-calls?lang=zh" alt="dsh-plugin-merge-tool-calls card"></a>
</p>

# dsh-plugin-merge-tool-calls

把 WebUI 会话流中**连续相邻**的同工具调用合并为一个「主卡片 + 紧凑子行」的树状展示，减少连续读文件/搜索/编辑/执行命令时多条卡片对流的占据。默认覆盖所有内置通用行工具（`read`/`grep`/`glob`/`edit`/`write`/`bash`/`pwsh`/`web_search`/`web_fetch`/`run_code`），也可配置白名单。

```
合并前：                         合并后：
[Read] foo ▸ 内容                 [Read] · 3 Files  ▸（点击展开子行）
[Read] bar ▸ 内容                   └ foo（子行，点击展开 foo 的内容）
[Read] baz ▸ 内容                   └ bar（子行，点击展开 bar 的内容）
                                    └ baz（子行，点击展开 baz 的内容）
```

主行只显示数量摘要（`Read · 3 Files`），具体文件路径全部下沉到子行；子行默认折叠，点击主行以动画展开/收起。单次调用（无合并）保持原行为（主行直接显示路径并展开内容）。

纯展示层合并：会话日志、模型可见数据、详情面板均不受影响；回放确定（合并结果是聊天快照的纯函数）。

## 工作原理

- 通过 keyed slot `tool.call.toolview` 的 shadow 机制（`priority: -1`，最低者渲染）接管工具的卡片；`tools: []` 时接管全部内置通用行工具（见 `src/client/tool-names.ts`），非空列表为显式白名单（任意 wire 名均可）。
- 组件经 `useChat` 读取聊天快照，在 order + node store 中按 call id 定位自身节点，前后扫描连续调用，按 `maxGroupSize` 分组。一组只合并**同名或同家族**调用：同名必合；已知家族（`grep`+`glob`、`bash`+`pwsh`、`read`+`web_fetch`）跨名合；未知工具名（variant `others`）仅与自身合，不相关的工具不会混进同一张卡。
- 组首座位渲染合并卡片；组内其余座位渲染 `null`，由注入的样式规则
  `[data-chat-flow-kind="tool-call"]:has([data-slot="tool.call.toolview"]:empty) { display: none; }`
  将其从流中收起（与内置 `.flowItem:empty` 语义一致；渲染器为每个 toolview 包一层
  `data-slot` 容器，故以该容器判空）。
- 主卡片镜像内置 ToolRow 的行面：按 variant 渲染标题/图标（Read/Search/Bash/Write/Edit/Code/`Tool call`，工具自定义标题如 `Pwsh`/`Inspect` 优先）。**合并组的主行摘要改为数量**（`Read · 5 Files` / `Search · 3 Queries` / `Bash · 4 Commands` 等，按 variant 选名词），不再显示首个调用的路径；单次调用（无合并）仍显示 args 摘要（读→路径、搜索→query、bash→description/command、未知工具带 `工具名 ·` 前缀），`read`/`write`/`edit` 单行的摘要是可点击文件链接。行状态、错误首行、bash 失败退出码转红点均与内置一致。
- 子行紧凑（20px 高），结构为主行的尾部（`sep 点 + 路径`）。合并组内**所有调用（含首个）**均渲染为子行，所以每个文件路径都在子行上。对齐**运行时自动测量**：组件挂载后量取主行 sep 点相对卡片的偏移（并用 ResizeObserver 跟随字体/布局变化），以 `--mtc-sep-left` 自定义属性设置子行缩进——sep 点与主行 sep 点同列、路径与主行摘要同列，不依赖任何手工字体宽度常量，任意工具名均自动对齐。展开的子行卡片以负向 `calc` 抵消该缩进，左边缘与主卡片内容区对齐（占满卡片宽度，左侧不留白）。无可展开内容（如运行中的 read）的子行渲染为静态行。
- 子行**默认折叠**：主行点击展开时，子行块以 `grid-template-rows: 0fr → 1fr` + 透明度过渡动画滑出（200ms），收起时反向滑回；折叠期间子行仍在 DOM 中（grid 0fr + `overflow: hidden` 收起，不卸载），所以动画平滑无闪烁。每个子行仍可独立点击展开其内容卡片。

## 配置

`cordis.patch.yml` 中的插件行 config：

| 字段 | 默认 | 说明 |
|------|------|------|
| `tools` | `[]`（全部） | 空数组 = 合并所有内置通用行工具（read/grep/glob/edit/write/bash/pwsh/web_search/web_fetch/run_code）；非空数组 = 显式白名单（任意 wire 名，如 `['read','todo_write']`） |
| `groupBy` | `adjacent` | `adjacent`：流中相邻即可合并；`step`：仅同 agent step |
| `maxGroupSize` | `8` | 每组最多合并数，超出部分自动另起新组 |

## 开发

前置：本机有 DSH checkout（`../dsh`，只读，仅类型引用）。

```sh
pnpm install            # 安装 registry 依赖（react/vitest/tsdown/schemastery…）
pnpm run typecheck      # tsc --noEmit；@deepseek-ai/* 类型来自本机 DSH checkout 的 node_modules junction
pnpm test               # vitest：纯逻辑 + jsdom 组件 + 注册形态
pnpm run build          # tsdown + tsc → lib/index.js、lib/client.js、lib/types/
```

注意：alpha 版 DSH 未发 npm，`@deepseek-ai/*` peer 类型在开发期经 `node_modules/@deepseek-ai/*`
junction 指向本机 DSH checkout（`../dsh` 的 `vendor/cordis` 与 `packages/**`）；`@deepseek-ai/*`
在 client half 全部为 type-only import，测试期仅 `dsh-client-ui-primitives` 经
`vitest.config.ts` 的 alias 指向 `tests/stubs/` 测试替身，组件测试无需宿主包。
Config 的 schemastery schema 使用 `@deepseek-ai/schemastery`（与 DSH 仓库同款，支持
`z.infer`）；host bundle 内联 schemastery（与范本 yet-another-subagent 一致），
profile 无需额外解析。

## 运行（挂载到 profile）

开发热更新（本地 clone，改源码重建 `lib/` 即生效）：

```sh
dsh plugin --profile web add link:D:\Projects\deepseek-harness\dsh-plugin-merge-tool-calls
```

分发安装（二选一）：

```sh
dsh plugin --profile web add "github:huanlinoto/dsh-plugin-merge-tool-calls"   # 源码分发
dsh plugin --profile web add "@huanlin/dsh-plugin-merge-tool-calls"     # npm registry 分发
```

然后由人类重启 `dsh web` 进程并硬刷新浏览器（`Ctrl+Shift+R`）。

## 检查

- `pnpm run typecheck && pnpm test && pnpm run build` 全绿；
- `git -C <dsh checkout> status` 干净（零源码 patch）；
- 浏览器验证：连续 3 个 `read` 主行显示 `Read · 3 Files`，点击主行以动画展开 3 个 `└` 子行（foo/bar/baz 各一行），再点击子行展开内容；
  `grep`/`glob` 同理；`write`/`edit` 子行展开显示 diff 卡片、`bash`/`pwsh` 显示终端卡片、
  路径子行可点击在侧边栏预览；中间隔了其他节点的调用保持单卡片。

## 边界行为

- 组内运行中（running）调用：主行显示数量摘要（如 `Read · 3 Files`），子行折叠时不可见，展开后各子行显示对应调用的 args 摘要路径；结果到达后原地更新。
- error / interrupted 调用按内置语义着色（错误首行 / 警告状态点）；bash 非零退出码按内置语义转为红色错误点。主行错误时摘要回退为错误首行（不显示数量）。
- `read`/`write`/`edit` 家族的子行摘要是可点击文件链接（与内置行为一致，打开侧边栏预览）；grep/glob 的 `path` 参数是搜索目录而非文件，摘要保持纯文本，不会误开目录。单次调用（无合并）的主行摘要是文件链接。
- 组被任何其他节点打断即断开；超过 `maxGroupSize` 的部分另起新组（不丢调用）。
- 非聊天节点场景（如被 dispatch 为子调用）回退为普通单行，绝不空白。
- 带自定义行卡片的工具（`skill`、只读 Cordis 检查 `cordis_inspect_list`/`cordis_inspect_query`/`cordis_inspect_self` 的 details 行，以及摘要格式特殊的 `todo_write`/`ask_user_question`）**不**默认接管，保持内置行；如需合并，用 `tools` 白名单显式加入（合并行按通用行面渲染）。<br>注：dsh 0.1.7-rc.1 起 `tool-cordis` 收窄为只读，动态 `cordis_define`/`cordis_run`/`cordis_stop`/`cordis_undefine` 与 `cordis_package_inspect`/`cordis_runtime_inspect` 已不存在。
- 准备阶段（`PreparingToolCall`，参数尚未流出）：渲染无参数轻量行（variant 标题/图标 + 可选原始参数前缀），不读取 `argsRaw`。
- 需要浏览器支持 `:has()`（Chrome 105+ / Safari 15.4+ / Firefox 121+）；不支持时仅退化为空行间距。

> v0.3.1（适配 DSH v0.1.2-rc.1）：按新版 invariant 规则不再发布空样板 `./invariant` 导出（本插件无独立可分歧观察，故不发布 invariant，见 AGENTS.md 新规则）。
