cursor-feedback 1.1.1 → 2.0.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/dist/extension.js CHANGED
@@ -39,14 +39,17 @@ const vscode = __importStar(require("vscode"));
39
39
  const http = __importStar(require("http"));
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
+ const child_process_1 = require("child_process");
42
43
  const i18n_1 = require("./i18n");
43
44
  let feedbackViewProvider = null;
44
- let pollingInterval = null;
45
+ /** 规范化路径:统一分隔符、去尾斜杠、转小写,用于工作区匹配 */
46
+ function normalizePath(p) {
47
+ return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
48
+ }
45
49
  function activate(context) {
46
- console.log('Cursor Feedback extension is now active!');
47
50
  // 注册侧边栏 WebView(端口从 61927 开始自动扫描)
48
- feedbackViewProvider = new FeedbackViewProvider(context.extensionUri, 61927);
49
- context.subscriptions.push(vscode.window.registerWebviewViewProvider('cursorFeedback.feedbackView', feedbackViewProvider));
51
+ feedbackViewProvider = new FeedbackViewProvider(context.extensionUri, 61927, context.globalState);
52
+ context.subscriptions.push(vscode.window.registerWebviewViewProvider('cursorFeedback.feedbackView', feedbackViewProvider, { webviewOptions: { retainContextWhenHidden: true } }));
50
53
  // 注册命令:显示反馈面板
51
54
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.showPanel', () => {
52
55
  vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
@@ -55,14 +58,25 @@ function activate(context) {
55
58
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.startPolling', () => {
56
59
  if (feedbackViewProvider) {
57
60
  feedbackViewProvider.startPolling();
58
- vscode.window.showInformationMessage(feedbackViewProvider.getMessage('startListening'));
59
61
  }
60
62
  }));
61
63
  // 注册命令:停止轮询
62
64
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.stopPolling', () => {
63
65
  if (feedbackViewProvider) {
64
66
  feedbackViewProvider.stopPolling();
65
- vscode.window.showInformationMessage(feedbackViewProvider.getMessage('stopListening'));
67
+ }
68
+ }));
69
+ // 注册命令:带入编辑器选中代码到反馈输入框
70
+ context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.insertSelection', () => {
71
+ feedbackViewProvider?.insertSelectionToFeedback();
72
+ }));
73
+ // 缓存最近的活动编辑器(焦点在 feedback webview 时 activeTextEditor 会变 undefined)
74
+ if (vscode.window.activeTextEditor) {
75
+ feedbackViewProvider.setLastActiveEditor(vscode.window.activeTextEditor);
76
+ }
77
+ context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor(editor => {
78
+ if (editor) {
79
+ feedbackViewProvider?.setLastActiveEditor(editor);
66
80
  }
67
81
  }));
68
82
  // 自动开始轮询
@@ -93,8 +107,7 @@ function getWorkspacePaths() {
93
107
  function isPathInWorkspace(targetPath) {
94
108
  const workspacePaths = getWorkspacePaths();
95
109
  // 规范化路径(去除末尾斜杠,统一分隔符,小写)
96
- const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
97
- const normalizedTarget = normalize(targetPath);
110
+ const normalizedTarget = normalizePath(targetPath);
98
111
  // 检查 targetPath 是否为空或默认值
99
112
  const isEmptyPath = !targetPath || targetPath === '.' || normalizedTarget === '' || normalizedTarget === '.';
100
113
  if (workspacePaths.length === 0) {
@@ -106,7 +119,7 @@ function isPathInWorkspace(targetPath) {
106
119
  return false;
107
120
  }
108
121
  for (const wsPath of workspacePaths) {
109
- const normalizedWs = normalize(wsPath);
122
+ const normalizedWs = normalizePath(wsPath);
110
123
  // 精确匹配:只匹配完全相同的路径
111
124
  if (normalizedTarget === normalizedWs) {
112
125
  return true;
@@ -118,13 +131,29 @@ function isPathInWorkspace(targetPath) {
118
131
  * 侧边栏 WebView Provider
119
132
  */
120
133
  class FeedbackViewProvider {
121
- constructor(_extensionUri, port) {
134
+ constructor(_extensionUri, port, _memento) {
122
135
  this._extensionUri = _extensionUri;
123
- this._pollingInterval = null;
136
+ this._memento = _memento;
137
+ this._polling = false;
138
+ this._pollTimer = null;
139
+ this._pollDelay = 1000;
124
140
  this._currentRequest = null;
141
+ // webview 脚本是否就绪;插入上下文消息需等就绪后再发,否则 focus 唤起重建时会丢消息
142
+ this._webviewReady = false;
143
+ this._pendingInsert = null;
125
144
  this._activePort = null;
126
145
  this._portScanRange = 20; // 扫描端口范围
127
146
  this._seenRequestIds = new Set(); // 已处理过的请求 ID
147
+ // 超时续期开关(由侧边栏按钮切换,随轮询同步给 MCP server)
148
+ this._autoRetry = true;
149
+ // 飞书配置(持久化在 globalState;secret 会回显给 webview,前端用小眼睛切换明文/掩码)
150
+ this._feishuConfig = { appId: '', appSecret: '' };
151
+ // 飞书绑定状态(运行时,来自轮询 server;绑定本身由 server 端磁盘持久化、多进程共享)
152
+ this._feishuBound = false;
153
+ // 飞书通知开关(用户可关:即使配置了凭证也不推飞书)
154
+ this._feishuEnabled = true;
155
+ // Get 表情回执子开关(飞书通知子项;关掉后用户飞书回复不加 Get 表情、也不发文字兜底)
156
+ this._feishuAck = true;
128
157
  this._debugInfo = {
129
158
  portRange: '',
130
159
  workspacePath: '',
@@ -136,6 +165,12 @@ class FeedbackViewProvider {
136
165
  this._debugInfo.portRange = `${port}-${port + this._portScanRange - 1}`;
137
166
  this._i18n = (0, i18n_1.loadMessages)(this._extensionUri.fsPath);
138
167
  this._debugInfo.lastStatus = this._i18n.checkingConnection;
168
+ this._autoRetry = this._memento?.get('autoRetry', true) ?? true;
169
+ // 凭证真相源在 server 端磁盘;这里从 globalState 读上次的值仅作首屏占位,poll 一到即被 server 覆盖。
170
+ const fc = this._memento?.get('feishuConfig', {}) ?? {};
171
+ this._feishuConfig = { appId: fc.appId || '', appSecret: fc.appSecret || '' };
172
+ this._feishuEnabled = this._memento?.get('feishuEnabled', true) ?? true;
173
+ this._feishuAck = this._memento?.get('feishuAck', true) ?? true;
139
174
  }
140
175
  /**
141
176
  * 获取翻译消息
@@ -143,8 +178,19 @@ class FeedbackViewProvider {
143
178
  getMessage(key) {
144
179
  return this._i18n[key] || key;
145
180
  }
181
+ /** 翻译并替换 {占位符} 参数(用于带变量的状态文案) */
182
+ _t(key, params) {
183
+ let s = this._i18n[key] || key;
184
+ if (params) {
185
+ for (const k of Object.keys(params)) {
186
+ s = s.replace(`{${k}}`, String(params[k]));
187
+ }
188
+ }
189
+ return s;
190
+ }
146
191
  resolveWebviewView(webviewView, _context, _token) {
147
192
  this._view = webviewView;
193
+ this._webviewReady = false;
148
194
  webviewView.webview.options = {
149
195
  enableScripts: true,
150
196
  localResourceRoots: [this._extensionUri]
@@ -157,11 +203,17 @@ class FeedbackViewProvider {
157
203
  await this._handleFeedbackSubmit(data.payload);
158
204
  break;
159
205
  case 'ready':
160
- console.log('Feedback WebView is ready');
206
+ this._webviewReady = true;
207
+ // 同步超时续期开关状态到 UI
208
+ this._postAutoRetryState();
209
+ this._postFeishuState();
161
210
  // WebView 准备就绪后,检查是否有待处理的请求
162
- if (this._currentRequest) {
211
+ // (插件通知关闭时保持静默,不显示缓存的请求)
212
+ if (this._currentRequest && this._isPluginNotifyEnabled()) {
163
213
  this._showFeedbackRequest(this._currentRequest);
164
214
  }
215
+ // 补发就绪前排队的「插入上下文」消息
216
+ this._flushPendingInsert();
165
217
  break;
166
218
  case 'checkServer':
167
219
  await this._checkServerHealth();
@@ -172,36 +224,78 @@ class FeedbackViewProvider {
172
224
  case 'switchLanguage':
173
225
  await this._handleSwitchLanguage();
174
226
  break;
227
+ case 'toggleAutoRetry':
228
+ await this._handleToggleAutoRetry();
229
+ break;
230
+ case 'searchFiles':
231
+ await this._handleSearchFiles();
232
+ break;
233
+ case 'requestSelection':
234
+ await this.insertSelectionToFeedback();
235
+ break;
236
+ case 'saveFeishuConfig':
237
+ await this._handleSaveFeishuConfig(data.payload);
238
+ break;
239
+ case 'openFeishuGuide':
240
+ await this._handleOpenFeishuGuide();
241
+ break;
242
+ case 'toggleFeishuEnabled':
243
+ await this._handleToggleFeishuEnabled(!!data.payload?.enabled);
244
+ break;
245
+ case 'toggleSystemNotification':
246
+ await this._handleToggleSystemNotification(!!data.payload?.enabled);
247
+ break;
248
+ case 'toggleOsNotification':
249
+ await this._handleToggleOsNotification(!!data.payload?.enabled);
250
+ break;
251
+ case 'toggleFeishuAck':
252
+ await this._handleToggleFeishuAck(!!data.payload?.enabled);
253
+ break;
175
254
  }
176
255
  });
177
256
  // 当 view 变为可见时,检查当前请求
257
+ // (插件通知关闭时保持静默:即使用户从侧边栏切回面板也不显示缓存的请求)
178
258
  webviewView.onDidChangeVisibility(() => {
179
- if (webviewView.visible && this._currentRequest) {
259
+ if (webviewView.visible && this._currentRequest && this._isPluginNotifyEnabled()) {
180
260
  this._showFeedbackRequest(this._currentRequest);
181
261
  }
182
262
  });
183
263
  }
184
264
  /**
185
265
  * 开始轮询 MCP Server
266
+ *
267
+ * 自适应间隔:连上 server 时每秒轮询(兼作 server watchdog 心跳,必须 < 30s 空闲阈值);
268
+ * 完全找不到 server 时指数退避到最多 5s,降低空闲 CPU / 电池开销。
269
+ * 一旦发现任意 server 立即恢复 1s。
186
270
  */
187
271
  startPolling() {
188
- if (this._pollingInterval) {
272
+ if (this._polling) {
189
273
  return;
190
274
  }
191
- console.log(`Starting polling MCP server from port ${this._basePort}`);
192
- this._pollingInterval = setInterval(async () => {
275
+ this._polling = true;
276
+ this._pollDelay = 1000;
277
+ const loop = async () => {
278
+ if (!this._polling)
279
+ return;
193
280
  await this._pollForFeedbackRequest();
194
- }, 1000); // 每秒检查一次
195
- // 立即执行一次
196
- this._pollForFeedbackRequest();
281
+ if (!this._polling)
282
+ return;
283
+ // 没连上任何 server → 退避;连上了 → 保持 1s 心跳
284
+ this._pollDelay = this._debugInfo.connectedPorts.length === 0
285
+ ? Math.min(this._pollDelay + 1000, 5000)
286
+ : 1000;
287
+ this._pollTimer = setTimeout(loop, this._pollDelay);
288
+ };
289
+ loop();
197
290
  }
198
291
  /**
199
292
  * 停止轮询
200
293
  */
201
294
  stopPolling() {
202
- if (this._pollingInterval) {
203
- clearInterval(this._pollingInterval);
204
- this._pollingInterval = null;
295
+ this._polling = false;
296
+ if (this._pollTimer) {
297
+ clearTimeout(this._pollTimer);
298
+ this._pollTimer = null;
205
299
  }
206
300
  }
207
301
  /**
@@ -212,29 +306,34 @@ class FeedbackViewProvider {
212
306
  try {
213
307
  // 更新工作区路径
214
308
  const workspacePaths = getWorkspacePaths();
215
- this._debugInfo.workspacePath = workspacePaths.length > 0 ? workspacePaths[0] : '(无工作区)';
309
+ this._debugInfo.workspacePath = workspacePaths.length > 0 ? workspacePaths[0] : this._t('statusNoWorkspace');
216
310
  const currentWorkspace = workspacePaths[0] || '';
217
- const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
218
- const normalizedCurrentWorkspace = normalize(currentWorkspace);
311
+ const normalizedCurrentWorkspace = normalizePath(currentWorkspace);
219
312
  // 如果有活跃端口,先尝试只轮询该端口
220
313
  if (this._activePort) {
221
314
  const result = await this._checkPortForRequest(this._activePort);
222
315
  // 检查是否仍然是我们的 Server
223
316
  if (result.connected) {
224
- const serverOwner = result.ownerWorkspace ? normalize(result.ownerWorkspace) : '';
317
+ const serverOwner = result.ownerWorkspace ? normalizePath(result.ownerWorkspace) : '';
225
318
  const isMyServer = !serverOwner || serverOwner === normalizedCurrentWorkspace;
226
319
  if (isMyServer) {
227
320
  // 端口仍然有效,保持使用
228
321
  this._debugInfo.connectedPorts = [this._activePort];
229
322
  this._debugInfo.activePort = this._activePort;
230
323
  if (result.request && !this._seenRequestIds.has(result.request.id)) {
231
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
324
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
232
325
  this._handleNewRequest(result.request, this._activePort);
233
326
  this._updateDebugInfo();
234
327
  return;
235
328
  }
329
+ // 正在显示的请求被「飞书回复」结束 → 重置面板,避免提交到已消失的请求。
330
+ // 仅当 server 明确标记该请求是被飞书 resolve 的才重置——超时续期时 request 也会短暂为 null,
331
+ // 但 feishuResolvedId 不会命中,于是保持原有等待逻辑,不误重置、不丢用户草稿。
332
+ if (this._currentRequest && result.feishuResolvedId === this._currentRequest.id) {
333
+ this._handleExternalResolve();
334
+ }
236
335
  // 端口有效但无新请求,继续保持连接
237
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
336
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
238
337
  this._updateDebugInfo();
239
338
  return;
240
339
  }
@@ -257,7 +356,7 @@ class FeedbackViewProvider {
257
356
  if (!r.request || this._seenRequestIds.has(r.request.id)) {
258
357
  return false;
259
358
  }
260
- const serverOwner = r.ownerWorkspace ? normalize(r.ownerWorkspace) : '';
359
+ const serverOwner = r.ownerWorkspace ? normalizePath(r.ownerWorkspace) : '';
261
360
  return !serverOwner || serverOwner === normalizedCurrentWorkspace;
262
361
  }).sort((a, b) => b.request.timestamp - a.request.timestamp);
263
362
  // 处理最新的请求
@@ -265,7 +364,7 @@ class FeedbackViewProvider {
265
364
  const newest = myRequests[0];
266
365
  this._activePort = newest.port;
267
366
  this._debugInfo.activePort = newest.port;
268
- this._debugInfo.lastStatus = `找到请求 (端口 ${newest.port})`;
367
+ this._debugInfo.lastStatus = this._t('statusFound', { port: newest.port });
269
368
  this._handleNewRequest(newest.request, newest.port);
270
369
  this._updateDebugInfo();
271
370
  return;
@@ -273,22 +372,22 @@ class FeedbackViewProvider {
273
372
  // 没有新请求,检查是否有当前请求
274
373
  if (this._currentRequest && this._activePort) {
275
374
  this._debugInfo.activePort = this._activePort;
276
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
375
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
277
376
  this._updateDebugInfo();
278
377
  return;
279
378
  }
280
379
  // 没有任何请求
281
380
  this._debugInfo.activePort = null;
282
381
  if (this._debugInfo.connectedPorts.length === 0) {
283
- this._debugInfo.lastStatus = '未找到 MCP Server';
382
+ this._debugInfo.lastStatus = this._t('statusNoServer');
284
383
  }
285
384
  else {
286
- this._debugInfo.lastStatus = `已连接 ${this._debugInfo.connectedPorts.length} 个端口,等待请求`;
385
+ this._debugInfo.lastStatus = this._t('statusConnected', { count: this._debugInfo.connectedPorts.length });
287
386
  }
288
387
  this._updateDebugInfo();
289
388
  }
290
389
  catch (error) {
291
- this._debugInfo.lastStatus = `轮询错误: ${error}`;
390
+ this._debugInfo.lastStatus = this._t('statusPollError', { error: String(error) });
292
391
  this._updateDebugInfo();
293
392
  }
294
393
  }
@@ -308,12 +407,14 @@ class FeedbackViewProvider {
308
407
  // 旧格式: FeedbackRequest | null
309
408
  let request;
310
409
  let ownerWorkspace = null;
311
- let startTime = 0;
410
+ let feishuResolvedId = null;
312
411
  if (parsed && typeof parsed === 'object' && 'startTime' in parsed) {
313
412
  // 新格式
314
413
  request = parsed.request;
315
414
  ownerWorkspace = parsed.ownerWorkspace;
316
- startTime = parsed.startTime;
415
+ feishuResolvedId = parsed.feishuResolvedId ?? null;
416
+ this._maybeSyncFeishu(port, parsed.feishu);
417
+ this._maybeSyncAutoRetry(parsed.autoRetry);
317
418
  }
318
419
  else {
319
420
  // 旧格式(兼容 npm 上的旧版本)
@@ -324,10 +425,10 @@ class FeedbackViewProvider {
324
425
  const isMatch = isPathInWorkspace(request.projectDir);
325
426
  if (!isMatch) {
326
427
  // 请求不属于当前工作区,返回特殊标记
327
- return { connected: true, request: null, port, mismatch: true, ownerWorkspace, startTime };
428
+ return { connected: true, request: null, port, mismatch: true, ownerWorkspace };
328
429
  }
329
430
  }
330
- return { connected: true, request, port, ownerWorkspace, startTime };
431
+ return { connected: true, request, port, ownerWorkspace, feishuResolvedId };
331
432
  }
332
433
  catch {
333
434
  return { connected: false, request: null, port };
@@ -344,7 +445,6 @@ class FeedbackViewProvider {
344
445
  // 判断是否为"新鲜"请求:创建后 10 秒内被发现
345
446
  const requestAge = Date.now() - request.timestamp;
346
447
  const isFreshRequest = requestAge < 10000; // 10秒内
347
- console.log(`Feedback request on port ${port}:`, request.id, `age: ${requestAge}ms, isFresh: ${isFreshRequest}`);
348
448
  // 标记为已见过
349
449
  this._seenRequestIds.add(request.id);
350
450
  // 清理旧的请求 ID(保留最近 100 个)
@@ -355,15 +455,90 @@ class FeedbackViewProvider {
355
455
  if (!this._currentRequest || request.id !== this._currentRequest.id) {
356
456
  this._currentRequest = request;
357
457
  this._activePort = port;
358
- // 显示请求内容
458
+ // 「插件通知」主开关(配置 key 历史原因仍叫 systemNotification):关掉后本窗口完全静默——
459
+ // 不推送内容、不弹面板、不抢焦点、不发系统通知;连用户之后主动切回 / 打开面板也不显示
460
+ // (见 resolveWebviewView 里 ready 与 onDidChangeVisibility 的同款判断)。
461
+ // 请求仍记录在 _currentRequest,仅用于去重与外部渠道(飞书 / 超时)resolve 关联。
462
+ if (!this._isPluginNotifyEnabled()) {
463
+ return;
464
+ }
465
+ // 开启:推送内容并显示面板
359
466
  this._showFeedbackRequest(request);
360
- // 只对新鲜请求自动聚焦和通知
467
+ // 只对新鲜请求做主动提醒(聚焦面板 + IDE 提示 + 失焦系统通知)
361
468
  if (isFreshRequest) {
362
469
  vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
363
- vscode.window.showInformationMessage(this._i18n.aiWaitingFeedback);
470
+ this._sendSystemNotification(request);
364
471
  }
365
472
  }
366
473
  }
474
+ /**
475
+ * 发送系统级通知(macOS / Windows / Linux)
476
+ *
477
+ * 场景:AI 运行较久时用户可能切去做别的事,IDE 内部的提示看不到,
478
+ * 等回来时反馈请求已超时。系统通知可以在 IDE 失焦时及时提醒用户回来。
479
+ *
480
+ * 仅在 IDE 窗口未聚焦时发送——窗口聚焦时 IDE 内部提示已足够,
481
+ * 避免每轮对话都弹系统通知造成打扰。
482
+ */
483
+ _sendSystemNotification(request) {
484
+ const config = vscode.workspace.getConfiguration('cursorFeedback');
485
+ if (!config.get('systemNotification', true)) {
486
+ return;
487
+ }
488
+ // 子开关「失焦时系统提示」:关掉后即使窗口失焦也不弹系统通知
489
+ if (!config.get('osNotification', true)) {
490
+ return;
491
+ }
492
+ if (vscode.window.state.focused) {
493
+ return;
494
+ }
495
+ const withSound = config.get('notificationSound', true);
496
+ const projectName = path.basename(request.projectDir || '')
497
+ || vscode.workspace.workspaceFolders?.[0]?.name
498
+ || '';
499
+ const title = projectName ? `Cursor Feedback · ${projectName}` : 'Cursor Feedback';
500
+ const body = this._i18n.aiWaitingFeedback;
501
+ try {
502
+ if (process.platform === 'darwin') {
503
+ // AppleScript 字符串转义(execFile 不经过 shell,无注入风险)
504
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
505
+ let script = `display notification "${esc(body)}" with title "${esc(title)}"`;
506
+ if (withSound) {
507
+ script += ' sound name "Glass"';
508
+ }
509
+ (0, child_process_1.execFile)('osascript', ['-e', script], () => { });
510
+ }
511
+ else if (process.platform === 'win32') {
512
+ // PowerShell 单引号字符串转义
513
+ const esc = (s) => s.replace(/'/g, "''");
514
+ // 使用 PowerShell 已注册的 AppId,未注册的 AppId 在 Win10/11 上会静默失败
515
+ const appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe';
516
+ const psScript = [
517
+ '$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime];',
518
+ '$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);',
519
+ "$texts = $template.GetElementsByTagName('text');",
520
+ `$null = $texts.Item(0).AppendChild($template.CreateTextNode('${esc(title)}'));`,
521
+ `$null = $texts.Item(1).AppendChild($template.CreateTextNode('${esc(body)}'));`,
522
+ ...(withSound ? [] : [
523
+ "$audio = $template.CreateElement('audio');",
524
+ "$audio.SetAttribute('silent', 'true');",
525
+ '$null = $template.DocumentElement.AppendChild($audio);'
526
+ ]),
527
+ '$toast = [Windows.UI.Notifications.ToastNotification]::new($template);',
528
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${esc(appId)}').Show($toast);`
529
+ ].join(' ');
530
+ (0, child_process_1.execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', psScript], () => { });
531
+ }
532
+ else {
533
+ // Linux:依赖 notify-send(GNOME/KDE 等主流桌面均自带),缺失时静默忽略
534
+ (0, child_process_1.execFile)('notify-send', ['-u', 'normal', title, body], () => { });
535
+ }
536
+ }
537
+ catch (error) {
538
+ // 系统通知失败不影响主流程
539
+ console.error('Failed to send system notification:', error);
540
+ }
541
+ }
367
542
  /**
368
543
  * 检查服务器健康状态
369
544
  */
@@ -389,6 +564,15 @@ class FeedbackViewProvider {
389
564
  payload: { connected: false }
390
565
  });
391
566
  }
567
+ /**
568
+ * 「插件通知」主开关是否开启(配置 key 历史原因仍叫 systemNotification)。
569
+ * 关闭即本窗口完全静默:不主动推送,被动切回 / ready 时也不显示缓存的请求。
570
+ */
571
+ _isPluginNotifyEnabled() {
572
+ return vscode.workspace
573
+ .getConfiguration('cursorFeedback')
574
+ .get('systemNotification', true);
575
+ }
392
576
  /**
393
577
  * 显示反馈请求
394
578
  */
@@ -399,7 +583,7 @@ class FeedbackViewProvider {
399
583
  type: 'showFeedbackRequest',
400
584
  payload: {
401
585
  requestId: request.id,
402
- summary: request.summary,
586
+ summary: this._inlineLocalImages(request.summary),
403
587
  projectDir: request.projectDir,
404
588
  timeout: request.timeout,
405
589
  timestamp: request.timestamp
@@ -417,6 +601,18 @@ class FeedbackViewProvider {
417
601
  });
418
602
  }
419
603
  }
604
+ /**
605
+ * 当前请求在 server 端被外部渠道结束(如飞书回复 / 超时)→ 重置面板,
606
+ * 防止用户对着已消失的请求提交,导致 "Request not found"。
607
+ */
608
+ _handleExternalResolve() {
609
+ if (!this._currentRequest)
610
+ return;
611
+ this._seenRequestIds.add(this._currentRequest.id);
612
+ this._currentRequest = null;
613
+ this._showWaitingState();
614
+ this._view?.webview.postMessage({ type: 'externalResolved' });
615
+ }
420
616
  /**
421
617
  * 更新调试信息到 WebView
422
618
  */
@@ -446,7 +642,6 @@ class FeedbackViewProvider {
446
642
  }));
447
643
  const result = JSON.parse(response);
448
644
  if (result.success) {
449
- vscode.window.showInformationMessage(this._i18n.feedbackSubmitted);
450
645
  this._currentRequest = null;
451
646
  this._showWaitingState();
452
647
  }
@@ -476,6 +671,72 @@ class FeedbackViewProvider {
476
671
  });
477
672
  }
478
673
  }
674
+ setLastActiveEditor(editor) {
675
+ this._lastActiveEditor = editor;
676
+ }
677
+ /**
678
+ * 带入最近活动编辑器的选中代码(或整个文件)到反馈输入框
679
+ */
680
+ async insertSelectionToFeedback() {
681
+ const editor = this._lastActiveEditor || vscode.window.activeTextEditor;
682
+ await vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
683
+ if (!editor) {
684
+ this._postOrQueue({ type: 'insertContextEmpty' });
685
+ return;
686
+ }
687
+ const doc = editor.document;
688
+ const sel = editor.selection;
689
+ const filePath = doc.uri.fsPath;
690
+ if (sel && !sel.isEmpty) {
691
+ this._postOrQueue({
692
+ type: 'insertContext',
693
+ payload: {
694
+ filePath,
695
+ lang: doc.languageId,
696
+ code: doc.getText(sel),
697
+ startLine: sel.start.line + 1,
698
+ endLine: sel.end.line + 1
699
+ }
700
+ });
701
+ }
702
+ else {
703
+ this._postOrQueue({
704
+ type: 'filesSelected',
705
+ payload: { paths: [filePath] }
706
+ });
707
+ }
708
+ }
709
+ /** 发送插入消息;若 webview 尚未就绪(focus 触发重建),先排队,待 ready 后补发 */
710
+ _postOrQueue(msg) {
711
+ this._pendingInsert = msg;
712
+ if (this._webviewReady) {
713
+ this._flushPendingInsert();
714
+ }
715
+ }
716
+ _flushPendingInsert() {
717
+ if (this._pendingInsert && this._view && this._webviewReady) {
718
+ this._view.webview.postMessage(this._pendingInsert);
719
+ this._pendingInsert = null;
720
+ }
721
+ }
722
+ /**
723
+ * 搜索工作区文件,回传给 webview 做 @ 引用选择
724
+ */
725
+ async _handleSearchFiles() {
726
+ try {
727
+ const uris = await vscode.workspace.findFiles('**/*', '**/{node_modules,.git,dist,out,build,.next,.cache,coverage}/**', 1000);
728
+ const files = uris.map(u => ({
729
+ path: u.fsPath,
730
+ name: u.path.split('/').pop() || u.fsPath,
731
+ rel: vscode.workspace.asRelativePath(u)
732
+ }));
733
+ files.sort((a, b) => a.rel.length - b.rel.length);
734
+ this._view?.webview.postMessage({ type: 'fileSearchResults', payload: { files } });
735
+ }
736
+ catch {
737
+ this._view?.webview.postMessage({ type: 'fileSearchResults', payload: { files: [] } });
738
+ }
739
+ }
479
740
  /**
480
741
  * 处理语言切换
481
742
  */
@@ -503,12 +764,188 @@ class FeedbackViewProvider {
503
764
  if (this._view) {
504
765
  this._view.webview.html = this._getHtmlForWebview(this._view.webview);
505
766
  }
506
- const effectiveLang = (0, i18n_1.getLanguage)();
507
- vscode.window.showInformationMessage(effectiveLang === 'en'
508
- ? 'Language changed to English'
509
- : '语言已切换为简体中文');
510
767
  }
511
768
  }
769
+ /**
770
+ * 切换超时续期开关(AI 超时后是否继续等待用户)
771
+ */
772
+ async _handleToggleAutoRetry() {
773
+ this._autoRetry = !this._autoRetry;
774
+ await this._memento?.update('autoRetry', this._autoRetry);
775
+ this._postAutoRetryState();
776
+ this._broadcastAutoRetry();
777
+ }
778
+ /** 把续期开关 POST 给所有已连接 server(server 写同一份磁盘、全局一致、重启保留) */
779
+ _broadcastAutoRetry() {
780
+ const ports = new Set(this._debugInfo.connectedPorts || []);
781
+ if (this._activePort)
782
+ ports.add(this._activePort);
783
+ const body = JSON.stringify({ autoRetry: this._autoRetry });
784
+ for (const port of ports) {
785
+ this._httpPost(`http://127.0.0.1:${port}/api/settings/autoRetry`, body).catch(() => { });
786
+ }
787
+ }
788
+ /**
789
+ * 把超时续期开关状态推给 WebView
790
+ */
791
+ _postAutoRetryState() {
792
+ this._view?.webview.postMessage({
793
+ type: 'autoRetryState',
794
+ payload: { enabled: this._autoRetry }
795
+ });
796
+ }
797
+ /** 从 server poll 回读续期开关并回显到 UI(server 为真相源,单向同步,避免多窗口拉锯) */
798
+ _maybeSyncAutoRetry(v) {
799
+ if (typeof v !== 'boolean' || v === this._autoRetry)
800
+ return;
801
+ this._autoRetry = v;
802
+ this._memento?.update('autoRetry', v);
803
+ this._postAutoRetryState();
804
+ }
805
+ /**
806
+ * 把飞书配置状态推给 WebView(含 secret 明文,前端用小眼睛切换显示/隐藏)
807
+ */
808
+ _postFeishuState() {
809
+ const { appId, appSecret } = this._feishuConfig;
810
+ const cfg = vscode.workspace.getConfiguration('cursorFeedback');
811
+ const systemNotification = cfg.get('systemNotification', true);
812
+ const osNotification = cfg.get('osNotification', true);
813
+ this._view?.webview.postMessage({
814
+ type: 'feishuState',
815
+ payload: {
816
+ appId,
817
+ appSecret,
818
+ hasSecret: !!appSecret,
819
+ configured: !!(appId && appSecret),
820
+ bound: this._feishuBound,
821
+ feishuEnabled: this._feishuEnabled,
822
+ feishuAck: this._feishuAck,
823
+ systemNotification: !!systemNotification,
824
+ osNotification: !!osNotification
825
+ }
826
+ });
827
+ }
828
+ /**
829
+ * 处理保存飞书配置(来自 webview)
830
+ * - 所见即生效:appId/secret 直接用输入框的值,删空就是删空(半填 = 凭证不全 = server 关闭)。
831
+ * - appId 变化:本地先解绑(真相仍以 server 磁盘 / poll 回读为准)。
832
+ */
833
+ async _handleSaveFeishuConfig(payload) {
834
+ const appId = (payload?.appId || '').trim();
835
+ const appSecret = (payload?.appSecret || '').trim();
836
+ if (appId !== this._feishuConfig.appId)
837
+ this._feishuBound = false;
838
+ this._feishuConfig = { appId, appSecret };
839
+ // 占位缓存(真相在 server 磁盘,poll 会回读覆盖)
840
+ await this._memento?.update('feishuConfig', this._feishuConfig);
841
+ // 下发给所有端口:server 写磁盘(全局真相源)+ configure;清空则下发空 → server 关闭 / 清空。
842
+ this._broadcastFeishuConfig();
843
+ this._postFeishuState();
844
+ }
845
+ /**
846
+ * 轮询时把 server 的飞书状态同步到本地用于回显。
847
+ * 凭证真相源在 server 端磁盘(跨窗口共享):插件只读不回写,从根上消除多窗口「A 删 B 又补回」的拉锯。
848
+ * 同步凭证 / 开关 / Get 表情 / 绑定,有变化才刷新面板。
849
+ */
850
+ _maybeSyncFeishu(_port, feishuStatus) {
851
+ if (!feishuStatus)
852
+ return;
853
+ const appId = feishuStatus.appId || '';
854
+ const appSecret = feishuStatus.appSecret || '';
855
+ const enabled = feishuStatus.enabled !== false;
856
+ const ack = feishuStatus.ackReaction !== false;
857
+ const bound = !!feishuStatus.boundChatId;
858
+ let changed = false;
859
+ if (appId !== this._feishuConfig.appId || appSecret !== this._feishuConfig.appSecret) {
860
+ this._feishuConfig = { appId, appSecret };
861
+ // 落一份到 globalState 作首屏占位缓存(真相仍以 server 为准)
862
+ this._memento?.update('feishuConfig', this._feishuConfig);
863
+ changed = true;
864
+ }
865
+ if (enabled !== this._feishuEnabled) {
866
+ this._feishuEnabled = enabled;
867
+ changed = true;
868
+ }
869
+ if (ack !== this._feishuAck) {
870
+ this._feishuAck = ack;
871
+ changed = true;
872
+ }
873
+ if (bound !== this._feishuBound) {
874
+ this._feishuBound = bound;
875
+ changed = true;
876
+ }
877
+ if (changed)
878
+ this._postFeishuState();
879
+ }
880
+ /** 把当前飞书配置 POST 给所有已连接的 server(server 端写同一份磁盘、全局一致;清空即下发空以关闭) */
881
+ _broadcastFeishuConfig() {
882
+ const ports = new Set(this._debugInfo.connectedPorts || []);
883
+ if (this._activePort)
884
+ ports.add(this._activePort);
885
+ const { appId, appSecret } = this._feishuConfig;
886
+ const body = JSON.stringify({
887
+ appId,
888
+ appSecret,
889
+ enabled: this._feishuEnabled,
890
+ ackReaction: this._feishuAck,
891
+ });
892
+ for (const port of ports) {
893
+ this._httpPost(`http://127.0.0.1:${port}/api/feishu/config`, body).catch(() => { });
894
+ }
895
+ }
896
+ /**
897
+ * 打开「如何配置飞书机器人」指引
898
+ */
899
+ async _handleOpenFeishuGuide() {
900
+ const guideUri = vscode.Uri.joinPath(this._extensionUri, 'docs', 'feishu-setup.md');
901
+ try {
902
+ await vscode.commands.executeCommand('markdown.showPreview', guideUri);
903
+ }
904
+ catch {
905
+ try {
906
+ await vscode.env.openExternal(guideUri);
907
+ }
908
+ catch {
909
+ // ignore
910
+ }
911
+ }
912
+ }
913
+ /**
914
+ * 切换飞书通知开关(关了即使已配置也不推飞书;长连接仍保留以便绑定/回复)
915
+ */
916
+ async _handleToggleFeishuEnabled(enabled) {
917
+ this._feishuEnabled = enabled;
918
+ await this._memento?.update('feishuEnabled', enabled);
919
+ this._broadcastFeishuConfig();
920
+ this._postFeishuState();
921
+ }
922
+ /**
923
+ * 切换系统通知开关(写入 VSCode 配置,_sendSystemNotification 会读取)
924
+ */
925
+ async _handleToggleSystemNotification(enabled) {
926
+ await vscode.workspace
927
+ .getConfiguration('cursorFeedback')
928
+ .update('systemNotification', enabled, vscode.ConfigurationTarget.Global);
929
+ this._postFeishuState();
930
+ }
931
+ /**
932
+ * 切换「失焦时系统提示」子开关(插件通知的子项,写入 VSCode 配置)
933
+ */
934
+ async _handleToggleOsNotification(enabled) {
935
+ await vscode.workspace
936
+ .getConfiguration('cursorFeedback')
937
+ .update('osNotification', enabled, vscode.ConfigurationTarget.Global);
938
+ this._postFeishuState();
939
+ }
940
+ /**
941
+ * 切换「Get 表情回执」子开关(飞书通知的子项;透传到 server 端 feishu bridge)
942
+ */
943
+ async _handleToggleFeishuAck(enabled) {
944
+ this._feishuAck = enabled;
945
+ await this._memento?.update('feishuAck', enabled);
946
+ this._broadcastFeishuConfig();
947
+ this._postFeishuState();
948
+ }
512
949
  /**
513
950
  * HTTP GET 请求
514
951
  */
@@ -557,29 +994,85 @@ class FeedbackViewProvider {
557
994
  req.end();
558
995
  });
559
996
  }
997
+ /**
998
+ * 把 summary 里的本地图片路径内联成 base64 data:,
999
+ * 这样 webview 无需 file:// 访问权限即可显示 AI 发来的本地图片。
1000
+ * 网络图(http/https)和已有的 data:/blob: 不处理,交给 CSP 放行。
1001
+ */
1002
+ _inlineLocalImages(md) {
1003
+ if (!md)
1004
+ return md;
1005
+ return md.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(\s+"[^"]*")?\s*\)/g, (full, alt, src, title) => {
1006
+ try {
1007
+ let p = String(src).trim().replace(/^["']|["']$/g, '');
1008
+ if (/^(https?:|data:|blob:)/i.test(p))
1009
+ return full;
1010
+ if (p.startsWith('file://'))
1011
+ p = decodeURIComponent(p.replace(/^file:\/\//, ''));
1012
+ if (!path.isAbsolute(p) || !fs.existsSync(p))
1013
+ return full;
1014
+ const mime = this._imageMime(p);
1015
+ if (!mime)
1016
+ return full;
1017
+ const b64 = fs.readFileSync(p).toString('base64');
1018
+ return `![${alt}](data:${mime};base64,${b64}${title || ''})`;
1019
+ }
1020
+ catch {
1021
+ return full;
1022
+ }
1023
+ });
1024
+ }
1025
+ _imageMime(p) {
1026
+ switch (path.extname(p).toLowerCase()) {
1027
+ case '.png': return 'image/png';
1028
+ case '.jpg':
1029
+ case '.jpeg': return 'image/jpeg';
1030
+ case '.gif': return 'image/gif';
1031
+ case '.webp': return 'image/webp';
1032
+ case '.svg': return 'image/svg+xml';
1033
+ case '.bmp': return 'image/bmp';
1034
+ default: return null;
1035
+ }
1036
+ }
560
1037
  _getHtmlForWebview(webview) {
561
1038
  // 获取资源文件的 URI
562
1039
  const markedJsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'vendor', 'marked.min.js'));
1040
+ const highlightJsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'vendor', 'highlight.min.js'));
563
1041
  const stylesCssUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'styles.css'));
564
1042
  const scriptJsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'script.js'));
565
1043
  // 读取 HTML 模板
566
1044
  const htmlTemplatePath = path.join(this._extensionUri.fsPath, 'dist', 'webview', 'index.html');
567
1045
  let htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf-8');
568
- // CSP 策略
569
- const csp = `default-src 'none'; style-src ${webview.cspSource}; script-src 'unsafe-inline' ${webview.cspSource}; img-src data:;`;
1046
+ // CSP 策略:放宽 img-src 以支持 AI 在 summary 里发来的网络图片 / vscode 资源 / base64
1047
+ const csp = `default-src 'none'; style-src ${webview.cspSource}; script-src 'unsafe-inline' ${webview.cspSource}; img-src ${webview.cspSource} https: data: blob:;`;
570
1048
  // 获取语言设置
571
1049
  const language = (0, i18n_1.getLanguage)();
572
1050
  const langCode = language === 'zh-TW' ? 'zh-TW' : (language === 'en' ? 'en' : 'zh-CN');
1051
+ // 按平台解析快捷键占位符(mac vs win/linux),未知占位符保持原样
1052
+ const isMac = process.platform === 'darwin';
1053
+ const placeholders = {
1054
+ shortcut: isMac ? '⇧⌘\'' : 'Ctrl+Shift+\'',
1055
+ ctrlEnter: isMac ? '⌘Enter' : 'Ctrl+Enter',
1056
+ shiftEnter: isMac ? '⇧Enter' : 'Shift+Enter',
1057
+ };
1058
+ const i18nResolved = {};
1059
+ for (const k of Object.keys(this._i18n)) {
1060
+ const v = this._i18n[k];
1061
+ i18nResolved[k] = typeof v === 'string'
1062
+ ? v.replace(/\{(\w+)\}/g, (m, name) => placeholders[name] ?? m)
1063
+ : v;
1064
+ }
573
1065
  // 替换占位符
574
1066
  htmlTemplate = htmlTemplate
575
1067
  .replace(/\{\{CSP\}\}/g, csp)
576
1068
  .replace(/\{\{LANG\}\}/g, langCode)
577
1069
  .replace(/\{\{MARKED_JS_URI\}\}/g, markedJsUri.toString())
1070
+ .replace(/\{\{HIGHLIGHT_JS_URI\}\}/g, highlightJsUri.toString())
578
1071
  .replace(/\{\{STYLES_CSS_URI\}\}/g, stylesCssUri.toString())
579
1072
  .replace(/\{\{SCRIPT_JS_URI\}\}/g, scriptJsUri.toString())
580
- .replace(/\{\{I18N_JSON\}\}/g, JSON.stringify(this._i18n))
1073
+ .replace(/\{\{I18N_JSON\}\}/g, JSON.stringify(i18nResolved))
581
1074
  .replace(/\{\{i18n\.(\w+)\}\}/g, (_, key) => {
582
- return this._i18n[key] || key;
1075
+ return i18nResolved[key] || key;
583
1076
  });
584
1077
  return htmlTemplate;
585
1078
  }