Saltar al contenido principal
1

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。

Instalar

dsh plugin --profile web add github:1583477320/dsh-ide-pane

README

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 relacionados