拆解 DSH-CCGUI 中

5,745 字 · 预计阅读 16 分钟

学会做插件以后,脑子里会冒出各种想法:

  • 在会话标题栏加一个按钮,点一下就跑测试。
  • 在右侧栏加一页,列出项目里所有的 TODO 注释。
  • 让左侧栏里的工作区能分组嵌套。

每个想法落到实处,都要先回答两个问题:

  • DSH 插件到底做不做得了?
  • 如果做得了,该照着谁的代码写?

dsh-ccgui 是个现成的参照物。它在 DSH 上动了十几处地方,插件能用上的办法,它几乎都用了一遍。

这一篇我按功能点把它过一遍。每一处改动我只讲清楚两件事:

  • 它是靠 DSH 的什么机制做到的;
  • 对应的代码写在哪个文件里。

读完这一篇,你能做到两件事:

  • 拿到一个新想法,能判断出插件做不做得了、该用哪种办法;
  • 能在 dsh-ccgui 源码里精准找到同类功能的代码,直接喂给 AI 当范本。

这一篇不用敲代码,但最好把 dsh-ccgui 的源码开在旁边对照着看。代码就在上一篇用过的 dsh-branch-chip/.reference/dsh-ccgui 文件夹里,用 VS Code 打开就行。


一、界面改动盘点

上一篇讲了 dsh-ccgui 最核心的一件事:把 Claude Code 这些 CLI 接成 DSH 的模型。那部分全在后台跑,界面上看不见。

界面上看得见的改动,主要有下面这些:

小黑把插件组件缝到现有界面的四个位置:左侧栏、标题栏、输入框和右侧栏

位置 加了什么
输入框下方 CLI 切换器;模型菜单
会话标题栏 启动脚本;终端按钮
左侧栏 每个会话前的 CLI 图标;会话列表的树形连线;项目右键「新建 Worktree…」和 Worktrees 分组;设置里的「CC GUI 设置」页
右侧栏 「变更」页;一展开就显示工作区文件;只显示图标的紧凑页签;文件右键「添加到对话」

源码一共 113 个文件,其中 70 个在前端界面这一半。文件看着多,其实归结起来只用了五种办法:

插件工位上的五种办法:插槽、界面服务、后台接口、登记服务,以及升级后容易失效的直接改界面


二、两个代码入口

读一个插件的源码,先抓它的两个入口文件。古法开发那一篇讲过:

  • 后台那一半从 src/index.ts 开始;
  • 界面那一半从 src/client/index.tsx 开始。

后台入口

先看它向 DSH 要了哪些服务。打开 src/index.ts,找到这一行:

typescript
  static inject = ['llm', 'agents', 'subprocess', 'tools']

dsh-ccgui 的后台入口写成了一个类,所以用 static inject。作用和前面写过的 export const inject 一样,用来列出“少了它我就没法干活”的核心服务:

服务 dsh-ccgui 用它干什么
llm 把每个 CLI 登记成一个模型
tools 登记那个只记录、不执行的 cli_tool
subprocess 启动 CLI,跑 git
agents 查出每个会话背后的智能体,好知道它选了哪个 CLI

还有几个服务没在这个列表里,属于“有就用,没有就算了”:

  • webServer 和 connection:给变更页开接口用;
  • approval:弹审批卡片用。

上一篇审方案时讲过“必须有”和“有就用”要分开。当时让 AI 照着写的范本,就是这个文件。

界面入口

打开 src/client/index.tsx,拉到文件末尾看 apply 的结尾:

typescript
  const settings = createFeatureSettings(BROWSER_STORAGE)
  registerSettingsPage(ctx, settings, cliPrefs)
  registerSwitchableFeatures(ctx, settings)
  registerAddToChat(ctx)
}

这里 registerSettingsPage 负责登记设置页,registerAddToChat 负责登记“添加到对话”。中间的 registerSwitchableFeatures 展开后,又依次调用了五个注册函数:

  • registerTerminalEntry
  • registerFilesDefault
  • registerChanges
  • registerWorktree
  • registerLaunchScripts

代码组织得很规整:一个功能对应一个 register 函数,各自放在独立的文件里。

你想看哪个功能,就在 src/client 里搜对应的 register 函数。用了哪些插槽、调了哪些服务,函数里都写着。

package.json 依赖声明

古法那一篇提过,package.json 里的 dsh.client.inject 用来声明“你要往哪块界面积木里放东西”。做分支查看时只声明了管输入框的那一块,而 dsh-ccgui 一口气声明了九块:

json
      "inject": [
        "@deepseek-ai/dsh-api-terminal-controller",
        "@deepseek-ai/dsh-client-ui-context-menu",
        "@deepseek-ai/dsh-client-ui-conversation",
        "@deepseek-ai/dsh-client-ui-layout",
        "@deepseek-ai/dsh-client-ui-model-selection",
        "@deepseek-ai/dsh-client-ui-session",
        "@deepseek-ai/dsh-client-ui-sidebar",
        "@deepseek-ai/dsh-client-ui-sidebar-right",
        "@deepseek-ai/dsh-client-ui-workspace"
      ]

看包名就能猜出大概用途:

  • context-menu 管右键菜单;
  • sidebar 管左侧栏;
  • sidebar-right 管右侧栏;
  • model-selection 管模型选择。

这份清单也是张地图。以后你的想法要动哪块界面,直接去读对应积木的文档就行。

目录结构

前后台代码在目录里是分开存放的,中间只留了一个共用文件:

后台和界面各放在工具箱的一格,只共用 constants.ts,界面代码不能越界导入后台文件

位置 放什么
src/index.ts 后台入口
src/cli-session.ts、src/session/ 一个 CLI 会话怎么跑,上一篇讲的 ACP 那一套
src/git/ 变更页和 Worktree 背后跑 git 的接口
src/client/ 界面那一半,所有看得见的东西
src/constants.ts 两半唯一共用的文件,只放几个名字

界面代码绝对不能导入后台文件,这在项目里是条硬规矩:src/client 下的代码,只允许导入 src/client 内部模块和 src/constants.ts。


三、插槽放组件

这是最常用的一种办法,之前做分支查看用的就是它。界面积木提前留好空位,你把写好的组件塞进去。

dsh-ccgui 用插槽做了下面这些界面:

加了什么 放进哪个插槽 代码在哪
CLI 切换器 conversation.input.left src/client/index.tsx
模型菜单 conversation.input.right src/client/model-menu.tsx
启动脚本 conversation.session.header.utilities src/client/launch-script-register.ts
终端按钮 conversation.session.header.utilities src/client/terminal-entry.tsx
会话前的 CLI 图标 sidebar.session.row.leading src/client/index.tsx
设置页 settings.section src/client/settings-register.ts

第一行的 conversation.input.left 就是分支查看器用的那个插槽,CLI 切换器和你的分支徽章刚好挨着。

会话标题栏上的启动脚本菜单和终端按钮,下面是终端里正在运行的 npm run dev

启动脚本和终端按钮塞进了同一个插槽。谁排左边,由 order 决定。

打开 src/client/terminal-entry.tsx,看终端按钮的登记代码:

typescript
const SLOT = 'conversation.session.header.utilities'
/** Just left of the header's overflow menu (order 0), right of the schedule chip (-5). */
const ENTRY_ORDER = -1
typescript
    child.slots.inject(SLOT, () =>
      child.slots.register(
        {
          name: SLOT,
          id: 'dsh-ccgui.terminal-entry',
          order: ENTRY_ORDER,
          inject: () => ({ sidebar: child.sidebarRight }),
        },
        TerminalEntry,
      ),
    )

先调 slots.inject 再调 register,套路和之前写过的一样,只是多了一个 order 字段。

order 相当于排队的号码,数字越小越靠前。源码注释里记了左右邻居的号码:

  • 标题栏最右边的 … 更多菜单是 0;
  • 定时任务标记是 -5;
  • 终端按钮设为 -1,刚好卡在 … 菜单左边;
  • 启动脚本设为 -2,紧挨在终端按钮左边,两个和终端相关的按钮就凑一块儿了。

插槽清单在哪

直接查官方的插槽清单,地址在上一篇已经写进了 AGENTS.md。我写这一篇时数过,清单里一共有 112 个插槽。

每个插槽在界面什么位置、会传给组件什么参数,清单里都列得很清楚。看名字前缀就能定位区域:

  • conversation. 在对话区;
  • sidebar. 在左侧栏;
  • sidebar.right. 在右侧栏;
  • settings. 在设置页。

四、调用界面服务

本文是会员内容

全文 5,745 字,已试读约 29%。开通专业版可阅读全文,并解锁全部官方课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟价 99 元/年(售完恢复365/年)

课程中涉及的第三方项目,其著作权与商标归各自权利人所有;引用处的署名与许可见《开源与来源说明》。