- Accueil
- Plugins
- Améliorations UI
- dsh-ide-pane
dsh-ide-pane
1583477320/dsh-ide-pane
A session-aware code pane for the DeepSeek Harness right sidebar: browse, edit and run workspace files, see what the Agent changed, and step through every recorded edit. 代码面板 for DeepSeek Harness。
Installer
dsh plugin --profile web add github:1583477320/dsh-ide-paneREADME
dsh-ide-pane
DSH 右侧栏的「代码」面板:在当前会话里浏览、编辑、运行工作区文件,并在 Agent 改动文件后给出可读的差异。
它不试图复制一套小号 VS Code;重点是回答一个会话语境里的问题:Agent 到底改了什么,我现在的保存会不会覆盖它。
能力
| 能力 | 当前行为 |
|---|---|
| 文件树 | 懒加载目录,目录在前、文件在后,按名称排序 |
| 源码视图 | 行号、Python / JS / CSS / Markdown 轻量语法着色、横向滚动 |
| Markdown 渲染 | 打开 .md 默认渲染:标题、列表、任务框、引用、表格、代码块、粗体/斜体/行内代码/链接,以及 $…$ / $$…$$ 公式;标题栏一键切回源码 |
| 编辑 | 「编辑」进入文本框,Ctrl+S 保存;保存后自动重读 |
| 差异视图 | 永远对比「这一次观察到的内容」和「上一次观察到的内容」;有变化时标题栏出现「差异 •」,按行标 + / -,未变部分折叠成「··· N 行未变 ···」。关掉再点开、切走再回来、刷新页面,对比都还在 |
| 会话版本 | 树上给 Agent 改过的文件(及其所在目录)打 ●;打开这样的文件,代码区上方出现版本条,逐条列出这次会话里的每次改动,选一条就只看那一次的前后差异 |
| 版本保护 | 打开文件时记录内容 hash;如果 Agent 在编辑期间改了文件,保存会拒绝覆盖并给出「看差异 / 载入新版本 / 保留草稿」选择 |
| 运行 | 走插件自己的 text/event-stream 路由:输出边产生边显示,颜色和 \r 都保留,可「■ 停止」 |
| 选区引用 | 编辑时选择一段文本,生成 @file:start-end;按钮把引用插入当前会话输入框,输入框版本变化时回退为复制 |
| 拖拽与复制 | 文件行可以拖到对话框,松手即插入 @路径 引用;右键菜单提供「引用到对话 / 复制路径 / 复制内容 / 打开」 |
| 跟随改动 | 打开文件每 2 秒比一次版本号:优先用第一方 workspaceFiles.stat,失败或不可用时退回插件自己的内容 hash;两者都失败会明确提示「无法检测文件变化」,不静默假死 |
| 入口 | 右侧栏「+」中的「代码」;Ctrl+Alt+I;或在对话里点文件引用——直接打开这个面板并定位到该文件 |
断点、单步、变量检查仍未实现。 这需要 Node CDP 或完整 DAP 客户端,是独立于文件面板的调试子系统。
差异怎么算
diffLines(before, after) 先裁掉公共前缀和后缀 —— 这正是「Agent 改了三行」的常见情况,代价接近线性。剩下的中段走 LCS 表;超过 250000 格的预算就退化成「整块被替换」,因为整文件重写不值得一张二次表,而说「这段被替换了」依然是真话。
渲染时只保留每处改动上下各两行,其余折叠成「··· N 行未变 ···」,所以两千行的文件也不会刷出两千个节点。
两条差异通路共用同一个渲染器:
- 与上一次比较:每次读到文件内容都记一条「当前文本 + 上一次文本」。显示差异时比的就是这两者,所以关掉差异、切走标签、刷新页面都不会丢失——它存在存储里,不在视图状态里。
- 保存冲突:Host 返回冲突时,「看差异」对比你的草稿和磁盘内容,
−是草稿、+是磁盘。
Markdown 为什么是自己渲染的
DSH 自带的文档预览有渲染器,但它没有把渲染能力作为服务暴露给第三方插件——插件能拿到的只有 react。所以 .md 在这个面板里由一段自己的渲染器处理,好处是编辑、差异、会话版本这些能力对 Markdown 同样有效,切到「源码」就能继续改。
实现上有两条硬约束:
- 解析成 React 节点,不拼 HTML 字符串。面板渲染的是任意文件内容,任何标记都不该以 DOM 形式落地;
- 链接一律
target="_blank"且阻止冒泡,点开不会把整个应用导航走。
支持的是常见子集:标题、围栏代码块、有序/无序列表(含嵌套缩进)、任务框、引用、表格、分隔线,以及粗体、斜体、行内代码、链接。不认识的行按段落原样显示,不会被悄悄吞掉。
公式
$…$ 是行内公式,$$…$$(或 \[…\])是独占一行的块级公式。
KaTeX 只存在于应用自身的 vendor 包里,插件的 require 拿不到它,所以这里是一段自带的 TeX 子集渲染器:
| 支持 | 例子 |
|---|---|
| 上下标 | 10^{-3}、x_i^2 |
| 分式 / 根式 | \frac{a}{b} → a/b、\sqrt{x} → √x |
| 重音 | \hat{y} → ŷ、\bar{x}、\vec{v}、\dot{x}、\tilde{z}、\overline{AB} |
| 字体 | \mathrm{ReLU}、\mathbf{W}、\mathbb{R}、\mathcal{L} |
| 定界符与间距 | \left( … \right)、\, \; \quad |
| 希腊字母、关系符、运算符 | \alpha \le \beta \times \infty \approx \sum \int |
| 函数名 | \log \ln \exp \max \lim |
重音用组合字符(如 ŷ = y + U+0302),所以它留在文字行内,接上去的下标不会浮起来;\bar / \overline / \underline 用 CSS 的 overline/underline,多字符也能覆盖。
认不出的命令原样显示(\unknowncmd 会带着反斜杠露出来),不会被静默丢掉。
$ 的识别有防护:紧贴空白、被反斜杠转义、或闭合 $ 后面紧跟数字的,都不算公式——「价格是 $5 和 $10」不会被误渲染。
配对是逐字符扫描的,不是一条正则。一个找不到合法配对的 $ 会退回普通文本,扫描从下一个字符继续——所以「成本 $8,误差 $\hat r=8$」里的价格不会把后面那个公式的开头吃掉。内容还必须像公式(含命令、上下标或数学符号)才按公式处理。
版本记录存在哪
localStorage,键为 dsh-ide-pane/base/v1/<sessionId>/<规范化路径>,值 { text, prev, at }。
text是最近一次观察到的内容,prev是它之前的那一版;- 路径先规范化(统一成
/),所以从文件树点进来(D:\a\b.py)和从对话引用点进来(D:/a/b.py)共享同一条记录; - 每个文件最多存 200 KB,超出就不记(大文件对比也没有意义);
- 最多保留 40 个文件,超了按
at淘汰最旧的; localStorage不可用时退化为内存Map,当次会话内仍然有效。
规则只有一条:内容变了才推进。重复读到同样的内容不动记录,因此最后一次真实改动会一直保留着,点多少次「差异」都是一样的对比。想主动忘掉它,点「清除对比」。
第一次打开某个文件时 prev 为空,没有可比对象——这是这套机制的信息边界,不是 bug。
三条通路
| 能力 | 通路 | 原因 |
|---|---|---|
| 读取 / 文件变化 | 第一方 remote.workspaceFiles | 使用会话 workspace 作用域与服务端越界检查 |
| 写入 / 运行 / 内容版本 | 插件受限 HTTP 路由 | 第一方 workspace namespace 只读;第三方 Remote 需要 Typert 构建期生成 |
| 会话版本 | ctx.sessions.get(id).snapshotEvents() → 插件受限路由 | 会话日志是第一方只读事实,插件不能写、只能折叠 |
| 选区引用 | 侧栏 slot 的标准 prop inputActions | 直接走宿主自己的 Lexical 编辑器,插入是一次可撤销的编辑 |
运行输出为什么是流式的
/dsh-ide-pane/runStream 是一条 text/event-stream 路由:宿主每收到一块输出就 res.write 一帧,浏览器用 response.body.getReader() 增量读取,所以输出边产生边显示。中止 fetch 会关闭响应,宿主在 res.on('close') 里 SIGKILL 子进程——这就是「停止」按钮。
帧格式:
{type:'start', command}
{type:'out'|'err', text}
{type:'end', code, ok, ms, command}
{type:'fail', error}
一个关键的环境变量:PYTHONUNBUFFERED=1。Python 在非 TTY 下默认块缓冲 stdout,没有它,脚本输出会在退出时一次性到达——管道明明是通的,看起来却像没有流式。PYTHONIOENCODING=utf-8 / PYTHONUTF8=1 则保证中文不被管道边界切坏。
浏览器侧还有两件清理:
sanitizeTerminal只保留 SGR(颜色),丢掉光标移动、擦除、OSC——纯文本视图无法兑现这些;applyCarriageReturns把\r按终端语义折叠(本行重写),进度条才是一行变化而不是一列快照。先归一\r\n,否则行尾那个\r会把整行清空。
拖到对话框为什么会变成引用
dragstart 里把 text/plain 设成 @路径(含空格时用 @"…")。输入框是宿主自己的 Lexical 编辑器,它本来就会把拖进来的 text/plain 插到落点上——所以最朴素的做法就能用。
但「能插入」和「一定插入」是两件事:外壳可能给拖放加了自己的遮罩,或者编辑器在落点判断上挑剔。所以另外在 document 上挂一个捕获阶段的 drop 监听:只要这次拖拽是从本面板发起的(模块级 draggingReference 标记),就由面板自己调用输入框的 insertText,并 preventDefault 吃掉事件,避免文字被插入两次。落回面板内部则视为取消。
insertText 失败时回退到剪贴板,和选区引用共用一条路径。
对话里点文件为什么能进这个面板
侧栏的 tab 类型登记表在跨 kind 的范围内给候选类型排序:先比 priority 档位,再比命中 pattern 的长度。DSH 自带的文本预览登记为 fallback(最低档),这个插件登记为默认的 extension,所以同一个 dsh-resource://file/… 地址会被本插件抢到。
具体做法是给自己的 ide kind 加上:
patterns: ['dsh-resource://file/[\\s\\S]+'],
canOpen: (address) => 是文本类扩展名,
title: (address) => 文件名,
canOpen 是必需的:图片、PDF、Office 文档也是文件地址,抢过来会把字节当源码渲染。只有已知的文本扩展名(以及 Makefile、.env 这类无扩展名/点文件)才接管。
地址格式是 dsh-resource://file/session/<sessionId>/<每段单独 percent-encoded 的路径>,解析后既能拿到路径也能拿到会话 id——所以从别的会话点文件也能读对。
想换回自带的文本预览:把 client.js 里的 CLAIM_FILE_TABS 改成 false,它就不再声明 pattern。
会话版本从哪来
edits 只读会话日志,把 tool/call 里真正写文件的调用挑出来:
| 形态 | 识别方式 | 能给出什么 |
|---|---|---|
| 片段替换 | old_string/new_string,或 old_str/new_str | 完整的前后片段,可做真实 diff |
| 整体写入 | content(或写入类工具的 text) | 只有写入后的内容;旧内容不在日志里 |
| 只提到路径 | 写入类工具名 + file_path | 只够在树上打标,没有差异 |
摘要不带正文,树上轮询它很便宜;只有真的点开某一条时才用 editText 从单个事件里取正文。
折叠是增量的:缓存记住已折叠的最大 seq,下次只读新尾部。日志 seq 的基准(0 起还是 1 起)没有写进契约,所以代码不猜——用包含下界的读取,再靠 event.seq < from 过滤;fork 或日志重置让水位倒退时自动全量重扫。
单个文件超过 12 条改动时,版本条只显示最近 12 条,更早的折成「更早 N 条」。
注意这里不是「完整文件的第 N 版」:片段替换只记录被替换的那一段,所以能精确说「这一次改了什么」,但给不出历史上某个时刻的整份文件。人工在面板里的保存也不在日志里——第三方插件写不进去。
Host 路由位于 POST /dsh-ide-pane/*:
stat { root, path } → { ok, exists, version, bytes }
write { root, path, text, baseVersion? } → { ok, version, bytes } | { conflict, currentVersion, currentText }
run { root, path, command? } → { ok, code, stdout, stderr, ms, command }
edits { sessionId } → { ok, seq, files: { path: [edit] } }
editText { sessionId, seq } → { ok, kind, path, old, new }
路径必须落在 root 内。请求还必须带 x-dsh-ide-pane: 1,并且 Origin 的 host 必须与当前 DSH 主机相同。
内容版本是插件自己算的 sha1 前 16 位,因为第一方 stat 报的版本号 Host 这边复现不出来;两端用同一个函数才能判断「这个文件还是我打开时那份吗」。
结构
index.js Host:受限 stat/write/run 路由 + 会话日志折叠(edits/editText)
client.js 文件树、改动脉标、版本条、查看编辑、差异、选区引用、运行与冲突 UI
test/smoke.mjs Client 注册契约
test/host.mjs 真实 HTTP:版本、冲突、写、跑、护栏、越界、会话版本与增量折叠
test/render.mjs 迷你 Hook:文件、引用、版本化保存、运行、Agent 差异、冲突差异、版本条
测试
node --check index.js
node --check client.js
node test/smoke.mjs
node test/host.mjs
node test/render.mjs
test/render.mjs 也直接断言了 diffLines、selectedLines、lineReference 这三个纯函数,包括相同文本、单行修改、插入、删除、整文件重写和带空格路径的引用格式。
安装 / 更新
从 npm 安装:
dsh plugin add dsh-ide-pane
从本地源码开发(改完立刻生效):
dsh plugin add link:D:/path/to/dsh-ide-pane
Host 半边可通过禁用再启用 include:dsh-ide-pane 重新挂载;Client 代码改变后需刷新当前 DSH 页面。
已知限制
- 单文件最多渲染 2000 行。
- 文件树根取自 Session
cwd;不是绝对目录时编辑和运行不可用。 - 「运行」不是终端:能流式、能停、保留颜色与
\r,但没有 stdin、没有光标定位、没有全屏 TUI。交互式脚本和 vim/top 这类程序请用侧栏终端。 - 差异对比的是「上一次观察到的内容」,不是 git HEAD:面板不做版本库对比,也不读取未打开过的历史内容。
- 会话版本是「每一次改动」,不是「完整文件的第 N 版」:片段替换只带被替换的那一段,整体写入没有旧内容。人工保存也不在会话日志里。
- 树上只标记当前会话日志里出现过的文件;别的会话、别的工具(终端里
git checkout之类)改的文件不会被标记。 - 一个会话最多折叠 400 次写文件调用,超出的更早改动不再计入。
@file:start-end中的行范围是给模型和人看的文本后缀;当前第一方文件引用解析器只把@file本体作为原子引用。- 没有断点调试。
Plugins associés
dsh-web (dsh-task-board)
zhu1090093659/dsh-web
dsh-web (dsh-web-all)
zhu1090093659/dsh-web
dsh-TUI
ccch1mneyyy/dsh-tui
dsh-web-ui (dsh-task-board)
zhu1090093659/dsh-web-ui