@srgay/cursor-extension 1.0.1 → 1.0.3
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 +9 -9
- package/package.json +1 -1
- package/src/mcp-followup/cursor-mcp-followup.js +323 -54
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)
|
|
@@ -115,10 +115,10 @@ npm run inject
|
|
|
115
115
|
|
|
116
116
|
- 注入到聊天框上方,样式贴合 Cursor 原生输入框。
|
|
117
117
|
- 自动扫描端口 `8765–8769`,识别正在监听的 MCP 服务(连得上或返回 `4004 无 session` 即视为活跃)。
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
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`(项目名)做次级匹配。多端口同时在线(别的窗口/常驻实例)也能选对、绝不误连;本窗口端口尚未出现时降频重扫等待,出现后自动连上;取不到工作区(多根工作区/空窗口)时回退原有逻辑。
|
|
122
122
|
- 持续保持为 WebSocket「最后连接」(每 3 秒重连 + 输入框聚焦时重连),确保始终能收到当前会话并成功提交反馈。
|
|
123
123
|
- 底部「常用提示词」下拉:一键把 `ui_settings.json` 里的提示词追加到输入框;旁边刷新按钮可重新拉取配置。
|
|
124
124
|
- 齿轮按钮展开**配置抽屉**:
|
|
@@ -147,9 +147,9 @@ npm run inject:followup
|
|
|
147
147
|
### 端口扫描与自定义
|
|
148
148
|
|
|
149
149
|
- 面板安装时自动扫描一次 `8765–8769`。
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
150
|
+
- **按窗口自动选端口**:扫描时借 `run_command` 读取每个端口实例的 `WORKSPACE_FOLDER_PATHS`,优先选中与当前窗口工作区相等的端口(次级用 `CURSOR_WORKSPACE_LABEL` 项目名);已知属于别的窗口的端口直接不连。这样即使「在 A 窗口让 AI 改 B 项目代码」(会话 `project_directory` 变成 B),也仍连回 A 窗口自己的端口。
|
|
151
|
+
- **扫描时机**:只在「面板启动时」和「每次点开端口下拉时」各扫一次,不做后台轮询扫描;连上本窗口端口后只发心跳维持 WebSocket「最后连接」。本窗口端口暂未出现时,点开下拉重扫即可发现并连上。
|
|
152
|
+
- 点击放大镜按钮可手动重扫(全量扫描并自动连上本窗口端口)。
|
|
153
153
|
- 在端口下拉里选择「自定义端口…」,就地输入端口号回车即可连接;输入框失焦或按 `Esc` 会回退到第一个可用端口。
|
|
154
154
|
|
|
155
155
|
### 查看状态
|
|
@@ -177,7 +177,7 @@ window.__cursorMcpFollowup.uninstall() // 卸载面板
|
|
|
177
177
|
脚本顶部 `config` 可调整:
|
|
178
178
|
|
|
179
179
|
- `scanStart` / `scanCount`:扫描起始端口与数量(默认从 `8765` 起、共 `5` 个,即 `8765–8769`)。
|
|
180
|
-
- `probeTimeoutMs`:单端口探测超时(默认 `
|
|
180
|
+
- `probeTimeoutMs`:单端口探测超时(默认 `2500` ms;含一次 `run_command` 读取窗口工作区的往返)。
|
|
181
181
|
- `reconnectMs`:自动重连间隔(默认 `3000` ms)。
|
|
182
182
|
|
|
183
183
|
### 排障
|
package/package.json
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
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)后,若服务端未及时推送会话状态,则等待此毫秒数后兜底直接发送。
|
|
@@ -40,8 +41,10 @@
|
|
|
40
41
|
scanning: false,
|
|
41
42
|
foundPorts: [],
|
|
42
43
|
portProjects: {},
|
|
43
|
-
// 端口 →
|
|
44
|
-
|
|
44
|
+
// 端口 → 窗口真实工作区路径 / 项目名(取自 server 进程的 WORKSPACE_FOLDER_PATHS / CURSOR_WORKSPACE_LABEL,
|
|
45
|
+
// 由 Cursor 注入、不受 AI 传入的 project_directory 影响),用于把面板锁定到「本窗口」对应的端口。
|
|
46
|
+
portWorkspaces: {},
|
|
47
|
+
portLabels: {},
|
|
45
48
|
portStatuses: {},
|
|
46
49
|
selectedStatus: null,
|
|
47
50
|
expanded: false,
|
|
@@ -63,6 +66,9 @@
|
|
|
63
66
|
// 输入法组字(拼音/注音等)进行中:此间回车用于上屏候选词,不应触发发送。
|
|
64
67
|
composing: false,
|
|
65
68
|
timers: [],
|
|
69
|
+
// 自定义端口下拉的文档级监听(点空白处 / Esc 关闭菜单),uninstall 时移除。
|
|
70
|
+
onDocPointerDown: null,
|
|
71
|
+
onDocKeydown: null,
|
|
66
72
|
};
|
|
67
73
|
|
|
68
74
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
@@ -176,6 +182,45 @@
|
|
|
176
182
|
#${config.panelId} .cmf-port:focus { background-color: var(--vscode-list-hoverBackground, rgba(228, 228, 228, .07)); }
|
|
177
183
|
#${config.panelId} .cmf-prompts { max-width: 170px; flex: 0 1 auto; }
|
|
178
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
|
+
|
|
179
224
|
#${config.panelId} .cmf-scan {
|
|
180
225
|
flex: 0 0 auto;
|
|
181
226
|
width: 22px; height: 22px;
|
|
@@ -346,6 +391,18 @@
|
|
|
346
391
|
refreshOptionLabels();
|
|
347
392
|
}
|
|
348
393
|
|
|
394
|
+
// 确保隐藏 select(数据载体)里存在某端口的 option。自定义浮层用 foundPorts 渲染,
|
|
395
|
+
// 但隐藏 select 只在全量扫描时重填;按需扫描新发现的端口必须补进来,否则给原生
|
|
396
|
+
// <select> 设一个不存在的 value 会被静默置空 → 连空端口 offline。
|
|
397
|
+
function ensurePortOption(value) {
|
|
398
|
+
if (!els.portSelect) return;
|
|
399
|
+
const v = String(value);
|
|
400
|
+
const opts = Array.from(els.portSelect.options);
|
|
401
|
+
if (opts.some((o) => o.value === v)) return;
|
|
402
|
+
const custom = opts.find((o) => o.value === config.customValue);
|
|
403
|
+
els.portSelect.insertBefore(h("option", { value: v }), custom || null);
|
|
404
|
+
}
|
|
405
|
+
|
|
349
406
|
function makeScanIcon() {
|
|
350
407
|
const svg = svgEl("svg", { viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true" });
|
|
351
408
|
svg.appendChild(svgEl("circle", { cx: "11", cy: "11", r: "6", stroke: "currentColor", "stroke-width": "2" }));
|
|
@@ -433,10 +490,35 @@
|
|
|
433
490
|
}
|
|
434
491
|
|
|
435
492
|
function buildPanel() {
|
|
436
|
-
|
|
493
|
+
// 隐藏的原生 <select> 仅作「数据载体」:继续承载当前选中 value 与 options 列表,
|
|
494
|
+
// 让 fillPortOptions / connectSelectedPort 等既有读写逻辑(els.portSelect.value)保持不变;
|
|
495
|
+
// 用户实际看到/交互的是下面的自定义触发按钮 + 浮层菜单(portBtn / portMenu)。
|
|
496
|
+
const portSelect = h("select", { class: "cmf-port", "aria-hidden": "true", tabindex: "-1" });
|
|
497
|
+
portSelect.style.display = "none";
|
|
437
498
|
els.portSelect = portSelect;
|
|
438
499
|
fillPortOptions(defaultPorts(), String(config.scanStart));
|
|
439
500
|
|
|
501
|
+
const portBtnText = h("span", { class: "cmf-portbtn-text" }, "Port " + config.scanStart);
|
|
502
|
+
const portBtn = h(
|
|
503
|
+
"button",
|
|
504
|
+
{
|
|
505
|
+
class: "cmf-portbtn",
|
|
506
|
+
type: "button",
|
|
507
|
+
"aria-haspopup": "listbox",
|
|
508
|
+
"aria-expanded": "false",
|
|
509
|
+
"aria-label": "选择 MCP 前端端口",
|
|
510
|
+
title: "选择 MCP 前端端口",
|
|
511
|
+
},
|
|
512
|
+
portBtnText
|
|
513
|
+
);
|
|
514
|
+
const portMenu = h("div", { class: "cmf-portmenu", role: "listbox" });
|
|
515
|
+
portMenu.style.display = "none";
|
|
516
|
+
const portWrap = h("div", { class: "cmf-portwrap" }, portBtn, portMenu, portSelect);
|
|
517
|
+
els.portBtn = portBtn;
|
|
518
|
+
els.portBtnText = portBtnText;
|
|
519
|
+
els.portMenu = portMenu;
|
|
520
|
+
els.portWrap = portWrap;
|
|
521
|
+
|
|
440
522
|
const customInput = h("input", {
|
|
441
523
|
class: "cmf-custom",
|
|
442
524
|
type: "text",
|
|
@@ -476,7 +558,7 @@
|
|
|
476
558
|
status,
|
|
477
559
|
h("span", { class: "cmf-spacer" }),
|
|
478
560
|
scanBtn,
|
|
479
|
-
|
|
561
|
+
portWrap,
|
|
480
562
|
customInput,
|
|
481
563
|
projectName,
|
|
482
564
|
gear
|
|
@@ -537,6 +619,7 @@
|
|
|
537
619
|
// 状态文本已从面板移除;游离占位元素让现有状态写入成为无害空操作。
|
|
538
620
|
els.hint = document.createElement("span");
|
|
539
621
|
|
|
622
|
+
updatePortButton();
|
|
540
623
|
wireEvents();
|
|
541
624
|
}
|
|
542
625
|
|
|
@@ -620,13 +703,41 @@
|
|
|
620
703
|
}
|
|
621
704
|
}
|
|
622
705
|
|
|
623
|
-
//
|
|
706
|
+
// 在 server 进程里读「窗口真实工作区」的命令:WORKSPACE_FOLDER_PATHS / CURSOR_WORKSPACE_LABEL 由 Cursor 注入,
|
|
707
|
+
// 不受 AI 传入的 project_directory 影响。用单个 print 表达式(不含 ';')+ chr(10) 分隔(不含 '|'),
|
|
708
|
+
// 规避服务端 _safe_parse_command 的危险子串过滤。
|
|
709
|
+
function buildWorkspaceProbeCmd() {
|
|
710
|
+
const code =
|
|
711
|
+
"print(__import__('os').environ.get('WORKSPACE_FOLDER_PATHS','')+chr(10)+__import__('os').environ.get('CURSOR_WORKSPACE_LABEL',''))";
|
|
712
|
+
return config.pyCmd + ' -c "' + code + '"';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// WORKSPACE_FOLDER_PATHS 可能含多个路径(多根工作区);按常见分隔符拆开后看是否包含当前工作区。
|
|
716
|
+
function matchFolders(folders, ws) {
|
|
717
|
+
if (!folders || !ws) return false;
|
|
718
|
+
return String(folders)
|
|
719
|
+
.split(/[:;,\n]/)
|
|
720
|
+
.map((s) => normalizePath(s))
|
|
721
|
+
.filter(Boolean)
|
|
722
|
+
.includes(ws);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 端口是否属于「当前窗口」:优先用 WORKSPACE_FOLDER_PATHS(完整路径),其次用 CURSOR_WORKSPACE_LABEL(项目名)。
|
|
726
|
+
function isPortMine(port, ws) {
|
|
727
|
+
if (!ws) return false;
|
|
728
|
+
if (matchFolders(state.portWorkspaces[port], ws)) return true;
|
|
729
|
+
const label = state.portLabels[port];
|
|
730
|
+
return !!(label && label === basename(ws));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 从一轮扫描结果里挑出「属于当前窗口」的端口:主用 WORKSPACE_FOLDER_PATHS,次用 CURSOR_WORKSPACE_LABEL。
|
|
624
734
|
function pickWorkspacePort(results) {
|
|
625
735
|
const ws = getWorkspacePath();
|
|
626
736
|
if (!ws) return null;
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
);
|
|
737
|
+
const wsLabel = basename(ws);
|
|
738
|
+
let hit = results.find((r) => r.alive && matchFolders(r.workspaceFolders, ws));
|
|
739
|
+
if (hit) return String(hit.port);
|
|
740
|
+
hit = results.find((r) => r.alive && r.workspaceLabel && r.workspaceLabel === wsLabel);
|
|
630
741
|
return hit ? String(hit.port) : null;
|
|
631
742
|
}
|
|
632
743
|
|
|
@@ -653,6 +764,7 @@
|
|
|
653
764
|
const status = isSelected ? state.selectedStatus : state.portStatuses[option.value] || null;
|
|
654
765
|
option.textContent = portText(option.value, status, state.expanded);
|
|
655
766
|
}
|
|
767
|
+
updatePortButton();
|
|
656
768
|
}
|
|
657
769
|
|
|
658
770
|
function setOptionLabel(port, status) {
|
|
@@ -660,6 +772,91 @@
|
|
|
660
772
|
refreshOptionLabels();
|
|
661
773
|
}
|
|
662
774
|
|
|
775
|
+
// ———————————————————— 自定义端口下拉 ————————————————————
|
|
776
|
+
// 触发按钮文字 = 当前选中端口(带状态、不带项目名;项目名在右侧 projectName 单独显示)。
|
|
777
|
+
function updatePortButton() {
|
|
778
|
+
if (!els.portBtnText) return;
|
|
779
|
+
const cur = els.portSelect.value;
|
|
780
|
+
els.portBtnText.textContent =
|
|
781
|
+
cur === config.customValue ? "自定义端口…" : portText(cur, state.selectedStatus, false);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// 渲染浮层菜单:扫描中显示「扫描中…」,否则按最新 foundPorts 列出可用端口(带状态·项目名)+「自定义端口…」。
|
|
785
|
+
// 关键:菜单仅在「打开瞬间」与「扫描完成时」各渲染一次,渲染后保持稳定 —— 自定义 div 不会像原生 <select>
|
|
786
|
+
// 那样在用户点击瞬间因重建 DOM 而打断点选,因此点选即时生效、无打断、无闪。
|
|
787
|
+
function renderPortMenu(opts) {
|
|
788
|
+
if (!els.portMenu) return;
|
|
789
|
+
const loading = !!(opts && opts.loading) || state.scanning;
|
|
790
|
+
clear(els.portMenu);
|
|
791
|
+
if (loading) {
|
|
792
|
+
els.portMenu.appendChild(
|
|
793
|
+
h(
|
|
794
|
+
"div",
|
|
795
|
+
{ class: "cmf-menuhint" },
|
|
796
|
+
h("span", { class: "cmf-spinner", "aria-hidden": "true" }),
|
|
797
|
+
h("span", null, "扫描中…")
|
|
798
|
+
)
|
|
799
|
+
);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const cur = els.portSelect.value;
|
|
803
|
+
const ports = state.foundPorts.length ? state.foundPorts.map(String) : defaultPorts();
|
|
804
|
+
for (const p of ports) {
|
|
805
|
+
const status = p === cur ? state.selectedStatus : state.portStatuses[p] || null;
|
|
806
|
+
const item = h("div", { class: "cmf-opt" + (p === cur ? " sel" : ""), role: "option" }, portText(p, status, true));
|
|
807
|
+
item.addEventListener("click", () => selectPort(p));
|
|
808
|
+
els.portMenu.appendChild(item);
|
|
809
|
+
}
|
|
810
|
+
const customItem = h("div", { class: "cmf-opt cmf-opt-custom", role: "option" }, "自定义端口…");
|
|
811
|
+
customItem.addEventListener("click", selectCustom);
|
|
812
|
+
els.portMenu.appendChild(customItem);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 打开菜单:先显示「扫描中…」,按需扫一次,扫完用最新结果填充并展示(满足「先扫描→填充→再展示」)。
|
|
816
|
+
async function openPortMenu() {
|
|
817
|
+
if (state.expanded) return;
|
|
818
|
+
state.expanded = true;
|
|
819
|
+
els.portMenu.style.display = "";
|
|
820
|
+
els.portBtn.classList.add("open");
|
|
821
|
+
els.portBtn.setAttribute("aria-expanded", "true");
|
|
822
|
+
renderPortMenu({ loading: true });
|
|
823
|
+
await scanPorts({ refreshOnly: true });
|
|
824
|
+
if (state.expanded) renderPortMenu();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function closePortMenu() {
|
|
828
|
+
state.expanded = false;
|
|
829
|
+
if (els.portMenu) els.portMenu.style.display = "none";
|
|
830
|
+
if (els.portBtn) {
|
|
831
|
+
els.portBtn.classList.remove("open");
|
|
832
|
+
els.portBtn.setAttribute("aria-expanded", "false");
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function togglePortMenu() {
|
|
837
|
+
if (state.expanded) closePortMenu();
|
|
838
|
+
else openPortMenu();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 点选某端口:写回隐藏 select 的 value,关菜单,刷新按钮,再走既有连接逻辑(onPortChange)。
|
|
842
|
+
function selectPort(port) {
|
|
843
|
+
const v = String(port);
|
|
844
|
+
// 关键:浮层从 foundPorts 渲染,可能领先于隐藏 select 的 options;先补齐再赋值,
|
|
845
|
+
// 否则原生 <select> 对不存在的 value 会静默置空,导致连空端口 → offline。
|
|
846
|
+
ensurePortOption(v);
|
|
847
|
+
els.portSelect.value = v;
|
|
848
|
+
closePortMenu();
|
|
849
|
+
updatePortButton();
|
|
850
|
+
onPortChange();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function selectCustom() {
|
|
854
|
+
els.portSelect.value = config.customValue;
|
|
855
|
+
closePortMenu();
|
|
856
|
+
updatePortButton();
|
|
857
|
+
onPortChange();
|
|
858
|
+
}
|
|
859
|
+
|
|
663
860
|
function deriveStatusLabel(info) {
|
|
664
861
|
if (!info) return "";
|
|
665
862
|
if (info.feedback_completed === true || info.status === "feedback_submitted" || info.status === "completed") {
|
|
@@ -722,7 +919,6 @@
|
|
|
722
919
|
|
|
723
920
|
const project = basename(info && info.project_directory);
|
|
724
921
|
if (project) state.portProjects[state.socketPort] = project;
|
|
725
|
-
if (info && info.project_directory) state.portPaths[state.socketPort] = info.project_directory;
|
|
726
922
|
state.portStatuses[state.socketPort] = deriveStatusLabel(info);
|
|
727
923
|
|
|
728
924
|
const status = info && info.status;
|
|
@@ -1251,6 +1447,10 @@
|
|
|
1251
1447
|
let opened = false;
|
|
1252
1448
|
let project = "";
|
|
1253
1449
|
let statusLabel = "";
|
|
1450
|
+
let workspaceFolders = "";
|
|
1451
|
+
let workspaceLabel = "";
|
|
1452
|
+
let cmdBuf = null;
|
|
1453
|
+
let needCmd = false;
|
|
1254
1454
|
let ws = null;
|
|
1255
1455
|
const finish = (alive) => {
|
|
1256
1456
|
if (done) return;
|
|
@@ -1264,13 +1464,24 @@
|
|
|
1264
1464
|
/* noop */
|
|
1265
1465
|
}
|
|
1266
1466
|
}
|
|
1267
|
-
resolve({ alive, project, statusLabel });
|
|
1467
|
+
resolve({ alive, project, statusLabel, workspaceFolders, workspaceLabel });
|
|
1268
1468
|
};
|
|
1269
1469
|
const timer = window.setTimeout(() => finish(opened), timeoutMs);
|
|
1270
1470
|
try {
|
|
1271
1471
|
ws = new WebSocket("ws://127.0.0.1:" + port + "/ws?lang=" + config.lang);
|
|
1272
1472
|
ws.onopen = () => {
|
|
1273
1473
|
opened = true;
|
|
1474
|
+
// 连上后用 run_command 读「窗口真实工作区」(不受 AI 传入的 project_directory 影响)。
|
|
1475
|
+
// pyCmd 占位符未被注入替换时跳过,退回靠 session_info 仅识别项目名。
|
|
1476
|
+
if (config.pyCmd.indexOf("__MCP_") !== 0) {
|
|
1477
|
+
cmdBuf = "";
|
|
1478
|
+
needCmd = true;
|
|
1479
|
+
try {
|
|
1480
|
+
ws.send(JSON.stringify({ type: "run_command", command: buildWorkspaceProbeCmd() }));
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
needCmd = false;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1274
1485
|
};
|
|
1275
1486
|
ws.onmessage = (event) => {
|
|
1276
1487
|
try {
|
|
@@ -1279,6 +1490,21 @@
|
|
|
1279
1490
|
if (info && info.project_directory) {
|
|
1280
1491
|
project = info.project_directory;
|
|
1281
1492
|
statusLabel = deriveStatusLabel(info);
|
|
1493
|
+
// 没发探测命令时(占位符未替换),拿到会话即可结束。
|
|
1494
|
+
if (!needCmd) finish(true);
|
|
1495
|
+
}
|
|
1496
|
+
if (data.type === "command_output") {
|
|
1497
|
+
if (cmdBuf !== null) cmdBuf += data.output || "";
|
|
1498
|
+
}
|
|
1499
|
+
if (data.type === "command_complete" || data.type === "command_error") {
|
|
1500
|
+
const raw = (cmdBuf || "").replace(/\r/g, "");
|
|
1501
|
+
const nl = raw.indexOf("\n");
|
|
1502
|
+
if (nl >= 0) {
|
|
1503
|
+
workspaceFolders = raw.slice(0, nl).trim();
|
|
1504
|
+
workspaceLabel = raw.slice(nl + 1).trim();
|
|
1505
|
+
} else {
|
|
1506
|
+
workspaceFolders = raw.trim();
|
|
1507
|
+
}
|
|
1282
1508
|
finish(true);
|
|
1283
1509
|
}
|
|
1284
1510
|
} catch (error) {
|
|
@@ -1293,7 +1519,8 @@
|
|
|
1293
1519
|
});
|
|
1294
1520
|
}
|
|
1295
1521
|
|
|
1296
|
-
async function scanPorts() {
|
|
1522
|
+
async function scanPorts(opts) {
|
|
1523
|
+
const refreshOnly = !!(opts && opts.refreshOnly);
|
|
1297
1524
|
if (state.scanning) return;
|
|
1298
1525
|
state.scanning = true;
|
|
1299
1526
|
if (els.scanBtn) {
|
|
@@ -1306,44 +1533,53 @@
|
|
|
1306
1533
|
els.hint.textContent = "正在扫描端口 " + from + "–" + to + "…";
|
|
1307
1534
|
|
|
1308
1535
|
const ports = defaultPorts();
|
|
1536
|
+
// 跳过探测「当前已连接的健康端口」:probe 会另建连接抢占服务端的「最后连接」,从而把面板自己挤下线。
|
|
1537
|
+
// 对已连端口直接用已知信息标记为活跃,保证下拉扫描/重扫不会断开当前会话。
|
|
1538
|
+
const curPort =
|
|
1539
|
+
state.socket && state.socket.readyState === WebSocket.OPEN ? String(state.socketPort) : null;
|
|
1309
1540
|
const results = await Promise.all(
|
|
1310
|
-
ports.map((port) =>
|
|
1311
|
-
|
|
1541
|
+
ports.map((port) => {
|
|
1542
|
+
if (curPort && String(port) === curPort) {
|
|
1543
|
+
return Promise.resolve({
|
|
1544
|
+
port,
|
|
1545
|
+
alive: true,
|
|
1546
|
+
project: state.currentSession ? state.currentSession.project_directory : "",
|
|
1547
|
+
statusLabel: state.portStatuses[port] || state.selectedStatus || "",
|
|
1548
|
+
workspaceFolders: state.portWorkspaces[port] || "",
|
|
1549
|
+
workspaceLabel: state.portLabels[port] || "",
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return probePort(port, config.probeTimeoutMs).then((res) => ({
|
|
1312
1553
|
port,
|
|
1313
1554
|
alive: res.alive,
|
|
1314
1555
|
project: res.project,
|
|
1315
1556
|
statusLabel: res.statusLabel,
|
|
1316
|
-
|
|
1317
|
-
|
|
1557
|
+
workspaceFolders: res.workspaceFolders,
|
|
1558
|
+
workspaceLabel: res.workspaceLabel,
|
|
1559
|
+
}));
|
|
1560
|
+
})
|
|
1318
1561
|
);
|
|
1319
1562
|
const found = [];
|
|
1320
1563
|
for (const item of results) {
|
|
1321
1564
|
if (item.alive) {
|
|
1322
1565
|
found.push(item.port);
|
|
1323
|
-
if (item.project)
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
}
|
|
1566
|
+
if (item.project) state.portProjects[item.port] = basename(item.project);
|
|
1567
|
+
else delete state.portProjects[item.port];
|
|
1568
|
+
if (item.workspaceFolders) state.portWorkspaces[item.port] = item.workspaceFolders;
|
|
1569
|
+
else delete state.portWorkspaces[item.port];
|
|
1570
|
+
if (item.workspaceLabel) state.portLabels[item.port] = item.workspaceLabel;
|
|
1571
|
+
else delete state.portLabels[item.port];
|
|
1330
1572
|
if (item.statusLabel) state.portStatuses[item.port] = item.statusLabel;
|
|
1331
1573
|
else delete state.portStatuses[item.port];
|
|
1332
1574
|
} else {
|
|
1333
1575
|
delete state.portProjects[item.port];
|
|
1334
|
-
delete state.
|
|
1576
|
+
delete state.portWorkspaces[item.port];
|
|
1577
|
+
delete state.portLabels[item.port];
|
|
1335
1578
|
delete state.portStatuses[item.port];
|
|
1336
1579
|
}
|
|
1337
1580
|
}
|
|
1338
1581
|
state.foundPorts = found;
|
|
1339
1582
|
|
|
1340
|
-
const wasCustom = els.portSelect.value === config.customValue;
|
|
1341
|
-
// 优先选中 project_directory 与当前窗口工作区相等的端口;取不到工作区或无匹配端口时维持原有选择
|
|
1342
|
-
//(combo 降级),连接层再由 connectSelectedPort 守卫兜底,避免连到别的项目。
|
|
1343
|
-
const preferred = pickWorkspacePort(results);
|
|
1344
|
-
fillPortOptions(found, preferred || (wasCustom ? null : els.portSelect.value));
|
|
1345
|
-
if (wasCustom && !preferred) els.portSelect.value = config.customValue;
|
|
1346
|
-
|
|
1347
1583
|
if (els.scanBtn) {
|
|
1348
1584
|
els.scanBtn.classList.remove("scanning");
|
|
1349
1585
|
els.scanBtn.disabled = false;
|
|
@@ -1354,6 +1590,21 @@
|
|
|
1354
1590
|
? "扫描完成:发现活跃端口 " + found.join("、") + "。"
|
|
1355
1591
|
: "扫描完成:" + from + "–" + to + " 未发现活跃 MCP 端口。";
|
|
1356
1592
|
|
|
1593
|
+
if (refreshOnly) {
|
|
1594
|
+
// 自定义下拉:先把本次扫到的端口补进隐藏 select(数据载体),保证点选时 value 设得上;
|
|
1595
|
+
// 菜单仍展开则用最新结果渲染浮层(渲染后保持稳定,点选即时生效、无打断、无闪)。
|
|
1596
|
+
found.forEach((p) => ensurePortOption(String(p)));
|
|
1597
|
+
if (state.expanded) renderPortMenu();
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const wasCustom = els.portSelect.value === config.customValue;
|
|
1602
|
+
// 优先选中 project_directory 与当前窗口工作区相等的端口;取不到工作区或无匹配端口时维持原有选择
|
|
1603
|
+
//(combo 降级),连接层再由 connectSelectedPort 守卫兜底,避免连到别的项目。
|
|
1604
|
+
const preferred = pickWorkspacePort(results);
|
|
1605
|
+
fillPortOptions(found, preferred || (wasCustom ? null : els.portSelect.value));
|
|
1606
|
+
if (wasCustom && !preferred) els.portSelect.value = config.customValue;
|
|
1607
|
+
|
|
1357
1608
|
// 探测会短暂抢占连接,扫描结束后立即重连选中端口抢回「最后连接」。
|
|
1358
1609
|
if (els.portSelect.value !== config.customValue) connectSelectedPort(false);
|
|
1359
1610
|
}
|
|
@@ -1400,10 +1651,11 @@
|
|
|
1400
1651
|
const port = els.portSelect.value;
|
|
1401
1652
|
if (port === config.customValue) return;
|
|
1402
1653
|
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
1654
|
+
// 守卫:能确定当前窗口、且该端口已知属于别的窗口时不连接,
|
|
1655
|
+
// 避免误连到其它窗口的实例或常驻 feedback 实例(等本窗口端口被识别后由 refreshTick 重扫接管)。
|
|
1405
1656
|
const ws = getWorkspacePath();
|
|
1406
|
-
|
|
1657
|
+
const known = !!(state.portWorkspaces[port] || state.portLabels[port]);
|
|
1658
|
+
if (ws && known && !isPortMine(port, ws)) {
|
|
1407
1659
|
if (state.socket) {
|
|
1408
1660
|
state.socket.onclose = null;
|
|
1409
1661
|
state.socket.onerror = null;
|
|
@@ -1411,8 +1663,8 @@
|
|
|
1411
1663
|
state.socket.close();
|
|
1412
1664
|
state.socket = null;
|
|
1413
1665
|
}
|
|
1414
|
-
setOptionLabel(port, "other
|
|
1415
|
-
setVisualState("offline", port + "
|
|
1666
|
+
setOptionLabel(port, "other window");
|
|
1667
|
+
setVisualState("offline", port + " 属于其它窗口,等待本窗口的 MCP 会话…");
|
|
1416
1668
|
return;
|
|
1417
1669
|
}
|
|
1418
1670
|
|
|
@@ -1470,6 +1722,10 @@
|
|
|
1470
1722
|
|
|
1471
1723
|
nextSocket.onerror = () => {
|
|
1472
1724
|
if (seq !== state.connectSeq) return;
|
|
1725
|
+
// 连接失败:端口很可能已关闭或换了端口(实例重启),清除其归属缓存,
|
|
1726
|
+
// 让 refreshTick 退避重扫去发现新的本窗口端口,而不是一直重连这个死端口。
|
|
1727
|
+
delete state.portWorkspaces[port];
|
|
1728
|
+
delete state.portLabels[port];
|
|
1473
1729
|
setOptionLabel(port, "offline");
|
|
1474
1730
|
setVisualState("offline", port + " 连接失败,可能端口未启动或没有 MCP WebUI。");
|
|
1475
1731
|
};
|
|
@@ -1501,17 +1757,22 @@
|
|
|
1501
1757
|
if (els.customInput === document.activeElement) return;
|
|
1502
1758
|
if (els.prompt === document.activeElement && els.prompt.value.trim()) return;
|
|
1503
1759
|
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
|
|
1507
|
-
if (
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1760
|
+
// 不做后台扫描(扫描仅发生在启动时与下拉展开期间)。这里只保持连接:
|
|
1761
|
+
// 已连且健康 → 发 heartbeat 维持「最后连接」;否则重连选中端口(守卫会拦掉别窗口端口;
|
|
1762
|
+
// 端口已关时 onerror 会清归属并置 offline,等待用户展开下拉重新选择本窗口端口)。
|
|
1763
|
+
if (
|
|
1764
|
+
state.socket &&
|
|
1765
|
+
state.socket.readyState === WebSocket.OPEN &&
|
|
1766
|
+
state.socketPort === els.portSelect.value
|
|
1767
|
+
) {
|
|
1768
|
+
try {
|
|
1769
|
+
state.socket.send(JSON.stringify({ type: "heartbeat", timestamp: Date.now() }));
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
/* 发送失败则下个周期走重连 */
|
|
1513
1772
|
}
|
|
1773
|
+
return;
|
|
1514
1774
|
}
|
|
1775
|
+
|
|
1515
1776
|
connectSelectedPort(true);
|
|
1516
1777
|
}
|
|
1517
1778
|
|
|
@@ -1574,16 +1835,22 @@
|
|
|
1574
1835
|
}
|
|
1575
1836
|
|
|
1576
1837
|
function wireEvents() {
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
refreshOptionLabels();
|
|
1582
|
-
});
|
|
1583
|
-
els.portSelect.addEventListener("blur", () => {
|
|
1584
|
-
state.expanded = false;
|
|
1585
|
-
refreshOptionLabels();
|
|
1838
|
+
// 自定义端口下拉:点按钮开/关;打开即「扫描中→填充→展示」;点选即时生效(见 selectPort / selectCustom)。
|
|
1839
|
+
els.portBtn.addEventListener("click", (event) => {
|
|
1840
|
+
event.stopPropagation();
|
|
1841
|
+
togglePortMenu();
|
|
1586
1842
|
});
|
|
1843
|
+
// 点击按钮与浮层之外的区域、或按 Esc 关闭菜单。
|
|
1844
|
+
state.onDocPointerDown = (event) => {
|
|
1845
|
+
if (!state.expanded) return;
|
|
1846
|
+
if (els.portWrap && els.portWrap.contains(event.target)) return;
|
|
1847
|
+
closePortMenu();
|
|
1848
|
+
};
|
|
1849
|
+
document.addEventListener("mousedown", state.onDocPointerDown, true);
|
|
1850
|
+
state.onDocKeydown = (event) => {
|
|
1851
|
+
if (state.expanded && event.key === "Escape") closePortMenu();
|
|
1852
|
+
};
|
|
1853
|
+
document.addEventListener("keydown", state.onDocKeydown, true);
|
|
1587
1854
|
els.scanBtn.addEventListener("click", () => {
|
|
1588
1855
|
scanPorts();
|
|
1589
1856
|
});
|
|
@@ -1655,6 +1922,8 @@
|
|
|
1655
1922
|
|
|
1656
1923
|
function uninstall() {
|
|
1657
1924
|
cancelAutoSubmit();
|
|
1925
|
+
if (state.onDocPointerDown) document.removeEventListener("mousedown", state.onDocPointerDown, true);
|
|
1926
|
+
if (state.onDocKeydown) document.removeEventListener("keydown", state.onDocKeydown, true);
|
|
1658
1927
|
for (const timer of state.timers) {
|
|
1659
1928
|
window.clearInterval(timer);
|
|
1660
1929
|
}
|