omdsh-sidepanel
English | 中文
给 DeepSeek Harness 网页界面加两块侧边面板:右边是文件浏览器,下边是终端。两者都属于 Work 模式,聊天模式下一个都不存在。
它提供什么
| 界面 | 从哪来 |
|---|---|
| 会话标题栏工具位上的两个开关 | conversation.session.header.utilities;标题栏收起时,由 shell.overlay 上的替补顶住那一行的角落 |
| 右边的文件面板,以及它一次预览的那一个文件 | shell.overlay,外加写到 #root 上的一条外边距——于是这一列站在应用旁边,而不是盖在它上面 |
| 下边的终端面板 | 同一层浮层,外加写到 [data-slot="conversation"] 父元素上的一条外边距,于是只有对话列被顶起 |
/omdsh-sidepanel 下的四条路由 | webServer —— 一层目录、一个文件、一张图片,以及终端的 socket |
panel.files 与 panel.terminal 两条命令 | 走受限 fiber 交给 omdsh-shortcuts 的 shortcut 服务;本包自己不绑任何键 |
| 终端是否提供 | 设置命名空间 omdsh-sidepanel;插件中心从 schema 画出那一个开关 |
harness 自带的是三列布局——会话列表、对话、详情——没有地方看一个文件,也没有地方跑一条命令。这个插件把这两件事补上,就此打住。右栏列出当前会话自己的目录,一次显示一个文件;下栏是同一目录里的一个 shell。没有编辑器,没有 git 面板,没有内置浏览器,没有标签栏:在这个位置上,一块能随手忽略的面板,比一个第二应用有用得多。
两个开关都放在会话标题栏右侧的工具位上,挨着 harness 本来就摆在那里的控件,而且始终只在这一个位置。会话还是空的时候,harness 会把整条标题栏收起来(那是新会话的起始屏),这对开关就改由全框浮层上的替补顶住那一行本来的角落:内边距从标题栏元素上量出来,高度取那一行的高度,落点正是标题栏回来之后它们要占的那几个像素。替补等的是标题栏的座位自己报到,而不是在这里再抄一遍 harness 何时收起标题栏的规则。开关排在行内最后也是有意为之——一旦有别的东西渲染在它们外侧,标题栏接手的那一刻开关就会被推着走。
每块面板都可以有一个键
这里不绑任何键。两块面板都是以命令的形式交出去的——panel.files 和 panel.terminal——而哪个组合键够得着它们、乃至有没有键够得着,归 omdsh-shortcuts 管:那是一份和其他所有键排在一起的设置表单,而不是一个写死在恰好拥有这个行为的插件里的常量。那个插件为这两条命令预置了 CmdOrCtrl+Shift+E 和 `Ctrl+ ``;而开关教给用户的,是设置文档此刻真正写着的那个键——组合键挂在 tooltip 里,文档每改一版就重读一次,所以改绑之后不需要刷新页面。
没有组装键位层时,压根没有可以交出去的对象。 这次注册挂在一条受限 fiber 上,服务不在,这条 fiber 就永远进不去;于是 tooltip 只报面板的名字,不去声称一个这套组装根本给不出的键。无论哪种情况,标题栏里的两个开关都是完整的入口——这正是 shortcut 不出现在本包 inject 列表里的原因:一个没人装的插件,代价应该是少一个快捷键,而不是少两块面板。见 [shortcut.ts](src/client/shortcut.ts)。
复制名称与路径
一个菜单,两个入口,一条规则:
- 面板标题栏的复制按钮作用于标题所指的东西——树视图下是项目文件夹,预览视图下是当前打开的文件;
- 右键作用于你指着的那一行,文件和文件夹都可以。
行本身不加任何控件,320px 的一列才读得下去。反馈就落在菜单里:按下的那一行变成 已复制(浏览器拒绝剪贴板时是 复制失败),过一拍菜单自己关掉——不需要 toast,不需要状态条,也不会出现复制了却没告诉你的情况。悬停一行本来就能在 tooltip 里看到完整路径,所以这个菜单是用来把路径带走的,不是用来读路径的。
聊天模式的规则
聊天模式下没有面板、没有收起的边条、也没有开关——屏幕的右边和下边什么都不显示。聊天没有可浏览的项目目录,也没有可执行命令的地方,所以诚实的做法就是不出现。
一个会话属于哪种模式是推导出来的,从不存储:宿主托管的 Chat 工作区把它记在名下时,它就是聊天,omdsh-chatmode 推导自己那个开关,依据的也是同一个事实。这也正是两个插件互不依赖的原因——这边不 import 那边的任何东西;而一个从没装过那个插件的部署,压根没有叫这个名字的工作区,于是每个会话都是 Work,面板始终可用。
而这个问题问的是哪一段对话——答案不是当前选中的那一段。 这些面板立在会话列旁边。有的模式,列并不是网页对话——omdsh-codemode 的终端就是这样——它故意不去选中自己显示的东西。所以面板跟的是 sessionModes.column,也就是 omdsh-basemode 正是为此发布的那个 scope,走的是受限 fiber:没有组装模式系统时,追踪器回答的就是选中项,也就是模式出现之前本插件一直直接读的那个值。见 [column.ts](src/client/column.ts)。
它如何占据布局
面板是 shell.overlay(ui-layout 公开的全框浮层)上的 fixed 定位元素。只是浮着的话,就成了盖住对话的一块帘子,所以应用外壳会被写上相应的外边距,页面正好让出它们占用的空间:
- 右栏收窄
#root,于是它在整个应用旁边得到一整列; - 下栏只顶起对话列,harness 自己的会话列表在它下面保持完整高度。
整个耦合只落在两个公开锚点上——#root,以及 [data-slot="conversation"](它的父元素就是框架的中间列)。两者都不是类名、也不是 DOM 形状;少了任何一个,这一步就跳过,面板退化为浮层。卸载插件会把它写过的每一条外边距都撤掉。
每块面板的内侧边缘是一条 8px 的把手,悬停出现小药丸——和 harness 给自己那几列的手感一致。拖动调整大小;双击把面板恢复成默认尺寸。 尺寸跨刷新记在 localStorage 里,键是 omdsh-sidepanel.panels——按浏览器存,不按 profile 存。文件树始终提供,并且默认开着。终端是一项设置:默认关,除非已经组装了 omdsh-codemode,那时默认开。取消勾选会把终端和标题栏上的开关一起藏掉;标题栏上的点击只收起一块仍然提供的面板,不会清掉设置。插件中心从 schema 画出这一个开关。双击就是从一个不想要的尺寸退回来的那条路。
双击是从把手自己的 pointer 事件里判定的,没有交给平台的 dblclick:把手会取消 pointerdown(这正是拖动时不会顺手选中整页文字的原因)并接管 pointer capture,而这两件事都把兼容性 click 序列推到了规范留给实现自行决定的地带。两次不移动的按压,间隔 400ms 以内、位置相差 6px 以内——是同一个手势,但没有那份不确定。而且只要这次按压移动过,这个窗口就作废,所以"拖完再按一下"绝不会把刚拖出来的尺寸弹回去。
这里没有任何对 harness 的改动:每一个插槽都是 harness 公开的座位,每一处注册都走 slots.inject(),删掉插件那一行,所有这些一起消失。
宿主侧
两块面板都没法靠 harness 已经公开的能力喂出来。host.listDirectory 只列目录(它服务的是工作区选择器),没有任何 API 读文件,而 ctx.terminals 这一层按 Agent 归属设了栅栏——人自己的 shell 不属于任何 agent。所以 node 侧在一个前缀下加了四个端点,仅此而已:
| 路由 | 它回答什么 |
|---|---|
GET /omdsh-sidepanel/tree | 一层目录,目录在前 |
GET /omdsh-sidepanel/file | 判定一个文件;是文本就读出来 |
GET /omdsh-sidepanel/raw | 一张图片的字节,供 <img> 指向 |
WS /omdsh-sidepanel/terminal | 终端 |
每一个都按会话隔离,并且设了两道栅栏。位置:请求带上会话 id,会话的工作目录来自宿主的 session store,解析后不落在它下面的路径一律以 out-of-scope 拒绝——一块能走到 ~/.ssh 的面板,不过是穿着侧边栏外衣的文件浏览器。来源:与 /api 网关完全相同的浏览器信任检查(Host 头为回环地址或已配置的授权主机,加上同源浏览器标记)——一条既读文件又交出 shell 的路由,可达性必须与 /api 一致,不能更宽。
栅栏按逻辑路径判定,所以工作区里的一个软链接仍然可以指向外面。这是有意的:面板的范围就是 agent 本来就在里面工作的目录,服务的是同时拥有两者的那个人;栅栏的职责是拦住走偏的请求,而不是把用户关在自己机器之外。
终端
一个会话一个 shell,底层是 node-pty,面板里是 xterm.js。
它故意活得比连接长。切换会话、收起面板、刷新页面都会断掉 socket,而这三件事没有一件的意思是"杀掉我的 shell"——所以进程留着,输出继续累积进一段有上限的回放缓冲,下一次连接先重放这段历史再转直播。回来时落在命令中间,而不是一个崭新的提示符。真正结束一个 shell 的是:exit、重连宽限期耗尽、会话的目录变了,或者插件被卸载。
协议几乎等于没有。服务端到客户端:原始终端文本。客户端到服务端:原始文本就是按键,除非这一帧以 NUL 开头——那是控制消息。这个前缀正是两者不会混淆的全部原因:在 shell 里敲 {"type":"resize"},它必须真的送到 shell。
工作区在服务器上的时候
宿主侧在读任何东西之前先问一个问题:这段会话的目录,真的在这台机器上吗?omdsh-remdev 发布的 remdev 服务回答它——当答案是一个远程工作区时,那一层目录、预览、图片和 shell 就都从那边来。同样的路由,同样的 JSON,同样的面板:那个插件是照着本插件自己的货币作答的,所以组件那一层从来察觉不到差别。
没有装远程开发插件时,答案是 undefined,每一次读都走它一直走的那条本地分支。这不是降级模式,这就是本插件出厂时的模式。服务是每次请求按名字现取的,而不是在激活时抓在手里——它可能比本插件更晚加载,也可能在 HMR 下走了又回来;两个包在任何方向上都不 import 对方:那张面孔来自[结构化描述](src/remote.ts)。
安装
npx @omdsh-plugins/omdsh-plughub add omdsh-sidepanel这就是插件中心的安装器,只是入 口从按钮换成了 argv。它从这套集合的 registry 里解析出这个插件、从它的 GitHub 仓库装上,并把那条 pnpm 构建白名单写好——裸的 dsh plugin add github:… 会把这一步留给你,而那条记录里带着 pnpm 解析出来的 commit,只能从报错里抄,事先 写不出来。
dsh plugin --profile web add @omdsh-plugins/omdsh-sidepanel 现在还不是那条命令:这个 包不在 npm 上,pnpm 会回 ERR_PNPM_FETCH_404。同一次安装也可以是一个按钮—— 只要 profile 里已经有插件中心,它就在设置 → 插件 → 插件中心里这个插件的卡片 上。
不管走哪条路,安装都会把包放进 profile 目录并追加到 dsh.profile.bundles;这里的 [cordis.patch.yml](cordis.patch.yml) 就是挂载用的那一行。harness 的代码树保持出厂状态。
或者从并排的本地检出装——还没发布的构建就得这么来:
pnpm install && pnpm run build
dsh plugin --profile web add link:../omdsh-plugins/omdsh-sidepanellink: 依赖在安装时不会被构建,前面那一步构建就是为它准备的。
卸载也是同一条路:
dsh plugin --profile web remove @omdsh-plugins/omdsh-sidepanel两侧一起摘掉,本插件写过的每一条外边距也跟着还回去。
这个集合里没有任何一个插件是它的前提,缺哪个就只损失哪个:没有 omdsh-shortcuts 时,两个开关照样打开面板,只是不声称任何快捷键;没有 omdsh-basemode 时,面板跟的是被选中的那段对话,也就是模式出现之前它一直读的那个值;没有 omdsh-chatmode 时,没有工作区叫 Chat,于是每个会话都是 Work,面板始终可用;没有 omdsh-codemode 时,终端默认关而不是开;没有 omdsh-remdev 时,每一次读都走它一直走的那条本地分支。这一行本身只在它 inject 的服务存在的地方激活——webServer、sessions、webRuntime。在没有网页服务器的形态上(TUI、headless)它根本不会激活,这是对的:那里没有浏览器要服务。
命令
pnpm install
pnpm run build # tsc 产出 lib/types,tsdown 打包两侧
pnpm run typecheck # 源码与测试
pnpm run test # vitestpnpm run build 产出三个产物:lib/index.js 与 lib/invariant.js(node 侧,由 loader import),以及 lib/client.js(浏览器侧,一个闭包工厂产物,由外壳在模块图之外 fetch)。xterm 直接打进 lib/client.js,而不是走一条 chunk 路由——这样终端就是一个普通产物。
提交的 manifest 固定指向已发布的 harness。要改成对着同级的 checkout 构建:
pnpm run harness:local ../../deepseek-harness # 那个 checkout 需要先构建过
pnpm install
pnpm run harness:npm # 提交前务必执行 —— link: 是某一台机器的目录布局
pnpm run check:harness-pin已知限制
- 文件树不监听变更。 面板标题栏上的刷新按钮就是全部的对账手段。
- 预览就只是预览。 文本按文本显示,图片内联,其他一律只说明是什么而不渲染;没有语法高亮,不能编辑,也不能保存。
- 终端是进程内的。 它活不过 harness 重启;同一会话在两个浏览器标签里打开时共用一个 shell。
node-pty是原生依赖。 常见平台有预编译二进制;没有的平台会在安装时现编。