CodeWhale VSCode 是一个 VSCode 扩展,为 CodeWhale TUI 提供图形界面。该扩展通过 WebView 与用户交互,连接到 CodeWhale TUI 引擎进行 AI 对话。
- Node.js 18+
- npm 或 yarn
- VSCode 或兼容的 IDE(如 Trae CN)
npm installnpm run compile- 使用 webpack 编译开发版本
- 输出文件:
dist/extension.js - 包含 source map,便于调试
npm run package- 使用 webpack 编译生产版本
- 代码压缩优化
- 输出文件更小(约 168KB)
npm test- 使用 vitest 运行单元测试
- 测试文件位于
src/**/*.test.ts;chat-provider的 25 个测试文件在src/chat-provider/下按领域分组(threads / turns / transcript / composer / settings / integrations),规则见该目录的README.md
npx @vscode/vsce package --no-dependencies- 生成
brotherwhale-vscode-0.1.0.vsix文件 - 包含所有必要的源文件和资源
- 文件大小约 180KB
code --install-extension ./brotherwhale-vscode-0.1.0.vsix --force使用 Trae CN 时如果
code不可用,先设置 alias:alias code="/Applications/Trae CN.app/Contents/Resources/app/bin/code"
npm run compile && \
npx @vscode/vsce package --no-dependencies && \
code --install-extension ./brotherwhale-vscode-0.1.0.vsix --force安装完成后,重新加载窗口:
- 按
Cmd+Shift+P打开命令面板 - 输入 "Reload Window" 并执行
DeepSeek-GUI/
├── src/
│ ├── extension.ts # 扩展入口点
│ ├── chat-provider.ts # 主聊天界面逻辑(业务编排)
│ ├── chat-provider/ # ↑ 拆出的 mcp/skills/fleet 模块 + 全部测试(见目录内 README.md)
│ ├── config-panel.ts # 配置面板
│ ├── i18n.ts # 国际化支持(en/zh/tr 三语映射)
│ ├── types.ts # 共享类型定义
│ ├── api/ # api-client.ts(Runtime API 客户端)、engine.ts(TUI 引擎管理)
│ ├── commands/ # slash-commands.ts(命令注册表)、slash-command-handler.ts(dispatcher)
│ ├── utils/ # cost-calculator、diff-utils、error-handler、session-state 等
│ └── webview/ # webview-html.ts(HTML 模板)、webview-css.ts、webview-js-*.ts(按域拆分的脚本模块)
├── dist/ # 编译输出
├── media/ # 图标等资源
├── package.json # 扩展配置
└── webpack.config.js # Webpack 配置
注意:webview 前端脚本已按域拆分到
src/webview/webview-js-*.ts(fleet / goal / input / messages / sidebar / tooltip / utilities / debug / event-handler 等),不再是单个巨型内联块;但「一损俱损」的教训仍然适用(见下文「编码注意的坑」)。
chat-provider.ts管理与 WebView 的双向通信- 消息类型:
sendMessage,slashCommand,loadThread,interrupt等 - 使用
postMessageAPI 发送消息
src/commands/slash-commands.ts定义命令注册表(COMMANDS数组 + availability 标记)src/commands/slash-command-handler.ts用 dispatcher Map(HANDLERS)分发命令逻辑,不再是巨型 switch- 命令格式:
command+args(例如:/task+show task_id) - 标记为
unavailable的命令必须有兜底提示;注意 HANDLERS 与 slash-commands.ts 的 COMMANDS 数组要保持同步
- 任务列表显示在侧边栏的 "Tasks" 标签页
- 支持创建、查看、取消任务
- 点击任务卡片触发
/task show <id>命令
- 侧边栏三个并列标签页:Sessions(默认激活)、Threads、Activity;没有设置可以隐藏其中任何一个
- Activity 标签内汇集 Work、Changes、Fleet、Tasks、Agents 区块(各自仍可折叠),区块的折叠状态也会跨窗口记住:写进
localStorage的codewhale:collapsedSections(只存折叠的 key,理由同下面的显示/隐藏:以后新增的区块默认展开),与显示/隐藏各用一个 key,两个选择不会互相覆盖。区块表头的折叠绑定按ACTIVITY_SECTION_KEYS名单(document.getElementById(key + '-section-toggle'),折叠的是#sidebar-' + key),不是按.sidebar-section-header类——能隐藏的区块和折叠会被记住的区块是同一份名单,加一个区块只需要在那一个地方登记;改名前那条按类的绑定会让名单外的表头“能折叠但不被记住”,那是个更难解释的状态。 - Activity 区块的显示/隐藏由用户决定:提示行右侧的 ⚙(
#activity-sections-toggle)展开勾选列表(#activity-sections-picker),取消勾选即隐藏,选择写进localStorage的codewhale:activitySections(只存隐藏的 key,这样以后新增的区块默认可见)。隐藏 ≠ 折叠:折叠保留表头,那是用户重新展开的点击目标;隐藏则什么都不留,所以勾选列表必须列出全部区块(含已隐藏的),并且它自己不能被区块的隐藏一并藏掉(它在提示行里,不在区块内)。全部隐藏时#activity-sections-empty给出提示。 - Activity 区块的高度是共享的:
#tab-activity是纵向 flex 容器,打开着的区块flex: 1 1 auto,随其他区块折叠/隐藏而涨、随别人需要空间而缩,内容超出自己那份就在区块内滚动。折叠用的是网格轨道(grid-template-rows: auto 1fr↔auto 0fr)而不是max-height:一份“份额”要布局完才知道,而max-height要么写死上限(正是它让区块无法共享空间的原因)、要么是none(无法过渡)。min-height: 5.5em是保底(表头 + 两行左右);份额不够时滚整个 tab(#tab-activity自带overflow-y: auto),而不是把某个区块挤没。 - 踩过的坑:
#tab-activity > .sidebar-section的display: grid是 id 选择器(具体度 1,1,0),会赢过普通的.sidebar-section.hidden(0,2,0)。所以隐藏规则必须写成.sidebar-section.hidden, #tab-activity > .sidebar-section.hidden,否则被隐藏的区块依然display: grid留在屏幕上(已用无头 Chrome 实测确认过这一点)。 - Threads 面板与对话并排(
#threads-panel是#layout里的普通 flex 子项,靠.open显示),从对话区让出宽度而不是盖住它;不要改回绝对定位的覆盖写法 - 面板宽度是请求,不是对整行的占用:
#threads-panel的min-width/max-width、#sidebar-resize-handle的宽度、以及对话区至少保留的列宽都从webview-css.ts导出的SIDEBAR_PANEL_MIN_WIDTH/SIDEBAR_PANEL_MAX_WIDTH/SIDEBAR_MIN_CHAT_WIDTH/SIDEBAR_HANDLE_WIDTH生成,webview-html.ts的拖拽脚本读同一组常量。关键那条是max-width: min(600px, calc(100% - <对话保底 + 把手宽度>px)):codewhale:sidebarWidth这个 store 跨工作区共享,在宽窗口里拖出来的宽度会被新项目继承,而旧写法只有 600px 这个固定上限 —— 它在新窗口里会占满整行,对话区被压成 0 宽、把手贴在屏幕边缘(#chat-area的overflow: hidden让它的自动最小尺寸为 0,所以 flex 完全允许它消失)。改成%/calc后宽度在每次布局变化时重新解析,把侧边栏拖窄就会把面板推回去,不需要任何 resize 监听;恢复的是当初选的宽度而不是当时画出来的宽度,所以视图变宽时面板会自己长回去。面板自己的保底优先于对话区保底(min-width赢max-width):窄到两者放不下时共享剩余空间,那已经窄到放不下分栏。拖拽结束时写回屏幕上画出来的宽度(style与 store 一起),不只是存储 —— 拖到被max-width截住时拖拽的算术还在往上加,只存这个请求会让下次开窗比用户离开时更宽;画出来的宽度是 0(Esc 在拖拽中途关掉面板)就什么都不写,否则会把用户的选择换成 0、下次退回默认宽度。 - 对话区工具栏是
flex-wrap: wrap:这条一行需要 ~480px(无头 Chrome 实测),窄了会把 mode / permission / agent chip 排到视口外,看不见也点不到;#settings-bar本来就是 wrap 的。 - 打开后保持打开状态,除非用户明确关闭(✕ 按钮或
Esc)。这个状态跨窗口记住:写进localStorage的codewhale:sidebarOpen(和宽度codewhale:sidebarWidth、区块选择codewhale:activitySections同一个 store),开窗时在首屏前恢复,所以留着的侧边栏不会先闪一下关闭态。开关面板**(改选择的那一步)**只有setThreadsPanelOpen一个入口——📋 切换、✕、Esc都走它(它们以前各自classList.remove('open'),那正是状态会和存储脱节的地方),由它统一写盘;首屏的applyStoredSidebarOpen()只读不写(把那一次恢复画出来,不发任何消息)。只认'true'/'false'两个值,别的(手改过的 store、别的构建写的形状)当作「没有选择」,保留面板自己的默认(关闭) - 点击线程项不会自动关闭侧边栏
- Skills 面板(Activity 的
skills区块)按作用域分组:项目(SKILL.md 落在引擎扫描的那个 workspace 里)、全局(其余位置),再加引擎自己的 Built-in 一组。Built-in 单独成组是因为它住在全局目录里,混进去会让「全局」变成「除了项目以外的一切」而不是「你全局装的那些」。作用域是客户端从引擎给的path推出来的(utils/skill-scope.ts的classifySkillScope,refreshSkillList逐条打上scope)——引擎的/v1/skills行带真实路径但不带 scope,而那个 workspace 是引擎自己报的(/v1/workspace/status回的正是list_skills解析技能所用的state.workspace,所以它是这些路径的出处;窗口自己的文件夹只在状态读回来之前兜底),包含关系就是唯一能自己核实的事实;path缺失(plugin snapshot)或 workspace 缺失一律算全局,宁可少认一个项目成员也不要猜。分组判定 built-in 优先于 scope(is_bundled先查):装在全局目录里的 built-in 副本不能被算成用户装的全局技能。每个分组可以折叠,折叠状态跨窗口记住:表头就是折叠控件(webview-js-sidebar.ts的group(key, title, rows)画<button class="skill-group-title">+.skill-group-body),只把折叠的 key 写进localStorage的codewhale:skillsCollapsedGroups(独立于codewhale:collapsedSections:那个存的是 Activity 区块 id,两个 store 装的是不同种类的名字,共用一个会让各自的读取器把对方的条目全丢掉),key 名单是导出的SKILL_GROUP_KEYS(['project','global','builtin']),载入时按它过滤,所以另一个构建留下的 key 折不动也留不住。折叠是用户明确且被记住的选择,所以过滤时也照折;表头上的计数在过滤时是命中数,折叠着的分组因此仍然说得出「里面有几个」。面板上方还有一个本地搜索框(按 name + description,大小写不敏感):它在 webview 内过滤,不发任何消息;输入框画在行容器外面,所以打字只重绘行、不重绘输入框(否则每击一键就丢焦点与光标)。搜索词存在模块级skillSearchQuery,一次引擎刷新(例如切换某个技能开关)重建面板后仍在——但不落盘,它是这一次浏览的过滤器,不是像codewhale:sidebarOpen那样的长期选择。折叠重建表头后会把焦点放回新表头(键盘读者不会因为自己点了一下就被扔回面板顶部)。没有任何技能时不画搜索框(没有可搜的东西),仍只给skillsEmpty空态。 - Changes 区块列出整个会话的文件变更,按轮次分组(每条变更带
turnIndex,changesState消息同时带turns分组元数据)。轮次边界只新开一个分组,不清空列表:sendMessage/ 外部回合开始时调beginChangeTurn,只有重建整个面板的路径(loadHistory、fork/retry、切会话)才调resetChangeGroups。changeIndex与 diff 重建都是按文件、跨整个会话编号的,改回「只留本轮」会让 Diff/回滚/Revert 的目标错位。 - Changes 现在列两类改动:文件工具自己的改动(
metadata.mutation,走detectFileChange),以及每一条命令在它那一次调用的工作区 span 里造成的改动。命令那条链路要记住:引擎按工具名分类(crates/tui/src/runtime_threads.rs的tool_kind_for_name:名字含 patch / write / edit →file_change;exec_shell*→command_execution;其余 →tool_call),shell 改文件不会产生file_change,只留下调用前后两个工作区快照(tool:<call_id>与post-tool:<call_id>)的差。所以分两趟:(1)首帧来自已经到手的数据——实时是turn.workspace_snapshot事件里kind === "post_tool"的changed_paths,重开窗口是GET /v1/threads/{id}里turns[].workspace_snapshots,接口是noteCallSnapshot/applyTurnCallSnapshots(历史那趟必须在flushAssistantSegment()之后才读,卡片那时才存在);(2)富化调新路由GET /v1/threads/{id}/turns/{turn_id}/calls/{tool_call_id}/changes(能力名callChanges,GET-only 所以探测用 POST),拿到change/ 行数 /diff/revision后按 (callId, path) 原地替换,每次调用只请求一次(shellCallsEnriched+shellCallsInFlight)。几个不能改回去的点:身份只能靠metadata.tool_use_id(= liveitem.started的tool.id,也是引擎给那两张快照收据的tool_call_id),所以ToolCallInfo.callId是新增字段;挂载必须扫this.messages找行(findToolCallRow),因为收据与卡片到达顺序两个方向都可能;noteCallSnapshot只认已经登记为 shell 的调用,否则文件工具的收据会被当成命令 span、同一路径列两遍;一个调用改多个文件,所以卡片用ToolCallInfo.fileChanges数组、由新消息callChangesDetected整片重绘(fileChangeDetected的单卡逻辑不要动);expectedHash直接取路由返回的sha256:<revision>,不要调stampReviewedHash(那会重新读磁盘,不是「用户看到的那一版」)。文案:面板不再有任何自述文案(changePanelHint/changeTurnShellNote连同 i18n key 与 CSS 一起删掉了)。曾经的说明是在讲「面板只列文件工具的改动」,而那个限制已经不存在;留着一句 caveat 也只会让人以为还有别的盲区 —— 判断依据是行本身。因此:表头只有计数,轮次分组里只有行,一个「跑了命令但没列出任何改动」的轮次就是一个只有表头的空组(这是刻意接受的,别再给它补一句解释,也别因为「空组难看」把该轮从 payload 里过滤掉 ——refreshChangesPanel的|| (turn.shellCommands ?? 0) > 0就是为它留的)。命令行的来源只靠卡片上的changeFromCommand小标(渲染用的标记是FileChangeInfo.fromCommand)表达,不要再用文字去解释「这里其实是段时间窗差异」。state !== "captured"表示这一段永远解不出来(call_not_bounded/post_snapshot_missing/pre_snapshot_missing/snapshots_pruned:最后这个表示收据还在、侧仓里的那对树已被清理,旧轮次很常见,不是故障),必须当作「未知」而不是「没有改动」。排查「用户说面板里看不到这一轮改动」时,先确认那轮是不是全靠 shell 改的文件,再看收据有没有post_tool。
原因:消息格式错误,将整个命令字符串作为 command 参数发送
修复:
// 错误格式
vscode.postMessage({ type: 'slashCommand', command: '/task show ' + taskId });
// 正确格式
vscode.postMessage({ type: 'slashCommand', command: '/task', args: 'show ' + taskId });原因:点击线程项时自动移除 open 类
修复:移除自动关闭代码
// 移除这行
threadsPanel.classList.remove('open');- 使用 TypeScript 严格模式
- 遵循 ESLint 规则
- 函数和变量使用 camelCase
- 类和接口使用 PascalCase
- 使用
console.log输出到 VSCode 开发者工具(Help → Toggle Developer Tools) - WebView 中的
console.log输出到浏览器控制台 - 使用
postMessage记录消息流
- WebView HTML 模板使用模板字符串,避免频繁 DOM 操作
- 使用事件委托处理列表项点击
- 避免在渲染函数中创建重复的事件监听器
- 更新
package.json中的版本号 - 运行测试:
npm test - 编译生产版本:
npm run package - 打包 VSIX:
npx @vscode/vsce package --no-dependencies - 测试安装:安装到本地 IDE 验证功能
- 发布到 VSCode Marketplace(如果需要)
- CodeWhale TUI: TUI 引擎,提供 API 服务
- DeepSeek API: DeepSeek AI API 接口
- 项目仓库:https://github.com/HengQuWorld/CodeWhale-VSCode
- 问题反馈:通过 GitHub Issues
现象:在
webview-html.ts的<script>块中添加了新函数updateUndoRetryState()引用了messages变量,但原始代码中只有messagesEl(DOM 元素引用),未声明过messages数组变量。新代码在 script 块靠前位置执行 → 抛出ReferenceError: messages is not defined→ 整个 script 块停止执行 → 后续所有初始化(status 更新、消息订阅、按钮事件绑定)全部死锁 → UI 永远停在 "Initializing..."。
-
大型 inline
<script>块是一荣俱荣、一损俱损的整体- VSCode WebView 的
webview.html是单文件模板,里面常常塞一个 80~200 KB 的<script>块 - 该块中任何位置的运行时错误(
ReferenceError/TypeError/ 语法错误)都会让整个块停止执行 - 后果:用户看到的不是"某个按钮不工作",而是整个 webview 看起来死了(Initializing 永远不消失、状态栏不更新、按钮全无响应)
- VSCode WebView 的
-
不要假设前端存在某个变量
- 大型 webview 脚本里有大量闭包、模块模式、IIFE,变量要么在
window上、要么闭包私有 - 修改前必须先 grep 确认变量是否被声明:
grep -n "let X\|var X\|const X" webview-html.ts - 如果找不到 → 不要直接用。要么走 postMessage 让后端判断,要么自己维护一个 IIFE 局部变量
- 大型 webview 脚本里有大量闭包、模块模式、IIFE,变量要么在
-
修改 webview 前端的最安全姿势
- 优先后端:按钮 →
vscode.postMessage({ type: 'xxx' })→ 后端判断 + 状态机 - 不依赖前端变量:所有判断逻辑放在后端
chat-provider.ts,前端只负责"显示/发消息" - 新逻辑放 IIFE 隔离:用
(function(){ ... })()包起来,自己声明局部变量,绝不污染外层作用域 - 绑定 handler 用 try/catch 包装:单个按钮的 handler 出错不应该影响其他按钮
- 事件委托复用现有模式:新按钮的 click 处理插到原有
addEventListener('click', ...)内部,跟着target.classList.contains('xxx')走
- 优先后端:按钮 →
-
debug 大型 webview 卡死的二分定位法
- 第一步:
git stash全部本地修改 → 打包测试- 正常 → 我的修改是问题源
- 还是卡 → 是环境问题(不是代码)
- 第二步:
git checkout逐个文件回退,找出哪个文件的修改导致问题 - 第三步:定位具体行时,从最简化的修改开始(只加 1 行 HTML / 1 个 postMessage)→ 测试 → 逐步加代码 → 找到出错的那行
- 不要用
console.log散弹枪调试大型 webview 块——错误抛出后后续所有console.log也不会执行,所以看到的"没日志"不代表"没出错",而可能脚本已经死了
- 第一步:
-
绝对不要把 HTML 调试标记 + 状态文字改动直接 commit
- 调试时的 BOOT marker、CSP 临时移除、
_debugMode = true、写日志到磁盘等都是一次性探针 - 找到根因后立即 revert 这些探针代码
- 探针代码混在生产代码里 → 下次再调试时浪费更多时间判断"这些是做什么的"
- 调试时的 BOOT marker、CSP 临时移除、
-
CSP / nonce / script 块语法问题的早期信号
- JS 完全不执行 + Console 完全没日志 → 整段 script 被浏览器拒绝(CSP 阻止、语法错误导致整个块拒绝解析、nonce 不匹配)
- JS 执行了一段然后挂 →
ReferenceError/TypeError/ 无限循环 - UI 部分渲染(HTML)但交互失效 → 事件 handler 报错或没绑上
- 已用
grep确认所有引用的变量在 script 块中存在 - 所有新 UI 逻辑尽量放后端
- 新代码用 IIFE 隔离,不污染外层
- 调试探针代码标记为
// DEBUG:,调试完立即删除 - 至少在 IDE 重新加载一次 webview 验证状态从 "Initializing" 变 "Ready"
- 修改量 > 50 行时,分批 commit,便于
git bisect定位问题
根因案例(2026-09-27 提示音测试期乱响):
playCompletionCue()用this.extensionUri?.fsPath ?? ""兜底,测试里new ChatProvider({} as any, ...)于是传入空串;path.join("", "media", "completion-chime.wav")得到的是相对路径,而/usr/bin/afplay是按宿主进程的工作目录解析它的——仓库根目录下真有这个 WAV,所以npm test在开发机上真的播了提示音(一次全量测试 5 次,几个 vitest worker 各一次;页面里看到的是"会话过程中连续响好几次")。
规则:
- 提示音只播扩展自己带的那个文件:路径必须来自绝对扩展目录,且
media/completion-chime.wav确实存在,两条缺一即静默(utils/completion-sound.ts的bundledCuePath)。相对路径、或不存在的文件,一律不播——播放器拿相对路径去问"当前工作目录",那不是我们能担保的对象。 - 空路径不做兜底:
playCompletionCue()在没有extensionUri.fsPath时直接返回,不传""。 - 写测试时:
- 验证"某一轮会响"→ 把
./utils/completion-soundmock 掉(见src/chat-provider/turns/completion-sound.test.ts),只测接线,不碰播放器。 - 验证播放本身(设置开关、间隔下限、播放器选择)→ 用
completion-sound.test.ts的注入宿主:时钟 / 平台 / PATH 查找全部注入,需要真文件就建临时扩展目录。 - 别给
ChatProvider传真实扩展目录,除非你真的想让这次测试发声。
- 验证"某一轮会响"→ 把
GUI 是 TUI 的图形前端,用户在两种界面下的操作应该产生相同的效果。实现新功能前,先读 TUI 源码理解其设计意图,再决定 GUI 的实现方式。
- 优先使用 TUI 已有的 Runtime API — GUI 通过 HTTP 调用 TUI 的
/v1/*端点 - 如果 TUI 有功能但缺少 API 端点 — 先在 TUI 的
runtime_api.rs中添加端点,再在 GUI 实现 - 如果 TUI 也没有该功能 — 先在 TUI 设计并实现,暴露 API,再在 GUI 对接
绝不在 GUI 中用 hack/变通方式模拟一个 TUI 已有但 GUI 没有对接的功能。
TUI 把「对话模式」和「权限姿态」当作两个独立维度,GUI 必须同样处理,不要再用 yolo 当作模式:
| 维度 | 取值 | 显示名 | 切换方式 |
|---|---|---|---|
| TUI mode | agent / plan / operate |
Act / Plan / Operate | 状态栏模式下拉;/mode;数字 1/2/3 |
| Permission posture | ask / auto_review / full_access |
Ask / Auto-Review / Full Access | 状态栏 Permission 下拉;/auto |
yolo(及4/bypass/bypass-permissions/bypasspermissions)是 Act + Full Access 的单向兼容别名,不是模式,也不出现在下拉里。- 单一事实来源:
src/utils/modes.ts(镜像crates/config/src/app_mode.rs、crates/execpolicy/src/approval_mode.rs与crates/tui/src/runtime_policy.rs)。状态栏下拉与配置面板的选项都由它生成,不要在任何新代码里硬编码模式/姿态字符串。 - 三套拼法不要混用:
POSTURE_WIRE(线程/Runtime 请求体,snake_case)、POSTURE_CONFIG(引擎配置approval_mode,hyphenated)、POSTURE_LABELS(UI 显示名)。配置面板的姿态下拉只提供ask/auto-review/full-access:use-tui-default属于另一个 keyapproval_policy,never是托管策略值,两者都不是approval_mode的合法取值。 - 改模式时只 PATCH
{ mode },改姿态时只 PATCH{ permission_posture };运行时会自行推导auto_approve/trust_mode(runtime_policy_with_overrides)。把 GUI 缓存的auto_approve: false一起发出去(且不带显式姿态)会把 Auto-Review 姿态重新推导成 Ask。 - 兼容旧记录的姿态推导必须与引擎的
RuntimePolicyProjection::from_persisted一致:permission_posture优先,其次看 mode 的yolo别名与auto_approve;不要参考trust_mode(引擎会忽略它)。 - 启动默认值:
brotherwhale.defaultMode(agent|plan|operate)与brotherwhale.defaultPermissionPosture(ask|auto_review|full_access)。它们只作用于新建线程;下拉把两个作用域分开列出(item 带data-scope="thread|default",由scopedDropdownItems()生成),第二组才写这两个配置项。/mode、/auto与计划确认只作用于当前线程;无当前线程时(新会话视图、浏览已保存会话)它们落到启动默认值并在提示里说明作用域。默认值的当前值经scopedDefaults消息推给 webview 用于打勾。 resume-thread请求体只有model/mode(runtime_api/sessions.rs),不支持permission_posture;恢复的线程姿态由引擎从会话本身推导。
注意:模式可选
operate与/v1/operate编排面(operate run / plan / keepalive)是两回事;后者仍未对接。
undo / retry / patch-undo / 快照恢复这条链路已完成对齐:
| 功能 | TUI 端点 | GUI 实现 | 状态 |
|---|---|---|---|
/undo |
POST /v1/threads/{id}/patch-undo |
chat-provider.ts handleUndoLastTurn() 调 patchUndoThreadTurn(),先快照回滚文件再删对话轮 |
已对齐 |
/retry |
POST /v1/threads/{id}/retry |
handleRetryLastTurn() 调 retryThreadTurn()(服务端 undo + 重发) |
已对齐 |
| 从指定轮次分叉(GUI 主导) | POST /v1/threads/{id}/fork-at-turn |
每条回答(每轮的最后一个 assistant 消息)下方的“分叉”行 → handleForkFromTurn(turnId);引擎解析 turn id,GUI 不数轮次 |
已对齐(路由为本功能新增,旧引擎无此路由 → 该行不渲染) |
/restore |
GET /v1/snapshots + POST /v1/snapshots/{id}/restore |
api-client.ts 已有 listSnapshots() / restoreSnapshot();chat-provider.ts 用 pre-turn 快照做恢复 |
API 已对接,/restore 斜杠命令入口缺失(slash-commands.ts 仍标 unavailable) |
当前真实缺口集中在「平台管理面」(TUI 有 API,GUI 未对接):
| TUI 功能面 | 端点 | GUI 现状 |
|---|---|---|
| MCP 服务器管理 | /v1/apps/mcp/servers 全套 CRUD + enable/disable/reconnect + /v1/apps/mcp/tools + /v1/apps/mcp/imports |
Activity 的 MCP 面板(列表/增删/启禁用/连接看工具/审查导入);/mcp 打开面板,/mcp list 打印列表 |
| 插件管理 | /v1/apps/plugins + install/update/trust/enable/disable/revoke |
完全缺失 |
| 市场管理 | /v1/apps/marketplaces + install |
完全缺失 |
| Skills 管理增强 | /v1/skills/install、/{name}/update、/{name}/trust、/{name}/audit |
只有 list + enable/disable |
| Agent mail | /v1/agent-mail、/v1/threads/{id}/agent-mail |
完全缺失 |
| Operate(模式) | 线程 mode: "operate" |
状态栏模式下拉 / /mode operate,已对齐 |
| Operate(编排 API) | /v1/operate 全套(run/plan/keepalive/auto-merge) |
完全缺失 |
| Memory | /v1/memory 全套 |
违反复用原则:slash-command-handler.ts 的 /memory 直接读写 ~/.deepseek/memory.md 本地文件,应改走 API |
本表是对齐检查的快照,会过时。做新功能前先重新核对源码:TUI 端点查
runtime_api.rs的build_router(),GUI 对接查src/api/api-client.ts,命令查commands/mod.rs的execute()与src/commands/slash-commands.ts。不要把此表当全量清单。
TUI 的 /fork 只能整会话复制,fork_at_user_message 又只按「距尾部多少轮」定位,所以「从某一轮分叉」没有现成的 TUI 命令,是 GUI 提出、并由 TUI 已有能力(fork_at_user_message)实现的:
- 锚点必须是 turn id(
GET /v1/threads/{id}返回的那个),不是 GUI 自己数出来的轮次序号。GUI 渲染出的对话(有 steer、纯图片输入、内部 handoff 等情况)和引擎的 turn store 不是同一份列表,客户端算出来的 depth 错一格就会分叉到错误的轮次,而且还返回 201。 - 语义是「保留锚点那一轮及之前的全部轮次」(不是丢掉锚点轮),所以入口画在每条回答的下方(
branchTurnId只打在每轮的最后一个 assistant 消息上),而不是用户消息上——分叉点是用户正在看的那条回答;锚点选最后一轮就等于整会话复制。回执里的original_user_text是第一个被丢掉的轮次的提问(即“原会话接下来问的”),GUI 把它放回输入框。 - 已保存会话(Sessions 轨道里查看的会话)不能直接分叉:分叉要的是 turn id,而一个被查看的会话只是一份 messages 文档,没有 turn。GUI 的做法是提供 Continue(工具栏按钮,
handleContinueSession→resumeViewedSessionForAction(),不发任何消息):把会话 resume 成活动线程后,它的每一轮都带上精确锚点、都有分叉行。undo/retry 早就静默做这一步,现在抽成同一个 helper,三个动作共用。按钮只在sessionLoaded时显示、threadLoaded/clearChat时隐藏。 - 该端点不回滚文件:分叉出的会话与原会话共用一个工作区,回滚会连带改掉被留下的那一支。文件回滚只属于
/undo与/patch-undo。 - 分叉可以在有轮次运行时进行(用户明确要求):运行时拥有在跑的轮次,切视图只是把它 parked,不中断——所以
adoptForkedThread和 retry 都在改currentThread之前调parkCurrentThread()(否则 park 读到的是刚赋上的新线程,源会话就丢了后台订阅;该信号就是backgroundThreads/streamEvents)。尚未跑完的轮次不作为分叉点(loadHistory的turnIsRunning判定):否则会把一个半成品回答复制进新会话。 - 分叉 / 撤销 / 重试共用
hostOperationInFlight:同一时刻只允许一个「换掉当前对话」的操作,后来者得到operationBusy提示而不是真的开始。 - 点击到落地之间有几秒:
chat-provider.ts先发status+hostOperation(active:true),webview 用它点亮状态栏活动点、把被点的那行标成is-pending、并暂缓这一组控件:输入区的发送(webview-js-input.ts的setHostOperation,只拦发送不锁输入;keydown/按钮都走sendMessage()这一个漏斗)+ 工具栏的 Undo / Retry / New Thread / Compact(它们作用在同一段对话上,跟发送一样会和在飞的切换抢)。理由:分叉会换掉输入区所属的那段对话,这几秒里发出的消息或触发的动作没有确定的落点(很可能落在原会话、然后从视野里消失),而输入框里的字是用户自己的,锁住它更糟。释放时统一把能力状态交还给applyApiCapabilities(),不要手写回滚(否则 Old engine 上 Undo 会被永久置灰)。对比:压缩(compact)只发busy而不拦发送,因为它不换对话——busy只画活动点,hostOperation才是「这段对话暂时归宿主所有」。 - 同一个分叉在飞行中时第二次点击会被
forkInFlight丢掉:否则会分叉出两个新线程、两份 session 文档,而第二次是从用户已经离开的源上切的。这个守卫在后端,因为那一行在整个等待期间都留在屏幕上。 - 能力检测用路由探测(
api-client.ts的probeRuntimeCapabilities():用 GET 探这条 POST-only 路由得到 405 → true,旧引擎 404 → false)。旧引擎上整行都不渲染,而不是退化成“分叉最后一轮”那件事——那样会在别的轮次上下刀还报告成功。
| 功能 | 文件 | 函数/结构 |
|---|---|---|
| undo 对话 | crates/tui/src/commands/groups/debug/undo.rs |
undo_conversation() |
| retry 重试 | crates/tui/src/commands/groups/debug/undo.rs |
retry() |
| patch_undo 文件回滚 | crates/tui/src/commands/groups/debug/undo.rs |
patch_undo() |
| 快照仓库 | crates/tui/src/snapshot/repo.rs |
SnapshotRepo |
| pre-turn 快照 | crates/tui/src/core/turn.rs |
pre_turn_snapshot() |
| 唯一 turn loop | crates/tui/src/core/engine/turn_loop.rs |
Engine::run_turn |
| revert_turn 工具 | crates/tui/src/tools/revert_turn.rs |
RevertTurnTool |
| 工具注册 builder 链 | crates/tui/src/tools/registry.rs + crates/tui/src/core/engine/tool_setup.rs |
with_agent_runtime_surface() 等 with_*_tool() |
| Runtime API 路由 | crates/tui/src/runtime_api.rs |
build_router()(新增端点的唯一落点) |
| /restore 命令 | crates/tui/src/commands/groups/skills/restore.rs |
restore() |
| 内置命令分发 | crates/tui/src/commands/mod.rs |
execute() |
TUI 的
commands/已按组重组(groups/core|config|session|debug|skills|plugins|utility|project|memory/),旧的单文件路径(如commands/debug.rs)不再存在。
- 在 TUI 源码中找到对应功能的实现
- 确认 TUI Runtime API 是否已暴露该功能(查
runtime_api.rs的build_router()) - 如果没有 API → 先在
runtime_api.rs添加端点(薄适配层,复用 Engine 已有能力) - 在 GUI 的
src/api/api-client.ts中添加调用方法 - 在
chat-provider.ts中实现业务逻辑 - 在
src/webview/中添加 UI(新脚本放对应的webview-js-*.ts模块,遵循 WebView 编码原则) - 对比 TUI 和 GUI 的行为是否一致
GUI 的 runtime API 端点(runtime_api.rs)必须复用 TUI 已有的核心能力,而不是自己重新实现一遍。TUI 已经有了完整的业务逻辑,API 端点只是把这些能力暴露给 GUI 调用。
错误:在 API 端点中自己重建数据
// ❌ 自己从 turns 重建消息、估算 token
let turns = runtime_threads.list_turns_for_thread_pub(&thread_id)?;
let messages = runtime_threads.reconstruct_messages_from_turns(&turns)?;
let total_tokens = messages.iter().map(|m| text.len() as u64 / 4).sum();正确:通过 Engine 获取 TUI 已有的真实数据
// ✅ 复用 Engine 的 get_session_snapshot(),和 TUI 的 build_session_snapshot 走同一路径
let engine = runtime_threads.get_engine(&thread_id).await?;
let snapshot = engine.get_session_snapshot().await?;
// snapshot.messages / snapshot.total_tokens / snapshot.model 都是 Engine 的真实状态写 API 端点时问自己:
- TUI 内部做这件事用的是什么? → API 端点应该调用同一个东西
- 我是在"暴露 TUI 的能力"还是"重新实现 TUI 的逻辑"? → 应该是前者
- 如果 TUI 的逻辑改了,我的 API 端点会不会不同步? → 如果会,说明没有复用
-
数据来源:用 Engine 的真实状态,不要自己重建
- Engine 的
session.messages是权威消息列表,不要从 turns/items 重建 - Engine 的
session.total_usage是权威 token 统计,不要从文本长度估算 - 通过
get_session_snapshot()获取完整快照
- Engine 的
-
业务逻辑:用 TUI 已有的函数,不要重写
- 保存 session:用
create_saved_session_with_id_and_mode()/update_session(),和 TUI 的build_session_snapshot一致 - 文件操作:用 Engine 的 Op 通道,不要绕过 Engine 直接操作文件
- 对话操作(undo/retry):用 Engine 的 Op 通道,确保状态一致
- 保存 session:用
-
API 端点的角色是"薄适配层"
- 接收 HTTP 请求 → 转换参数 → 调用 TUI 已有能力 → 返回结果
- 不应该在 API 层做数据转换、计算、重建等重逻辑
- 如果发现 API 端点里有超过 20 行的业务逻辑,大概率是在重复造轮子
-
Engine 是唯一的状态权威
- Engine 持有当前 session 的完整状态(messages、tokens、model 等)
- Thread store 只持久化 turn items,不是消息的权威来源
- 从 turns 重建消息是
ensure_engine_loaded的内部实现细节,API 不应该依赖它
| 方面 | 错误方式 | 正确方式 |
|---|---|---|
| 消息来源 | 从 turns 重建 | Engine get_session_snapshot() |
| token 计算 | text.len() / 4 估算 |
Engine 的 total_usage |
| model/workspace | 从 thread 记录读取 | Engine 快照自带 |
| session 构建 | 自己拼 SavedSession |
用 create_saved_session_with_id_and_mode() |
| 保存方式 | 自己写文件 | 用 SessionManager::save_session() |