# CLAUDE.md — 开发手册 > 给接手的开发者/agent 的完整指南。阅读顺序:**这是什么** → **架构** → **关键机制** → **状态矩阵** → **构建与热重载** → **常见陷阱** → **文件地图**。 ## 1. 这是什么 `dsh-shell-card-plus` 是 DSH Web(DeepSeek Harness)的**客户端插件**,用 React 渲染的完全自定义卡片替换官方 bash/pwsh 工具行。 它**不是** host 插件——不注入 CSS、不改 React DOM,而是通过官方 **Conversation 的 toolview 插槽机制**注册一个 React 组件,由 React 自己渲染。 **一句话**:注册 `tool.call.toolview` 的 `bash`/`pwsh` 两个 key,替换官方原子工具行。 --- ## 2. 架构 ``` src/ ├── index.ts # host 半边 stub(空 apply,仅让包被 Loader 激活) └── client/ # client 半边(浏览器 React) ├── index.tsx # 插件入口:注册 bash/pwsh 的 toolview slot ├── ToolCallRow.tsx # 分派器:终端意图 → ShellToolRow(折叠行 + 展开 │ # ShellCard);非终端意图 → NativeToolRow ├── NativeToolRow.tsx # 原生通用行(官方 ToolRow 外观):摘要 + 可展开 IN/OUT ├── native-row-model.ts # 原生行模型(官方 tool-call-model.ts 内联移植) ├── ToolCallRow.module.css # 折叠行 + 原生行 IN/OUT 卡片 + 运行扫光 ├── ShellCard.tsx # 展开后卡片:head(状态/cwd/分割按钮)+ 命令区 + 输出区 ├── ShellCard.module.css ├── terminal-card-model.ts # 状态矩阵派生 + 复制工具 + 标签 + cwd 解析 ├── icons.tsx # 图标 re-export(从 assets/*.svg 自动生成) └── assets/ ├── copy_command.svg # 复制命令图标(入库,构建时自动转组件) └── copy_output.svg # 复制输出图标(入库,构建时自动转组件) ``` ### 运行时装配(为什么这么接) 1. `package.json` 的 `dsh.bundle.patch` 指向本包 `cordis.patch.yml`,`dsh plugin add` 时被自动加进 profile 的 `dsh.profile.bundles`,包内 patch(`- insert: - id: shell-card-plus / name: dsh-shell-card-plus`)随之自动应用——**安装即生效,无手动配置**。 2. Loader 加载 host stub(`src/index.ts`,`apply` 为空)——**它存在的唯一意义**是让包在 Loader 里成为 entry。 3. `dsh-client-modules` 扫描 loader entries,读到 `package.json` 的 `dsh.client` 声明(`platform: 'web'` + `exports["./client"]`),把 `lib/client.js` 编进 `window.__DSH_BOOT__`。 4. 浏览器加载 `lib/client.js`,`__ModuleLoader__.load({ id: "dsh-shell-card-plus", factory })` 注册 bundle。 5. bundle 的 `apply()` 调用 `ctx.slots.inject('tool.call.toolview', ...)`,注册 `bash`/`pwsh` 两个 key 的渲染器。 6. 官方 `ToolCallTree` 渲染每个 tool call 时,按 `toolName` 分派到 keyed slot——命中我们的 key → 渲染 `ToolCallRow`。 ### 为什么用 toolview 而不是 Conversation Node **不要**注册独立的 `conversationEvents` Conversation Node。如果注册了,它会和官方 `tool-call` node 同时匹配同一个 `tool/call` 事件,导致**两张卡片**同时渲染。官方工具行已经在 `tool-call` node 里渲染,我们只需要替换**行内视图**,所以走 `tool.call.toolview` keyed slot。 --- ## 3. 关键机制 ### 3.1 toolview keyed slot 与 priority 阴影 官方 `ToolCallTree`(`dsh-client-ui-tool` 包内)的分派: ```tsx renderSlot('tool.call.toolview', owner, { entryKey: toolName, fallback: GenericToolCard }) ``` - 官方在**同一包内**已注册 `key: 'bash'`(组件 `BashRow`,未设 priority,默认 0)。 - 我们注册 `key: 'bash'` + `priority: -1`——slot 是**最低 priority 渲染**,`-1 < 0`,所以我们替换官方。 - **必须** `priority: -1`,否则同 key 同 priority 会抛错(`Failed to load plugins ... already has an entry for key "bash"`)。 ### 3.2 数据来源(ToolCallBlock) 数据完全来自 `tool/call` + `tool/result` 事件,**不需要 host 端发任何新事件**: - `callView`(`card: 'terminal'`):command(`title`)、cwd、description —— 运行中也有 - `resultView`(`card: 'terminal'`):output、exitCode、signal —— 结束后才有 契约见官方 `@deepseek-ai/dsh-tools/src/presentation.ts` 的 `TerminalCallView`/`TerminalResultView`。 ### 3.3 __DEV__ / prod 分离 `build.mjs` 用 esbuild `define` 把 `__DEV__` 替换为 `true`(dev)或 `false`(prod): - dev(`npm run dev` / `node scripts/build.mjs --watch`):`__DEV__ = true`,卡片顶部显示 `HOT-7 | ToolCallRow` 标签(改 `HOT` 数字验证热重载)。 - prod(`npm run build`):`__DEV__ = false`,死代码被 esbuild 消除,HOT 标签完全不在产物里。 **用户永远跑 prod 构建**。开发时用 dev。 --- ## 4. 状态矩阵 `terminal-card-model.ts` 的 `terminalCardModel(block, sessionCwd)` 派生 7 种状态: | 状态 kind | 触发条件 | 折叠行图标 | head 可见异常信息 | |---|---|---|---| | `running` | 无 `kind` 字段(只有 callView) | 普通图标 + 动画 | 无(状态文字仅读屏) | | `done` | resultView 存在,exitCode 0,无 signal | 普通图标 | 无(状态文字仅读屏) | | `failed` | resultView.exitCode ≠ 0 | 红点 | 红色 `退出码 N` | | `signaled` | resultView.signal 存在 | 红点 | 红色 `信号 SIGxxx` | | `interrupted` | block.isError && error.code === 'interrupted'(合成块) | 黄点 | 无(状态文字仅读屏) | | `error` | block.error 存在(工具调用本身出错) | 红点 | 红色 `错误: code` | | ~~`non-terminal`~~ | resultView.card 不是 'terminal' | — | —(见下方「非终端意图 = 原生回退」) | ### 非终端意图 = 原生回退 `non-terminal` 是**保留状态**,当前不作为 `terminalCardModel()` 的返回值:当 `callView` / `resultView` 都不是 `card: 'terminal'` 时,函数返回 **`null`** (沿用官方的 null 逃生出口),`ToolCallRow` 改渲染 `NativeToolRow` —— 与卸载 本插件时官方 `GenericToolCard` 的表现一致(折叠行 `Title · 摘要` + 可展开 IN/OUT + Inspect + 运行扫光)。 覆盖三种调用: 1. `bash`/`pwsh` 的后台 job ack(`run_in_background: true`)—— host 端 `presentCall`/`presentResult` 都返回 `card: 'generic'`(后台确认不带终端 exit status); 2. 前台执行报错 —— `presentResult` 对 `isError` 返回 `card: 'generic'`; 3. window 截断丢了 call head 的 settled 结果。 **预留开关**:`terminal-card-model.ts` 的 `RENDER_NON_TERMINAL_AS_CARD` (默认 `false` = 原生回退)。置 `true` 后这些调用改由我们的 `ShellCard` 渲染: 派生逻辑在 `nonTerminalModel()`(已从 args 取 command / cwd / description), 接线点在 `customNonTerminalModel()`。后续要做后台 job 的自定义显示只改这两处, `ToolCallRow` 与 `ShellCard` 的接线不动。 **每个状态承载的数据**: - command:始终有(callView.title,或 resultView.title 替换) - cwd:`resolveTerminalCwd`(相对路径拼 sessionCwd,`.`/`..` 折叠),显示时只取**最后一段**(子目录名,官方 `promptLabel` 逻辑) - output:`running`/`interrupted` 无;其他状态可能无(空输出) - 复制命令:command 非空即可用 - 复制输出:output 非空才可用 **注意**:`failed` 状态的 `block.isError` 是 `false`(bash/pwsh 把非零退出码当正常结果返回),所以不能靠 `isError` 判断失败——要查 `resultView.exitCode`/`signal`。 --- ## 5. 构建与热重载 ### 5.1 独立构建 本包**完全独立**,不依赖 dsh 单仓库。构建脚本 `scripts/build.mjs`: - esbuild 打包 `src/client/index.tsx` → `lib/client.js` - `banner`/`footer` 做 `__ModuleLoader__.load` 包装(**必须**,否则插件不注册) - externals(运行时从模块表 require,不打包):`react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-primitives`、`@deepseek-ai/dsh-client-runtime/client` - `x.module.css` 经 lightningcss 编译为 hashed class map,注入 `