dsh-round-rightclick 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +111 -0
- package/assets/screenshots/radial-menu.png +0 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +758 -0
- package/lib/index.js +169 -0
- package/package.json +69 -0
- package/screenshots.json +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
## 0.1.0(2026-09-13)
|
|
4
|
+
|
|
5
|
+
- 在 DSH Web 对话区域右键打开 240 像素圆盘,提供六个带图标的快捷操作。
|
|
6
|
+
- 从指定已完成轮次创建分支,保留此前完整对话并打开新会话。
|
|
7
|
+
- 支持打开工作目录、复制路径与会话 ID、打断生成、导出日志。
|
|
8
|
+
- 页面顶部显示操作反馈:绿色圆形对勾表示成功,红色圆形感叹号表示失败。
|
|
9
|
+
- 支持方向键选择、Enter 执行、Esc 关闭、窗口边缘避让和重复点击保护。
|
|
10
|
+
- 提供中英文文案、自动化测试和已安装宿主的分叉契约检查脚本。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hmr-BH
|
|
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.en.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# dsh-round-rightclick
|
|
2
|
+
|
|
3
|
+
A radial right-click menu for DeepSeek Harness. Right-click a specific turn in the conversation to run common session actions at the cursor.
|
|
4
|
+
|
|
5
|
+
[简体中文](README.md) · [Changelog](CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## Why this plugin exists
|
|
11
|
+
|
|
12
|
+
Session actions in DSH are spread across the interface. The workspace folder has to be found in a sidebar, interrupting generation requires catching the right moment, and branching from an earlier turn is not available at all — the only option is copying the preceding conversation into a new session by hand and verifying nothing was missed.
|
|
13
|
+
|
|
14
|
+
The radial menu collects these actions in one place. Right-click a specific question or answer and six actions are arranged around the cursor, selectable by mouse or keyboard.
|
|
15
|
+
|
|
16
|
+
The central action is **Fork this turn**. Take a session with three completed turns:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Turn 1: pin down the request
|
|
20
|
+
Turn 2: compare approaches
|
|
21
|
+
Turn 3: polish the copy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Forking turn 1 produces a session containing only turn 1; forking turn 2 produces one containing turns 1–2. The source session is left unchanged, so both can stay open for comparison. When a conversation drifts, work resumes from the turn before the drift instead of re-supplying context to a blank session.
|
|
25
|
+
|
|
26
|
+
## Screenshot
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
## The six actions
|
|
31
|
+
|
|
32
|
+
| Action | Purpose | Available when |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| Open folder | Opens the session's workspace in the OS file manager | The session has a directory and DSH runs locally |
|
|
35
|
+
| Copy path | Writes the full workspace path to the clipboard | The browser permits clipboard writes |
|
|
36
|
+
| Copy session ID | Copies the current session's ID | A session is open |
|
|
37
|
+
| Interrupt | Stops the running generation or tool call, keeping the conversation | The session is running |
|
|
38
|
+
| Fork this turn | Creates a branch session carrying the conversation through that turn | The turn is complete and loaded |
|
|
39
|
+
| Export log | Downloads this session and its children as a log ZIP | The host exposes the export service |
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
### From a release tarball (recommended)
|
|
44
|
+
|
|
45
|
+
Download `dsh-round-rightclick-0.1.0.tgz` from [Releases](https://github.com/hmr-BH/dsh-round-rightclick/releases), then run:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
dsh plugin --profile web add ./dsh-round-rightclick-0.1.0.tgz
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Restart `dsh web` and refresh the page. The tarball contains built output, so neither pnpm nor a local build is required.
|
|
52
|
+
|
|
53
|
+
### From source
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
git clone https://github.com/hmr-BH/dsh-round-rightclick.git
|
|
57
|
+
cd dsh-round-rightclick
|
|
58
|
+
pnpm install --frozen-lockfile
|
|
59
|
+
pnpm run build
|
|
60
|
+
dsh plugin --profile web add .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Restart `dsh web` after a source install as well. To remove the plugin:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
dsh plugin --profile web remove dsh-round-rightclick
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Interaction
|
|
70
|
+
|
|
71
|
+
- Right-click a completed question or answer to open the wheel.
|
|
72
|
+
- Left-click a sector to run it, or navigate with the arrow keys and press Enter.
|
|
73
|
+
- Releasing the right button never fires an action, so holding it while moving across sectors is safe.
|
|
74
|
+
- Escape, Tab, page scrolling, window blur, window resizing, and clicking outside the wheel all close the menu.
|
|
75
|
+
- The menu is locked while an action runs; repeated clicks do not issue a second request.
|
|
76
|
+
- Inputs, `contenteditable` regions, and other editable controls keep the browser's native context menu. The plugin does not take over these areas.
|
|
77
|
+
|
|
78
|
+
## Troubleshooting
|
|
79
|
+
|
|
80
|
+
**Right-click does nothing.** Confirm the plugin is installed under the `web` profile, restart `dsh web`, and refresh the page. The click must land on conversation content; the composer is deliberately excluded.
|
|
81
|
+
|
|
82
|
+
**"Fork this turn" is disabled.** Check that the right-click landed on a specific turn's question or answer, and that the turn has finished. If it is still generating, run Interrupt first and reopen the menu once the end event has loaded.
|
|
83
|
+
|
|
84
|
+
**Copy fails.** There are two common causes: the browser has not granted clipboard permission to the page, or the page is not served over localhost or HTTPS. The Clipboard API refuses writes in both cases.
|
|
85
|
+
|
|
86
|
+
**Open folder is unavailable in an SSH session.** This is intentional. The file manager can only launch on the machine running DSH; mapping it regardless would open a window on a server with no attached display, so the plugin refuses and reports the reason.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
pnpm install --frozen-lockfile
|
|
92
|
+
pnpm typecheck
|
|
93
|
+
pnpm test
|
|
94
|
+
node scripts/package-release.mjs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Code layout:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
src/client/RadialMenu.tsx rendering, keyboard navigation, action feedback
|
|
101
|
+
src/client/menu-layout.ts wheel geometry, sector hit areas, viewport avoidance
|
|
102
|
+
src/client/fork-target.ts fork turn resolution
|
|
103
|
+
src/client/index.ts wiring to the host session service
|
|
104
|
+
src/client/styles.ts wheel, icon, and notification styles
|
|
105
|
+
src/opener.ts directory validation and file-manager launch
|
|
106
|
+
tests/ unit and component tests
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
[MIT](LICENSE). A community plugin maintained by an individual, with no affiliation to DeepSeek.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# dsh-round-rightclick
|
|
2
|
+
|
|
3
|
+
为 DeepSeek Harness 提供圆盘式右键菜单:在对话的具体轮次上点击右键,即可在光标位置执行常用会话操作。
|
|
4
|
+
|
|
5
|
+
[English](README.en.md) · [更新记录](CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## 为什么需要这个插件
|
|
11
|
+
|
|
12
|
+
DSH 的会话操作分散在页面各处。工作目录需要在侧栏中查找,打断生成需要判断时机,而「从中间某一轮创建分支」在原生功能中并不存在——只能手动把此前的对话复制到新会话,并自行确认没有遗漏。
|
|
13
|
+
|
|
14
|
+
圆盘菜单把这些操作集中到一处。右键点击某条具体的提问或回复,六个动作围绕光标排列,鼠标与键盘均可选择。
|
|
15
|
+
|
|
16
|
+
核心动作是**从此轮分叉**。以一个包含三轮完整对话的会话为例:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
第 1 轮:确定需求
|
|
20
|
+
第 2 轮:比较方案
|
|
21
|
+
第 3 轮:完善文案
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
在第 1 轮上分叉,新会话只包含第 1 轮;在第 2 轮上分叉,新会话包含第 1、2 轮。源会话保持不变,两个会话可以并行对照。对话方向偏离时,从偏离前的那一轮重新开始即可,不必在新会话中重新提供上下文。
|
|
25
|
+
|
|
26
|
+
## 截图
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
## 六个动作
|
|
31
|
+
|
|
32
|
+
| 动作 | 作用 | 可用条件 |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| 打开目录 | 在系统文件管理器中打开该会话的工作目录 | 会话已设置目录,且 DSH 运行在本机 |
|
|
35
|
+
| 复制路径 | 将工作目录的完整路径写入剪贴板 | 浏览器允许剪贴板写入 |
|
|
36
|
+
| 复制会话 ID | 复制当前会话的 ID | 存在已打开的会话 |
|
|
37
|
+
| 打断 | 停止正在执行的生成或工具调用,保留已有对话 | 会话处于运行状态 |
|
|
38
|
+
| 从此轮分叉 | 携带截至该轮的对话创建新的分支会话 | 该轮已完成且已加载 |
|
|
39
|
+
| 导出日志 | 下载当前会话及其子会话的日志 ZIP | 宿主提供导出服务 |
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
### Release 包安装(推荐)
|
|
44
|
+
|
|
45
|
+
从 [Releases](https://github.com/hmr-BH/dsh-round-rightclick/releases) 下载 `dsh-round-rightclick-0.1.0.tgz`,然后执行:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
dsh plugin --profile web add ./dsh-round-rightclick-0.1.0.tgz
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
重启 `dsh web` 并刷新页面。安装包内已含构建产物,无需安装 pnpm,也无需本地编译。
|
|
52
|
+
|
|
53
|
+
### 源码安装
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
git clone https://github.com/hmr-BH/dsh-round-rightclick.git
|
|
57
|
+
cd dsh-round-rightclick
|
|
58
|
+
pnpm install --frozen-lockfile
|
|
59
|
+
pnpm run build
|
|
60
|
+
dsh plugin --profile web add .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
安装后同样需要重启 `dsh web`。卸载命令:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
dsh plugin --profile web remove dsh-round-rightclick
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 操作方式
|
|
70
|
+
|
|
71
|
+
- 在已完成的提问或回复上点击右键,圆盘菜单出现。
|
|
72
|
+
- 左键点击扇区执行动作,或使用方向键选择后按 Enter。
|
|
73
|
+
- 松开右键不会触发动作,因此按住右键在扇区之间移动是安全的。
|
|
74
|
+
- Esc、Tab、页面滚动、窗口失焦、窗口缩放、点击圆盘外部,均会关闭菜单。
|
|
75
|
+
- 动作执行期间菜单锁定,重复点击不会发出第二次请求。
|
|
76
|
+
- 输入框、`contenteditable` 等可编辑区域保留浏览器原生右键菜单,插件不接管这些区域。
|
|
77
|
+
|
|
78
|
+
## 常见问题
|
|
79
|
+
|
|
80
|
+
**右键无响应。** 确认插件安装在 `web` profile 下,重启 `dsh web` 并刷新页面。此外需要点击对话内容,输入框区域被有意排除。
|
|
81
|
+
|
|
82
|
+
**「从此轮分叉」置灰。** 确认右键点击的是具体轮次的提问或回复,且该轮已经结束。若仍在生成,可先执行「打断」,待结束事件加载后重新打开菜单。
|
|
83
|
+
|
|
84
|
+
**复制失败。** 常见原因有两类:浏览器未授予该页面剪贴板权限,或页面并非通过 localhost / HTTPS 提供。这两种情况下 Clipboard API 都会拒绝写入。
|
|
85
|
+
|
|
86
|
+
**远程 SSH 会话无法打开目录。** 这是有意设计。文件管理器只能在运行 DSH 的机器上启动,若强行映射,只会在服务器上打开一个无人可见的窗口,因此插件直接拒绝并说明原因。
|
|
87
|
+
|
|
88
|
+
## 开发
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
pnpm install --frozen-lockfile
|
|
92
|
+
pnpm typecheck
|
|
93
|
+
pnpm test
|
|
94
|
+
node scripts/package-release.mjs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
代码结构:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
src/client/RadialMenu.tsx 渲染、键盘导航、操作反馈
|
|
101
|
+
src/client/menu-layout.ts 圆盘几何、扇区命中区域、视口避让
|
|
102
|
+
src/client/fork-target.ts 分叉轮次解析
|
|
103
|
+
src/client/index.ts 与宿主会话服务的接线
|
|
104
|
+
src/client/styles.ts 圆盘、图标、顶部提示的样式
|
|
105
|
+
src/opener.ts 目录校验与文件管理器调用
|
|
106
|
+
tests/ 单元测试与组件测试
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 许可
|
|
110
|
+
|
|
111
|
+
[MIT](LICENSE)。个人维护的社区插件,与 DeepSeek 官方无隶属关系。
|
|
Binary file
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-round-rightclick", factory: (require) => {
|
|
2
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
3
|
+
"use strict";
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
|
|
22
|
+
// src/client/index.ts
|
|
23
|
+
var index_exports = {};
|
|
24
|
+
__export(index_exports, {
|
|
25
|
+
OPEN_DIR_ROUTE: () => OPEN_DIR_ROUTE,
|
|
26
|
+
OVERLAY_ID: () => OVERLAY_ID,
|
|
27
|
+
PIE_ACTION_IDS: () => PIE_ACTION_IDS,
|
|
28
|
+
RadialMenu: () => RadialMenu,
|
|
29
|
+
apply: () => apply,
|
|
30
|
+
createActionDeps: () => createActionDeps,
|
|
31
|
+
downloadSessionExport: () => downloadSessionExport,
|
|
32
|
+
hitTestPie: () => hitTestPie,
|
|
33
|
+
inject: () => inject,
|
|
34
|
+
isActionEnabled: () => isActionEnabled,
|
|
35
|
+
isSessionConversationSurface: () => isSessionConversationSurface,
|
|
36
|
+
runAction: () => runAction,
|
|
37
|
+
sliceCssMidDegrees: () => sliceCssMidDegrees,
|
|
38
|
+
sliceCssSpanDegrees: () => sliceCssSpanDegrees,
|
|
39
|
+
sliceMidAngle: () => sliceMidAngle
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(index_exports);
|
|
42
|
+
|
|
43
|
+
// src/client/RadialMenu.tsx
|
|
44
|
+
var import_react = require("react");
|
|
45
|
+
|
|
46
|
+
// src/client/actions.ts
|
|
47
|
+
var PIE_ACTION_IDS = [
|
|
48
|
+
"open-dir",
|
|
49
|
+
"copy-cwd",
|
|
50
|
+
"copy-session-id",
|
|
51
|
+
"stop",
|
|
52
|
+
"fork",
|
|
53
|
+
"export"
|
|
54
|
+
];
|
|
55
|
+
function isActionEnabled(id, state) {
|
|
56
|
+
switch (id) {
|
|
57
|
+
case "open-dir":
|
|
58
|
+
case "copy-cwd":
|
|
59
|
+
return state.cwd !== "";
|
|
60
|
+
case "copy-session-id":
|
|
61
|
+
case "export":
|
|
62
|
+
return state.sessionId !== void 0 && state.sessionId !== "";
|
|
63
|
+
case "fork":
|
|
64
|
+
return Boolean(state.sessionId) && state.forkTarget?.kind === "ready";
|
|
65
|
+
case "stop":
|
|
66
|
+
return Boolean(state.sessionId) && state.running;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function runAction(id, state, deps) {
|
|
70
|
+
if (!isActionEnabled(id, state)) return;
|
|
71
|
+
switch (id) {
|
|
72
|
+
case "open-dir":
|
|
73
|
+
await deps.openDirectory(state.cwd);
|
|
74
|
+
return;
|
|
75
|
+
case "copy-cwd":
|
|
76
|
+
await deps.clipboardWrite(state.cwd);
|
|
77
|
+
return;
|
|
78
|
+
case "copy-session-id":
|
|
79
|
+
await deps.clipboardWrite(state.sessionId);
|
|
80
|
+
return;
|
|
81
|
+
case "stop":
|
|
82
|
+
await deps.cancel(state.sessionId);
|
|
83
|
+
return;
|
|
84
|
+
case "fork":
|
|
85
|
+
if (state.forkTarget?.kind === "ready") await deps.fork(state.sessionId, state.forkTarget.atSeq);
|
|
86
|
+
return;
|
|
87
|
+
case "export":
|
|
88
|
+
await deps.exportLog(state.sessionId);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function sessionLogZipFilename(sessionId) {
|
|
93
|
+
return `dsh-session-${sessionId.replace(/[^A-Za-z0-9_-]/g, "_")}.zip`;
|
|
94
|
+
}
|
|
95
|
+
async function downloadSessionExport(sessionId, deps) {
|
|
96
|
+
const url = new URL("/api/session.export", deps.hostBase());
|
|
97
|
+
url.searchParams.set("sessionId", sessionId);
|
|
98
|
+
url.searchParams.set("includeDescendants", "true");
|
|
99
|
+
const response = await deps.fetch(url, { method: "HEAD" });
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`Export failed: HTTP ${String(response.status)}`);
|
|
102
|
+
}
|
|
103
|
+
deps.save(url.toString(), sessionLogZipFilename(sessionId));
|
|
104
|
+
}
|
|
105
|
+
function hostBase() {
|
|
106
|
+
const origin = globalThis.location?.origin;
|
|
107
|
+
return origin !== void 0 && origin !== "null" ? origin : "http://dsh.internal";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/client/locales.ts
|
|
111
|
+
var NS = "dsh-round-rightclick";
|
|
112
|
+
var zh = {
|
|
113
|
+
"open-dir": "\u6253\u5F00\u76EE\u5F55",
|
|
114
|
+
"copy-cwd": "\u590D\u5236\u8DEF\u5F84",
|
|
115
|
+
"copy-session-id": "\u590D\u5236\u4F1A\u8BDD ID",
|
|
116
|
+
stop: "\u6253\u65AD",
|
|
117
|
+
fork: "\u4ECE\u6B64\u8F6E\u5206\u53C9",
|
|
118
|
+
export: "\u5BFC\u51FA\u65E5\u5FD7",
|
|
119
|
+
"open-dir.description": "\u5728\u7CFB\u7EDF\u6587\u4EF6\u7BA1\u7406\u5668\u4E2D\u6253\u5F00\u6B64\u4F1A\u8BDD\u7684\u5DE5\u4F5C\u76EE\u5F55",
|
|
120
|
+
"copy-cwd.description": "\u590D\u5236\u6B64\u4F1A\u8BDD\u5DE5\u4F5C\u76EE\u5F55\u7684\u5B8C\u6574\u8DEF\u5F84",
|
|
121
|
+
"copy-session-id.description": "\u590D\u5236\u6B64\u4F1A\u8BDD\u7684\u552F\u4E00 ID",
|
|
122
|
+
"stop.description": "\u6253\u65AD\u5F53\u524D\u751F\u6210\u6216\u5DE5\u5177\u6267\u884C\uFF0C\u4FDD\u7559\u5DF2\u6709\u5BF9\u8BDD",
|
|
123
|
+
"fork.description": "\u4FDD\u7559\u622A\u81F3\u7B2C {turn} \u8F6E\u7684\u5B8C\u6574\u5BF9\u8BDD\uFF0C\u5E76\u6253\u5F00\u65B0\u7684\u5206\u652F\u4F1A\u8BDD",
|
|
124
|
+
"export.description": "\u4E0B\u8F7D\u6B64\u4F1A\u8BDD\u53CA\u5B50\u4F1A\u8BDD\u7684\u65E5\u5FD7 ZIP \u6587\u4EF6",
|
|
125
|
+
"open-dir.success": "\u5DF2\u8BF7\u6C42\u6253\u5F00\u5DE5\u4F5C\u76EE\u5F55",
|
|
126
|
+
"copy-cwd.success": "\u5DE5\u4F5C\u76EE\u5F55\u8DEF\u5F84\u5DF2\u590D\u5236",
|
|
127
|
+
"copy-session-id.success": "\u4F1A\u8BDD ID \u5DF2\u590D\u5236",
|
|
128
|
+
"stop.success": "\u5DF2\u53D1\u9001\u6253\u65AD\u8BF7\u6C42",
|
|
129
|
+
"fork.success": "\u5DF2\u4ECE\u7B2C {turn} \u8F6E\u521B\u5EFA\u5E76\u6253\u5F00\u5206\u652F\u4F1A\u8BDD",
|
|
130
|
+
"export.success": "\u5DF2\u5F00\u59CB\u4E0B\u8F7D\u4F1A\u8BDD\u65E5\u5FD7",
|
|
131
|
+
"open-dir.pending": "\u6B63\u5728\u6253\u5F00\u5DE5\u4F5C\u76EE\u5F55\u2026",
|
|
132
|
+
"copy-cwd.pending": "\u6B63\u5728\u590D\u5236\u8DEF\u5F84\u2026",
|
|
133
|
+
"copy-session-id.pending": "\u6B63\u5728\u590D\u5236\u4F1A\u8BDD ID\u2026",
|
|
134
|
+
"stop.pending": "\u6B63\u5728\u8BF7\u6C42\u6253\u65AD\u2026",
|
|
135
|
+
"fork.pending": "\u6B63\u5728\u4ECE\u7B2C {turn} \u8F6E\u521B\u5EFA\u5206\u652F\u2026",
|
|
136
|
+
"export.pending": "\u6B63\u5728\u51C6\u5907\u4F1A\u8BDD\u65E5\u5FD7\u2026",
|
|
137
|
+
menu: "\u4F1A\u8BDD\u5FEB\u6377\u83DC\u5355",
|
|
138
|
+
choose: "\u9009\u62E9\u64CD\u4F5C",
|
|
139
|
+
cancel: "\u5173\u95ED\u83DC\u5355",
|
|
140
|
+
keys: "\u65B9\u5411\u952E\u9009\u62E9 \xB7 Enter \u6267\u884C \xB7 Esc \u5173\u95ED",
|
|
141
|
+
turn: "\u7B2C {turn} \u8F6E",
|
|
142
|
+
"no-turn": "\u8BF7\u5728\u8981\u5206\u53C9\u7684\u63D0\u95EE\u6216\u56DE\u590D\u4E0A\u53F3\u952E",
|
|
143
|
+
unfinished: "\u6B64\u8F6E\u5C1A\u672A\u7ED3\u675F\uFF0C\u7ED3\u675F\u6216\u6253\u65AD\u540E\u53EF\u5206\u53C9",
|
|
144
|
+
unavailable: "\u6B64\u8F6E\u8BB0\u5F55\u5C1A\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7B49\u5F85\u52A0\u8F7D\u540E\u91CD\u65B0\u6253\u5F00\u83DC\u5355",
|
|
145
|
+
"no-directory": "\u6B64\u4F1A\u8BDD\u672A\u8BBE\u7F6E\u5DE5\u4F5C\u76EE\u5F55",
|
|
146
|
+
idle: "\u5F53\u524D\u6CA1\u6709\u6B63\u5728\u8FDB\u884C\u7684\u751F\u6210\u6216\u5DE5\u5177\u6267\u884C",
|
|
147
|
+
busy: "\u6B63\u5728\u5904\u7406\u4E0A\u4E00\u9879\u64CD\u4F5C\uFF0C\u8BF7\u7A0D\u5019",
|
|
148
|
+
failed: "\u64CD\u4F5C\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u8BD5",
|
|
149
|
+
"clipboard-denied": "\u65E0\u6CD5\u5199\u5165\u526A\u8D34\u677F\uFF0C\u8BF7\u5141\u8BB8\u6D4F\u89C8\u5668\u8BBF\u95EE\u526A\u8D34\u677F\u540E\u91CD\u8BD5",
|
|
150
|
+
"network-error": "\u65E0\u6CD5\u8FDE\u63A5\u672C\u5730\u670D\u52A1\uFF0C\u8BF7\u68C0\u67E5\u8FDE\u63A5\u540E\u91CD\u8BD5",
|
|
151
|
+
dismiss: "\u5173\u95ED\u63D0\u793A"
|
|
152
|
+
};
|
|
153
|
+
var en = {
|
|
154
|
+
"open-dir": "Open folder",
|
|
155
|
+
"copy-cwd": "Copy path",
|
|
156
|
+
"copy-session-id": "Copy session ID",
|
|
157
|
+
stop: "Interrupt",
|
|
158
|
+
fork: "Fork this turn",
|
|
159
|
+
export: "Export log",
|
|
160
|
+
"open-dir.description": "Open this session\u2019s workspace in your file manager",
|
|
161
|
+
"copy-cwd.description": "Copy the full workspace directory path",
|
|
162
|
+
"copy-session-id.description": "Copy this session\u2019s unique ID",
|
|
163
|
+
"stop.description": "Interrupt generation or tool execution and keep the conversation",
|
|
164
|
+
"fork.description": "Keep the conversation through turn {turn} and open a new branch",
|
|
165
|
+
"export.description": "Download this session and its children as a log ZIP",
|
|
166
|
+
"open-dir.success": "Requested to open the workspace",
|
|
167
|
+
"copy-cwd.success": "Workspace path copied",
|
|
168
|
+
"copy-session-id.success": "Session ID copied",
|
|
169
|
+
"stop.success": "Interrupt requested",
|
|
170
|
+
"fork.success": "Created and opened a branch from turn {turn}",
|
|
171
|
+
"export.success": "Session log download started",
|
|
172
|
+
"open-dir.pending": "Opening workspace\u2026",
|
|
173
|
+
"copy-cwd.pending": "Copying path\u2026",
|
|
174
|
+
"copy-session-id.pending": "Copying session ID\u2026",
|
|
175
|
+
"stop.pending": "Requesting interruption\u2026",
|
|
176
|
+
"fork.pending": "Creating a branch from turn {turn}\u2026",
|
|
177
|
+
"export.pending": "Preparing session log\u2026",
|
|
178
|
+
menu: "Session shortcuts",
|
|
179
|
+
choose: "Choose action",
|
|
180
|
+
cancel: "Close menu",
|
|
181
|
+
keys: "Arrows to select \xB7 Enter to run \xB7 Esc to close",
|
|
182
|
+
turn: "Turn {turn}",
|
|
183
|
+
"no-turn": "Right-click the question or answer you want to branch from",
|
|
184
|
+
unfinished: "This turn is still running; finish or interrupt it before branching",
|
|
185
|
+
unavailable: "This turn is not loaded yet; reopen the menu after loading",
|
|
186
|
+
"no-directory": "This session has no workspace directory",
|
|
187
|
+
idle: "No generation or tool execution is running",
|
|
188
|
+
busy: "An action is already in progress",
|
|
189
|
+
failed: "Action failed. Please retry",
|
|
190
|
+
"clipboard-denied": "Clipboard access failed. Allow clipboard access and retry",
|
|
191
|
+
"network-error": "Cannot reach the local service. Check the connection and retry",
|
|
192
|
+
dismiss: "Dismiss notification"
|
|
193
|
+
};
|
|
194
|
+
function dictionaryForDocument() {
|
|
195
|
+
const lang = typeof document === "undefined" ? "" : document.documentElement.lang;
|
|
196
|
+
return lang === "" || lang.toLowerCase().startsWith("zh") ? zh : en;
|
|
197
|
+
}
|
|
198
|
+
function withTurn(text, state) {
|
|
199
|
+
return text.replace("{turn}", String(state.forkTarget?.turn ?? ""));
|
|
200
|
+
}
|
|
201
|
+
function actionDescription(id, state, copy) {
|
|
202
|
+
if ((id === "open-dir" || id === "copy-cwd") && state.cwd === "") return copy["no-directory"];
|
|
203
|
+
if (id === "stop" && !state.running) return copy.idle;
|
|
204
|
+
if (id === "fork" && state.forkTarget?.kind !== "ready") return copy[state.forkTarget?.kind ?? "no-turn"];
|
|
205
|
+
return withTurn(copy[`${id}.description`], state);
|
|
206
|
+
}
|
|
207
|
+
function actionError(error, id, copy) {
|
|
208
|
+
if (id === "copy-cwd" || id === "copy-session-id") return copy["clipboard-denied"];
|
|
209
|
+
if (error instanceof TypeError) return copy["network-error"];
|
|
210
|
+
return `${copy[id]}\uFF1A${copy.failed}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/client/styles.ts
|
|
214
|
+
var TAG_ID = "dsh-round-rightclick/radial";
|
|
215
|
+
var CSS = `
|
|
216
|
+
[data-dsh-rr-layer] { --dsw-corner-shape: round; position: fixed; inset: 0; z-index: 2147483000; pointer-events: none; font-family: var(--dsw-font-family, system-ui, sans-serif); }
|
|
217
|
+
[data-dsh-rr-catcher] { position: fixed; inset: 0; pointer-events: auto; }
|
|
218
|
+
/* \u5BBF\u4E3B\u4E3B\u9898\u4F1A\u7ED9\u6240\u6709\u5143\u7D20\u8BBE\u7F6E\u8D85\u692D\u5706\uFF0C\u5706\u76D8\u53CA\u5176\u540C\u5FC3\u5706\u5FC5\u987B\u660E\u786E\u4F7F\u7528\u6807\u51C6\u5706\u5F27\u3002 */
|
|
219
|
+
[data-dsh-rr-pie], [data-dsh-rr-pie]::before, [data-dsh-rr-pie]::after, [data-dsh-rr-hole], [data-dsh-rr-action] { corner-shape: round; }
|
|
220
|
+
[data-dsh-rr-pie] { position: fixed; width: var(--dsh-rr-size); height: var(--dsh-rr-size); transform: translate(-50%, -50%); pointer-events: auto; border-radius: 50%; overflow: visible; color: #dce4ed; background: radial-gradient(circle at 40% 25%, #292e34, #181b20 75%); box-shadow: 0 0 0 1px rgb(188 207 224 / .12), 0 0 0 4px rgb(147 186 220 / .045), 0 10px 30px rgb(0 0 0 / .4), 0 0 22px rgb(129 195 249 / .13); animation: dsh-rr-open .16s ease-out; }
|
|
221
|
+
[data-dsh-rr-pie]::before { content: ''; position: absolute; inset: 3%; border-radius: 50%; border: 1px solid rgb(200 219 237 / .15); box-shadow: inset 0 0 25px rgb(0 0 0 / .2); pointer-events: none; z-index: 3; }
|
|
222
|
+
[data-dsh-rr-pie]::after { content: ''; position: absolute; inset: 3%; border-radius: 50%; background: repeating-conic-gradient(from -30deg, rgb(209 224 236 / .14) 0deg .45deg, transparent .45deg 60deg); mask: radial-gradient(circle, transparent 0 calc(var(--dsh-rr-hole) + 1px), #000 calc(var(--dsh-rr-hole) + 2px)); pointer-events: none; z-index: 3; }
|
|
223
|
+
[data-dsh-rr-hole] { appearance: none; box-sizing: border-box; position: absolute; left: 50%; top: 50%; width: calc(var(--dsh-rr-hole) * 2); height: calc(var(--dsh-rr-hole) * 2); margin: 0; padding: 8px; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; font: inherit; color: #c3d1df; border-radius: 50%; background: radial-gradient(circle at 40% 30%, #343c45, #242a31); border: 1px solid rgb(212 230 245 / .19); box-shadow: inset 0 0 0 7px rgb(255 255 255 / .025), 0 0 0 5px rgb(0 0 0 / .12), 0 0 22px rgb(165 206 239 / .08); z-index: 4; cursor: pointer; }
|
|
224
|
+
[data-dsh-rr-hole] strong { font-size: clamp(10px, calc(var(--dsh-rr-size) * .046), 11px); font-weight: 550; }
|
|
225
|
+
[data-dsh-rr-hole] span { font: 10px/1 system-ui, sans-serif; color: #8997a7; letter-spacing: .08em; }
|
|
226
|
+
[data-dsh-rr-hole]:hover { border-color: #789ab6; }
|
|
227
|
+
[data-dsh-rr-hole]:focus-visible { outline: 2px solid #8bcaff; outline-offset: 3px; }
|
|
228
|
+
[data-dsh-rr-action] { appearance: none; position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #dce4ed; cursor: pointer; z-index: 2; transition: background .16s, color .16s; }
|
|
229
|
+
[data-dsh-rr-action][data-active]:not([data-disabled]), [data-dsh-rr-action]:focus-visible:not([data-disabled]) { background: radial-gradient(circle, rgb(100 190 250 / .06) 15%, rgb(109 188 249 / .16) 55%, rgb(133 206 255 / .3)); color: #fff; outline: none; }
|
|
230
|
+
[data-dsh-rr-action][data-disabled] { color: #78818e; cursor: not-allowed; }
|
|
231
|
+
[data-dsh-rr-action][data-active][data-disabled] { background: rgb(255 255 255 / .025); }
|
|
232
|
+
[data-dsh-rr-action][data-action="stop"][data-active]:not([data-disabled]) { color: #ffd7be; background: radial-gradient(circle, transparent 18%, rgb(237 162 108 / .2)); }
|
|
233
|
+
[data-dsh-rr-action]:active:not([data-disabled]) [data-dsh-rr-label] { transform: translate(-50%, -50%) scale(.95); }
|
|
234
|
+
[data-dsh-rr-label] { position: absolute; width: 30%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 5px; color: inherit; font: 500 clamp(10px, calc(var(--dsh-rr-size) * .046), 11px)/1.3 system-ui, sans-serif; text-align: center; user-select: none; pointer-events: none; transition: transform .12s; }
|
|
235
|
+
[data-dsh-rr-icon] { display: block; flex: none; }
|
|
236
|
+
[data-dsh-rr-label] [data-dsh-rr-icon] { width: calc(var(--dsh-rr-size) * .083); height: calc(var(--dsh-rr-size) * .083); }
|
|
237
|
+
[data-active]:not([data-disabled]) [data-dsh-rr-icon] { filter: drop-shadow(0 0 7px rgb(180 222 255 / .5)); }
|
|
238
|
+
[data-dsh-rr-detail] { position: absolute; left: 50%; top: calc(100% + 10px); width: max-content; max-width: min(var(--dsh-rr-size), calc(100vw - 24px)); box-sizing: border-box; transform: translateX(-50%); padding: 5px 9px; border: 1px solid #434d5c; border-radius: 8px; color: #b5c3d3; background: rgb(23 27 33 / .96); box-shadow: 0 4px 12px rgb(0 0 0 / .2); font: 11px/1.4 system-ui, sans-serif; text-align: center; pointer-events: none; z-index: 5; visibility: hidden; }
|
|
239
|
+
[data-dsh-rr-detail][data-visible] { visibility: visible; }
|
|
240
|
+
[data-dsh-rr-toast] { position: fixed; left: 50%; top: 16px; bottom: auto; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; width: max-content; max-width: min(480px, calc(100vw - 32px)); box-sizing: border-box; padding: 10px 12px; border: 1px solid rgb(255 255 255 / .14); border-radius: 12px; color: #f6f8fb; background: rgb(22 27 35 / .96); box-shadow: 0 6px 24px rgb(0 0 0 / .3); backdrop-filter: blur(12px); font-size: 13px; line-height: 20px; font-weight: 500; animation: dsh-rr-toast-in .18s ease-out; }
|
|
241
|
+
[data-dsh-rr-toast-text] { min-width: 0; overflow-wrap: anywhere; }
|
|
242
|
+
[data-dsh-rr-toast][data-kind="success"] > [data-dsh-rr-icon] { color: #42bd7b; }
|
|
243
|
+
[data-dsh-rr-toast][data-kind="error"] > [data-dsh-rr-icon] { color: #f16b73; }
|
|
244
|
+
[data-dsh-rr-toast][data-kind="pending"] > [data-dsh-rr-icon] { color: #9ac9f0; animation: dsh-rr-spin .8s linear infinite; }
|
|
245
|
+
[data-dsh-rr-toast-dismiss] { appearance: none; flex: none; display: grid; place-items: center; width: 22px; height: 22px; margin: 0; padding: 3px; border: 0; border-radius: 5px; color: #aeb9c8; background: transparent; cursor: pointer; pointer-events: auto; }
|
|
246
|
+
[data-dsh-rr-toast-dismiss]:hover { color: #fff; background: rgb(255 255 255 / .08); }
|
|
247
|
+
[data-dsh-rr-toast-dismiss]:focus-visible { outline: 2px solid #8bcaff; outline-offset: 2px; }
|
|
248
|
+
[data-dsh-rr-toast][data-kind="error"] { border-color: rgb(255 105 105 / .5); }
|
|
249
|
+
@keyframes dsh-rr-toast-in { from { opacity: 0; transform: translate(-50%, -8px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
|
250
|
+
@keyframes dsh-rr-spin { to { transform: rotate(360deg); } }
|
|
251
|
+
@keyframes dsh-rr-open { from { opacity: 0; transform: translate(-50%, -50%) scale(.94); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
|
|
252
|
+
@media (prefers-reduced-motion: reduce) { [data-dsh-rr-pie], [data-dsh-rr-toast], [data-dsh-rr-toast][data-kind="pending"] > [data-dsh-rr-icon] { animation: none; } [data-dsh-rr-action], [data-dsh-rr-label] { transition: none; } }
|
|
253
|
+
`;
|
|
254
|
+
function ensureRadialStyles() {
|
|
255
|
+
if (typeof document === "undefined") return;
|
|
256
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(TAG_ID)}]`) !== null) return;
|
|
257
|
+
const tag = document.createElement("style");
|
|
258
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
259
|
+
tag.textContent = CSS;
|
|
260
|
+
document.head.appendChild(tag);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/client/surface.ts
|
|
264
|
+
var EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
|
|
265
|
+
function isSessionConversationSurface(target) {
|
|
266
|
+
if (!(target instanceof Element)) return false;
|
|
267
|
+
if (target.closest(EDITABLE) !== null) return false;
|
|
268
|
+
if (target.closest("[data-composer-seat]") !== null) return false;
|
|
269
|
+
return target.closest("[data-conversation-scroll], [data-phase], [data-chat-flow-key]") !== null;
|
|
270
|
+
}
|
|
271
|
+
function conversationTarget(target) {
|
|
272
|
+
if (!(target instanceof Element)) return {};
|
|
273
|
+
const row = target.closest("[data-chat-flow-key], [data-chat-turn]");
|
|
274
|
+
if (row === null) return {};
|
|
275
|
+
const raw = row.getAttribute("data-chat-turn");
|
|
276
|
+
const turn = raw !== null && /^\d+$/.test(raw) ? Number(raw) : void 0;
|
|
277
|
+
return {
|
|
278
|
+
turn: turn !== void 0 && Number.isSafeInteger(turn) ? turn : void 0,
|
|
279
|
+
nodeKey: row.dataset.chatFlowKey
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/client/Icons.tsx
|
|
284
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
285
|
+
var PATHS = {
|
|
286
|
+
"open-dir": "M3 8V5a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v2 M3 9h18l-3 11H3Z",
|
|
287
|
+
"copy-cwd": "M9 9h11v11H9Z M15 6V3H3v12h3",
|
|
288
|
+
"copy-session-id": "M9 3 7 21 M17 3 15 21 M4 9h17 M3 15h17",
|
|
289
|
+
stop: "M8 4v8 M12 3v8 M16 5v7 M8 9V6a2 2 0 0 0-4 0v7l4 7h8l4-8a2 2 0 0 0-3-2l-1 2",
|
|
290
|
+
fork: "M6 7v10 M6 11c10 0 12-2 12-4 M4 3h4v4H4Z M4 17h4v4H4Z M16 3h4v4h-4Z",
|
|
291
|
+
export: "M12 3v12 M7 10l5 5 5-5 M4 15v6h16v-6",
|
|
292
|
+
close: "M6 6l12 12 M18 6 6 18",
|
|
293
|
+
check: "m5 12 4 4L19 6",
|
|
294
|
+
error: "M12 8v5 M12 17h.01 M12 3 2 21h20Z",
|
|
295
|
+
loading: "M21 12a9 9 0 1 1-9-9",
|
|
296
|
+
menu: "M12 2v4 M12 18v4 M2 12h4 M18 12h4 M9 9h6v6H9Z",
|
|
297
|
+
"check-circle": "m7.5 12 3 3 6-6",
|
|
298
|
+
"error-circle": "M12 7v6 M12 17h.01"
|
|
299
|
+
};
|
|
300
|
+
function MenuIcon({ name, size = 24 }) {
|
|
301
|
+
const circularStatus = name === "check-circle" || name === "error-circle";
|
|
302
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
303
|
+
"svg",
|
|
304
|
+
{
|
|
305
|
+
width: size,
|
|
306
|
+
height: size,
|
|
307
|
+
viewBox: "0 0 24 24",
|
|
308
|
+
fill: "none",
|
|
309
|
+
stroke: "currentColor",
|
|
310
|
+
strokeWidth: "1.65",
|
|
311
|
+
strokeLinecap: "round",
|
|
312
|
+
strokeLinejoin: "round",
|
|
313
|
+
"aria-hidden": "true",
|
|
314
|
+
focusable: "false",
|
|
315
|
+
"data-dsh-rr-icon": name,
|
|
316
|
+
children: [
|
|
317
|
+
circularStatus && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "12", cy: "12", r: "10", fill: "currentColor", stroke: "none" }),
|
|
318
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: PATHS[name], stroke: circularStatus ? "#fff" : void 0, strokeWidth: circularStatus ? 2 : void 0 })
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/client/menu-layout.ts
|
|
325
|
+
var MENU_SIZE = 240;
|
|
326
|
+
var INNER_RATIO = 0.18;
|
|
327
|
+
var OUTER_RATIO = 0.47;
|
|
328
|
+
var LABEL_RATIO = 0.33;
|
|
329
|
+
var MENU_COUNT = 6;
|
|
330
|
+
function placeMenu(x, y, width, height) {
|
|
331
|
+
const margin = 12;
|
|
332
|
+
const detailClearance = 52;
|
|
333
|
+
const size = Math.max(0, Math.min(MENU_SIZE, width - margin * 2, height - margin * 2 - detailClearance));
|
|
334
|
+
const radius = size / 2;
|
|
335
|
+
return {
|
|
336
|
+
size,
|
|
337
|
+
x: Math.max(radius + margin, Math.min(x, width - radius - margin)),
|
|
338
|
+
y: Math.max(radius + margin, Math.min(y, height - radius - margin - detailClearance))
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function point(deg, radius) {
|
|
342
|
+
const angle = deg * Math.PI / 180;
|
|
343
|
+
return [50 + Math.sin(angle) * radius, 50 - Math.cos(angle) * radius];
|
|
344
|
+
}
|
|
345
|
+
function sectorClip(index) {
|
|
346
|
+
const start = index * 360 / MENU_COUNT - 180 / MENU_COUNT;
|
|
347
|
+
const points = [];
|
|
348
|
+
for (const [radius, direction] of [[OUTER_RATIO * 100, 1], [INNER_RATIO * 100, -1]]) {
|
|
349
|
+
for (let step = 0; step <= 20; step++) {
|
|
350
|
+
const progress = direction === 1 ? step / 20 : 1 - step / 20;
|
|
351
|
+
const [x, y] = point(start + progress * 360 / MENU_COUNT, radius);
|
|
352
|
+
points.push(`${x.toFixed(3)}% ${y.toFixed(3)}%`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return `polygon(${points.join(",")})`;
|
|
356
|
+
}
|
|
357
|
+
function labelPosition(index) {
|
|
358
|
+
const [x, y] = point(index * 360 / MENU_COUNT, LABEL_RATIO * 100);
|
|
359
|
+
return { left: `${x}%`, top: `${y}%` };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/client/RadialMenu.tsx
|
|
363
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
364
|
+
var SECTORS = PIE_ACTION_IDS.map((id, index) => ({ id, clip: sectorClip(index), position: labelPosition(index) }));
|
|
365
|
+
function readState(list) {
|
|
366
|
+
const sessionId = list.current;
|
|
367
|
+
const row = sessionId === void 0 ? void 0 : list.byId[sessionId];
|
|
368
|
+
return {
|
|
369
|
+
sessionId,
|
|
370
|
+
cwd: row?.cwd ?? "",
|
|
371
|
+
running: row?.running ?? false
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function equalState(a, b) {
|
|
375
|
+
return a.sessionId === b.sessionId && a.cwd === b.cwd && a.running === b.running;
|
|
376
|
+
}
|
|
377
|
+
function RadialMenu(props) {
|
|
378
|
+
const state = props.useSessions(readState, equalState);
|
|
379
|
+
const [open, setOpen] = (0, import_react.useState)(null);
|
|
380
|
+
const [hover, setHover] = (0, import_react.useState)(null);
|
|
381
|
+
const [feedback, setFeedback] = (0, import_react.useState)(null);
|
|
382
|
+
const busyRef = (0, import_react.useRef)(false);
|
|
383
|
+
const aliveRef = (0, import_react.useRef)(true);
|
|
384
|
+
const focusBeforeOpen = (0, import_react.useRef)(null);
|
|
385
|
+
const menuElement = (0, import_react.useRef)(null);
|
|
386
|
+
const centerElement = (0, import_react.useRef)(null);
|
|
387
|
+
const detailId = (0, import_react.useId)();
|
|
388
|
+
const openRef = (0, import_react.useRef)(open);
|
|
389
|
+
const stateRef = (0, import_react.useRef)(state);
|
|
390
|
+
const depsRef = (0, import_react.useRef)(props.deps);
|
|
391
|
+
openRef.current = open;
|
|
392
|
+
stateRef.current = state;
|
|
393
|
+
depsRef.current = props.deps;
|
|
394
|
+
(0, import_react.useEffect)(() => {
|
|
395
|
+
if (feedback === null || feedback.kind === "pending") return;
|
|
396
|
+
const timer = window.setTimeout(() => setFeedback(null), feedback.kind === "error" ? 8e3 : 4500);
|
|
397
|
+
return () => window.clearTimeout(timer);
|
|
398
|
+
}, [feedback]);
|
|
399
|
+
const close = (0, import_react.useCallback)(() => {
|
|
400
|
+
if (openRef.current === null) return;
|
|
401
|
+
openRef.current = null;
|
|
402
|
+
setOpen(null);
|
|
403
|
+
setHover(null);
|
|
404
|
+
if (focusBeforeOpen.current?.isConnected) focusBeforeOpen.current.focus({ preventScroll: true });
|
|
405
|
+
}, []);
|
|
406
|
+
const onPieClick = (event) => {
|
|
407
|
+
const menu = openRef.current;
|
|
408
|
+
if (menu === null) return;
|
|
409
|
+
event.preventDefault();
|
|
410
|
+
event.stopPropagation();
|
|
411
|
+
if (event.target !== event.currentTarget) return;
|
|
412
|
+
close();
|
|
413
|
+
};
|
|
414
|
+
(0, import_react.useEffect)(() => {
|
|
415
|
+
if (open !== null) centerElement.current?.focus({ preventScroll: true });
|
|
416
|
+
}, [open]);
|
|
417
|
+
(0, import_react.useEffect)(() => {
|
|
418
|
+
if (openRef.current !== null && openRef.current.snapshot.sessionId !== state.sessionId) close();
|
|
419
|
+
}, [state.sessionId, close]);
|
|
420
|
+
(0, import_react.useEffect)(() => {
|
|
421
|
+
ensureRadialStyles();
|
|
422
|
+
aliveRef.current = true;
|
|
423
|
+
const onContextMenu = (event) => {
|
|
424
|
+
const pie = event.target instanceof Element ? event.target.closest("[data-dsh-rr-layer]") : null;
|
|
425
|
+
if (pie !== null) {
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!isSessionConversationSurface(event.target)) return;
|
|
430
|
+
const current = stateRef.current;
|
|
431
|
+
if (current.sessionId === void 0 || current.sessionId === "") return;
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
event.stopPropagation();
|
|
434
|
+
if (busyRef.current) return;
|
|
435
|
+
const placed = placeMenu(event.clientX, event.clientY, window.innerWidth, window.innerHeight);
|
|
436
|
+
const target = conversationTarget(event.target);
|
|
437
|
+
let forkTarget = { kind: "no-turn" };
|
|
438
|
+
if (target.turn !== void 0 || target.nodeKey !== void 0) {
|
|
439
|
+
try {
|
|
440
|
+
forkTarget = depsRef.current.resolveForkTarget?.(current.sessionId, target) ?? { kind: "unavailable" };
|
|
441
|
+
} catch {
|
|
442
|
+
forkTarget = { kind: "unavailable" };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
focusBeforeOpen.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
446
|
+
const next = { ...placed, snapshot: { ...current, forkTarget } };
|
|
447
|
+
openRef.current = next;
|
|
448
|
+
setOpen(next);
|
|
449
|
+
setHover(null);
|
|
450
|
+
};
|
|
451
|
+
const onKeyDown = (event) => {
|
|
452
|
+
if (openRef.current === null) return;
|
|
453
|
+
if (event.key === "Escape" || event.key === "Tab") {
|
|
454
|
+
if (event.key === "Escape") event.preventDefault();
|
|
455
|
+
event.stopPropagation();
|
|
456
|
+
close();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft", "Home", "End"].includes(event.key)) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
event.stopPropagation();
|
|
462
|
+
const items = Array.from(menuElement.current?.querySelectorAll('[role="menuitem"]') ?? []);
|
|
463
|
+
if (items.length === 0) return;
|
|
464
|
+
const index = items.indexOf(document.activeElement);
|
|
465
|
+
const direction = event.key === "ArrowUp" || event.key === "ArrowLeft" ? -1 : 1;
|
|
466
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : index < 0 ? direction === 1 ? 0 : items.length - 1 : (index + direction + items.length) % items.length;
|
|
467
|
+
items[next]?.focus({ preventScroll: true });
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
document.addEventListener("contextmenu", onContextMenu, true);
|
|
471
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
472
|
+
window.addEventListener("resize", close);
|
|
473
|
+
window.addEventListener("blur", close);
|
|
474
|
+
document.addEventListener("scroll", close, true);
|
|
475
|
+
return () => {
|
|
476
|
+
aliveRef.current = false;
|
|
477
|
+
document.removeEventListener("contextmenu", onContextMenu, true);
|
|
478
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
479
|
+
window.removeEventListener("resize", close);
|
|
480
|
+
window.removeEventListener("blur", close);
|
|
481
|
+
document.removeEventListener("scroll", close, true);
|
|
482
|
+
};
|
|
483
|
+
}, [close]);
|
|
484
|
+
const onCatcherDown = (event) => {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
close();
|
|
487
|
+
};
|
|
488
|
+
const execute = async (id) => {
|
|
489
|
+
const menu = openRef.current;
|
|
490
|
+
if (menu === null || busyRef.current) return;
|
|
491
|
+
if (menu.snapshot.sessionId !== stateRef.current.sessionId) {
|
|
492
|
+
close();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const snapshot = { ...menu.snapshot, running: stateRef.current.running };
|
|
496
|
+
if (!isActionEnabled(id, snapshot)) {
|
|
497
|
+
setHover(PIE_ACTION_IDS.indexOf(id));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
busyRef.current = true;
|
|
501
|
+
const copy2 = dictionaryForDocument();
|
|
502
|
+
setFeedback({ kind: "pending", text: withTurn(copy2[`${id}.pending`], snapshot) });
|
|
503
|
+
close();
|
|
504
|
+
try {
|
|
505
|
+
await runAction(id, snapshot, depsRef.current);
|
|
506
|
+
if (aliveRef.current) setFeedback({ kind: "success", text: withTurn(copy2[`${id}.success`], snapshot) });
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (aliveRef.current) setFeedback({ kind: "error", text: actionError(error, id, copy2) });
|
|
509
|
+
} finally {
|
|
510
|
+
busyRef.current = false;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const copy = dictionaryForDocument();
|
|
514
|
+
const menuState = { ...open?.snapshot ?? state, running: state.running };
|
|
515
|
+
const description = hover === null ? copy.keys : hover === "cancel" ? copy.cancel : actionDescription(PIE_ACTION_IDS[hover], menuState, copy);
|
|
516
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { "data-dsh-rr-layer": "", children: [
|
|
517
|
+
open !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
|
|
518
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { "data-dsh-rr-catcher": "", onPointerDown: onCatcherDown }),
|
|
519
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
520
|
+
"div",
|
|
521
|
+
{
|
|
522
|
+
ref: menuElement,
|
|
523
|
+
"data-dsh-rr-pie": "",
|
|
524
|
+
role: "menu",
|
|
525
|
+
"aria-label": copy.menu,
|
|
526
|
+
style: {
|
|
527
|
+
left: open.x,
|
|
528
|
+
top: open.y,
|
|
529
|
+
["--dsh-rr-size"]: `${String(open.size)}px`,
|
|
530
|
+
["--dsh-rr-hole"]: `${String(open.size * INNER_RATIO)}px`
|
|
531
|
+
},
|
|
532
|
+
onClick: onPieClick,
|
|
533
|
+
onPointerDown: (event) => {
|
|
534
|
+
event.stopPropagation();
|
|
535
|
+
},
|
|
536
|
+
children: [
|
|
537
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
538
|
+
"button",
|
|
539
|
+
{
|
|
540
|
+
ref: centerElement,
|
|
541
|
+
"data-dsh-rr-hole": "",
|
|
542
|
+
onClick: close,
|
|
543
|
+
type: "button",
|
|
544
|
+
"aria-label": copy.cancel,
|
|
545
|
+
onPointerEnter: () => setHover("cancel"),
|
|
546
|
+
onFocus: () => setHover("cancel"),
|
|
547
|
+
children: [
|
|
548
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { name: typeof hover === "number" ? PIE_ACTION_IDS[hover] : "close", size: 20 }),
|
|
549
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("strong", { children: menuState.forkTarget?.turn === void 0 ? copy.choose : withTurn(copy.turn, menuState) }),
|
|
550
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: "Esc" })
|
|
551
|
+
]
|
|
552
|
+
}
|
|
553
|
+
),
|
|
554
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { "data-dsh-rr-detail": "", id: detailId, "data-visible": typeof hover === "number" ? "" : void 0, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: description }) }),
|
|
555
|
+
SECTORS.map(({ id, clip, position }, index) => {
|
|
556
|
+
const enabled = isActionEnabled(id, menuState);
|
|
557
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
558
|
+
"button",
|
|
559
|
+
{
|
|
560
|
+
type: "button",
|
|
561
|
+
role: "menuitem",
|
|
562
|
+
tabIndex: -1,
|
|
563
|
+
"data-dsh-rr-action": "",
|
|
564
|
+
"data-action": id,
|
|
565
|
+
"data-active": hover === index ? "" : void 0,
|
|
566
|
+
"data-disabled": enabled ? void 0 : "",
|
|
567
|
+
"aria-disabled": enabled ? void 0 : true,
|
|
568
|
+
title: copy[id],
|
|
569
|
+
"aria-label": copy[id],
|
|
570
|
+
"aria-describedby": detailId,
|
|
571
|
+
style: { clipPath: clip },
|
|
572
|
+
onPointerEnter: () => setHover(index),
|
|
573
|
+
onPointerLeave: () => setHover(null),
|
|
574
|
+
onFocus: () => setHover(index),
|
|
575
|
+
onClick: (event) => {
|
|
576
|
+
event.stopPropagation();
|
|
577
|
+
void execute(id);
|
|
578
|
+
},
|
|
579
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { "data-dsh-rr-label": "", style: position, children: [
|
|
580
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { name: id, size: 22 }),
|
|
581
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "data-dsh-rr-text": "", children: copy[id] })
|
|
582
|
+
] })
|
|
583
|
+
},
|
|
584
|
+
id
|
|
585
|
+
);
|
|
586
|
+
})
|
|
587
|
+
]
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
] }),
|
|
591
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", children: feedback !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { "data-dsh-rr-toast": "", "data-kind": feedback.kind, children: [
|
|
592
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { name: feedback.kind === "success" ? "check-circle" : feedback.kind === "error" ? "error-circle" : "loading", size: 20 }),
|
|
593
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { "data-dsh-rr-toast-text": "", children: feedback.text }),
|
|
594
|
+
feedback.kind !== "pending" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
595
|
+
"button",
|
|
596
|
+
{
|
|
597
|
+
type: "button",
|
|
598
|
+
"data-dsh-rr-toast-dismiss": "",
|
|
599
|
+
"aria-label": copy.dismiss,
|
|
600
|
+
onClick: () => setFeedback(null),
|
|
601
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(MenuIcon, { name: "close", size: 16 })
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
] }) })
|
|
605
|
+
] });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/shared.ts
|
|
609
|
+
var OPEN_DIR_ROUTE = "/dsh-round-rightclick/open-dir";
|
|
610
|
+
var OVERLAY_ID = "dsh-round-rightclick";
|
|
611
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
612
|
+
|
|
613
|
+
// src/client/fork-target.ts
|
|
614
|
+
function resolveTurnFork(entries, turn, anchorSeq) {
|
|
615
|
+
if (turn === void 0 && anchorSeq === void 0) return { kind: "no-turn" };
|
|
616
|
+
if (turn !== void 0 && (!Number.isSafeInteger(turn) || turn < 0)) return { kind: "unavailable" };
|
|
617
|
+
if (turn === void 0 && (anchorSeq === void 0 || !Number.isFinite(anchorSeq) || anchorSeq < 0 || anchorSeq > Number.MAX_SAFE_INTEGER)) {
|
|
618
|
+
return { kind: "unavailable" };
|
|
619
|
+
}
|
|
620
|
+
for (const { type, event } of entries) {
|
|
621
|
+
if (type !== "event" || event.type !== "turn/end") continue;
|
|
622
|
+
const data = event.data;
|
|
623
|
+
if (typeof data !== "object" || data === null || !("turn" in data)) continue;
|
|
624
|
+
const endedTurn = data.turn;
|
|
625
|
+
if (typeof endedTurn !== "number" || !Number.isSafeInteger(endedTurn) || endedTurn < 0) continue;
|
|
626
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) continue;
|
|
627
|
+
if (turn !== void 0 ? endedTurn === turn : event.seq >= anchorSeq) {
|
|
628
|
+
return { kind: "ready", turn: endedTurn, atSeq: event.seq };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { kind: entries.length === 0 ? "unavailable" : "unfinished", turn };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/client/pie.ts
|
|
635
|
+
var TWO_PI = Math.PI * 2;
|
|
636
|
+
var DEFAULT_START_ANGLE = -Math.PI / 2;
|
|
637
|
+
function normalizeAngle(angle) {
|
|
638
|
+
return (angle % TWO_PI + TWO_PI) % TWO_PI;
|
|
639
|
+
}
|
|
640
|
+
function sliceMidAngle(count, index, startAngle = DEFAULT_START_ANGLE) {
|
|
641
|
+
if (count <= 0) throw new Error("sliceMidAngle: count must be positive");
|
|
642
|
+
const width = TWO_PI / count;
|
|
643
|
+
return startAngle + (index + 0.5) * width;
|
|
644
|
+
}
|
|
645
|
+
function sliceCssMidDegrees(count, index) {
|
|
646
|
+
if (count <= 0) throw new Error("sliceCssMidDegrees: count must be positive");
|
|
647
|
+
return (index + 0.5) * 360 / count;
|
|
648
|
+
}
|
|
649
|
+
function sliceCssSpanDegrees(count, index) {
|
|
650
|
+
if (count <= 0) throw new Error("sliceCssSpanDegrees: count must be positive");
|
|
651
|
+
const width = 360 / count;
|
|
652
|
+
return { start: index * width, end: (index + 1) * width };
|
|
653
|
+
}
|
|
654
|
+
function hitTestPie(input) {
|
|
655
|
+
const { center, pointer, innerRadius, count } = input;
|
|
656
|
+
const startAngle = input.startAngle ?? DEFAULT_START_ANGLE;
|
|
657
|
+
if (count <= 0) return { kind: "outside" };
|
|
658
|
+
const dx = pointer.x - center.x;
|
|
659
|
+
const dy = pointer.y - center.y;
|
|
660
|
+
const dist = Math.hypot(dx, dy);
|
|
661
|
+
if (dist < innerRadius) return { kind: "cancel" };
|
|
662
|
+
if (input.outerRadius !== void 0 && dist > input.outerRadius) return { kind: "outside" };
|
|
663
|
+
const rel = normalizeAngle(Math.atan2(dy, dx) - startAngle);
|
|
664
|
+
const width = TWO_PI / count;
|
|
665
|
+
const index = Math.min(count - 1, Math.floor(rel / width));
|
|
666
|
+
return { kind: "slice", index };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/client/index.ts
|
|
670
|
+
var inject = ["slots", "sessions", "locale"];
|
|
671
|
+
function defaultClipboardWrite(text) {
|
|
672
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
673
|
+
if (clipboard === void 0) return Promise.reject(new Error("clipboard unavailable"));
|
|
674
|
+
return clipboard.writeText(text);
|
|
675
|
+
}
|
|
676
|
+
function defaultSave(url, filename) {
|
|
677
|
+
const doc = globalThis.document;
|
|
678
|
+
if (doc === void 0) return;
|
|
679
|
+
const anchor = doc.createElement("a");
|
|
680
|
+
anchor.href = url;
|
|
681
|
+
anchor.download = filename;
|
|
682
|
+
anchor.click();
|
|
683
|
+
}
|
|
684
|
+
async function postOpenDirectory(absDir) {
|
|
685
|
+
const response = await fetch(new URL(OPEN_DIR_ROUTE, hostBase()), {
|
|
686
|
+
method: "POST",
|
|
687
|
+
headers: { "content-type": "application/json" },
|
|
688
|
+
body: JSON.stringify({ path: absDir })
|
|
689
|
+
});
|
|
690
|
+
if (!response.ok) throw new Error(`open-dir failed: HTTP ${String(response.status)}`);
|
|
691
|
+
}
|
|
692
|
+
function createActionDeps(ctx) {
|
|
693
|
+
return {
|
|
694
|
+
openDirectory: postOpenDirectory,
|
|
695
|
+
clipboardWrite: defaultClipboardWrite,
|
|
696
|
+
cancel: async (sessionId) => {
|
|
697
|
+
const scoped = ctx.sessions.scope(sessionId);
|
|
698
|
+
const session = scoped === void 0 ? void 0 : ctx.sessions.sessionOf(scoped);
|
|
699
|
+
if (session === void 0) throw new Error(`session "${sessionId}" is not scoped`);
|
|
700
|
+
const result = await session.cancel();
|
|
701
|
+
if (typeof result === "object" && result !== null && "ok" in result && result.ok === false) {
|
|
702
|
+
const error = "error" in result ? result.error : void 0;
|
|
703
|
+
throw Object.assign(new Error("Interrupt request rejected"), { cause: error });
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
fork: async (sessionId, atSeq) => {
|
|
707
|
+
if (atSeq === void 0 || !Number.isSafeInteger(atSeq) || atSeq < 0) throw new Error("A completed turn is required");
|
|
708
|
+
if (typeof ctx.sessions.open !== "function") throw new Error("Session navigation is unavailable");
|
|
709
|
+
const childId = await ctx.sessions.fork({ sessionId, atSeq, increaseTitle: true });
|
|
710
|
+
ctx.sessions.open(childId);
|
|
711
|
+
},
|
|
712
|
+
resolveForkTarget: (sessionId, target) => {
|
|
713
|
+
const conversation = ctx.get?.("uiConversation");
|
|
714
|
+
const node = target.nodeKey === void 0 ? void 0 : conversation?.binding(sessionId).target("chat").getSnapshot()?.nodes.get(target.nodeKey);
|
|
715
|
+
const location = node?.location;
|
|
716
|
+
const turn = location?.kind === "turn" || location?.kind === "step" ? location.turn : void 0;
|
|
717
|
+
if (turn?.status === "closed" && turn.end !== void 0 && Number.isSafeInteger(turn.end.seq) && turn.end.seq >= 0) {
|
|
718
|
+
return { kind: "ready", turn: turn.turn, atSeq: turn.end.seq };
|
|
719
|
+
}
|
|
720
|
+
if (turn?.status === "open") return { kind: "unfinished", turn: turn.turn };
|
|
721
|
+
const entries = ctx.sessions.binding?.(sessionId)?.eventSource?.getSnapshot().entries ?? [];
|
|
722
|
+
if (target.nodeKey !== void 0 && node === void 0 && target.turn === void 0) return { kind: "unavailable" };
|
|
723
|
+
return resolveTurnFork(entries, turn?.turn ?? target.turn, node?.anchorSeq);
|
|
724
|
+
},
|
|
725
|
+
exportLog: async (sessionId) => {
|
|
726
|
+
const downloader = ctx.get?.("sessionLogDownload");
|
|
727
|
+
if (downloader !== void 0) {
|
|
728
|
+
await downloader.download(sessionId);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
await downloadSessionExport(sessionId, {
|
|
732
|
+
fetch: (input, init) => fetch(input, init),
|
|
733
|
+
save: defaultSave,
|
|
734
|
+
hostBase
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function apply(ctx) {
|
|
740
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-round-rightclick: dictionaries");
|
|
741
|
+
const deps = createActionDeps(ctx);
|
|
742
|
+
ctx.effect(() => {
|
|
743
|
+
let disposeSlot;
|
|
744
|
+
ctx.slots.inject("shell.overlay", () => {
|
|
745
|
+
disposeSlot = ctx.slots.register({
|
|
746
|
+
name: "shell.overlay",
|
|
747
|
+
id: OVERLAY_ID,
|
|
748
|
+
order: 40,
|
|
749
|
+
inject: () => ({ deps })
|
|
750
|
+
}, RadialMenu);
|
|
751
|
+
return disposeSlot;
|
|
752
|
+
});
|
|
753
|
+
return () => {
|
|
754
|
+
disposeSlot?.();
|
|
755
|
+
};
|
|
756
|
+
}, "dsh-round-rightclick: overlay");
|
|
757
|
+
}
|
|
758
|
+
return module.exports; } });
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/opener.ts
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { stat as fsStat } from "node:fs/promises";
|
|
5
|
+
function isAbsolutePath(filePath, platform) {
|
|
6
|
+
if (platform === "win32") return path.win32.isAbsolute(filePath);
|
|
7
|
+
return path.posix.isAbsolute(filePath);
|
|
8
|
+
}
|
|
9
|
+
function fileManagerCommand(platform, absDir) {
|
|
10
|
+
switch (platform) {
|
|
11
|
+
case "win32":
|
|
12
|
+
return { command: "explorer.exe", args: [absDir] };
|
|
13
|
+
case "darwin":
|
|
14
|
+
return { command: "open", args: [absDir] };
|
|
15
|
+
case "linux":
|
|
16
|
+
return { command: "xdg-open", args: [absDir] };
|
|
17
|
+
default:
|
|
18
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function checkOpenableDirectory(filePath, platform, statFn = fsStat) {
|
|
22
|
+
if (typeof filePath !== "string" || filePath === "") {
|
|
23
|
+
return { ok: false, reason: "empty" };
|
|
24
|
+
}
|
|
25
|
+
if (!isAbsolutePath(filePath, platform)) {
|
|
26
|
+
return { ok: false, reason: "not-absolute" };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const info = await statFn(filePath);
|
|
30
|
+
if (!info.isDirectory()) return { ok: false, reason: "not-directory" };
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, reason: "missing" };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, path: filePath };
|
|
35
|
+
}
|
|
36
|
+
function defaultSpawn(command, args) {
|
|
37
|
+
const child = spawn(command, [...args], {
|
|
38
|
+
detached: true,
|
|
39
|
+
stdio: "ignore",
|
|
40
|
+
windowsHide: false
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
}
|
|
44
|
+
async function openDirectoryInFileManager(platform, filePath, deps = {}) {
|
|
45
|
+
const check = await checkOpenableDirectory(filePath, platform, deps.stat);
|
|
46
|
+
if (!check.ok) return check;
|
|
47
|
+
const mapped = fileManagerCommand(platform, check.path);
|
|
48
|
+
const run = deps.spawn ?? defaultSpawn;
|
|
49
|
+
run(mapped.command, mapped.args);
|
|
50
|
+
return check;
|
|
51
|
+
}
|
|
52
|
+
function launchedThroughSsh(env = process.env) {
|
|
53
|
+
return Boolean(env.SSH_CONNECTION && env.SSH_CONNECTION !== "" || env.SSH_TTY && env.SSH_TTY !== "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/shared.ts
|
|
57
|
+
var OPEN_DIR_ROUTE = "/dsh-round-rightclick/open-dir";
|
|
58
|
+
var OVERLAY_ID = "dsh-round-rightclick";
|
|
59
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
60
|
+
|
|
61
|
+
// src/index.ts
|
|
62
|
+
var name = "dsh-round-rightclick";
|
|
63
|
+
var inject = ["webServer"];
|
|
64
|
+
var internals = { current: {} };
|
|
65
|
+
function sendJson(res, status, payload) {
|
|
66
|
+
res.statusCode = status;
|
|
67
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
68
|
+
res.setHeader("cache-control", "no-store");
|
|
69
|
+
res.end(JSON.stringify(payload));
|
|
70
|
+
}
|
|
71
|
+
async function readBoundedBody(req) {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let size = 0;
|
|
74
|
+
for await (const chunk of req) {
|
|
75
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
76
|
+
size += buf.byteLength;
|
|
77
|
+
if (size > MAX_BODY_BYTES) {
|
|
78
|
+
req.resume();
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(buf);
|
|
82
|
+
}
|
|
83
|
+
return Buffer.concat(chunks, size).toString("utf8");
|
|
84
|
+
}
|
|
85
|
+
function parseOpenBody(text) {
|
|
86
|
+
let body;
|
|
87
|
+
try {
|
|
88
|
+
body = JSON.parse(text);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (typeof body !== "object" || body === null) return null;
|
|
93
|
+
const { path: filePath } = body;
|
|
94
|
+
return typeof filePath === "string" ? { path: filePath } : null;
|
|
95
|
+
}
|
|
96
|
+
async function handleOpenDirRequest(req, res, opts = {}) {
|
|
97
|
+
if (req.method !== "POST") {
|
|
98
|
+
res.statusCode = 405;
|
|
99
|
+
res.setHeader("allow", "POST");
|
|
100
|
+
res.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const essence = String(req.headers["content-type"]).split(";", 1)[0]?.trim().toLowerCase();
|
|
104
|
+
if (essence !== "application/json") {
|
|
105
|
+
sendJson(res, 415, { code: "unsupported-media-type", message: "content-type must be application/json" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const env = opts.env ?? process.env;
|
|
109
|
+
if (launchedThroughSsh(env)) {
|
|
110
|
+
sendJson(res, 403, { code: "ssh", message: "refusing to open a file manager over SSH" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let text;
|
|
114
|
+
try {
|
|
115
|
+
text = await readBoundedBody(req);
|
|
116
|
+
} catch {
|
|
117
|
+
sendJson(res, 400, { code: "bad-request", message: "request body unreadable" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (text === null) {
|
|
121
|
+
sendJson(res, 413, { code: "payload-too-large", message: "request body is too large" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const parsed = parseOpenBody(text);
|
|
125
|
+
if (parsed === null) {
|
|
126
|
+
sendJson(res, 400, { code: "bad-request", message: "expected JSON { path: string }" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const platform = opts.platform ?? process.platform;
|
|
130
|
+
const result = await openDirectoryInFileManager(platform, parsed.path, {
|
|
131
|
+
spawn: opts.spawn,
|
|
132
|
+
stat: opts.stat
|
|
133
|
+
});
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
sendJson(res, 400, { code: result.reason, message: `path rejected: ${result.reason}` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
sendJson(res, 200, { ok: true });
|
|
139
|
+
}
|
|
140
|
+
function apply(ctx) {
|
|
141
|
+
ctx.effect(() => ctx.webServer.register({
|
|
142
|
+
kind: "exact",
|
|
143
|
+
path: OPEN_DIR_ROUTE,
|
|
144
|
+
handler: async (req, res) => {
|
|
145
|
+
const connection = ctx.get?.("connection");
|
|
146
|
+
const rejection = connection?.requestRejection(req);
|
|
147
|
+
if (rejection !== void 0) {
|
|
148
|
+
res.statusCode = rejection;
|
|
149
|
+
res.end();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
await handleOpenDirRequest(req, res, internals.current);
|
|
153
|
+
}
|
|
154
|
+
}), "dsh-round-rightclick: POST open-dir");
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
OPEN_DIR_ROUTE,
|
|
158
|
+
OVERLAY_ID,
|
|
159
|
+
apply,
|
|
160
|
+
checkOpenableDirectory,
|
|
161
|
+
fileManagerCommand,
|
|
162
|
+
handleOpenDirRequest,
|
|
163
|
+
inject,
|
|
164
|
+
internals,
|
|
165
|
+
isAbsolutePath,
|
|
166
|
+
launchedThroughSsh,
|
|
167
|
+
name,
|
|
168
|
+
openDirectoryInFileManager
|
|
169
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-round-rightclick",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 圆盘快捷菜单:按轮次分叉、打断生成、复制路径与会话 ID、打开目录和导出日志。",
|
|
5
|
+
"author": "hmr-BH",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hmr-BH/dsh-round-rightclick.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/hmr-BH/dsh-round-rightclick#readme",
|
|
11
|
+
"bugs": { "url": "https://github.com/hmr-BH/dsh-round-rightclick/issues" },
|
|
12
|
+
"keywords": ["dsh-plugin", "deepseek-harness", "radial-menu", "context-menu", "session", "fork"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./lib/index.js",
|
|
17
|
+
"./client": "./lib/client.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib/index.js",
|
|
22
|
+
"lib/client.js",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"README.md",
|
|
25
|
+
"README.en.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"assets/screenshots",
|
|
29
|
+
"screenshots.json"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node build.mjs",
|
|
33
|
+
"prepare": "node build.mjs",
|
|
34
|
+
"test": "node build.mjs && vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-locale",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
47
|
+
],
|
|
48
|
+
"platform": "web"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "*"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.15.0",
|
|
56
|
+
"@types/react": "~18.3.1",
|
|
57
|
+
"@types/react-dom": "~18.3.1",
|
|
58
|
+
"esbuild": "^0.25.0",
|
|
59
|
+
"jsdom": "^26.1.0",
|
|
60
|
+
"react": "^18.3.1",
|
|
61
|
+
"react-dom": "^18.3.1",
|
|
62
|
+
"typescript": "^5.8.3",
|
|
63
|
+
"vitest": "^3.2.4"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
67
|
+
},
|
|
68
|
+
"license": "MIT"
|
|
69
|
+
}
|
package/screenshots.json
ADDED