Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 e9d6ba3a9e fix: stop MCP approval attention when a later window focuses
Watch app browser-window-focus while a flash or dock bounce is active so a
window created after the alert starts can clear it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:16:30 +00:00
Cursor Agentand陈大猫 37e7ac9f56 feat: alert when an external MCP approval is waiting
Show a system notification and request taskbar or Dock attention so confirm-mode approvals are not missed before the existing timeout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 02:58:25 +00:00
22 changed files with 740 additions and 5 deletions

No files matched your search

+8
View File
@@ -356,6 +356,14 @@ export const enAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
'ai.externalMcp.approvalAlert': 'Approval alerts',
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
'ai.externalMcp.approvalAlert.off': 'Off',
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
'ai.externalMcp.approvalAlert.always': 'Always',
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
'ai.externalMcp.silentSessions': 'Silent MCP sessions',
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
+8
View File
@@ -347,6 +347,14 @@ export const esAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
'ai.externalMcp.approvalAlert.off': 'Desactivado',
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
'ai.externalMcp.approvalAlert.always': 'Siempre',
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'ai.externalMcp.approvalAlert',
'ai.externalMcp.approvalAlert.description',
'ai.externalMcp.approvalAlert.off',
'ai.externalMcp.approvalAlert.unfocused',
'ai.externalMcp.approvalAlert.always',
'ai.externalMcp.approvalAlert.notificationTitle',
'ai.externalMcp.approvalAlert.notificationBody',
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
] as const;
test('External MCP approval alerts are localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing approval alert labels`);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
}
});
+8
View File
@@ -303,6 +303,14 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'мин',
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
'ai.externalMcp.approvalAlert.off': 'Выкл.',
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
'ai.externalMcp.approvalAlert.always': 'Всегда',
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
+8
View File
@@ -356,6 +356,14 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分钟',
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
'ai.externalMcp.approvalAlert': '审批提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
'ai.externalMcp.approvalAlert.off': '关闭',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
'ai.externalMcp.approvalAlert.always': '始终',
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
'ai.externalMcp.silentSessions': 'AI 会话静默运行',
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
+8
View File
@@ -346,6 +346,14 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分鐘',
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
'ai.externalMcp.approvalAlert': '核准提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
'ai.externalMcp.approvalAlert.off': '關閉',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
'ai.externalMcp.approvalAlert.always': '一律',
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
return `${values?.tool} on ${values?.host} is waiting for approval`;
}
return key;
};
test('approval alerts stay quiet when the preference is off', () => {
const calls: unknown[] = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
dedupeKey: 'mcp_approval_1',
mode: 'off',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, false);
assert.deepEqual(calls, []);
});
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
hostLabel: 'bastion',
dedupeKey: 'mcp_approval_2',
mode: 'unfocused',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, true);
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'sftp.write on bastion is waiting for approval',
dedupeKey: 'mcp_approval_2',
whenUnfocused: true,
}]);
});
test('always approval alerts notify even while a window may be focused', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
presentExternalMcpApprovalNotification({
toolName: 'terminal.execute',
dedupeKey: 'mcp_approval_3',
mode: 'always',
t,
notify: (payload) => calls.push(payload),
});
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'terminal.execute is waiting for approval',
dedupeKey: 'mcp_approval_3',
whenUnfocused: false,
}]);
});
@@ -0,0 +1,68 @@
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
readExternalMcpApprovalAlertMode,
type ExternalMcpApprovalAlertMode,
} from './useExternalMcpToggleState';
export type ExternalMcpApprovalNotification = {
title: string;
body: string;
dedupeKey: string;
whenUnfocused: boolean;
};
type Translate = (
key: string,
values?: Record<string, string | number | boolean | null | undefined>,
) => string;
/**
* Build the system notification for one External MCP approval.
* Returns null when the preference is off. Focus gating stays in the main
* process so every Netcatty window is considered, not only the sender.
*/
export function presentExternalMcpApprovalNotification(options: {
toolName: string;
hostLabel?: string;
dedupeKey: string;
mode: ExternalMcpApprovalAlertMode;
t: Translate;
notify: (payload: ExternalMcpApprovalNotification) => void;
}): boolean {
if (options.mode === 'off') return false;
const tool = options.toolName || '';
const host = options.hostLabel?.trim() || '';
const body = host
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
options.notify({
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
body,
dedupeKey: options.dedupeKey,
whenUnfocused: options.mode === 'unfocused',
});
return true;
}
export function publishExternalMcpApprovalAlertMode(): void {
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
mode: readExternalMcpApprovalAlertMode(),
});
}
export function notifyExternalMcpApproval(
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
t: Translate,
): boolean {
return presentExternalMcpApprovalNotification({
toolName: request.toolName,
hostLabel: request.target?.label || request.target?.hostname || '',
dedupeKey: request.toolCallId,
mode: readExternalMcpApprovalAlertMode(),
t,
notify: (payload) => {
void netcattyBridge.get()?.showSystemNotification?.(payload);
},
});
}
@@ -11,9 +11,11 @@ import {
getExternalMcpStartupReadyWaiterCountForTests,
isExternalMcpStartupReady,
markExternalMcpStartupReady,
normalizeExternalMcpApprovalAlertMode,
normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes,
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen,
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
@@ -25,6 +27,7 @@ import {
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
} from './useExternalMcpToggleState.ts';
@@ -69,6 +72,22 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
});
it('stores the approval alert preference and defaults to unfocused', () => {
const restore = installMemoryLocalStorage();
try {
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
} finally {
restore();
}
});
for (const savedMinutes of [null, 30]) {
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import {
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
@@ -14,6 +15,9 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export type ExternalMcpMode = 'temporary' | 'persistent';
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
@@ -183,6 +187,26 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
}
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
return value === 'off' || value === 'always' || value === 'unfocused'
? value
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
}
/** System notification preference for confirm-mode External MCP approvals. */
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
return normalizeExternalMcpApprovalAlertMode(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
);
}
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
return normalized;
}
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
export function readExternalMcpSilentSessions(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
+26 -2
View File
@@ -4,7 +4,7 @@
* even when the Catty AI side panel has never been opened.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ToolCall } from '../ai-elements/tool-call';
import { Button } from '../ui/button';
import {
@@ -20,6 +20,12 @@ import {
resolveCapabilityId,
} from '../../infrastructure/ai/harness/permissionGrants';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
import {
notifyExternalMcpApproval,
publishExternalMcpApprovalAlertMode,
} from '../../application/state/externalMcpApprovalAlert';
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
@@ -31,19 +37,37 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
export const ExternalMcpApprovalsHost: React.FC = () => {
const { t } = useI18n();
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const alertedIdsRef = useRef(new Set<string>());
useEffect(() => {
const pushMode = () => {
publishExternalMcpApprovalAlertMode();
};
pushMode();
const onChange = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
};
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
}, []);
useEffect(() => {
const handler = (request: ApprovalRequest) => {
if (!isExternalMcpApproval(request)) return;
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
if (alertedIdsRef.current.has(request.toolCallId)) return;
alertedIdsRef.current.add(request.toolCallId);
notifyExternalMcpApproval(request, t);
};
const unsub = onApprovalRequest(handler);
replayPendingApprovals(handler);
return unsub;
}, []);
}, [t]);
useEffect(() => {
return onApprovalCleared((clearedIds) => {
for (const id of clearedIds) alertedIdsRef.current.delete(id);
setPendingApprovals((prev) => {
const next = new Map(prev);
for (const id of clearedIds) next.delete(id);
@@ -2,16 +2,19 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpApprovalAlertMode,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
@@ -297,6 +300,9 @@ export const ExternalMcpCard: React.FC = () => {
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
() => readExternalMcpApprovalAlertMode(),
);
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
@@ -344,6 +350,10 @@ export const ExternalMcpCard: React.FC = () => {
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []);
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions);
@@ -698,6 +708,24 @@ export const ExternalMcpCard: React.FC = () => {
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-approval-alert"
label={t("ai.externalMcp.approvalAlert")}
description={t("ai.externalMcp.approvalAlert.description")}
>
<Select
value={approvalAlert}
options={[
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
]}
onChange={(value) => setApprovalAlert(
value === "always" || value === "off" ? value : "unfocused",
)}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")}
+9
View File
@@ -714,6 +714,15 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "ai.externalMcp.title",
keywords: ["mcp"],
},
{
id: "ai-external-mcp-approval-alert",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.externalMcp.approvalAlert",
descriptionKey: "ai.externalMcp.approvalAlert.description",
sectionKey: "ai.externalMcp.title",
keywords: ["notification", "approval", "dock"],
},
{
id: "ai-user-skills",
tab: "ai",
+18
View File
@@ -25,6 +25,7 @@ const { safeSend } = require("./ipcUtils.cjs");
const { emitTerminalSessionData } = require("./emitTerminalSessionData.cjs");
const { getCliDiscoveryFilePath } = require("../cli/discoveryPath.cjs");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../cli/externalMcpDiscoveryPath.cjs");
const { syncExternalMcpApprovalAttention } = require("./windowAttention.cjs");
const sftpBridge = require("./sftpBridge.cjs");
const portForwardingBridge = require("./portForwardingBridge.cjs");
@@ -236,6 +237,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
resolve(false);
// Notify renderer(s) to remove the stale approval card
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
syncExternalApprovalAttention();
};
const clearTimer = () => {
@@ -280,6 +282,7 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalCont
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
&& externalMcpSockets.has(approvalContext.externalSocket),
});
syncExternalApprovalAttention();
});
}
@@ -338,9 +341,24 @@ function cancelApprovalTimeoutFromRenderer(approvalId) {
return true;
}
function countExternalMcpApprovals() {
let pending = 0;
for (const entry of pendingApprovals.values()) {
if (entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID) pending += 1;
}
return pending;
}
function syncExternalApprovalAttention() {
try {
syncExternalMcpApprovalAttention(countExternalMcpApprovals());
} catch { /* attention must not change approval results */ }
}
function notifyRendererApprovalCleared(approvalIds) {
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
syncExternalApprovalAttention();
}
/**
+12 -2
View File
@@ -55,10 +55,18 @@ function showSystemNotification({
return { shown: true };
}
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow }) {
function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow, getAppFocused }) {
const shownKeys = new Set();
ipcMain.handle("netcatty:notification:show", async (event, payload) => {
try {
return showSystemNotification({
if (payload?.whenUnfocused === true && typeof getAppFocused === "function" && getAppFocused()) {
return { shown: false, reason: "focused" };
}
const dedupeKey = typeof payload?.dedupeKey === "string" ? payload.dedupeKey : "";
if (dedupeKey && shownKeys.has(dedupeKey)) {
return { shown: false, reason: "duplicate" };
}
const result = showSystemNotification({
Notification: electronModule?.Notification,
BrowserWindow,
sender: event?.sender,
@@ -66,6 +74,8 @@ function registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWi
body: payload?.body,
sessionId: payload?.sessionId,
});
if (result?.shown && dedupeKey) shownKeys.add(dedupeKey);
return result;
} catch (err) {
return { shown: false, reason: err?.message || "failed" };
}
@@ -72,6 +72,67 @@ test("showSystemNotification reports unsupported platforms", () => {
);
});
test("registerSystemNotificationHandlers skips unfocused alerts while the app is focused", async () => {
const handlers = new Map();
const shown = [];
function FakeNotification(payload) {
shown.push(payload);
}
FakeNotification.isSupported = () => true;
FakeNotification.prototype.on = () => {};
FakeNotification.prototype.show = () => {};
registerSystemNotificationHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
}, {
electronModule: { Notification: FakeNotification },
BrowserWindow: { fromWebContents: () => null },
getAppFocused: () => true,
});
const skipped = await handlers.get("netcatty:notification:show")(
{ sender: { isDestroyed: () => false, send() {} } },
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: true, dedupeKey: "a1" },
);
assert.deepEqual(skipped, { shown: false, reason: "focused" });
assert.equal(shown.length, 0);
const forced = await handlers.get("netcatty:notification:show")(
{ sender: { isDestroyed: () => false, send() {} } },
{ title: "External MCP approval", body: "sftp.write is waiting", whenUnfocused: false, dedupeKey: "a1" },
);
assert.deepEqual(forced, { shown: true });
assert.equal(shown.length, 1);
});
test("registerSystemNotificationHandlers dedupes the same approval across windows", async () => {
const handlers = new Map();
const shown = [];
function FakeNotification(payload) {
shown.push(payload);
}
FakeNotification.isSupported = () => true;
FakeNotification.prototype.on = () => {};
FakeNotification.prototype.show = () => {};
registerSystemNotificationHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
}, {
electronModule: { Notification: FakeNotification },
BrowserWindow: { fromWebContents: () => null },
getAppFocused: () => false,
});
const show = (dedupeKey) => handlers.get("netcatty:notification:show")(
{ sender: { isDestroyed: () => false, send() {} } },
{ title: "External MCP approval", body: "tool", dedupeKey },
);
assert.deepEqual(await show("mcp_approval_1"), { shown: true });
assert.deepEqual(await show("mcp_approval_1"), { shown: false, reason: "duplicate" });
assert.deepEqual(await show("mcp_approval_2"), { shown: true });
assert.equal(shown.length, 2);
});
test("registerSystemNotificationHandlers exposes the IPC channel", async () => {
const handlers = new Map();
const shown = [];
+122
View File
@@ -0,0 +1,122 @@
"use strict";
const DEFAULT_MODE = "unfocused";
function normalizeExternalMcpApprovalAlertMode(value) {
return value === "off" || value === "always" || value === "unfocused" ? value : DEFAULT_MODE;
}
function createWindowAttentionController({ app, getWindows, platform }) {
let bounceId = -1;
let flashing = false;
let flashedWin = null;
let watchingFocus = false;
function onBrowserWindowFocus() {
stop();
}
function startFocusWatch() {
if (watchingFocus || typeof app?.on !== "function") return;
app.on("browser-window-focus", onBrowserWindowFocus);
watchingFocus = true;
}
function stopFocusWatch() {
if (!watchingFocus) return;
watchingFocus = false;
try { app.removeListener("browser-window-focus", onBrowserWindowFocus); } catch { /* ignore */ }
}
function stop() {
stopFocusWatch();
if (flashing && flashedWin && !flashedWin.isDestroyed?.() && typeof flashedWin.flashFrame === "function") {
try { flashedWin.flashFrame(false); } catch { /* ignore */ }
}
flashing = false;
flashedWin = null;
if (bounceId >= 0) {
try { app?.dock?.cancelBounce?.(bounceId); } catch { /* ignore */ }
bounceId = -1;
}
}
function request() {
const windows = (typeof getWindows === "function" ? getWindows() : [])
.filter((win) => win && !win.isDestroyed?.());
if (windows.length === 0) return { requested: false, reason: "no-window" };
if (windows.some((win) => typeof win.isFocused === "function" && win.isFocused())) {
return { requested: false, reason: "focused" };
}
if (platform === "darwin") {
if (bounceId >= 0) return { requested: true, kind: "bounce" };
const id = app?.dock?.bounce?.("informational");
if (typeof id !== "number" || id < 0) return { requested: false, reason: "unavailable" };
bounceId = id;
startFocusWatch();
return { requested: true, kind: "bounce" };
}
const win = windows[0];
if (typeof win.flashFrame !== "function") return { requested: false, reason: "unavailable" };
win.flashFrame(true);
flashing = true;
flashedWin = win;
startFocusWatch();
return { requested: true, kind: "flash" };
}
return { request, stop };
}
let mode = DEFAULT_MODE;
let controller = null;
function installWindowAttention(options) {
controller?.stop?.();
controller = createWindowAttentionController(options);
return controller;
}
function setExternalMcpApprovalAlertMode(value) {
mode = normalizeExternalMcpApprovalAlertMode(value);
if (mode === "off") controller?.stop?.();
return mode;
}
function getExternalMcpApprovalAlertMode() {
return mode;
}
function syncExternalMcpApprovalAttention(pendingCount) {
if (!controller) return { attention: false, reason: "uninstalled" };
if (!(pendingCount > 0) || mode === "off") {
controller.stop();
return { attention: false };
}
return controller.request();
}
function resetWindowAttentionForTests() {
controller?.stop?.();
controller = null;
mode = DEFAULT_MODE;
}
function registerExternalMcpApprovalAlertHandlers(ipcMain) {
ipcMain.handle("netcatty:mcp:approval-alert-mode", (_event, payload) => {
return { mode: setExternalMcpApprovalAlertMode(payload?.mode) };
});
}
module.exports = {
normalizeExternalMcpApprovalAlertMode,
createWindowAttentionController,
installWindowAttention,
setExternalMcpApprovalAlertMode,
getExternalMcpApprovalAlertMode,
syncExternalMcpApprovalAttention,
resetWindowAttentionForTests,
registerExternalMcpApprovalAlertHandlers,
};
+186
View File
@@ -0,0 +1,186 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const mcpServerBridge = require("./mcpServerBridge.cjs");
const {
createWindowAttentionController,
installWindowAttention,
resetWindowAttentionForTests,
setExternalMcpApprovalAlertMode,
syncExternalMcpApprovalAttention,
} = require("./windowAttention.cjs");
function createWindow(overrides = {}) {
const win = new EventEmitter();
win.destroyed = false;
win.focused = false;
win.flashes = [];
win.isDestroyed = () => win.destroyed;
win.isFocused = () => win.focused;
win.flashFrame = (flag) => { win.flashes.push(flag); };
return Object.assign(win, overrides);
}
test("linux attention flashes until focus or stop", () => {
const app = new EventEmitter();
const win = createWindow();
const controller = createWindowAttentionController({
app,
platform: "linux",
getWindows: () => [win],
});
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
assert.deepEqual(win.flashes, [true]);
assert.equal(app.listenerCount("browser-window-focus"), 1);
app.emit("browser-window-focus", {}, win);
assert.deepEqual(win.flashes, [true, false]);
assert.equal(app.listenerCount("browser-window-focus"), 0);
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
controller.stop();
assert.deepEqual(win.flashes, [true, false, true, false]);
assert.equal(app.listenerCount("browser-window-focus"), 0);
});
test("focus on a window created after attention starts clears the flash", () => {
const app = new EventEmitter();
const original = createWindow();
const windows = [original];
const controller = createWindowAttentionController({
app,
platform: "win32",
getWindows: () => windows,
});
assert.deepEqual(controller.request(), { requested: true, kind: "flash" });
const later = createWindow();
windows.push(later);
later.emit("focus");
assert.deepEqual(original.flashes, [true]);
app.emit("browser-window-focus", {}, later);
assert.deepEqual(original.flashes, [true, false]);
assert.equal(app.listenerCount("browser-window-focus"), 0);
app.emit("browser-window-focus", {}, later);
assert.deepEqual(original.flashes, [true, false]);
});
test("attention stays quiet while any window is focused", () => {
const idle = createWindow();
const active = createWindow({ focused: true });
const controller = createWindowAttentionController({
platform: "win32",
getWindows: () => [idle, active],
});
assert.deepEqual(controller.request(), { requested: false, reason: "focused" });
assert.deepEqual(idle.flashes, []);
});
test("macOS attention uses an informational dock bounce and cancels it", () => {
const win = createWindow();
const cancelled = [];
const app = new EventEmitter();
app.dock = {
bounce(type) {
assert.equal(type, "informational");
return 4;
},
cancelBounce(id) { cancelled.push(id); },
};
const controller = createWindowAttentionController({
platform: "darwin",
app,
getWindows: () => [win],
});
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
assert.deepEqual(controller.request(), { requested: true, kind: "bounce" });
assert.equal(app.listenerCount("browser-window-focus"), 1);
assert.deepEqual(win.flashes, []);
controller.stop();
assert.deepEqual(cancelled, [4]);
app.emit("browser-window-focus", {}, win);
assert.deepEqual(cancelled, [4]);
assert.equal(app.listenerCount("browser-window-focus"), 0);
});
test("external MCP approvals flash while unfocused and stop when resolved", (t) => {
const contents = new EventEmitter();
contents.id = 42;
contents.send = (channel, payload) => contents.emit("send", channel, payload);
contents.isDestroyed = () => false;
const win = createWindow();
win.webContents = contents;
installWindowAttention({
platform: "linux",
getWindows: () => [win],
});
setExternalMcpApprovalAlertMode("unfocused");
mcpServerBridge.setMainWindowGetter(() => win);
t.after(() => {
mcpServerBridge.clearPendingApprovals();
mcpServerBridge.setMainWindowGetter(() => null);
resetWindowAttentionForTests();
});
const requests = [];
contents.on("send", (channel, payload) => {
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
});
const approval = mcpServerBridge.requestApprovalFromRenderer(
"sftp.write",
{ path: "/tmp/x" },
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
);
assert.equal(requests.length, 1);
assert.deepEqual(win.flashes, [true]);
mcpServerBridge.resolveApprovalFromRenderer(requests[0].approvalId, false);
assert.deepEqual(win.flashes, [true, false]);
return approval.then((approved) => {
assert.equal(approved, false);
});
});
test("approval attention follows the off preference and ignores in-app approvals", (t) => {
const contents = new EventEmitter();
contents.id = 7;
contents.send = (channel, payload) => contents.emit("send", channel, payload);
contents.isDestroyed = () => false;
const win = createWindow();
win.webContents = contents;
installWindowAttention({
platform: "linux",
getWindows: () => [win],
});
setExternalMcpApprovalAlertMode("off");
mcpServerBridge.setMainWindowGetter(() => win);
t.after(() => {
mcpServerBridge.clearPendingApprovals();
mcpServerBridge.setMainWindowGetter(() => null);
resetWindowAttentionForTests();
});
const external = mcpServerBridge.requestApprovalFromRenderer(
"sftp.write",
{},
mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID,
);
const internal = mcpServerBridge.requestApprovalFromRenderer(
"terminal.execute",
{},
"chat-1",
);
assert.deepEqual(win.flashes, []);
assert.deepEqual(syncExternalMcpApprovalAttention(2), { attention: false });
mcpServerBridge.clearPendingApprovals();
return Promise.all([external, internal]).then((results) => {
assert.deepEqual(results, [false, false]);
});
});
+24 -1
View File
@@ -4,6 +4,10 @@ let bridgesRegistered = false;
let cloudSyncSessionPassword = null;
const { readClipboardFiles, readClipboardImage, hasClipboardImage } = require("../bridges/clipboardFiles.cjs");
const { registerSystemNotificationHandlers } = require("../bridges/systemNotification.cjs");
const {
installWindowAttention,
registerExternalMcpApprovalAlertHandlers,
} = require("../bridges/windowAttention.cjs");
const excludedFigSpecPrefixes = ["aws", "gcloud", "az"];
@@ -992,7 +996,26 @@ function createBridgeRegistrar(context) {
);
// OSC 9/777/99 desktop notifications (Codex, iTerm2, kitty, rxvt)
registerSystemNotificationHandlers(ipcMain, { electronModule, BrowserWindow });
// and External MCP approval alerts, which reuse the same click-to-focus channel.
registerSystemNotificationHandlers(ipcMain, {
electronModule,
BrowserWindow,
getAppFocused: () => BrowserWindow.getAllWindows().some(
(win) => win && !win.isDestroyed?.() && win.isFocused?.(),
),
});
installWindowAttention({
app,
platform: process.platform,
getWindows: () => {
try {
return BrowserWindow.getAllWindows();
} catch {
return [];
}
},
});
registerExternalMcpApprovalAlertHandlers(ipcMain);
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
+3
View File
@@ -1840,6 +1840,9 @@ function createPreloadApi(ctx) {
showSystemNotification: async (payload) => {
return ipcRenderer.invoke("netcatty:notification:show", payload ?? {});
},
setExternalMcpApprovalAlertMode: async (payload) => {
return ipcRenderer.invoke("netcatty:mcp:approval-alert-mode", payload ?? {});
},
// Clipboard fallback helpers
readClipboardText: async () => {
+2
View File
@@ -227,6 +227,8 @@ export const STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN = 'netcatty_ai_exter
export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_idle_timeout_minutes_v1';
/** External MCP: whether host_open sessions stay hidden from the tab bar (default false). */
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
/** External MCP confirm-mode approval alert: off, unfocused, or always. Default unfocused. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT = 'netcatty_ai_external_mcp_approval_alert_v1';
/** AI panel diagnostic hide list (comma-separated part names). */
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
/** AI panel React profiler toggle. */
+7
View File
@@ -139,7 +139,14 @@ declare global {
title: string;
body: string;
sessionId?: string;
/** Skip a second notification for the same approval across windows. */
dedupeKey?: string;
/** Show only when no Netcatty window is focused. */
whenUnfocused?: boolean;
}): Promise<{ shown: boolean; reason?: string }>;
setExternalMcpApprovalAlertMode?(payload: {
mode: 'off' | 'unfocused' | 'always';
}): Promise<{ mode: 'off' | 'unfocused' | 'always' }>;
readClipboardText?(): Promise<string>;
writeClipboardText?(text: string): Promise<boolean>;
readClipboardFiles?(): Promise<Array<{ path: string; name: string; isDirectory: boolean; size?: number }>>;