@srgay/cursor-extension 1.0.0 → 1.0.2
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/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
一组本机 Cursor workbench 增强脚本,包含三部分:
|
|
4
4
|
|
|
5
5
|
1. **MAX Mode 守护**:保持 `MAX Mode` 关闭,并在彩色 `MAX` 标记出现且聊天框有内容时阻止发送。
|
|
6
|
-
2. **MCP Follow-up 面板**:在 Cursor 聊天框上方注入一个 MCP 反馈面板,连接 `mcp-feedback-enhanced` 的 WebSocket,让你直接在 Cursor 里回复 `interactive_feedback
|
|
6
|
+
2. **MCP Follow-up 面板**:在 Cursor 聊天框上方注入一个 MCP 反馈面板,连接 `mcp-feedback-enhanced` 的 WebSocket,让你直接在 Cursor 里回复 `interactive_feedback`,支持端口自动扫描/自定义,并能按当前窗口自动匹配对应端口(多窗口、多端口同时在线时也不误连)。
|
|
7
7
|
3. **输入法回车修复**:修复用中文输入法(拼音/注音等)组字时,按回车上屏候选词会被 Cursor 误当成「提交」的问题(含自带的 AskQuestion「Other」输入框)。
|
|
8
8
|
|
|
9
9
|
## 安装与使用(npx)
|
|
@@ -29,6 +29,7 @@ npx @srgay/cursor-extension help
|
|
|
29
29
|
- Cursor 更新可能覆盖 `workbench.html`,功能失效时重新执行 `install`。
|
|
30
30
|
- 自定义 Cursor 安装目录:设置环境变量 `CURSOR_WORKBENCH_DIR`。
|
|
31
31
|
- `inject` 自定义调试端口:设置环境变量 `CURSOR_DEBUG_PORT`(默认 `9222`)。
|
|
32
|
+
- MCP 面板配置读写所用的 python 命令:默认按平台自动探测(`python` / `py` / `python3`),可用环境变量 `CURSOR_MCP_PYTHON` 强制指定(某些 Windows 环境用得上)。
|
|
32
33
|
|
|
33
34
|
> 说明:下文「推荐方式 / 备用方式」里的 `npm run patch`、`npm run inject` 等是**本仓库源码开发**用法;最终用户用上面的 `npx` 命令即可,二者等价。
|
|
34
35
|
|
|
@@ -52,10 +53,10 @@ npm run patch
|
|
|
52
53
|
Patch 行为:
|
|
53
54
|
|
|
54
55
|
- 注入 `<script src="./cursor-max-mode-guard.js">`、`<script src="./cursor-mcp-followup.js">`、`<script src="./cursor-ime-enter-fix.js">` 到 `workbench.html`(各自带注释标记,便于幂等与回退)。
|
|
55
|
-
- 把 `cursor-mcp-followup.js` 拷入 workbench
|
|
56
|
+
- 把 `cursor-mcp-followup.js` 拷入 workbench 目录时,按当前系统替换其中的路径/命令占位符:配置路径 `~/.config/mcp-feedback-enhanced/ui_settings.json`(Windows 为 `C:/Users/<你>/.config/...`,统一用正斜杠),以及读取命令(Mac/Linux 用 `cat`、Windows 用 `python -X utf8` 读取)与写入用的 python 命令名(面板借 WS `run_command` 读写该文件,实现常用提示词与自动提交配置的查看/编辑)。
|
|
56
57
|
- 同步更新 Cursor `product.json` 里的 `workbench.html` checksum,避免 Cursor 把这次修改识别为安装损坏。
|
|
57
58
|
|
|
58
|
-
> 注意:占位符在 patch
|
|
59
|
+
> 注意:占位符在 patch 时按当前用户主目录与操作系统写死。若更换用户/主目录或操作系统,重新执行 `npm run patch`。
|
|
59
60
|
|
|
60
61
|
然后完整退出 Cursor,再重新打开 Cursor。
|
|
61
62
|
|
|
@@ -114,16 +115,17 @@ npm run inject
|
|
|
114
115
|
|
|
115
116
|
- 注入到聊天框上方,样式贴合 Cursor 原生输入框。
|
|
116
117
|
- 自动扫描端口 `8765–8769`,识别正在监听的 MCP 服务(连得上或返回 `4004 无 session` 即视为活跃)。
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
-
|
|
118
|
+
- 端口下拉(自定义浮层菜单):**点开下拉即扫描一次**——先显示「扫描中…」,扫完用最新结果填充并展示可用端口;点选**一次即生效**(自定义菜单不依赖原生 `<select>`,不打断、不闪),末尾提供「自定义端口…」可手动输入任意端口。点空白处或按 `Esc` 关闭菜单。
|
|
119
|
+
- 菜单每项显示「状态 · 项目名」(如 `Port 8766 · waiting · my-project`)便于区分;触发按钮收起时只显示「Port X · 状态」以贴合宽度,避免与右侧项目名重复。
|
|
120
|
+
- 放大镜按钮可随时重新扫描(全量扫描并自动连上本窗口端口)。
|
|
121
|
+
- **自动匹配当前窗口端口**:每个 Cursor 窗口会起一个独立的 mcp-feedback 实例。面板从 Cursor 原生 `window.vscode` 取当前窗口工作区路径,再借 `run_command` 读取每个端口所在实例的 `WORKSPACE_FOLDER_PATHS`(窗口真实工作区,由 Cursor 注入、**不受 AI 传入的 `project_directory` 影响**),据此连接「本窗口」对应的端口;读不到时退用 `CURSOR_WORKSPACE_LABEL`(项目名)做次级匹配。多端口同时在线(别的窗口/常驻实例)也能选对、绝不误连;本窗口端口尚未出现时降频重扫等待,出现后自动连上;取不到工作区(多根工作区/空窗口)时回退原有逻辑。
|
|
120
122
|
- 持续保持为 WebSocket「最后连接」(每 3 秒重连 + 输入框聚焦时重连),确保始终能收到当前会话并成功提交反馈。
|
|
121
123
|
- 底部「常用提示词」下拉:一键把 `ui_settings.json` 里的提示词追加到输入框;旁边刷新按钮可重新拉取配置。
|
|
122
124
|
- 齿轮按钮展开**配置抽屉**:
|
|
123
125
|
- **常用提示词**增删改(写回 `ui_settings.json`,删除带二次确认)。
|
|
124
126
|
- **自动提交**配置:启用开关、超时(秒)、选择提示词,状态实时回显。
|
|
125
127
|
- **自动提交运行时**:仅在「等待反馈」时倒计时,输入框右侧显示 `自动提交 m:ss` 小标签;到点自动以所选提示词发送(走与手动一致的重连抢占)。用户输入/手动插入提示词/手动发送/点击倒计时/会话离开等待态,本轮即取消自动提交。
|
|
126
|
-
- 配置读写均借现有 WebSocket 的 `run_command`
|
|
128
|
+
- 配置读写均借现有 WebSocket 的 `run_command` 完成(Mac/Linux 读用 `cat`,Windows 读用 `python -X utf8`;写入两平台都用 python base64 解码落盘),不依赖本地文件系统访问或 CORS。服务端以 `shell=False` + `shlex.split` 执行命令,故 Windows 路径统一用正斜杠以规避转义。
|
|
127
129
|
|
|
128
130
|
### 加载方式
|
|
129
131
|
|
|
@@ -140,12 +142,14 @@ open -na /Applications/Cursor.app --args --remote-debugging-port=9222
|
|
|
140
142
|
npm run inject:followup
|
|
141
143
|
```
|
|
142
144
|
|
|
143
|
-
> CDP 注入是临时的,**重启 Cursor 后需要重新执行**;`install-cursor-mcp-followup.mjs`
|
|
145
|
+
> CDP 注入是临时的,**重启 Cursor 后需要重新执行**;`install-cursor-mcp-followup.mjs` 会在注入时按平台替换路径/命令占位符(详见 `src/shared/mcp-settings.mjs`)。
|
|
144
146
|
|
|
145
147
|
### 端口扫描与自定义
|
|
146
148
|
|
|
147
149
|
- 面板安装时自动扫描一次 `8765–8769`。
|
|
148
|
-
-
|
|
150
|
+
- **按窗口自动选端口**:扫描时借 `run_command` 读取每个端口实例的 `WORKSPACE_FOLDER_PATHS`,优先选中与当前窗口工作区相等的端口(次级用 `CURSOR_WORKSPACE_LABEL` 项目名);已知属于别的窗口的端口直接不连。这样即使「在 A 窗口让 AI 改 B 项目代码」(会话 `project_directory` 变成 B),也仍连回 A 窗口自己的端口。
|
|
151
|
+
- **扫描时机**:只在「面板启动时」和「每次点开端口下拉时」各扫一次,不做后台轮询扫描;连上本窗口端口后只发心跳维持 WebSocket「最后连接」。本窗口端口暂未出现时,点开下拉重扫即可发现并连上。
|
|
152
|
+
- 点击放大镜按钮可手动重扫(全量扫描并自动连上本窗口端口)。
|
|
149
153
|
- 在端口下拉里选择「自定义端口…」,就地输入端口号回车即可连接;输入框失焦或按 `Esc` 会回退到第一个可用端口。
|
|
150
154
|
|
|
151
155
|
### 查看状态
|
|
@@ -173,7 +177,7 @@ window.__cursorMcpFollowup.uninstall() // 卸载面板
|
|
|
173
177
|
脚本顶部 `config` 可调整:
|
|
174
178
|
|
|
175
179
|
- `scanStart` / `scanCount`:扫描起始端口与数量(默认从 `8765` 起、共 `5` 个,即 `8765–8769`)。
|
|
176
|
-
- `probeTimeoutMs`:单端口探测超时(默认 `
|
|
180
|
+
- `probeTimeoutMs`:单端口探测超时(默认 `2500` ms;含一次 `run_command` 读取窗口工作区的往返)。
|
|
177
181
|
- `reconnectMs`:自动重连间隔(默认 `3000` ms)。
|
|
178
182
|
|
|
179
183
|
### 排障
|
|
@@ -220,6 +224,7 @@ window.__cursorImeEnterFix.uninstall() // 卸载
|
|
|
220
224
|
- `install-cursor-ime-enter-fix.mjs`: 通过 CDP 临时注入输入法回车修复。
|
|
221
225
|
- `src/shared/` — 共享工具
|
|
222
226
|
- `cursor-workbench-paths.mjs`: macOS/Windows workbench 路径解析。
|
|
227
|
+
- `mcp-settings.mjs`: 按平台生成 MCP 面板的配置路径与读/写命令(探测 python,Windows 用正斜杠)。
|
|
223
228
|
- `patch-cursor-workbench.mjs`: patch Cursor 的 `workbench.html`(注入全部三个脚本)。
|
|
224
229
|
- `unpatch-cursor-workbench.mjs`: 恢复 patch。
|
|
225
230
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@srgay/cursor-extension",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "本机 Cursor workbench 增强:MAX Mode 守护、MCP Follow-up 面板、输入法回车修复(支持随 Cursor 启动持久加载,或 CDP 临时注入)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check src/max-mode-guard/cursor-max-mode-guard.js && node --check src/mcp-followup/cursor-mcp-followup.js && node --check src/ime-enter-fix/cursor-ime-enter-fix.js && node --check src/shared/cursor-workbench-paths.mjs && node --check src/max-mode-guard/install-cursor-max-mode-guard.mjs && node --check src/mcp-followup/install-cursor-mcp-followup.mjs && node --check src/ime-enter-fix/install-cursor-ime-enter-fix.mjs && node --check src/shared/patch-cursor-workbench.mjs && node --check src/shared/unpatch-cursor-workbench.mjs && node --check bin/cli.mjs",
|
|
19
|
+
"check": "node --check src/max-mode-guard/cursor-max-mode-guard.js && node --check src/mcp-followup/cursor-mcp-followup.js && node --check src/ime-enter-fix/cursor-ime-enter-fix.js && node --check src/shared/cursor-workbench-paths.mjs && node --check src/shared/mcp-settings.mjs && node --check src/max-mode-guard/install-cursor-max-mode-guard.mjs && node --check src/mcp-followup/install-cursor-mcp-followup.mjs && node --check src/ime-enter-fix/install-cursor-ime-enter-fix.mjs && node --check src/shared/patch-cursor-workbench.mjs && node --check src/shared/unpatch-cursor-workbench.mjs && node --check bin/cli.mjs",
|
|
20
20
|
"inject": "node src/max-mode-guard/install-cursor-max-mode-guard.mjs",
|
|
21
21
|
"inject:followup": "node src/mcp-followup/install-cursor-mcp-followup.mjs",
|
|
22
22
|
"inject:ime": "node src/ime-enter-fix/install-cursor-ime-enter-fix.mjs",
|
|
@@ -12,14 +12,19 @@
|
|
|
12
12
|
reconnectMs: 3000,
|
|
13
13
|
scanStart: 8765,
|
|
14
14
|
scanCount: 5,
|
|
15
|
-
probeTimeoutMs:
|
|
15
|
+
probeTimeoutMs: 2500,
|
|
16
|
+
// 按需扫描:仅在「启动时」和「展开端口下拉框时」各扫一次,不做后台/持续扫描。
|
|
16
17
|
customValue: "__custom__",
|
|
17
18
|
lang: "zh-CN",
|
|
18
19
|
// 发送场景重连成功(onopen)后,若服务端未及时推送会话状态,则等待此毫秒数后兜底直接发送。
|
|
19
20
|
sendAfterOpenMs: 350,
|
|
20
|
-
// 常用提示词所在 ui_settings.json
|
|
21
|
-
// 面板借道现有 WS 的 run_command
|
|
21
|
+
// 常用提示词所在 ui_settings.json 的绝对路径;注入脚本会按平台替换此占位符(Windows 用正斜杠)。
|
|
22
|
+
// 面板借道现有 WS 的 run_command 读写该文件,不读本地 fs、不依赖 CORS。
|
|
22
23
|
settingsPath: "__MCP_SETTINGS_PATH__",
|
|
24
|
+
// 读取配置的完整命令:Mac/Linux 为 `cat <path>`,Windows 为 python UTF-8 读取(注入时按平台生成)。
|
|
25
|
+
readCmd: "__MCP_READ_CMD__",
|
|
26
|
+
// 写回配置时使用的 python 命令名(注入时探测:python / py / python3)。
|
|
27
|
+
pyCmd: "__MCP_PY__",
|
|
23
28
|
// run_command 拉取提示词的兜底超时(毫秒)。
|
|
24
29
|
promptLoadTimeoutMs: 4000,
|
|
25
30
|
};
|
|
@@ -36,6 +41,10 @@
|
|
|
36
41
|
scanning: false,
|
|
37
42
|
foundPorts: [],
|
|
38
43
|
portProjects: {},
|
|
44
|
+
// 端口 → 窗口真实工作区路径 / 项目名(取自 server 进程的 WORKSPACE_FOLDER_PATHS / CURSOR_WORKSPACE_LABEL,
|
|
45
|
+
// 由 Cursor 注入、不受 AI 传入的 project_directory 影响),用于把面板锁定到「本窗口」对应的端口。
|
|
46
|
+
portWorkspaces: {},
|
|
47
|
+
portLabels: {},
|
|
39
48
|
portStatuses: {},
|
|
40
49
|
selectedStatus: null,
|
|
41
50
|
expanded: false,
|
|
@@ -57,6 +66,9 @@
|
|
|
57
66
|
// 输入法组字(拼音/注音等)进行中:此间回车用于上屏候选词,不应触发发送。
|
|
58
67
|
composing: false,
|
|
59
68
|
timers: [],
|
|
69
|
+
// 自定义端口下拉的文档级监听(点空白处 / Esc 关闭菜单),uninstall 时移除。
|
|
70
|
+
onDocPointerDown: null,
|
|
71
|
+
onDocKeydown: null,
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
@@ -170,6 +182,45 @@
|
|
|
170
182
|
#${config.panelId} .cmf-port:focus { background-color: var(--vscode-list-hoverBackground, rgba(228, 228, 228, .07)); }
|
|
171
183
|
#${config.panelId} .cmf-prompts { max-width: 170px; flex: 0 1 auto; }
|
|
172
184
|
|
|
185
|
+
/* 自定义端口下拉:触发按钮 + 浮层菜单(替代原生 <select>,以支持「点开→扫描→填充→展示」而不打断点选)。 */
|
|
186
|
+
#${config.panelId} .cmf-portwrap { position: relative; display: inline-flex; flex: 0 0 auto; }
|
|
187
|
+
#${config.panelId} .cmf-portbtn {
|
|
188
|
+
appearance: none; -webkit-appearance: none;
|
|
189
|
+
height: 22px; padding: 0 20px 0 8px;
|
|
190
|
+
border-radius: 4px;
|
|
191
|
+
border: 1px solid transparent;
|
|
192
|
+
background-color: transparent;
|
|
193
|
+
background-image: url("${chevron}");
|
|
194
|
+
background-repeat: no-repeat;
|
|
195
|
+
background-position: right 5px center;
|
|
196
|
+
color: var(--vscode-descriptionForeground, rgba(228, 228, 228, .7));
|
|
197
|
+
font-family: inherit; font-size: 11.5px; cursor: pointer; outline: none;
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
transition: background-color .12s ease;
|
|
200
|
+
}
|
|
201
|
+
#${config.panelId} .cmf-portbtn:hover { background-color: var(--vscode-list-hoverBackground, rgba(228, 228, 228, .07)); }
|
|
202
|
+
#${config.panelId} .cmf-portbtn.open { background-color: var(--vscode-list-hoverBackground, rgba(228, 228, 228, .1)); }
|
|
203
|
+
#${config.panelId} .cmf-portmenu {
|
|
204
|
+
position: absolute; top: calc(100% + 4px); right: 0; z-index: 1000;
|
|
205
|
+
min-width: 100%; max-width: 300px; max-height: 240px; overflow-y: auto;
|
|
206
|
+
padding: 4px;
|
|
207
|
+
border: 1px solid var(--vscode-input-border, rgba(228, 228, 228, .18));
|
|
208
|
+
border-radius: 8px;
|
|
209
|
+
background: var(--vscode-menu-background, var(--vscode-editorWidget-background, #252526));
|
|
210
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .32);
|
|
211
|
+
}
|
|
212
|
+
#${config.panelId} .cmf-opt {
|
|
213
|
+
display: block; white-space: nowrap;
|
|
214
|
+
padding: 4px 8px; border-radius: 5px;
|
|
215
|
+
font-size: 11.5px; line-height: 1.5;
|
|
216
|
+
color: var(--vscode-foreground, rgba(228, 228, 228, .9));
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
}
|
|
219
|
+
#${config.panelId} .cmf-opt:hover { background: var(--vscode-list-hoverBackground, rgba(228, 228, 228, .1)); }
|
|
220
|
+
#${config.panelId} .cmf-opt.sel { color: var(--vscode-foreground, #fff); background: var(--vscode-list-activeSelectionBackground, rgba(129, 161, 193, .22)); }
|
|
221
|
+
#${config.panelId} .cmf-opt-custom { margin-top: 3px; padding-top: 6px; border-top: 1px solid var(--vscode-input-border, rgba(228, 228, 228, .12)); color: var(--vscode-descriptionForeground, rgba(228, 228, 228, .7)); }
|
|
222
|
+
#${config.panelId} .cmf-menuhint { display: flex; align-items: center; gap: 7px; padding: 6px 8px; font-size: 11.5px; color: var(--vscode-descriptionForeground, rgba(228, 228, 228, .65)); }
|
|
223
|
+
|
|
173
224
|
#${config.panelId} .cmf-scan {
|
|
174
225
|
flex: 0 0 auto;
|
|
175
226
|
width: 22px; height: 22px;
|
|
@@ -427,10 +478,35 @@
|
|
|
427
478
|
}
|
|
428
479
|
|
|
429
480
|
function buildPanel() {
|
|
430
|
-
|
|
481
|
+
// 隐藏的原生 <select> 仅作「数据载体」:继续承载当前选中 value 与 options 列表,
|
|
482
|
+
// 让 fillPortOptions / connectSelectedPort 等既有读写逻辑(els.portSelect.value)保持不变;
|
|
483
|
+
// 用户实际看到/交互的是下面的自定义触发按钮 + 浮层菜单(portBtn / portMenu)。
|
|
484
|
+
const portSelect = h("select", { class: "cmf-port", "aria-hidden": "true", tabindex: "-1" });
|
|
485
|
+
portSelect.style.display = "none";
|
|
431
486
|
els.portSelect = portSelect;
|
|
432
487
|
fillPortOptions(defaultPorts(), String(config.scanStart));
|
|
433
488
|
|
|
489
|
+
const portBtnText = h("span", { class: "cmf-portbtn-text" }, "Port " + config.scanStart);
|
|
490
|
+
const portBtn = h(
|
|
491
|
+
"button",
|
|
492
|
+
{
|
|
493
|
+
class: "cmf-portbtn",
|
|
494
|
+
type: "button",
|
|
495
|
+
"aria-haspopup": "listbox",
|
|
496
|
+
"aria-expanded": "false",
|
|
497
|
+
"aria-label": "选择 MCP 前端端口",
|
|
498
|
+
title: "选择 MCP 前端端口",
|
|
499
|
+
},
|
|
500
|
+
portBtnText
|
|
501
|
+
);
|
|
502
|
+
const portMenu = h("div", { class: "cmf-portmenu", role: "listbox" });
|
|
503
|
+
portMenu.style.display = "none";
|
|
504
|
+
const portWrap = h("div", { class: "cmf-portwrap" }, portBtn, portMenu, portSelect);
|
|
505
|
+
els.portBtn = portBtn;
|
|
506
|
+
els.portBtnText = portBtnText;
|
|
507
|
+
els.portMenu = portMenu;
|
|
508
|
+
els.portWrap = portWrap;
|
|
509
|
+
|
|
434
510
|
const customInput = h("input", {
|
|
435
511
|
class: "cmf-custom",
|
|
436
512
|
type: "text",
|
|
@@ -470,7 +546,7 @@
|
|
|
470
546
|
status,
|
|
471
547
|
h("span", { class: "cmf-spacer" }),
|
|
472
548
|
scanBtn,
|
|
473
|
-
|
|
549
|
+
portWrap,
|
|
474
550
|
customInput,
|
|
475
551
|
projectName,
|
|
476
552
|
gear
|
|
@@ -531,6 +607,7 @@
|
|
|
531
607
|
// 状态文本已从面板移除;游离占位元素让现有状态写入成为无害空操作。
|
|
532
608
|
els.hint = document.createElement("span");
|
|
533
609
|
|
|
610
|
+
updatePortButton();
|
|
534
611
|
wireEvents();
|
|
535
612
|
}
|
|
536
613
|
|
|
@@ -593,6 +670,65 @@
|
|
|
593
670
|
);
|
|
594
671
|
}
|
|
595
672
|
|
|
673
|
+
// 路径规范化:统一分隔符并去掉尾部斜杠,便于和 session 的 project_directory 精确比较。
|
|
674
|
+
function normalizePath(dir) {
|
|
675
|
+
return String(dir || "")
|
|
676
|
+
.replace(/\\/g, "/")
|
|
677
|
+
.replace(/\/+$/, "");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 当前 Cursor 窗口的工作区绝对路径,取自原生 window.vscode(无需额外注入信息)。
|
|
681
|
+
// 取不到(多根工作区 / 空窗口 / 接口缺失)时返回 null,调用方据此降级为原有端口选择逻辑。
|
|
682
|
+
function getWorkspacePath() {
|
|
683
|
+
try {
|
|
684
|
+
const ctx = window.vscode && window.vscode.context;
|
|
685
|
+
const cfg = ctx && typeof ctx.configuration === "function" ? ctx.configuration() : null;
|
|
686
|
+
const uri = cfg && cfg.workspace && cfg.workspace.uri;
|
|
687
|
+
const p = uri && (uri.path || uri.fsPath || uri._fsPath);
|
|
688
|
+
return p ? normalizePath(p) : null;
|
|
689
|
+
} catch (error) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 在 server 进程里读「窗口真实工作区」的命令:WORKSPACE_FOLDER_PATHS / CURSOR_WORKSPACE_LABEL 由 Cursor 注入,
|
|
695
|
+
// 不受 AI 传入的 project_directory 影响。用单个 print 表达式(不含 ';')+ chr(10) 分隔(不含 '|'),
|
|
696
|
+
// 规避服务端 _safe_parse_command 的危险子串过滤。
|
|
697
|
+
function buildWorkspaceProbeCmd() {
|
|
698
|
+
const code =
|
|
699
|
+
"print(__import__('os').environ.get('WORKSPACE_FOLDER_PATHS','')+chr(10)+__import__('os').environ.get('CURSOR_WORKSPACE_LABEL',''))";
|
|
700
|
+
return config.pyCmd + ' -c "' + code + '"';
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// WORKSPACE_FOLDER_PATHS 可能含多个路径(多根工作区);按常见分隔符拆开后看是否包含当前工作区。
|
|
704
|
+
function matchFolders(folders, ws) {
|
|
705
|
+
if (!folders || !ws) return false;
|
|
706
|
+
return String(folders)
|
|
707
|
+
.split(/[:;,\n]/)
|
|
708
|
+
.map((s) => normalizePath(s))
|
|
709
|
+
.filter(Boolean)
|
|
710
|
+
.includes(ws);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 端口是否属于「当前窗口」:优先用 WORKSPACE_FOLDER_PATHS(完整路径),其次用 CURSOR_WORKSPACE_LABEL(项目名)。
|
|
714
|
+
function isPortMine(port, ws) {
|
|
715
|
+
if (!ws) return false;
|
|
716
|
+
if (matchFolders(state.portWorkspaces[port], ws)) return true;
|
|
717
|
+
const label = state.portLabels[port];
|
|
718
|
+
return !!(label && label === basename(ws));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 从一轮扫描结果里挑出「属于当前窗口」的端口:主用 WORKSPACE_FOLDER_PATHS,次用 CURSOR_WORKSPACE_LABEL。
|
|
722
|
+
function pickWorkspacePort(results) {
|
|
723
|
+
const ws = getWorkspacePath();
|
|
724
|
+
if (!ws) return null;
|
|
725
|
+
const wsLabel = basename(ws);
|
|
726
|
+
let hit = results.find((r) => r.alive && matchFolders(r.workspaceFolders, ws));
|
|
727
|
+
if (hit) return String(hit.port);
|
|
728
|
+
hit = results.find((r) => r.alive && r.workspaceLabel && r.workspaceLabel === wsLabel);
|
|
729
|
+
return hit ? String(hit.port) : null;
|
|
730
|
+
}
|
|
731
|
+
|
|
596
732
|
// 选项文本:Port {port}[ · 状态][ · 项目名](状态在前、项目在后)。
|
|
597
733
|
// 下拉展开时所有项都带「状态 · 项目名」,便于按项目/状态区分端口;
|
|
598
734
|
// 收起时框里显示的是「选中项」,为避免与右侧项目名重复,选中项收起时不带项目名。
|
|
@@ -616,6 +752,7 @@
|
|
|
616
752
|
const status = isSelected ? state.selectedStatus : state.portStatuses[option.value] || null;
|
|
617
753
|
option.textContent = portText(option.value, status, state.expanded);
|
|
618
754
|
}
|
|
755
|
+
updatePortButton();
|
|
619
756
|
}
|
|
620
757
|
|
|
621
758
|
function setOptionLabel(port, status) {
|
|
@@ -623,6 +760,87 @@
|
|
|
623
760
|
refreshOptionLabels();
|
|
624
761
|
}
|
|
625
762
|
|
|
763
|
+
// ———————————————————— 自定义端口下拉 ————————————————————
|
|
764
|
+
// 触发按钮文字 = 当前选中端口(带状态、不带项目名;项目名在右侧 projectName 单独显示)。
|
|
765
|
+
function updatePortButton() {
|
|
766
|
+
if (!els.portBtnText) return;
|
|
767
|
+
const cur = els.portSelect.value;
|
|
768
|
+
els.portBtnText.textContent =
|
|
769
|
+
cur === config.customValue ? "自定义端口…" : portText(cur, state.selectedStatus, false);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 渲染浮层菜单:扫描中显示「扫描中…」,否则按最新 foundPorts 列出可用端口(带状态·项目名)+「自定义端口…」。
|
|
773
|
+
// 关键:菜单仅在「打开瞬间」与「扫描完成时」各渲染一次,渲染后保持稳定 —— 自定义 div 不会像原生 <select>
|
|
774
|
+
// 那样在用户点击瞬间因重建 DOM 而打断点选,因此点选即时生效、无打断、无闪。
|
|
775
|
+
function renderPortMenu(opts) {
|
|
776
|
+
if (!els.portMenu) return;
|
|
777
|
+
const loading = !!(opts && opts.loading) || state.scanning;
|
|
778
|
+
clear(els.portMenu);
|
|
779
|
+
if (loading) {
|
|
780
|
+
els.portMenu.appendChild(
|
|
781
|
+
h(
|
|
782
|
+
"div",
|
|
783
|
+
{ class: "cmf-menuhint" },
|
|
784
|
+
h("span", { class: "cmf-spinner", "aria-hidden": "true" }),
|
|
785
|
+
h("span", null, "扫描中…")
|
|
786
|
+
)
|
|
787
|
+
);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const cur = els.portSelect.value;
|
|
791
|
+
const ports = state.foundPorts.length ? state.foundPorts.map(String) : defaultPorts();
|
|
792
|
+
for (const p of ports) {
|
|
793
|
+
const status = p === cur ? state.selectedStatus : state.portStatuses[p] || null;
|
|
794
|
+
const item = h("div", { class: "cmf-opt" + (p === cur ? " sel" : ""), role: "option" }, portText(p, status, true));
|
|
795
|
+
item.addEventListener("click", () => selectPort(p));
|
|
796
|
+
els.portMenu.appendChild(item);
|
|
797
|
+
}
|
|
798
|
+
const customItem = h("div", { class: "cmf-opt cmf-opt-custom", role: "option" }, "自定义端口…");
|
|
799
|
+
customItem.addEventListener("click", selectCustom);
|
|
800
|
+
els.portMenu.appendChild(customItem);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// 打开菜单:先显示「扫描中…」,按需扫一次,扫完用最新结果填充并展示(满足「先扫描→填充→再展示」)。
|
|
804
|
+
async function openPortMenu() {
|
|
805
|
+
if (state.expanded) return;
|
|
806
|
+
state.expanded = true;
|
|
807
|
+
els.portMenu.style.display = "";
|
|
808
|
+
els.portBtn.classList.add("open");
|
|
809
|
+
els.portBtn.setAttribute("aria-expanded", "true");
|
|
810
|
+
renderPortMenu({ loading: true });
|
|
811
|
+
await scanPorts({ refreshOnly: true });
|
|
812
|
+
if (state.expanded) renderPortMenu();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function closePortMenu() {
|
|
816
|
+
state.expanded = false;
|
|
817
|
+
if (els.portMenu) els.portMenu.style.display = "none";
|
|
818
|
+
if (els.portBtn) {
|
|
819
|
+
els.portBtn.classList.remove("open");
|
|
820
|
+
els.portBtn.setAttribute("aria-expanded", "false");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function togglePortMenu() {
|
|
825
|
+
if (state.expanded) closePortMenu();
|
|
826
|
+
else openPortMenu();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// 点选某端口:写回隐藏 select 的 value,关菜单,刷新按钮,再走既有连接逻辑(onPortChange)。
|
|
830
|
+
function selectPort(port) {
|
|
831
|
+
els.portSelect.value = String(port);
|
|
832
|
+
closePortMenu();
|
|
833
|
+
updatePortButton();
|
|
834
|
+
onPortChange();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function selectCustom() {
|
|
838
|
+
els.portSelect.value = config.customValue;
|
|
839
|
+
closePortMenu();
|
|
840
|
+
updatePortButton();
|
|
841
|
+
onPortChange();
|
|
842
|
+
}
|
|
843
|
+
|
|
626
844
|
function deriveStatusLabel(info) {
|
|
627
845
|
if (!info) return "";
|
|
628
846
|
if (info.feedback_completed === true || info.status === "feedback_submitted" || info.status === "completed") {
|
|
@@ -763,8 +981,8 @@
|
|
|
763
981
|
state.cmdBuffer = "";
|
|
764
982
|
if (els.promptRefresh) els.promptRefresh.classList.add("scanning");
|
|
765
983
|
try {
|
|
766
|
-
//
|
|
767
|
-
state.socket.send(JSON.stringify({ type: "run_command", command:
|
|
984
|
+
// 读取命令由注入脚本按平台生成(Mac/Linux: cat;Windows: python -X utf8 读取)。
|
|
985
|
+
state.socket.send(JSON.stringify({ type: "run_command", command: config.readCmd }));
|
|
768
986
|
} catch (error) {
|
|
769
987
|
state.loadingPrompts = false;
|
|
770
988
|
state.cmdBuffer = null;
|
|
@@ -836,7 +1054,7 @@
|
|
|
836
1054
|
"').write_bytes(__import__('base64').b64decode('" +
|
|
837
1055
|
b64 +
|
|
838
1056
|
"'))";
|
|
839
|
-
command = '
|
|
1057
|
+
command = config.pyCmd + ' -c "' + py + '"';
|
|
840
1058
|
const lower = command.toLowerCase();
|
|
841
1059
|
safe = dangerous.every((p) => lower.indexOf(p) === -1);
|
|
842
1060
|
if (!safe) payload += "\n";
|
|
@@ -1213,6 +1431,10 @@
|
|
|
1213
1431
|
let opened = false;
|
|
1214
1432
|
let project = "";
|
|
1215
1433
|
let statusLabel = "";
|
|
1434
|
+
let workspaceFolders = "";
|
|
1435
|
+
let workspaceLabel = "";
|
|
1436
|
+
let cmdBuf = null;
|
|
1437
|
+
let needCmd = false;
|
|
1216
1438
|
let ws = null;
|
|
1217
1439
|
const finish = (alive) => {
|
|
1218
1440
|
if (done) return;
|
|
@@ -1226,13 +1448,24 @@
|
|
|
1226
1448
|
/* noop */
|
|
1227
1449
|
}
|
|
1228
1450
|
}
|
|
1229
|
-
resolve({ alive, project, statusLabel });
|
|
1451
|
+
resolve({ alive, project, statusLabel, workspaceFolders, workspaceLabel });
|
|
1230
1452
|
};
|
|
1231
1453
|
const timer = window.setTimeout(() => finish(opened), timeoutMs);
|
|
1232
1454
|
try {
|
|
1233
1455
|
ws = new WebSocket("ws://127.0.0.1:" + port + "/ws?lang=" + config.lang);
|
|
1234
1456
|
ws.onopen = () => {
|
|
1235
1457
|
opened = true;
|
|
1458
|
+
// 连上后用 run_command 读「窗口真实工作区」(不受 AI 传入的 project_directory 影响)。
|
|
1459
|
+
// pyCmd 占位符未被注入替换时跳过,退回靠 session_info 仅识别项目名。
|
|
1460
|
+
if (config.pyCmd.indexOf("__MCP_") !== 0) {
|
|
1461
|
+
cmdBuf = "";
|
|
1462
|
+
needCmd = true;
|
|
1463
|
+
try {
|
|
1464
|
+
ws.send(JSON.stringify({ type: "run_command", command: buildWorkspaceProbeCmd() }));
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
needCmd = false;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1236
1469
|
};
|
|
1237
1470
|
ws.onmessage = (event) => {
|
|
1238
1471
|
try {
|
|
@@ -1241,6 +1474,21 @@
|
|
|
1241
1474
|
if (info && info.project_directory) {
|
|
1242
1475
|
project = info.project_directory;
|
|
1243
1476
|
statusLabel = deriveStatusLabel(info);
|
|
1477
|
+
// 没发探测命令时(占位符未替换),拿到会话即可结束。
|
|
1478
|
+
if (!needCmd) finish(true);
|
|
1479
|
+
}
|
|
1480
|
+
if (data.type === "command_output") {
|
|
1481
|
+
if (cmdBuf !== null) cmdBuf += data.output || "";
|
|
1482
|
+
}
|
|
1483
|
+
if (data.type === "command_complete" || data.type === "command_error") {
|
|
1484
|
+
const raw = (cmdBuf || "").replace(/\r/g, "");
|
|
1485
|
+
const nl = raw.indexOf("\n");
|
|
1486
|
+
if (nl >= 0) {
|
|
1487
|
+
workspaceFolders = raw.slice(0, nl).trim();
|
|
1488
|
+
workspaceLabel = raw.slice(nl + 1).trim();
|
|
1489
|
+
} else {
|
|
1490
|
+
workspaceFolders = raw.trim();
|
|
1491
|
+
}
|
|
1244
1492
|
finish(true);
|
|
1245
1493
|
}
|
|
1246
1494
|
} catch (error) {
|
|
@@ -1255,7 +1503,8 @@
|
|
|
1255
1503
|
});
|
|
1256
1504
|
}
|
|
1257
1505
|
|
|
1258
|
-
async function scanPorts() {
|
|
1506
|
+
async function scanPorts(opts) {
|
|
1507
|
+
const refreshOnly = !!(opts && opts.refreshOnly);
|
|
1259
1508
|
if (state.scanning) return;
|
|
1260
1509
|
state.scanning = true;
|
|
1261
1510
|
if (els.scanBtn) {
|
|
@@ -1268,15 +1517,31 @@
|
|
|
1268
1517
|
els.hint.textContent = "正在扫描端口 " + from + "–" + to + "…";
|
|
1269
1518
|
|
|
1270
1519
|
const ports = defaultPorts();
|
|
1520
|
+
// 跳过探测「当前已连接的健康端口」:probe 会另建连接抢占服务端的「最后连接」,从而把面板自己挤下线。
|
|
1521
|
+
// 对已连端口直接用已知信息标记为活跃,保证下拉扫描/重扫不会断开当前会话。
|
|
1522
|
+
const curPort =
|
|
1523
|
+
state.socket && state.socket.readyState === WebSocket.OPEN ? String(state.socketPort) : null;
|
|
1271
1524
|
const results = await Promise.all(
|
|
1272
|
-
ports.map((port) =>
|
|
1273
|
-
|
|
1525
|
+
ports.map((port) => {
|
|
1526
|
+
if (curPort && String(port) === curPort) {
|
|
1527
|
+
return Promise.resolve({
|
|
1528
|
+
port,
|
|
1529
|
+
alive: true,
|
|
1530
|
+
project: state.currentSession ? state.currentSession.project_directory : "",
|
|
1531
|
+
statusLabel: state.portStatuses[port] || state.selectedStatus || "",
|
|
1532
|
+
workspaceFolders: state.portWorkspaces[port] || "",
|
|
1533
|
+
workspaceLabel: state.portLabels[port] || "",
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
return probePort(port, config.probeTimeoutMs).then((res) => ({
|
|
1274
1537
|
port,
|
|
1275
1538
|
alive: res.alive,
|
|
1276
1539
|
project: res.project,
|
|
1277
1540
|
statusLabel: res.statusLabel,
|
|
1278
|
-
|
|
1279
|
-
|
|
1541
|
+
workspaceFolders: res.workspaceFolders,
|
|
1542
|
+
workspaceLabel: res.workspaceLabel,
|
|
1543
|
+
}));
|
|
1544
|
+
})
|
|
1280
1545
|
);
|
|
1281
1546
|
const found = [];
|
|
1282
1547
|
for (const item of results) {
|
|
@@ -1284,19 +1549,21 @@
|
|
|
1284
1549
|
found.push(item.port);
|
|
1285
1550
|
if (item.project) state.portProjects[item.port] = basename(item.project);
|
|
1286
1551
|
else delete state.portProjects[item.port];
|
|
1552
|
+
if (item.workspaceFolders) state.portWorkspaces[item.port] = item.workspaceFolders;
|
|
1553
|
+
else delete state.portWorkspaces[item.port];
|
|
1554
|
+
if (item.workspaceLabel) state.portLabels[item.port] = item.workspaceLabel;
|
|
1555
|
+
else delete state.portLabels[item.port];
|
|
1287
1556
|
if (item.statusLabel) state.portStatuses[item.port] = item.statusLabel;
|
|
1288
1557
|
else delete state.portStatuses[item.port];
|
|
1289
1558
|
} else {
|
|
1290
1559
|
delete state.portProjects[item.port];
|
|
1560
|
+
delete state.portWorkspaces[item.port];
|
|
1561
|
+
delete state.portLabels[item.port];
|
|
1291
1562
|
delete state.portStatuses[item.port];
|
|
1292
1563
|
}
|
|
1293
1564
|
}
|
|
1294
1565
|
state.foundPorts = found;
|
|
1295
1566
|
|
|
1296
|
-
const wasCustom = els.portSelect.value === config.customValue;
|
|
1297
|
-
fillPortOptions(found, wasCustom ? null : els.portSelect.value);
|
|
1298
|
-
if (wasCustom) els.portSelect.value = config.customValue;
|
|
1299
|
-
|
|
1300
1567
|
if (els.scanBtn) {
|
|
1301
1568
|
els.scanBtn.classList.remove("scanning");
|
|
1302
1569
|
els.scanBtn.disabled = false;
|
|
@@ -1307,6 +1574,20 @@
|
|
|
1307
1574
|
? "扫描完成:发现活跃端口 " + found.join("、") + "。"
|
|
1308
1575
|
: "扫描完成:" + from + "–" + to + " 未发现活跃 MCP 端口。";
|
|
1309
1576
|
|
|
1577
|
+
if (refreshOnly) {
|
|
1578
|
+
// 自定义下拉:扫描结果已写入 state;菜单仍展开则用最新结果渲染一次浮层(渲染后保持稳定,
|
|
1579
|
+
// 不会在用户点击瞬间重建 DOM,故点选即时生效、无打断、无闪)。
|
|
1580
|
+
if (state.expanded) renderPortMenu();
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const wasCustom = els.portSelect.value === config.customValue;
|
|
1585
|
+
// 优先选中 project_directory 与当前窗口工作区相等的端口;取不到工作区或无匹配端口时维持原有选择
|
|
1586
|
+
//(combo 降级),连接层再由 connectSelectedPort 守卫兜底,避免连到别的项目。
|
|
1587
|
+
const preferred = pickWorkspacePort(results);
|
|
1588
|
+
fillPortOptions(found, preferred || (wasCustom ? null : els.portSelect.value));
|
|
1589
|
+
if (wasCustom && !preferred) els.portSelect.value = config.customValue;
|
|
1590
|
+
|
|
1310
1591
|
// 探测会短暂抢占连接,扫描结束后立即重连选中端口抢回「最后连接」。
|
|
1311
1592
|
if (els.portSelect.value !== config.customValue) connectSelectedPort(false);
|
|
1312
1593
|
}
|
|
@@ -1352,6 +1633,24 @@
|
|
|
1352
1633
|
function connectSelectedPort(auto) {
|
|
1353
1634
|
const port = els.portSelect.value;
|
|
1354
1635
|
if (port === config.customValue) return;
|
|
1636
|
+
|
|
1637
|
+
// 守卫:能确定当前窗口、且该端口已知属于别的窗口时不连接,
|
|
1638
|
+
// 避免误连到其它窗口的实例或常驻 feedback 实例(等本窗口端口被识别后由 refreshTick 重扫接管)。
|
|
1639
|
+
const ws = getWorkspacePath();
|
|
1640
|
+
const known = !!(state.portWorkspaces[port] || state.portLabels[port]);
|
|
1641
|
+
if (ws && known && !isPortMine(port, ws)) {
|
|
1642
|
+
if (state.socket) {
|
|
1643
|
+
state.socket.onclose = null;
|
|
1644
|
+
state.socket.onerror = null;
|
|
1645
|
+
state.socket.onmessage = null;
|
|
1646
|
+
state.socket.close();
|
|
1647
|
+
state.socket = null;
|
|
1648
|
+
}
|
|
1649
|
+
setOptionLabel(port, "other window");
|
|
1650
|
+
setVisualState("offline", port + " 属于其它窗口,等待本窗口的 MCP 会话…");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1355
1654
|
const seq = ++state.connectSeq;
|
|
1356
1655
|
|
|
1357
1656
|
if (state.socket) {
|
|
@@ -1406,6 +1705,10 @@
|
|
|
1406
1705
|
|
|
1407
1706
|
nextSocket.onerror = () => {
|
|
1408
1707
|
if (seq !== state.connectSeq) return;
|
|
1708
|
+
// 连接失败:端口很可能已关闭或换了端口(实例重启),清除其归属缓存,
|
|
1709
|
+
// 让 refreshTick 退避重扫去发现新的本窗口端口,而不是一直重连这个死端口。
|
|
1710
|
+
delete state.portWorkspaces[port];
|
|
1711
|
+
delete state.portLabels[port];
|
|
1409
1712
|
setOptionLabel(port, "offline");
|
|
1410
1713
|
setVisualState("offline", port + " 连接失败,可能端口未启动或没有 MCP WebUI。");
|
|
1411
1714
|
};
|
|
@@ -1436,6 +1739,23 @@
|
|
|
1436
1739
|
if (els.portSelect.value === config.customValue) return;
|
|
1437
1740
|
if (els.customInput === document.activeElement) return;
|
|
1438
1741
|
if (els.prompt === document.activeElement && els.prompt.value.trim()) return;
|
|
1742
|
+
|
|
1743
|
+
// 不做后台扫描(扫描仅发生在启动时与下拉展开期间)。这里只保持连接:
|
|
1744
|
+
// 已连且健康 → 发 heartbeat 维持「最后连接」;否则重连选中端口(守卫会拦掉别窗口端口;
|
|
1745
|
+
// 端口已关时 onerror 会清归属并置 offline,等待用户展开下拉重新选择本窗口端口)。
|
|
1746
|
+
if (
|
|
1747
|
+
state.socket &&
|
|
1748
|
+
state.socket.readyState === WebSocket.OPEN &&
|
|
1749
|
+
state.socketPort === els.portSelect.value
|
|
1750
|
+
) {
|
|
1751
|
+
try {
|
|
1752
|
+
state.socket.send(JSON.stringify({ type: "heartbeat", timestamp: Date.now() }));
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
/* 发送失败则下个周期走重连 */
|
|
1755
|
+
}
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1439
1759
|
connectSelectedPort(true);
|
|
1440
1760
|
}
|
|
1441
1761
|
|
|
@@ -1498,16 +1818,22 @@
|
|
|
1498
1818
|
}
|
|
1499
1819
|
|
|
1500
1820
|
function wireEvents() {
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
refreshOptionLabels();
|
|
1506
|
-
});
|
|
1507
|
-
els.portSelect.addEventListener("blur", () => {
|
|
1508
|
-
state.expanded = false;
|
|
1509
|
-
refreshOptionLabels();
|
|
1821
|
+
// 自定义端口下拉:点按钮开/关;打开即「扫描中→填充→展示」;点选即时生效(见 selectPort / selectCustom)。
|
|
1822
|
+
els.portBtn.addEventListener("click", (event) => {
|
|
1823
|
+
event.stopPropagation();
|
|
1824
|
+
togglePortMenu();
|
|
1510
1825
|
});
|
|
1826
|
+
// 点击按钮与浮层之外的区域、或按 Esc 关闭菜单。
|
|
1827
|
+
state.onDocPointerDown = (event) => {
|
|
1828
|
+
if (!state.expanded) return;
|
|
1829
|
+
if (els.portWrap && els.portWrap.contains(event.target)) return;
|
|
1830
|
+
closePortMenu();
|
|
1831
|
+
};
|
|
1832
|
+
document.addEventListener("mousedown", state.onDocPointerDown, true);
|
|
1833
|
+
state.onDocKeydown = (event) => {
|
|
1834
|
+
if (state.expanded && event.key === "Escape") closePortMenu();
|
|
1835
|
+
};
|
|
1836
|
+
document.addEventListener("keydown", state.onDocKeydown, true);
|
|
1511
1837
|
els.scanBtn.addEventListener("click", () => {
|
|
1512
1838
|
scanPorts();
|
|
1513
1839
|
});
|
|
@@ -1579,6 +1905,8 @@
|
|
|
1579
1905
|
|
|
1580
1906
|
function uninstall() {
|
|
1581
1907
|
cancelAutoSubmit();
|
|
1908
|
+
if (state.onDocPointerDown) document.removeEventListener("mousedown", state.onDocPointerDown, true);
|
|
1909
|
+
if (state.onDocKeydown) document.removeEventListener("keydown", state.onDocKeydown, true);
|
|
1582
1910
|
for (const timer of state.timers) {
|
|
1583
1911
|
window.clearInterval(timer);
|
|
1584
1912
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { applyMcpFollowupReplacements } from "../shared/mcp-settings.mjs";
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const port = Number(process.env.CURSOR_DEBUG_PORT || "9222");
|
|
@@ -69,10 +69,9 @@ if (!target) {
|
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// 面板运行在浏览器环境(无 fs),改用 WS run_command
|
|
73
|
-
// 这里在 Node
|
|
74
|
-
const
|
|
75
|
-
const source = (await readFile(sourcePath, "utf8")).replace("__MCP_SETTINGS_PATH__", settingsPath);
|
|
72
|
+
// 面板运行在浏览器环境(无 fs),改用 WS run_command 读写 ui_settings.json;
|
|
73
|
+
// 这里在 Node 侧按平台把占位符替换成真实路径与命令(Windows 用正斜杠 + python,详见 shared/mcp-settings.mjs)。
|
|
74
|
+
const source = applyMcpFollowupReplacements(await readFile(sourcePath, "utf8"));
|
|
76
75
|
const client = connect(target.webSocketDebuggerUrl);
|
|
77
76
|
|
|
78
77
|
await client.opened;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// MCP Follow-up 面板运行在浏览器环境(无 fs),通过现有 WS 的 run_command 读/写 ui_settings.json。
|
|
6
|
+
// 服务端(mcp-feedback-enhanced)以 shell=False + shlex.split(POSIX) 执行命令,因此:
|
|
7
|
+
// - 路径里的反斜杠会被 shlex 与 Python 字面量双重转义 → Windows 统一改用正斜杠;
|
|
8
|
+
// - Windows 没有 cat / 不一定有 python3 → 读取改用探测到的 python,并用 -X utf8 规避编码问题。
|
|
9
|
+
// 这里在注入脚本(Node 侧、与 Cursor/MCP 同机)按平台生成真实路径与命令,替换面板里的占位符。
|
|
10
|
+
|
|
11
|
+
// 探测可用的 python 命令名:优先环境变量 CURSOR_MCP_PYTHON,其次按平台候选逐一 `--version` 验证。
|
|
12
|
+
function detectPython() {
|
|
13
|
+
const override = process.env.CURSOR_MCP_PYTHON;
|
|
14
|
+
if (override && override.trim()) return override.trim();
|
|
15
|
+
|
|
16
|
+
const isWin = process.platform === "win32";
|
|
17
|
+
const candidates = isWin ? ["python", "py", "python3"] : ["python3", "python"];
|
|
18
|
+
for (const cmd of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
const result = spawnSync(cmd, ["--version"], { stdio: "ignore" });
|
|
21
|
+
if (!result.error && result.status === 0) return cmd;
|
|
22
|
+
} catch {
|
|
23
|
+
// 忽略,尝试下一个候选
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return isWin ? "python" : "python3";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 生成面板占位符的真实取值:配置文件绝对路径、python 命令名、完整读取命令。
|
|
30
|
+
export function buildMcpFollowupReplacements() {
|
|
31
|
+
const isWin = process.platform === "win32";
|
|
32
|
+
|
|
33
|
+
// 与 mcp-feedback-enhanced 一致:~/.config/mcp-feedback-enhanced/ui_settings.json(无平台分支)。
|
|
34
|
+
let settingsPath = join(homedir(), ".config", "mcp-feedback-enhanced", "ui_settings.json");
|
|
35
|
+
if (isWin) settingsPath = settingsPath.replace(/\\/g, "/");
|
|
36
|
+
|
|
37
|
+
const pyCmd = detectPython();
|
|
38
|
+
|
|
39
|
+
// 读取命令:Mac/Linux 用现成的 cat;Windows 无 cat,用 python 的 UTF-8 模式读取。
|
|
40
|
+
const readCmd = isWin
|
|
41
|
+
? `${pyCmd} -X utf8 -c "print(open('${settingsPath}').read())"`
|
|
42
|
+
: `cat ${settingsPath}`;
|
|
43
|
+
|
|
44
|
+
return { settingsPath, pyCmd, readCmd };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 把面板源码里的占位符替换为真实取值。用函数式替换以避免 $ 等特殊字符被当作替换模式。
|
|
48
|
+
export function applyMcpFollowupReplacements(code) {
|
|
49
|
+
const { settingsPath, pyCmd, readCmd } = buildMcpFollowupReplacements();
|
|
50
|
+
return code
|
|
51
|
+
.replace("__MCP_SETTINGS_PATH__", () => settingsPath)
|
|
52
|
+
.replace("__MCP_READ_CMD__", () => readCmd)
|
|
53
|
+
.replace("__MCP_PY__", () => pyCmd);
|
|
54
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { copyFile, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { applyMcpFollowupReplacements, buildMcpFollowupReplacements } from "./mcp-settings.mjs";
|
|
6
6
|
import { resolveWorkbenchPaths } from "./cursor-workbench-paths.mjs";
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -10,8 +10,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
const guardSource = resolve(__dirname, "../max-mode-guard/cursor-max-mode-guard.js");
|
|
11
11
|
const followupSource = resolve(__dirname, "../mcp-followup/cursor-mcp-followup.js");
|
|
12
12
|
const imeFixSource = resolve(__dirname, "../ime-enter-fix/cursor-ime-enter-fix.js");
|
|
13
|
-
// 面板在浏览器环境无 fs,靠 WS run_command
|
|
14
|
-
const settingsPath =
|
|
13
|
+
// 面板在浏览器环境无 fs,靠 WS run_command 读写 ui_settings.json;settingsPath 仅用于下方日志输出。
|
|
14
|
+
const { settingsPath } = buildMcpFollowupReplacements();
|
|
15
15
|
const {
|
|
16
16
|
workbenchDir,
|
|
17
17
|
workbenchHtml,
|
|
@@ -113,8 +113,8 @@ async function main() {
|
|
|
113
113
|
|
|
114
114
|
await copyFile(guardSource, guardTarget);
|
|
115
115
|
|
|
116
|
-
// MCP
|
|
117
|
-
const followupCode = (await readFile(followupSource, "utf8"))
|
|
116
|
+
// MCP 面板:不是单纯拷贝,需按平台把占位符(路径/命令)替换后再写入。
|
|
117
|
+
const followupCode = applyMcpFollowupReplacements(await readFile(followupSource, "utf8"));
|
|
118
118
|
await writeFile(followupTarget, followupCode);
|
|
119
119
|
|
|
120
120
|
// 输入法回车修复:无占位符,直接拷贝。
|