Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions agents.md → AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.

## How Things Talk
- UI calls application hooks hooks call domain helpers persistence/config via infrastructure adapters.
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.

Expand All @@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
- Avoid direct network/fetch in components; add a service/adaptor first.
- Maintain ASCII-only unless required by existing file content.

## Review Boundaries
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.

---

## Aside Panel Design System
Expand All @@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha

Import from `./ui/aside-panel`:
```tsx
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
import {
AsidePanel,
AsidePanelHeader,
AsidePanelContent,
AsidePanelFooter,
AsideActionMenu,
AsideActionMenuItem
AsideActionMenuItem
} from "./ui/aside-panel";
```

### Basic Usage
```tsx
<AsidePanel
open={isOpen}
<AsidePanel
open={isOpen}
onClose={handleClose}
title="Panel Title"
subtitle="Optional subtitle"
Expand Down
9 changes: 7 additions & 2 deletions application/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1739,6 +1739,11 @@ const en: Messages = {
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
'ai.toolAccess.title': 'Tool Access',
'ai.toolAccess.mode': 'Netcatty Access Mode',
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',

// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
Expand Down Expand Up @@ -1815,7 +1820,7 @@ const en: Messages = {
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
Expand All @@ -1825,7 +1830,7 @@ const en: Messages = {
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
Expand Down
9 changes: 7 additions & 2 deletions application/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,11 @@ const zhCN: Messages = {
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty(内置)',
'ai.toolAccess.title': '工具接入',
'ai.toolAccess.mode': 'Netcatty 接入模式',
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',

// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
Expand Down Expand Up @@ -1823,7 +1828,7 @@ const zhCN: Messages = {
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
Expand All @@ -1833,7 +1838,7 @@ const zhCN: Messages = {
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
Expand Down
48 changes: 45 additions & 3 deletions application/state/useAIState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
Expand All @@ -19,6 +20,7 @@ import {
import type {
AISession,
AIPermissionMode,
AIToolIntegrationMode,
ProviderConfig,
HostAIPermission,
ExternalAgentConfig,
Expand All @@ -29,8 +31,17 @@ import type {
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';

/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
interface AIBridge {
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
}

function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}

const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
Expand Down Expand Up @@ -192,6 +203,10 @@ export function useAIState() {
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
});
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
return stored === 'skills' ? 'skills' : 'mcp';
});
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
);
Expand Down Expand Up @@ -252,7 +267,7 @@ export function useAIState() {
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};

for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
Expand Down Expand Up @@ -330,6 +345,13 @@ export function useAIState() {
});
}, []);

const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
const bridge = getAIBridge();
bridge?.aiMcpSetToolIntegrationMode?.(mode);
}, []);

const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
Expand Down Expand Up @@ -396,6 +418,15 @@ export function useAIState() {
}
break;
}
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
{
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
setToolIntegrationModeRaw(mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) {
Expand Down Expand Up @@ -511,8 +542,17 @@ export function useAIState() {
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
const initialPermMode: AIPermissionMode =
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
? storedPermMode
: 'confirm';
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
const initialToolMode: AIToolIntegrationMode =
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
}, []);

// ── Session CRUD ──
Expand Down Expand Up @@ -819,6 +859,8 @@ export function useAIState() {
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,

Expand Down
44 changes: 37 additions & 7 deletions components/AIChatSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AgentModelPreset,
AIPermissionMode,
AIToolIntegrationMode,
AISession,
AISessionScope,
ChatMessage,
Expand All @@ -39,7 +39,11 @@ import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import {
useAIChatStreaming,
getNetcattyBridge,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
Expand Down Expand Up @@ -89,6 +93,7 @@ interface AIChatSidePanelProps {

// Agent info
defaultAgentId: string;
toolIntegrationMode: AIToolIntegrationMode;
externalAgents: ExternalAgentConfig[];
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
agentModelMap: Record<string, string>;
Expand Down Expand Up @@ -210,6 +215,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
activeProviderId,
activeModelId,
defaultAgentId,
toolIntegrationMode,
externalAgents,
setExternalAgents,
agentModelMap,
Expand Down Expand Up @@ -241,6 +247,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({

const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});

const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
Expand Down Expand Up @@ -305,6 +312,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);

const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);

if (scopeType === 'terminal' && scopeTargetId) {
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
if (target) {
return {
...target,
source: 'scope-target',
};
}
}

if (connectedSessions.length === 1) {
return {
...connectedSessions[0],
source: 'only-connected-in-scope',
};
}

return undefined;
}, [terminalSessions, scopeType, scopeTargetId]);

const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;

Expand Down Expand Up @@ -440,7 +470,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({

const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});

// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
Expand All @@ -452,8 +481,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);

// Ref to read agentModelMap inside the effect without re-triggering it
// when setAgentModel updates the map (avoids double ACP spawn).
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;

Expand Down Expand Up @@ -495,7 +522,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({

const agentModelPresets = useMemo(
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
);

// Per-agent model: recall last selection or use first preset as default
Expand Down Expand Up @@ -677,8 +704,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
defaultTargetSession,
providers,
selectedAgentModel,
toolIntegrationMode,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
Expand Down Expand Up @@ -714,8 +743,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
]);

const handleStop = useCallback(() => {
Expand Down
2 changes: 2 additions & 0 deletions components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const SettingsAITabContainer: React.FC = () => {
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
Expand Down
1 change: 1 addition & 0 deletions components/TerminalLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
Expand Down
Loading