dsh-split-view 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-split-view contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # dsh-split-view
2
+
3
+ 把 DeepSeek Harness 主窗口变成终端式分屏,横竖随便拆,每一格都是一整个对话,分屏、关闭、最大化全程不重载。
4
+
5
+ 你有没有过这种经历。一个 agent 正在跑任务,你想同时在旁边再起一个,可官方 web 界面一个窗口只能放一个会话。开新标签页吧,两边各跑各的,切来切去心累;等任务跑完想回头看一眼,又得在一堆标签里翻。你希望它像终端,旁边拆一格,各干各的,抬眼全在。
6
+
7
+ dsh-split-view 就是干这个的。装上之后主窗口变成一个可拖拽的分屏容器,每一格里跑一个完整的 DSH 客户端。
8
+
9
+ ## 长什么样
10
+
11
+ 截图待补,先说行为。
12
+
13
+ - 横竖任意嵌套分屏,可拖动调整大小
14
+ - 每个面板一条标题栏,左边实时显示当前会话标题,右边五个按钮,向右分屏、向下分屏、放大还原、刷新、关闭
15
+ - 一键复制「会话 id + 会话标题」
16
+
17
+ ## 安装
18
+
19
+ npm 包还没发布,先从源码装。
20
+
21
+ ```sh
22
+ dsh plugin --profile web add -w /path/to/dsh-split-view
23
+ dsh web # 重启生效
24
+ ```
25
+
26
+ `-w` 在第一次 add 时必须带。每个 profile 自带 `pnpm-workspace.yaml`,pnpm 会把它当成 workspace 根,裸加会报 `ERR_PNPM_ADDING_TO_ROOT`。
27
+
28
+ 发布之后一条命令就行,`dsh plugin --profile web add dsh-split-view`(CLI 安装要重启 dsh web 生效)。也可以在插件市场(dshmarket)里搜索 dsh-split-view 一键安装:市场会把插件热挂载进运行中的实例,不用重启,页面刷新一次即可。
29
+
30
+ 卸载用 `dsh plugin --profile web remove dsh-split-view`,重启恢复单窗口。插件本身如果出问题,boot 会停下来报错,不会静默白屏,所以卸载也就是紧急恢复的办法。
31
+
32
+ ## 快捷键
33
+
34
+ | 键(macOS ⌘,Windows/Linux Ctrl) | 动作 |
35
+ |---|---|
36
+ | ⇧⌘H | 向右分屏 |
37
+ | ⇧⌘V | 向下分屏 |
38
+ | ⇧⌘X | 关闭聚焦面板 |
39
+ | ⇧⌘Enter | 最大化 / 还原聚焦面板 |
40
+
41
+ 这几个组合是挑出来的,⌘W 关页、⌘D 书签、⌘V 粘贴这些浏览器保留键页面拦不住。关闭键原本用过 ⌘⇧Q,会把浏览器锁死;换 ⌘⇧W,又被飞书的全局热键占了;最后落在 X,就当它是 ✕。都是默认值,可以在设置里改(见下节)。
42
+
43
+ 焦点在 iframe 里时快捷键照样有效,面板内的 agent 捕获后转发给顶层。标题栏按钮做的是同一批动作,tooltip 里的写法按平台来,macOS 显示 ⌘⇧,其余显示 Ctrl+Shift+。点标题栏任意位置就是聚焦那个面板。面板超过 4 秒没就绪会挂一个「⚠ 未就绪 · 重载」角标,点一下单独重载它。
44
+
45
+ ## 设置
46
+
47
+ 在任意面板里打开设置,左侧导航有一项「分屏」。四组设置:
48
+
49
+ - **快捷键**:点某个动作的组合键进入录制,按新组合完成改绑,Esc 取消。录制时会拦掉不安全的组合——不带 ⌘/Ctrl 的、单个字母不带 Shift/Alt 的(浏览器保留区)、⌘⇧W/Q/T/N 这类有前科或被系统占用的、以及和别的动作撞车的,都会给出提示并拒绝。
50
+ - **选中高亮色**:聚焦面板的描边和标题栏染色。预设色板、自定义取色器,外加「跟随主题」——跟随当前皮肤的品牌色 token,换皮肤自动跟着变。
51
+ - **标题栏**:一个开关,隐藏所有面板的标题栏。注意标题栏上有复制会话信息、分屏、最大化、刷新、关闭按钮,双击放大、点标题重命名也走标题栏,隐藏后这些动作只能靠快捷键;点面板聚焦不受影响。
52
+ - **恢复默认**:一键把上面全部还原,点之前会弹确认。
53
+
54
+ 设置的存储边界和布局一致:真源在顶层窗口的 localStorage(键 `dsh-split:prefs`),因为画标题栏、描边、分屏容器的都是顶层。面板里的设置面板改完通过 postMessage 交给顶层校验、落盘,再广播回所有面板——跨 origin 的面板也走这条路,启动时还会拉一次全量。所以不管在哪个面板里改、改完立刻全局生效,刷新也不丢。
55
+
56
+ ## 供其它插件调用
57
+
58
+ 分屏能力整体暴露成了一个 Cordis 服务:面板操作、面板×会话操作(加载指定会话、发消息、改名、换模型、取消回合)、查询与订阅。面向集成方的完整契约——服务 API、URL 契约(给做原生壳的人)与已知限制——见 [api.md](./api.md)。
59
+
60
+ ## 协议
61
+
62
+ MIT © dsh-split-view contributors
package/api.md ADDED
@@ -0,0 +1,292 @@
1
+ # 分屏服务(dsh-split-view)
2
+
3
+ dsh-split-view 把全部的分屏能力暴露成一个名为 `dsh-split-view` 的 Cordis 服务。通过它,其他插件可以增删和操作面板、管理面板里展示的会话,并观察布局变化。本文是这个服务的完整契约。
4
+
5
+ ## 获取服务
6
+
7
+ ### 必需依赖
8
+
9
+ 在 `inject` 里声明,框架保证 `apply` 执行时服务已就绪:
10
+
11
+ ```js
12
+ export const inject = ['dsh-split-view']
13
+
14
+ export function apply(ctx) {
15
+ const sv = ctx['dsh-split-view']
16
+ const panes = sv.panes()
17
+ }
18
+ ```
19
+
20
+ 服务不存在时(用户没有安装 dsh-split-view),你的插件不会加载。注意:框架层没有安装插件的 API(插件清单服务是只读投影),但运行时安装已有现成途径——插件市场(dshmarket)会把新装的插件热挂载进运行中的实例,不需要重启 dsh web,页面刷新一次即可;`dsh plugin --profile web add` 的 CLI 途径则仍需重启生效。依赖方插件依然不能自动安装本插件:安装涉及 pnpm、bundle 层同步与热挂载/重启回退,是市场的职责。所以除非你能接受「缺席即不加载」,请用下一节的可选依赖模式。
21
+
22
+ ### 可选依赖
23
+
24
+ 省略 `inject`,在使用处用 `ctx.get()` 查询:
25
+
26
+ ```js
27
+ export function apply(ctx) {
28
+ const sv = ctx.get('dsh-split-view')
29
+ if (!sv) return // 分屏插件未安装,走单会话逻辑
30
+ // ...
31
+ }
32
+ ```
33
+
34
+ 推荐可选依赖:分屏是增强能力,你的插件应当在它缺席时仍然可用。
35
+
36
+ ### 顶层窗口约束
37
+
38
+ 服务只由**顶层窗口** provide。每个面板是一个独立的 cordis 树(独立的完整 DSH 客户端),跑在面板里的插件拿不到这个服务。如果你的插件在顶层和面板都会加载,先分流:
39
+
40
+ ```js
41
+ const isPane = new URLSearchParams(location.search).has('dshPane')
42
+ ```
43
+
44
+ ## 约定
45
+
46
+ - **面板寻址处处可省**:所有方法的目标面板都可以省略,省略时操作聚焦面板。显式传入不存在的 `paneId` 会抛错(查询类方法例外,返回 `null`)。
47
+ - **带载荷的动词**(`open` / `newSession` / `send` / `rename` / `configure`)通过 `options.pane` 指定目标面板;**只作用于面板的动词**(`close` / `focus` / `maximize` / `wake` / `reload` / `cancel`)按位置传 `paneId`。
48
+ - **变更器都是 async,查询都是同步快照**。查询方法反映调用瞬间的状态,不做缓存。
49
+ - **错误统一抛 `Error("dsh-split-view: …")`**,消息可直接展示给用户。常见错误见[错误处理](#错误处理)。
50
+ - **能力探测**:服务对象带 `version` 字段(当前为 `2`)。老版本只有 `split`,按成员存在性探测即可:
51
+
52
+ ```js
53
+ if (typeof sv.open === 'function') {
54
+ // 新 API 可用
55
+ }
56
+ ```
57
+
58
+ ## 面板描述符
59
+
60
+ 查询方法返回的面板描述符形状如下:
61
+
62
+ | 字段 | 类型 | 说明 |
63
+ |---|---|---|
64
+ | `id` | `string` | 面板 id,即布局树的叶子 id,所有方法的寻址键 |
65
+ | `session` | `string \| null` | 面板当前展示的会话;来自面板自己的上报,面板未就绪时为 `null` |
66
+ | `title` | `string \| null` | 会话标题(面板上报的 displayTitle) |
67
+ | `focused` | `boolean` | 是否为聚焦面板 |
68
+ | `maximized` | `boolean` | 是否为最大化面板 |
69
+ | `ready` | `boolean` | 面板客户端是否已就绪并上报 |
70
+ | `dormant` | `boolean` | 是否休眠(LRU 预算外,无 iframe,唤醒后恢复) |
71
+
72
+ ## 查询
73
+
74
+ ```js
75
+ const sv = ctx.get('dsh-split-view')
76
+
77
+ sv.panes() // 面板描述符数组,布局树序
78
+ sv.pane(paneId?) // 单个描述符,找不到返回 null(省略 paneId 时取聚焦面板)
79
+ sv.focused() // 聚焦面板的 id,没有则 null
80
+ sv.layout() // { tree, focused, maximized } 深拷贝
81
+ ```
82
+
83
+ `layout()` 返回的 `tree` 就是 `applyLayout()` 接受的形状,可以先取出来改再写回。
84
+
85
+ ## 面板生命周期
86
+
87
+ ### 拆分面板
88
+
89
+ `split()` 一次调用开一个新面板,面板落在一个全新的会话上,会话按传入的参数搭好。所有参数可省,省略时等同于快捷键分屏。
90
+
91
+ ```js
92
+ const { paneId, sessionId } = await sv.split({
93
+ pane: 'pXXXX', // 从哪个面板拆,默认聚焦面板
94
+ direction: 'right', // 'right'(默认)向右,'down' 向下
95
+ prompt: '帮我跑一遍测试', // 会话的第一条消息
96
+ title: '回归测试', // 会话标题
97
+ model: 'deepseek/deepseek-v4',
98
+ workspaceId: 'ws-123',
99
+ cwd: '/path/to/project',
100
+ mode: 'cordis', // agent preset id
101
+ session: 'session-…', // 钉一个已有会话进新面板(跳过创建/配置)
102
+ })
103
+ ```
104
+
105
+ 参数说明:
106
+
107
+ | 参数 | 类型 | 说明 |
108
+ |---|---|---|
109
+ | `pane` | `string` | 锚点面板,默认聚焦面板 |
110
+ | `direction` | `'right' \| 'down'` | 新面板出现在锚点的右侧 / 下方,默认 `'right'` |
111
+ | `prompt` | `string` | 会话的第一条消息,创建后作为第一回合发出 |
112
+ | `title` | `string` | 会话标题,创建后重命名 |
113
+ | `model` | `string \| { provider, model, reasoningEffort? }` | 模型,字符串用 `provider/model` 简写 |
114
+ | `workspaceId` | `string` | 工作区,与 `cwd` 二选一 |
115
+ | `cwd` | `string` | 工作目录,与 `workspaceId` 二选一 |
116
+ | `mode` | `string` | agent preset id |
117
+ | `session` | `string` | 已有会话 id:新面板直接钉到它,跳过创建和配置 |
118
+
119
+ 搭会话的顺序是固定的:先按 `workspaceId` 或 `cwd` 建会话(都没有就在默认工作目录建),再选模式、选模型、改标题,最后把 `prompt` 作为第一条消息发出。没传的参数跳过对应步骤。
120
+
121
+ 返回 `{ paneId, leafId, sessionId }`:`paneId` 是新面板的 id,`sessionId` 是搭好的会话(未做创建/配置时为 `null`,面板会自建空白会话)。`leafId` 是 `paneId` 的旧名别名,保留给既有调用方。
122
+
123
+ ### 关闭与焦点
124
+
125
+ ```js
126
+ await sv.close(paneId?) // 关闭面板,相邻面板继承其空间和焦点
127
+ await sv.focus(paneId?) // 聚焦:布局焦点与浏览器焦点一起转移
128
+ await sv.maximize(paneId?, on?) // on 为 true/false 时显式设置,省略时在聚焦面板上 toggle
129
+ await sv.restore() // 退出最大化
130
+ ```
131
+
132
+ 关闭最后一个面板不会清空布局:独苗面板被替换成一个新的空白面板。
133
+
134
+ ### 唤醒与重载
135
+
136
+ ```js
137
+ await sv.wake(paneId?) // 唤醒休眠面板,重新进入活面板预算
138
+ await sv.reload(paneId?) // 活面板原地重载其客户端;休眠面板等价于 wake
139
+ ```
140
+
141
+ ### 替换布局树
142
+
143
+ `applyLayout(tree)` 是逃生舱:整树校验替换,移动面板、调整比例、任意重排都走它。
144
+
145
+ ```js
146
+ const lay = sv.layout()
147
+ lay.tree.children.reverse() // 左右交换两个面板
148
+ await sv.applyLayout(lay.tree)
149
+ ```
150
+
151
+ 树只有两种节点:
152
+
153
+ - **叶子**:`{ kind: 'leaf', id?, session?, origin? }`
154
+ - **分支**:`{ kind: 'branch', id?, dir: 'h' | 'v', children: 节点[], fractions?: number[] }`
155
+
156
+ 校验规则:分支至少两个孩子;`fractions` 缺失或不合法时均分;`dir` 的 `right/down/left/up/v` 等写法统一折叠为 `h/v`;id 缺失或冲突时重新生成。**复用原有叶子 id 的面板不会重载**(iframe 存活,面板内的对话状态保留);新 id 启动新面板。
157
+
158
+ ## 面板 × 会话
159
+
160
+ 这一组动词回答「哪个面板展示哪个会话」以及「对那个会话做什么」。
161
+
162
+ ### 加载已有会话
163
+
164
+ ```js
165
+ await sv.open('session-…', { pane: paneId })
166
+ ```
167
+
168
+ 活面板**热切换**:顶层通过 postMessage 通知面板自己执行 `sessions.open`,不重载 iframe,面板内的客户端状态保留。休眠面板更新启动种子,下次唤醒时生效。会话不在列表里会抛错(列表基线未就绪时跳过校验,由面板侧兜底)。
169
+
170
+ ### 新建会话
171
+
172
+ ```js
173
+ await sv.newSession({
174
+ pane: paneId, // 省略时在聚焦面板中新建
175
+ cwd: '/path', // 会话参数同 split():workspaceId / cwd / title / model / mode / prompt
176
+ title: '…',
177
+ prompt: '…',
178
+ })
179
+ ```
180
+
181
+ 创建并配置好会话后,把它加载进目标面板(机制同 `open`),替换面板原来的会话。
182
+
183
+ ### 发送消息
184
+
185
+ ```js
186
+ await sv.send('继续', { pane: paneId })
187
+ await sv.send('停一下,换个方向', { pane: paneId, mode: 'steer' })
188
+ await sv.send([{ type: 'text', text: '…' }], { pane: paneId })
189
+ ```
190
+
191
+ `content` 接受字符串或 prompt 内容块数组。`mode` 省略为 `'queue'`(追加一个回合);`'steer'` 打断进行中的回合。
192
+
193
+ 实现上,会话是后端的,面板只是它的显示器:顶层窗口用自己的客户端绑定该会话执行 `prompt`,面板经后端事件流看到这一回合——休眠面板同样适用,无需唤醒。
194
+
195
+ ### 重命名与切换模型
196
+
197
+ ```js
198
+ await sv.rename('新标题', { pane: paneId })
199
+ await sv.configure({ model: 'deepseek/deepseek-v4', mode: 'cordis' }, { pane: paneId })
200
+ ```
201
+
202
+ `rename` 对空白会话(标题为工作区名的占位会话)抛错,与标题栏编辑器的行为一致。`configure` 的 `model` / `mode` 至少给一个,语义同 `split()` 的同名参数。
203
+
204
+ ### 取消回合
205
+
206
+ ```js
207
+ await sv.cancel(paneId?)
208
+ ```
209
+
210
+ 取消面板展示的会话正在进行的回合;已排队的消息保留,按原顺序继续。
211
+
212
+ ### 一个时序边界
213
+
214
+ 面板的 `session` 字段来自面板自己的上报。刚 `split()` 出来的面板还没上报时,对它调这组动词会抛错。要随拆分带第一条消息,直接用 `split({ prompt })`——会话在顶层搭好再交给面板,没有时序窗口。
215
+
216
+ ## 观察变化
217
+
218
+ ```js
219
+ const off = sv.subscribe((state) => {
220
+ // state: { panes, focused, maximized },panes 同 sv.panes()
221
+ })
222
+ off() // 取消订阅
223
+ ```
224
+
225
+ 布局结构、焦点、面板会话、标题、就绪与休眠状态任一变化都会触发;相同状态合并,监听器拿到的总是与上次不同的快照。监听器抛出的异常不会影响其他订阅者。
226
+
227
+ ## 错误处理
228
+
229
+ 所有变更器失败时抛 `Error`,消息以 `dsh-split-view: ` 前缀开头。常见的几种:
230
+
231
+ | 消息 | 原因 |
232
+ |---|---|
233
+ | `no such pane: X` | paneId 不存在(已关闭,或传了分支节点 / 布局树的 id) |
234
+ | `unknown session: X` | `open` 的会话不在会话列表里 |
235
+ | `the pane has no session yet (…)` | 面板还没上报会话,见[时序边界](#一个时序边界) |
236
+ | `cannot bind session X` | 顶层客户端无法绑定该会话(通常已不在列表) |
237
+ | `preset select failed: …` / `model select failed: …` | 后端拒绝了 preset / 模型切换,冒号后是后端给出的原因 |
238
+ | `prompt failed: …` / `cancel failed: …` | 后端拒绝的业务错误,同样冒号后是原因 |
239
+
240
+ ## 完整示例
241
+
242
+ 一个「并行跑任务」插件的骨架:开两个面板、各发一条消息、盯着它们跑完:
243
+
244
+ ```js
245
+ export const inject = ['dsh-split-view']
246
+
247
+ export function apply(ctx) {
248
+ const sv = ctx['dsh-split-view']
249
+
250
+ const off = sv.subscribe(({ panes }) => {
251
+ const running = panes.filter((p) => p.session && !p.dormant)
252
+ // 按你的业务消费面板状态…
253
+ })
254
+
255
+ ctx.effect(() => off) // 卸载时取消订阅
256
+
257
+ // 用法示例(真实场景里由你的业务触发):
258
+ // const a = await sv.split({ title: '前端', cwd: '/repo/web' })
259
+ // const b = await sv.split({ title: '后端', cwd: '/repo/api', direction: 'down' })
260
+ // await sv.send('跑一遍单测', { pane: a.paneId })
261
+ // await sv.send('跑一遍集成测试', { pane: b.paneId })
262
+ }
263
+ ```
264
+
265
+ ## 开发调试
266
+
267
+ 给 localStorage 设 `dsh-split:debug` 后,服务会镜像到 `window.__dshSplitView`,可以在 devtools 控制台直接敲 API。默认关闭;插件间集成请走 `inject` / `ctx.get`。
268
+
269
+ ## URL 契约(给做原生壳的人)
270
+
271
+ 原生壳不走分屏容器、直接开独立窗口/标签加载单个面板时,用这组查询参数:
272
+
273
+ - `/?dshPane=<id>` 进入 pane 模式,不套分屏
274
+ - `/?dshPane=<id>&dshSession=<sessionId>` 启动即钉到该会话
275
+ - `/?dshPane=<id>&dshWorkspace=<workspaceId>` 新建并钉到该工作区的会话,空白会话复用
276
+ - `/?dshPane=<id>&dshCwd=<path>` 按工作目录新建会话
277
+ - `/?dshPane=<id>&dshNew=1>` 启动进入无会话空态
278
+
279
+ 跨 origin 面板和顶层之间只走 postMessage,referrer 推导 targetOrigin,加 origin 白名单校验,不读 contentDocument,所以 origin 轮换对面板行为是透明的。
280
+
281
+ ## 已知限制
282
+
283
+ - 顶层窗口不再是 AppFrame,设置面板这类 modal 在顶层打不开,要进任意一个面板里开(「分屏」设置项也一样,在面板的设置里)
284
+ - 每个面板都是完整客户端,内存和连接数跟面板数成正比;休眠的面板没有 iframe,内存也跟着释放
285
+ - 连接预算是浏览器 profile 级 per-origin 共享的,同一个浏览器里别的标签页也开着这个端口的话会挤占名额,九个可能开不满
286
+ - 依赖 DSH 内部接口,slot shadow、ctx.sessions、localStorage 键,DSH 没有稳定承诺,升级后要回归验证
287
+
288
+ ## 下一步
289
+
290
+ - [README](./README.md) — 安装、快捷键与设置
291
+ - [第一个插件](https://deepseek-harness.github.io/deepseek-harness/develop/basic/) — Harness 官方插件开发教程
292
+ - [服务与依赖](https://deepseek-harness.github.io/deepseek-harness/develop/framework/service) — Cordis 服务的 provide / inject 机制
@@ -0,0 +1,7 @@
1
+ # dsh-split-view profile patch layer — insert one loader entry for the plugin
2
+ # package. The entry is a normal cordis loader entry (id + package name); the
3
+ # browser half is picked up by dsh-client-modules through the package's
4
+ # dsh.client declaration, exactly like the shipped ui-* packages.
5
+ - insert:
6
+ - id: split-view
7
+ name: 'dsh-split-view'