cursor-feedback 1.2.0 → 2.0.1

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
@@ -42,12 +42,14 @@ const path = __importStar(require("path"));
42
42
  const child_process_1 = require("child_process");
43
43
  const i18n_1 = require("./i18n");
44
44
  let feedbackViewProvider = null;
45
- let pollingInterval = null;
45
+ /** 规范化路径:统一分隔符、去尾斜杠、转小写,用于工作区匹配 */
46
+ function normalizePath(p) {
47
+ return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
48
+ }
46
49
  function activate(context) {
47
- console.log('Cursor Feedback extension is now active!');
48
50
  // 注册侧边栏 WebView(端口从 61927 开始自动扫描)
49
- feedbackViewProvider = new FeedbackViewProvider(context.extensionUri, 61927);
50
- 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 } }));
51
53
  // 注册命令:显示反馈面板
52
54
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.showPanel', () => {
53
55
  vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
@@ -56,14 +58,25 @@ function activate(context) {
56
58
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.startPolling', () => {
57
59
  if (feedbackViewProvider) {
58
60
  feedbackViewProvider.startPolling();
59
- vscode.window.showInformationMessage(feedbackViewProvider.getMessage('startListening'));
60
61
  }
61
62
  }));
62
63
  // 注册命令:停止轮询
63
64
  context.subscriptions.push(vscode.commands.registerCommand('cursorFeedback.stopPolling', () => {
64
65
  if (feedbackViewProvider) {
65
66
  feedbackViewProvider.stopPolling();
66
- 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);
67
80
  }
68
81
  }));
69
82
  // 自动开始轮询
@@ -94,8 +107,7 @@ function getWorkspacePaths() {
94
107
  function isPathInWorkspace(targetPath) {
95
108
  const workspacePaths = getWorkspacePaths();
96
109
  // 规范化路径(去除末尾斜杠,统一分隔符,小写)
97
- const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
98
- const normalizedTarget = normalize(targetPath);
110
+ const normalizedTarget = normalizePath(targetPath);
99
111
  // 检查 targetPath 是否为空或默认值
100
112
  const isEmptyPath = !targetPath || targetPath === '.' || normalizedTarget === '' || normalizedTarget === '.';
101
113
  if (workspacePaths.length === 0) {
@@ -107,7 +119,7 @@ function isPathInWorkspace(targetPath) {
107
119
  return false;
108
120
  }
109
121
  for (const wsPath of workspacePaths) {
110
- const normalizedWs = normalize(wsPath);
122
+ const normalizedWs = normalizePath(wsPath);
111
123
  // 精确匹配:只匹配完全相同的路径
112
124
  if (normalizedTarget === normalizedWs) {
113
125
  return true;
@@ -119,13 +131,29 @@ function isPathInWorkspace(targetPath) {
119
131
  * 侧边栏 WebView Provider
120
132
  */
121
133
  class FeedbackViewProvider {
122
- constructor(_extensionUri, port) {
134
+ constructor(_extensionUri, port, _memento) {
123
135
  this._extensionUri = _extensionUri;
124
- this._pollingInterval = null;
136
+ this._memento = _memento;
137
+ this._polling = false;
138
+ this._pollTimer = null;
139
+ this._pollDelay = 1000;
125
140
  this._currentRequest = null;
141
+ // webview 脚本是否就绪;插入上下文消息需等就绪后再发,否则 focus 唤起重建时会丢消息
142
+ this._webviewReady = false;
143
+ this._pendingInsert = null;
126
144
  this._activePort = null;
127
145
  this._portScanRange = 20; // 扫描端口范围
128
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;
129
157
  this._debugInfo = {
130
158
  portRange: '',
131
159
  workspacePath: '',
@@ -137,6 +165,12 @@ class FeedbackViewProvider {
137
165
  this._debugInfo.portRange = `${port}-${port + this._portScanRange - 1}`;
138
166
  this._i18n = (0, i18n_1.loadMessages)(this._extensionUri.fsPath);
139
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;
140
174
  }
141
175
  /**
142
176
  * 获取翻译消息
@@ -144,8 +178,19 @@ class FeedbackViewProvider {
144
178
  getMessage(key) {
145
179
  return this._i18n[key] || key;
146
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
+ }
147
191
  resolveWebviewView(webviewView, _context, _token) {
148
192
  this._view = webviewView;
193
+ this._webviewReady = false;
149
194
  webviewView.webview.options = {
150
195
  enableScripts: true,
151
196
  localResourceRoots: [this._extensionUri]
@@ -158,11 +203,17 @@ class FeedbackViewProvider {
158
203
  await this._handleFeedbackSubmit(data.payload);
159
204
  break;
160
205
  case 'ready':
161
- console.log('Feedback WebView is ready');
206
+ this._webviewReady = true;
207
+ // 同步超时续期开关状态到 UI
208
+ this._postAutoRetryState();
209
+ this._postFeishuState();
162
210
  // WebView 准备就绪后,检查是否有待处理的请求
163
- if (this._currentRequest) {
211
+ // (插件通知关闭时保持静默,不显示缓存的请求)
212
+ if (this._currentRequest && this._isPluginNotifyEnabled()) {
164
213
  this._showFeedbackRequest(this._currentRequest);
165
214
  }
215
+ // 补发就绪前排队的「插入上下文」消息
216
+ this._flushPendingInsert();
166
217
  break;
167
218
  case 'checkServer':
168
219
  await this._checkServerHealth();
@@ -173,36 +224,78 @@ class FeedbackViewProvider {
173
224
  case 'switchLanguage':
174
225
  await this._handleSwitchLanguage();
175
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;
176
254
  }
177
255
  });
178
256
  // 当 view 变为可见时,检查当前请求
257
+ // (插件通知关闭时保持静默:即使用户从侧边栏切回面板也不显示缓存的请求)
179
258
  webviewView.onDidChangeVisibility(() => {
180
- if (webviewView.visible && this._currentRequest) {
259
+ if (webviewView.visible && this._currentRequest && this._isPluginNotifyEnabled()) {
181
260
  this._showFeedbackRequest(this._currentRequest);
182
261
  }
183
262
  });
184
263
  }
185
264
  /**
186
265
  * 开始轮询 MCP Server
266
+ *
267
+ * 自适应间隔:连上 server 时每秒轮询(兼作 server watchdog 心跳,必须 < 30s 空闲阈值);
268
+ * 完全找不到 server 时指数退避到最多 5s,降低空闲 CPU / 电池开销。
269
+ * 一旦发现任意 server 立即恢复 1s。
187
270
  */
188
271
  startPolling() {
189
- if (this._pollingInterval) {
272
+ if (this._polling) {
190
273
  return;
191
274
  }
192
- console.log(`Starting polling MCP server from port ${this._basePort}`);
193
- this._pollingInterval = setInterval(async () => {
275
+ this._polling = true;
276
+ this._pollDelay = 1000;
277
+ const loop = async () => {
278
+ if (!this._polling)
279
+ return;
194
280
  await this._pollForFeedbackRequest();
195
- }, 1000); // 每秒检查一次
196
- // 立即执行一次
197
- 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();
198
290
  }
199
291
  /**
200
292
  * 停止轮询
201
293
  */
202
294
  stopPolling() {
203
- if (this._pollingInterval) {
204
- clearInterval(this._pollingInterval);
205
- this._pollingInterval = null;
295
+ this._polling = false;
296
+ if (this._pollTimer) {
297
+ clearTimeout(this._pollTimer);
298
+ this._pollTimer = null;
206
299
  }
207
300
  }
208
301
  /**
@@ -213,29 +306,34 @@ class FeedbackViewProvider {
213
306
  try {
214
307
  // 更新工作区路径
215
308
  const workspacePaths = getWorkspacePaths();
216
- this._debugInfo.workspacePath = workspacePaths.length > 0 ? workspacePaths[0] : '(无工作区)';
309
+ this._debugInfo.workspacePath = workspacePaths.length > 0 ? workspacePaths[0] : this._t('statusNoWorkspace');
217
310
  const currentWorkspace = workspacePaths[0] || '';
218
- const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
219
- const normalizedCurrentWorkspace = normalize(currentWorkspace);
311
+ const normalizedCurrentWorkspace = normalizePath(currentWorkspace);
220
312
  // 如果有活跃端口,先尝试只轮询该端口
221
313
  if (this._activePort) {
222
314
  const result = await this._checkPortForRequest(this._activePort);
223
315
  // 检查是否仍然是我们的 Server
224
316
  if (result.connected) {
225
- const serverOwner = result.ownerWorkspace ? normalize(result.ownerWorkspace) : '';
317
+ const serverOwner = result.ownerWorkspace ? normalizePath(result.ownerWorkspace) : '';
226
318
  const isMyServer = !serverOwner || serverOwner === normalizedCurrentWorkspace;
227
319
  if (isMyServer) {
228
320
  // 端口仍然有效,保持使用
229
321
  this._debugInfo.connectedPorts = [this._activePort];
230
322
  this._debugInfo.activePort = this._activePort;
231
323
  if (result.request && !this._seenRequestIds.has(result.request.id)) {
232
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
324
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
233
325
  this._handleNewRequest(result.request, this._activePort);
234
326
  this._updateDebugInfo();
235
327
  return;
236
328
  }
329
+ // 正在显示的请求被「飞书回复」结束 → 重置面板,避免提交到已消失的请求。
330
+ // 仅当 server 明确标记该请求是被飞书 resolve 的才重置——超时续期时 request 也会短暂为 null,
331
+ // 但 feishuResolvedId 不会命中,于是保持原有等待逻辑,不误重置、不丢用户草稿。
332
+ if (this._currentRequest && result.feishuResolvedId === this._currentRequest.id) {
333
+ this._handleExternalResolve();
334
+ }
237
335
  // 端口有效但无新请求,继续保持连接
238
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
336
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
239
337
  this._updateDebugInfo();
240
338
  return;
241
339
  }
@@ -258,7 +356,7 @@ class FeedbackViewProvider {
258
356
  if (!r.request || this._seenRequestIds.has(r.request.id)) {
259
357
  return false;
260
358
  }
261
- const serverOwner = r.ownerWorkspace ? normalize(r.ownerWorkspace) : '';
359
+ const serverOwner = r.ownerWorkspace ? normalizePath(r.ownerWorkspace) : '';
262
360
  return !serverOwner || serverOwner === normalizedCurrentWorkspace;
263
361
  }).sort((a, b) => b.request.timestamp - a.request.timestamp);
264
362
  // 处理最新的请求
@@ -266,7 +364,7 @@ class FeedbackViewProvider {
266
364
  const newest = myRequests[0];
267
365
  this._activePort = newest.port;
268
366
  this._debugInfo.activePort = newest.port;
269
- this._debugInfo.lastStatus = `找到请求 (端口 ${newest.port})`;
367
+ this._debugInfo.lastStatus = this._t('statusFound', { port: newest.port });
270
368
  this._handleNewRequest(newest.request, newest.port);
271
369
  this._updateDebugInfo();
272
370
  return;
@@ -274,22 +372,22 @@ class FeedbackViewProvider {
274
372
  // 没有新请求,检查是否有当前请求
275
373
  if (this._currentRequest && this._activePort) {
276
374
  this._debugInfo.activePort = this._activePort;
277
- this._debugInfo.lastStatus = `监听端口 ${this._activePort}`;
375
+ this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
278
376
  this._updateDebugInfo();
279
377
  return;
280
378
  }
281
379
  // 没有任何请求
282
380
  this._debugInfo.activePort = null;
283
381
  if (this._debugInfo.connectedPorts.length === 0) {
284
- this._debugInfo.lastStatus = '未找到 MCP Server';
382
+ this._debugInfo.lastStatus = this._t('statusNoServer');
285
383
  }
286
384
  else {
287
- this._debugInfo.lastStatus = `已连接 ${this._debugInfo.connectedPorts.length} 个端口,等待请求`;
385
+ this._debugInfo.lastStatus = this._t('statusConnected', { count: this._debugInfo.connectedPorts.length });
288
386
  }
289
387
  this._updateDebugInfo();
290
388
  }
291
389
  catch (error) {
292
- this._debugInfo.lastStatus = `轮询错误: ${error}`;
390
+ this._debugInfo.lastStatus = this._t('statusPollError', { error: String(error) });
293
391
  this._updateDebugInfo();
294
392
  }
295
393
  }
@@ -309,12 +407,14 @@ class FeedbackViewProvider {
309
407
  // 旧格式: FeedbackRequest | null
310
408
  let request;
311
409
  let ownerWorkspace = null;
312
- let startTime = 0;
410
+ let feishuResolvedId = null;
313
411
  if (parsed && typeof parsed === 'object' && 'startTime' in parsed) {
314
412
  // 新格式
315
413
  request = parsed.request;
316
414
  ownerWorkspace = parsed.ownerWorkspace;
317
- startTime = parsed.startTime;
415
+ feishuResolvedId = parsed.feishuResolvedId ?? null;
416
+ this._maybeSyncFeishu(port, parsed.feishu);
417
+ this._maybeSyncAutoRetry(parsed.autoRetry);
318
418
  }
319
419
  else {
320
420
  // 旧格式(兼容 npm 上的旧版本)
@@ -325,10 +425,10 @@ class FeedbackViewProvider {
325
425
  const isMatch = isPathInWorkspace(request.projectDir);
326
426
  if (!isMatch) {
327
427
  // 请求不属于当前工作区,返回特殊标记
328
- return { connected: true, request: null, port, mismatch: true, ownerWorkspace, startTime };
428
+ return { connected: true, request: null, port, mismatch: true, ownerWorkspace };
329
429
  }
330
430
  }
331
- return { connected: true, request, port, ownerWorkspace, startTime };
431
+ return { connected: true, request, port, ownerWorkspace, feishuResolvedId };
332
432
  }
333
433
  catch {
334
434
  return { connected: false, request: null, port };
@@ -345,7 +445,6 @@ class FeedbackViewProvider {
345
445
  // 判断是否为"新鲜"请求:创建后 10 秒内被发现
346
446
  const requestAge = Date.now() - request.timestamp;
347
447
  const isFreshRequest = requestAge < 10000; // 10秒内
348
- console.log(`Feedback request on port ${port}:`, request.id, `age: ${requestAge}ms, isFresh: ${isFreshRequest}`);
349
448
  // 标记为已见过
350
449
  this._seenRequestIds.add(request.id);
351
450
  // 清理旧的请求 ID(保留最近 100 个)
@@ -356,12 +455,18 @@ class FeedbackViewProvider {
356
455
  if (!this._currentRequest || request.id !== this._currentRequest.id) {
357
456
  this._currentRequest = request;
358
457
  this._activePort = port;
359
- // 显示请求内容
458
+ // 「插件通知」主开关(配置 key 历史原因仍叫 systemNotification):关掉后本窗口完全静默——
459
+ // 不推送内容、不弹面板、不抢焦点、不发系统通知;连用户之后主动切回 / 打开面板也不显示
460
+ // (见 resolveWebviewView 里 ready 与 onDidChangeVisibility 的同款判断)。
461
+ // 请求仍记录在 _currentRequest,仅用于去重与外部渠道(飞书 / 超时)resolve 关联。
462
+ if (!this._isPluginNotifyEnabled()) {
463
+ return;
464
+ }
465
+ // 开启:推送内容并显示面板
360
466
  this._showFeedbackRequest(request);
361
- // 只对新鲜请求自动聚焦和通知
467
+ // 只对新鲜请求做主动提醒(聚焦面板 + IDE 提示 + 失焦系统通知)
362
468
  if (isFreshRequest) {
363
469
  vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
364
- vscode.window.showInformationMessage(this._i18n.aiWaitingFeedback);
365
470
  this._sendSystemNotification(request);
366
471
  }
367
472
  }
@@ -380,6 +485,10 @@ class FeedbackViewProvider {
380
485
  if (!config.get('systemNotification', true)) {
381
486
  return;
382
487
  }
488
+ // 子开关「失焦时系统提示」:关掉后即使窗口失焦也不弹系统通知
489
+ if (!config.get('osNotification', true)) {
490
+ return;
491
+ }
383
492
  if (vscode.window.state.focused) {
384
493
  return;
385
494
  }
@@ -455,6 +564,15 @@ class FeedbackViewProvider {
455
564
  payload: { connected: false }
456
565
  });
457
566
  }
567
+ /**
568
+ * 「插件通知」主开关是否开启(配置 key 历史原因仍叫 systemNotification)。
569
+ * 关闭即本窗口完全静默:不主动推送,被动切回 / ready 时也不显示缓存的请求。
570
+ */
571
+ _isPluginNotifyEnabled() {
572
+ return vscode.workspace
573
+ .getConfiguration('cursorFeedback')
574
+ .get('systemNotification', true);
575
+ }
458
576
  /**
459
577
  * 显示反馈请求
460
578
  */
@@ -465,7 +583,7 @@ class FeedbackViewProvider {
465
583
  type: 'showFeedbackRequest',
466
584
  payload: {
467
585
  requestId: request.id,
468
- summary: request.summary,
586
+ summary: this._inlineLocalImages(request.summary),
469
587
  projectDir: request.projectDir,
470
588
  timeout: request.timeout,
471
589
  timestamp: request.timestamp
@@ -483,6 +601,18 @@ class FeedbackViewProvider {
483
601
  });
484
602
  }
485
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
+ }
486
616
  /**
487
617
  * 更新调试信息到 WebView
488
618
  */
@@ -512,7 +642,6 @@ class FeedbackViewProvider {
512
642
  }));
513
643
  const result = JSON.parse(response);
514
644
  if (result.success) {
515
- vscode.window.showInformationMessage(this._i18n.feedbackSubmitted);
516
645
  this._currentRequest = null;
517
646
  this._showWaitingState();
518
647
  }
@@ -542,6 +671,72 @@ class FeedbackViewProvider {
542
671
  });
543
672
  }
544
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
+ }
545
740
  /**
546
741
  * 处理语言切换
547
742
  */
@@ -569,12 +764,188 @@ class FeedbackViewProvider {
569
764
  if (this._view) {
570
765
  this._view.webview.html = this._getHtmlForWebview(this._view.webview);
571
766
  }
572
- const effectiveLang = (0, i18n_1.getLanguage)();
573
- vscode.window.showInformationMessage(effectiveLang === 'en'
574
- ? 'Language changed to English'
575
- : '语言已切换为简体中文');
576
767
  }
577
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
+ }
578
949
  /**
579
950
  * HTTP GET 请求
580
951
  */
@@ -623,29 +994,85 @@ class FeedbackViewProvider {
623
994
  req.end();
624
995
  });
625
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
+ }
626
1037
  _getHtmlForWebview(webview) {
627
1038
  // 获取资源文件的 URI
628
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'));
629
1041
  const stylesCssUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'styles.css'));
630
1042
  const scriptJsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'script.js'));
631
1043
  // 读取 HTML 模板
632
1044
  const htmlTemplatePath = path.join(this._extensionUri.fsPath, 'dist', 'webview', 'index.html');
633
1045
  let htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf-8');
634
- // CSP 策略
635
- 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:;`;
636
1048
  // 获取语言设置
637
1049
  const language = (0, i18n_1.getLanguage)();
638
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
+ }
639
1065
  // 替换占位符
640
1066
  htmlTemplate = htmlTemplate
641
1067
  .replace(/\{\{CSP\}\}/g, csp)
642
1068
  .replace(/\{\{LANG\}\}/g, langCode)
643
1069
  .replace(/\{\{MARKED_JS_URI\}\}/g, markedJsUri.toString())
1070
+ .replace(/\{\{HIGHLIGHT_JS_URI\}\}/g, highlightJsUri.toString())
644
1071
  .replace(/\{\{STYLES_CSS_URI\}\}/g, stylesCssUri.toString())
645
1072
  .replace(/\{\{SCRIPT_JS_URI\}\}/g, scriptJsUri.toString())
646
- .replace(/\{\{I18N_JSON\}\}/g, JSON.stringify(this._i18n))
1073
+ .replace(/\{\{I18N_JSON\}\}/g, JSON.stringify(i18nResolved))
647
1074
  .replace(/\{\{i18n\.(\w+)\}\}/g, (_, key) => {
648
- return this._i18n[key] || key;
1075
+ return i18nResolved[key] || key;
649
1076
  });
650
1077
  return htmlTemplate;
651
1078
  }