chore: 死代码清理与架构分层修复 (#524)
* chore: 移除死代码并修复架构分层违规 - 删除未使用的 ACP 模块 (infrastructure/ai/acp/) - 删除未使用的 AI 组件 (ExecutionPlan, PermissionDialog) - 将 syncPayload.ts 从 domain 移至 application 层,修复分层违规 - 移除未使用的导出 (useSecurityState, useProviderStatus, GitHubAuthState, getAgentCommandLabel, ImageAttachment, HotkeyActions) - 收窄 Electron bridge module.exports,移除未使用的导出函数 - 将仅内部使用的函数/类型取消导出 (isSupportedLocale, SyncDashboard) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: 二次审查清理 — 移除更多死代码和架构违规 - 移除未使用的 ConversationEmptyState 组件和类型 - 移除未使用的 PromptInputSelect 系列组件 (5 个导出) - 移除 global.d.ts 中残留的 SMBConfig 类型和 cloudSyncSmb* 方法声明 - 移除 useAutoSync.ts 中未使用的 toast 导入 (同时修复 application→components 反向依赖) - 清理因删除而产生的多余 import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: 消除直接 localStorage 访问,提取 safeSend 共享工具 localStorage 集中化: - 新增 storageKeys 常量: SIDE_PANEL_WIDTH, PF_RECONNECT_CANCEL, DEBUG_HOTKEYS, DEBUG_UPDATE_DEMO - TerminalLayer/SettingsApplicationTab/App.tsx/useUpdateCheck 改用 localStorageAdapter - CloudSyncManager 内部方法改用 localStorageAdapter - portForwardingService 改用 localStorageAdapter + 集中 key safeSend 去重: - 新增 electron/bridges/ipcUtils.cjs 共享模块 - sshBridge/sftpBridge/portForwardingBridge/sshAuthHelper/aiBridge 统一引用 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: 终审清理 — 移除未使用的 require 和废弃类型别名 - 移除 sftpBridge.cjs 中未使用的 require("node:net") - 移除 aiBridge.cjs 中未使用的 require("node:path") - 移除 types.ts 中已废弃的 ChatMessageImage 类型别名 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: 修复 ESLint 错误 — 组件不再直接导入 infrastructure - 新增 useStoredNumber hook,TerminalLayer 通过 hook 访问侧边栏宽度 - SettingsApplicationTab 的 isUpdateDemoMode 改为从 useUpdateCheck hook 传入 - 移除 useCloudSync.ts 中未使用的 CloudSyncManager 导入和 GitHubAuthState 接口 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: 提取 notification port,消除 application 层对 components 的依赖 将 toast 通知抽象为 application/notification.ts 端口, UI 层通过 setNotify 注入实现,useAutoSync 改用 notify 接口。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
App.tsx
6
App.tsx
@@ -19,10 +19,11 @@ import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { applySyncPayload } from './application/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -103,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage?.getItem("debug.hotkeys") === "1";
|
||||
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
|
||||
|
||||
const LazySftpView = lazy(() =>
|
||||
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
|
||||
|
||||
38
application/notification.ts
Normal file
38
application/notification.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application-layer notification port.
|
||||
*
|
||||
* UI layers (e.g. toast) register their implementation via `setNotify`.
|
||||
* Application code calls `notify.*` without importing any UI module.
|
||||
*/
|
||||
|
||||
export interface NotifyOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
|
||||
|
||||
interface Notify {
|
||||
success: NotifyFn;
|
||||
error: NotifyFn;
|
||||
warning: NotifyFn;
|
||||
info: NotifyFn;
|
||||
}
|
||||
|
||||
const noop: NotifyFn = () => {};
|
||||
|
||||
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
|
||||
|
||||
/** Called once by the UI layer to wire up the real implementation. */
|
||||
export function setNotify(impl: Notify): void {
|
||||
_impl = impl;
|
||||
}
|
||||
|
||||
export const notify: Notify = {
|
||||
success: (...args) => _impl.success(...args),
|
||||
error: (...args) => _impl.error(...args),
|
||||
warning: (...args) => _impl.warning(...args),
|
||||
info: (...args) => _impl.info(...args),
|
||||
};
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { notify } from '../notification';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -189,7 +189,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw error;
|
||||
}
|
||||
console.error('[AutoSync] Sync failed:', error);
|
||||
toast.error(
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
@@ -231,7 +231,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -103,12 +102,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -472,60 +465,4 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook for just the security state (lighter weight)
|
||||
*/
|
||||
export const useSecurityState = () => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [securityState, setSecurityState] = useState<SecurityState>(
|
||||
() => manager.getSecurityState()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === 'SECURITY_STATE_CHANGED') {
|
||||
setSecurityState(event.state);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager]);
|
||||
|
||||
return {
|
||||
securityState,
|
||||
isUnlocked: securityState === 'UNLOCKED',
|
||||
isLocked: securityState === 'LOCKED',
|
||||
hasNoKey: securityState === 'NO_KEY',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for provider status indicators
|
||||
*/
|
||||
export const useProviderStatus = (provider: CloudProvider) => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [connection, setConnection] = useState<ProviderConnection>(
|
||||
() => manager.getProviderConnection(provider)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe(() => {
|
||||
setConnection(manager.getProviderConnection(provider));
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager, provider]);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
lastSyncFormatted: formatLastSync(connection.lastSync),
|
||||
};
|
||||
};
|
||||
|
||||
export default useCloudSync;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
export interface HotkeyActions {
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
|
||||
29
application/state/useStoredNumber.ts
Normal file
29
application/state/useStoredNumber.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
|
||||
* on every state change — call `persist()` explicitly when ready (e.g. on
|
||||
* mouseup after a drag). This avoids flooding localStorage during
|
||||
* high-frequency updates like resize drags.
|
||||
*/
|
||||
export const useStoredNumber = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return [value, setValue, persist] as const;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// arrives after 8s the duplicate check is avoided.
|
||||
const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
|
||||
|
||||
// Debug logging for update checks (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
@@ -44,6 +43,7 @@ export interface UseUpdateCheckResult {
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -653,5 +653,6 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
PortForwardingRule,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
@@ -611,7 +611,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
|
||||
@@ -68,9 +68,10 @@ interface SettingsApplicationTabProps {
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
@@ -94,10 +95,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
// Check if demo mode is enabled for development testing
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
|
||||
|
||||
@@ -149,7 +149,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
const isImmersive = settings.immersiveMode;
|
||||
@@ -260,6 +260,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
@@ -473,10 +475,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Side panel state - per-tab tracking of which sub-panel is active
|
||||
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
|
||||
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
|
||||
const stored = window.localStorage.getItem('netcatty_side_panel_width');
|
||||
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
|
||||
});
|
||||
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
|
||||
);
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -616,13 +617,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
sftpResizingRef.current = false;
|
||||
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
|
||||
persistSidePanelWidth(lastWidth);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [sidePanelWidth, sidePanelPosition]);
|
||||
}, [sidePanelWidth, sidePanelPosition, setSidePanelWidth, persistSidePanelWidth]);
|
||||
|
||||
// Pre-compute host lookup map for O(1) access
|
||||
const hostMap = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
@@ -17,13 +15,6 @@ import type {
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
|
||||
@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { setNotify } from '../../application/notification';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
@@ -96,6 +97,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
// Register global toast function
|
||||
useEffect(() => {
|
||||
globalShowToast = showToast;
|
||||
setNotify(toast);
|
||||
return () => {
|
||||
globalShowToast = null;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ const http = require("node:http");
|
||||
const { URL } = require("node:url");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
@@ -224,14 +223,7 @@ function killTrackedProcessTree(rootPid, childPids) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely send an IPC message to a renderer, guarding against destroyed senders.
|
||||
*/
|
||||
function safeSend(sender, channel, ...args) {
|
||||
if (sender && !sender.isDestroyed()) {
|
||||
sender.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
|
||||
@@ -551,6 +551,4 @@ function registerHandlers(ipcMain) {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
checkTarAvailable,
|
||||
checkRemoteTarAvailable,
|
||||
};
|
||||
|
||||
@@ -380,10 +380,5 @@ function cleanup() {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
stopWatchersForSession,
|
||||
listWatchers,
|
||||
registerTempFile,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
@@ -726,14 +726,6 @@ function cleanup() {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
registerGlobalHotkey,
|
||||
unregisterGlobalHotkey,
|
||||
setCloseToTray,
|
||||
isCloseToTrayEnabled,
|
||||
handleWindowClose,
|
||||
toggleWindowVisibility,
|
||||
getHotkeyStatus,
|
||||
setTrayMenuData,
|
||||
updateTrayMenu,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
20
electron/bridges/ipcUtils.cjs
Normal file
20
electron/bridges/ipcUtils.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared IPC utilities for bridge modules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely send an IPC message to a renderer, guarding against destroyed senders.
|
||||
* @param {Electron.WebContents} sender
|
||||
* @param {string} channel
|
||||
* @param {...unknown} args
|
||||
*/
|
||||
function safeSend(sender, channel, ...args) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, ...args);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown / HMR reload.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { safeSend };
|
||||
@@ -35,17 +35,7 @@ function isTunnelCancelled(tunnelState) {
|
||||
return Boolean(tunnelState?.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const net = require("node:net");
|
||||
const { TextDecoder } = require("node:util");
|
||||
const SftpClient = require("ssh2-sftp-client");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
@@ -412,17 +411,7 @@ function buildSftpAlgorithms(legacyEnabled) {
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Initialize the SFTP bridge with dependencies
|
||||
|
||||
@@ -565,17 +565,7 @@ function createKeyboardInteractiveHandler(options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
|
||||
@@ -361,14 +361,7 @@ function resolveLangFromCharset(charset) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
@@ -2281,16 +2274,4 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getServerStats,
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
};
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import type { RemoteFile, SftpFilenameEncoding } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare module "*.cjs" {
|
||||
const value: Record<string, unknown>;
|
||||
@@ -440,14 +440,6 @@ declare global {
|
||||
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
|
||||
|
||||
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
|
||||
cloudSyncSmbUpload?(
|
||||
config: SMBConfig,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<{ resourceId: string }>;
|
||||
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
|
||||
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
|
||||
|
||||
// Port Forwarding
|
||||
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import type {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
SessionCreateParams,
|
||||
PromptParams,
|
||||
SessionUpdateParams,
|
||||
PermissionRequestParams,
|
||||
AgentCapabilities,
|
||||
} from './protocol';
|
||||
import { ACP_METHODS } from './protocol';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
|
||||
type EventHandler<T = unknown> = (params: T) => void;
|
||||
|
||||
// ── Lightweight runtime type guards ──
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isPermissionRequestParams(v: unknown): v is PermissionRequestParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (!isRecord(v.toolCall)) return false;
|
||||
if (typeof v.toolCall.name !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSessionUpdateParams(v: unknown): v is SessionUpdateParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (typeof v.type !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isJsonRpcError(v: unknown): v is { code: number; message: string } {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.code !== 'number') return false;
|
||||
if (typeof v.message !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge interface to the Electron main process for agent management
|
||||
*/
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Client - manages a single external agent connection over JSON-RPC 2.0 / NDJSON stdio.
|
||||
*/
|
||||
export class ACPClient {
|
||||
private agentId: string;
|
||||
private config: ExternalAgentConfig;
|
||||
private bridge: AgentBridge;
|
||||
private nextId = 1;
|
||||
private pendingRequests = new Map<number | string, {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
private buffer = '';
|
||||
private cleanupFns: (() => void)[] = [];
|
||||
private agentCapabilities: AgentCapabilities | null = null;
|
||||
private _isConnected = false;
|
||||
|
||||
// Event handlers
|
||||
private onSessionUpdate: EventHandler<SessionUpdateParams> | null = null;
|
||||
private onPermissionRequest: EventHandler<PermissionRequestParams> | null = null;
|
||||
private onStderr: EventHandler<string> | null = null;
|
||||
private onExit: EventHandler<number> | null = null;
|
||||
|
||||
constructor(config: ExternalAgentConfig, bridge: AgentBridge) {
|
||||
this.agentId = `acp_${config.id}_${Date.now()}`;
|
||||
this.config = config;
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
get isConnected() { return this._isConnected; }
|
||||
get capabilities() { return this.agentCapabilities; }
|
||||
|
||||
/** Set event handlers */
|
||||
on(event: 'session_update', handler: EventHandler<SessionUpdateParams>): this;
|
||||
on(event: 'permission_request', handler: EventHandler<PermissionRequestParams>): this;
|
||||
on(event: 'stderr', handler: EventHandler<string>): this;
|
||||
on(event: 'exit', handler: EventHandler<number>): this;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on(event: string, handler: EventHandler<any>): this {
|
||||
switch (event) {
|
||||
case 'session_update': this.onSessionUpdate = handler as EventHandler<SessionUpdateParams>; break;
|
||||
case 'permission_request': this.onPermissionRequest = handler as EventHandler<PermissionRequestParams>; break;
|
||||
case 'stderr': this.onStderr = handler as EventHandler<string>; break;
|
||||
case 'exit': this.onExit = handler as EventHandler<number>; break;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Start the agent process and perform ACP initialization handshake */
|
||||
async connect(): Promise<InitializeResult> {
|
||||
// Spawn the agent process
|
||||
const result = await this.bridge.aiSpawnAgent(
|
||||
this.agentId,
|
||||
this.config.command,
|
||||
this.config.args,
|
||||
this.config.env,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to spawn agent: ${result.error}`);
|
||||
}
|
||||
|
||||
// Listen for stdout (NDJSON messages)
|
||||
const unsubStdout = this.bridge.onAiAgentStdout(this.agentId, (data) => {
|
||||
this.handleStdoutData(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStdout);
|
||||
|
||||
// Listen for stderr (logging)
|
||||
const unsubStderr = this.bridge.onAiAgentStderr(this.agentId, (data) => {
|
||||
this.onStderr?.(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStderr);
|
||||
|
||||
// Listen for exit
|
||||
const unsubExit = this.bridge.onAiAgentExit(this.agentId, (code) => {
|
||||
this._isConnected = false;
|
||||
this.onExit?.(code);
|
||||
// Reject all pending requests
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error(`Agent exited with code ${code}`));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
this.cleanupFns.push(unsubExit);
|
||||
|
||||
// Send initialize request
|
||||
const initParams: InitializeParams = {
|
||||
clientInfo: { name: 'netcatty', version: '1.0.0' },
|
||||
capabilities: {
|
||||
terminal: { create: true, output: true, waitForExit: true, kill: true },
|
||||
fileSystem: { read: true, write: true },
|
||||
permissions: { requestPermission: true },
|
||||
},
|
||||
};
|
||||
|
||||
const initResult = await this.sendRequest<InitializeResult>(ACP_METHODS.INITIALIZE, initParams);
|
||||
this.agentCapabilities = initResult.capabilities;
|
||||
this._isConnected = true;
|
||||
|
||||
return initResult;
|
||||
}
|
||||
|
||||
/** Create a new session */
|
||||
async createSession(params?: SessionCreateParams): Promise<{ sessionId: string }> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CREATE, params || {});
|
||||
}
|
||||
|
||||
/** Send a prompt to the agent */
|
||||
async prompt(params: PromptParams): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_PROMPT, params);
|
||||
}
|
||||
|
||||
/** Cancel the current operation */
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CANCEL, { sessionId });
|
||||
}
|
||||
|
||||
/** Respond to a permission request */
|
||||
respondPermission(requestId: number | string, approved: boolean): void {
|
||||
this.sendResponse(requestId, { approved });
|
||||
}
|
||||
|
||||
/** Respond to a terminal create request */
|
||||
respondTerminalCreate(requestId: number | string, terminalId: string): void {
|
||||
this.sendResponse(requestId, { terminalId });
|
||||
}
|
||||
|
||||
/** Respond to a file read request */
|
||||
respondFileRead(requestId: number | string, content: string): void {
|
||||
this.sendResponse(requestId, { content });
|
||||
}
|
||||
|
||||
/** Respond to a file write request */
|
||||
respondFileWrite(requestId: number | string, success: boolean): void {
|
||||
this.sendResponse(requestId, { success });
|
||||
}
|
||||
|
||||
/** Disconnect and kill the agent process */
|
||||
async disconnect(): Promise<void> {
|
||||
this._isConnected = false;
|
||||
for (const cleanup of this.cleanupFns) {
|
||||
try { cleanup(); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
this.cleanupFns = [];
|
||||
await this.bridge.aiKillAgent(this.agentId);
|
||||
// Reject all pending requests before clearing
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error('Agent disconnected'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
// ── Private methods ──
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async sendRequest<T = unknown>(method: string, params?: Record<string, any>): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
// Track timeout so we can clear it when the request resolves
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (result: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
(resolve as (result: unknown) => void)(result);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
const line = JSON.stringify(request) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.pendingRequests.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(id: number | string, result: unknown): void {
|
||||
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
console.error('[ACP] Failed to send response:', err);
|
||||
});
|
||||
}
|
||||
|
||||
private sendErrorResponse(id: number | string, code: number, message: string): void {
|
||||
const response: JsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message },
|
||||
};
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
/** Max NDJSON buffer size (10 MB) to prevent unbounded memory growth */
|
||||
private static readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
private handleStdoutData(data: string): void {
|
||||
this.buffer += data;
|
||||
|
||||
// Guard against unbounded buffer growth
|
||||
if (this.buffer.length > ACPClient.MAX_BUFFER_SIZE) {
|
||||
console.warn(`[ACP] NDJSON buffer exceeded ${ACPClient.MAX_BUFFER_SIZE} bytes, clearing buffer`);
|
||||
this.buffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(trimmed) as JsonRpcMessage;
|
||||
this.handleMessage(message);
|
||||
} catch {
|
||||
// Skip non-JSON lines (agent may print logs to stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: JsonRpcMessage): void {
|
||||
// Response to our request
|
||||
if ('id' in message && ('result' in message || 'error' in message)) {
|
||||
const response = message as JsonRpcResponse;
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id);
|
||||
if (response.error) {
|
||||
const errMsg = isJsonRpcError(response.error)
|
||||
? response.error.message
|
||||
: JSON.stringify(response.error);
|
||||
pending.reject(new Error(errMsg));
|
||||
} else {
|
||||
pending.resolve(response.result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Request from agent (needs our response)
|
||||
if ('id' in message && 'method' in message) {
|
||||
const request = message as JsonRpcRequest;
|
||||
this.handleAgentRequest(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notification from agent (no response needed)
|
||||
if ('method' in message && !('id' in message)) {
|
||||
const notification = message as JsonRpcNotification;
|
||||
this.handleAgentNotification(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentRequest(request: JsonRpcRequest): void {
|
||||
switch (request.method) {
|
||||
case ACP_METHODS.REQUEST_PERMISSION: {
|
||||
if (!isPermissionRequestParams(request.params)) {
|
||||
this.sendErrorResponse(request.id, -32602, 'Invalid permission request params');
|
||||
break;
|
||||
}
|
||||
if (this.onPermissionRequest) {
|
||||
this.onPermissionRequest({
|
||||
...request.params,
|
||||
// Attach the request ID so the handler can respond via respondPermission()
|
||||
_requestId: request.id,
|
||||
} as PermissionRequestParams & { _requestId: number | string });
|
||||
} else {
|
||||
this.sendErrorResponse(request.id, -32603, 'Permission request handler not configured');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ACP_METHODS.TERMINAL_CREATE:
|
||||
case ACP_METHODS.TERMINAL_WAIT_EXIT:
|
||||
case ACP_METHODS.TERMINAL_KILL:
|
||||
case ACP_METHODS.FS_READ:
|
||||
case ACP_METHODS.FS_WRITE:
|
||||
// Surface as tool_call so the UI layer can handle and respond
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(request.params?.sessionId || ''),
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: String(request.id),
|
||||
name: request.method,
|
||||
arguments: (request.params as Record<string, unknown>) || {},
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown method - respond with JSON-RPC method-not-found error
|
||||
this.sendErrorResponse(request.id, -32601, `Method not found: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentNotification(notification: JsonRpcNotification): void {
|
||||
switch (notification.method) {
|
||||
case ACP_METHODS.SESSION_UPDATE:
|
||||
if (isSessionUpdateParams(notification.params)) {
|
||||
this.onSessionUpdate?.(notification.params);
|
||||
}
|
||||
break;
|
||||
case ACP_METHODS.TERMINAL_OUTPUT:
|
||||
// Surface terminal output as a session update with tool_result type
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(notification.params?.sessionId || ''),
|
||||
type: 'tool_result',
|
||||
toolResult: {
|
||||
toolCallId: String(notification.params?.terminalId || ''),
|
||||
content: String(notification.params?.data || ''),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Ignore unknown notifications
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ACPClient } from './client';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
import type { SessionUpdateParams, PermissionRequestParams, InitializeResult } from './protocol';
|
||||
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface ACPManagerCallbacks {
|
||||
onSessionUpdate: (agentConfigId: string, params: SessionUpdateParams) => void;
|
||||
onPermissionRequest: (agentConfigId: string, params: PermissionRequestParams) => void;
|
||||
onAgentError: (agentConfigId: string, error: string) => void;
|
||||
onAgentExit: (agentConfigId: string, code: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages multiple ACP agent connections.
|
||||
*/
|
||||
export class ACPManager {
|
||||
private clients = new Map<string, ACPClient>();
|
||||
private bridge: AgentBridge;
|
||||
private callbacks: ACPManagerCallbacks;
|
||||
|
||||
constructor(bridge: AgentBridge, callbacks: ACPManagerCallbacks) {
|
||||
this.bridge = bridge;
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/** Connect to an external agent */
|
||||
async connect(config: ExternalAgentConfig): Promise<InitializeResult> {
|
||||
if (this.clients.has(config.id)) {
|
||||
await this.disconnect(config.id);
|
||||
}
|
||||
|
||||
const client = new ACPClient(config, this.bridge);
|
||||
|
||||
client
|
||||
.on('session_update', (params) => {
|
||||
this.callbacks.onSessionUpdate(config.id, params);
|
||||
})
|
||||
.on('permission_request', (params) => {
|
||||
this.callbacks.onPermissionRequest(config.id, params);
|
||||
})
|
||||
.on('stderr', (data) => {
|
||||
this.callbacks.onAgentError(config.id, data);
|
||||
})
|
||||
.on('exit', (code) => {
|
||||
this.clients.delete(config.id);
|
||||
this.callbacks.onAgentExit(config.id, code);
|
||||
});
|
||||
|
||||
const result = await client.connect();
|
||||
this.clients.set(config.id, client);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get a connected client */
|
||||
getClient(configId: string): ACPClient | undefined {
|
||||
return this.clients.get(configId);
|
||||
}
|
||||
|
||||
/** Check if an agent is connected */
|
||||
isConnected(configId: string): boolean {
|
||||
return this.clients.get(configId)?.isConnected ?? false;
|
||||
}
|
||||
|
||||
/** Disconnect a specific agent */
|
||||
async disconnect(configId: string): Promise<void> {
|
||||
const client = this.clients.get(configId);
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
this.clients.delete(configId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect all agents */
|
||||
async disconnectAll(): Promise<void> {
|
||||
const promises = Array.from(this.clients.keys()).map(id => this.disconnect(id));
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/** Get list of connected agent IDs */
|
||||
getConnectedAgentIds(): string[] {
|
||||
return Array.from(this.clients.entries())
|
||||
.filter(([, client]) => client.isConnected)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// JSON-RPC 2.0 base types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
||||
|
||||
// ACP-specific types
|
||||
|
||||
/** Capabilities that the client (Netcatty) declares it supports */
|
||||
export interface ClientCapabilities {
|
||||
fileSystem?: { read?: boolean; write?: boolean };
|
||||
terminal?: { create?: boolean; output?: boolean; waitForExit?: boolean; kill?: boolean };
|
||||
permissions?: { requestPermission?: boolean };
|
||||
}
|
||||
|
||||
/** Capabilities that the agent declares it supports */
|
||||
export interface AgentCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/** ACP initialize params */
|
||||
export interface InitializeParams {
|
||||
clientInfo: { name: string; version: string };
|
||||
capabilities: ClientCapabilities;
|
||||
}
|
||||
|
||||
/** ACP initialize result */
|
||||
export interface InitializeResult {
|
||||
agentInfo: { name: string; version: string };
|
||||
capabilities: AgentCapabilities;
|
||||
}
|
||||
|
||||
/** ACP session create params */
|
||||
export interface SessionCreateParams {
|
||||
sessionId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP prompt params - send a user message */
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP session update events (streamed as notifications) */
|
||||
export interface SessionUpdateParams {
|
||||
sessionId: string;
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done';
|
||||
content?: string;
|
||||
toolCall?: { id: string; name: string; arguments: Record<string, unknown> };
|
||||
toolResult?: { toolCallId: string; content: string; isError?: boolean };
|
||||
}
|
||||
|
||||
/** ACP permission request */
|
||||
export interface PermissionRequestParams {
|
||||
sessionId: string;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> };
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ACP method names
|
||||
export const ACP_METHODS = {
|
||||
INITIALIZE: 'initialize',
|
||||
SESSION_CREATE: 'session/create',
|
||||
SESSION_PROMPT: 'session/prompt',
|
||||
SESSION_CANCEL: 'session/cancel',
|
||||
SESSION_UPDATE: 'session/update', // notification from agent
|
||||
REQUEST_PERMISSION: 'session/request_permission', // request from agent
|
||||
TERMINAL_CREATE: 'terminal/create', // request from agent
|
||||
TERMINAL_OUTPUT: 'terminal/output', // notification from agent
|
||||
TERMINAL_WAIT_EXIT: 'terminal/waitForExit', // request from agent
|
||||
TERMINAL_KILL: 'terminal/kill', // request from agent
|
||||
FS_READ: 'fs/readTextFile', // request from agent
|
||||
FS_WRITE: 'fs/writeTextFile', // request from agent
|
||||
} as const;
|
||||
@@ -32,7 +32,7 @@ interface AcpBridge {
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
images?: FileAttachment[],
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
@@ -57,9 +57,6 @@ export interface FileAttachment {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use FileAttachment instead */
|
||||
export type ImageAttachment = FileAttachment;
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
@@ -72,7 +69,7 @@ export async function runAcpAgentTurn(
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
images?: FileAttachment[],
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
|
||||
|
||||
@@ -30,9 +30,6 @@ export interface ChatMessageAttachment {
|
||||
filePath?: string; // original filesystem path (for ACP agents to read directly)
|
||||
}
|
||||
|
||||
/** @deprecated Use ChatMessageAttachment instead */
|
||||
export type ChatMessageImage = ChatMessageAttachment;
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SUPPORTED_UI_LOCALES: LocaleOption[] = [
|
||||
{ id: 'zh-CN', label: '简体中文' },
|
||||
];
|
||||
|
||||
export const isSupportedLocale = (locale: string): boolean => {
|
||||
const isSupportedLocale = (locale: string): boolean => {
|
||||
return SUPPORTED_UI_LOCALES.some((l) => l.id === locale);
|
||||
};
|
||||
|
||||
|
||||
@@ -93,3 +93,13 @@ export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
|
||||
// Immersive Mode
|
||||
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
|
||||
|
||||
// Side Panel
|
||||
export const STORAGE_KEY_SIDE_PANEL_WIDTH = 'netcatty_side_panel_width';
|
||||
|
||||
// Port Forwarding (transient cross-window broadcast key)
|
||||
export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
|
||||
|
||||
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
|
||||
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
|
||||
export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import packageJson from '../../package.json';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import { localStorageAdapter } from '../persistence/localStorageAdapter';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
|
||||
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
|
||||
@@ -239,31 +240,15 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
private loadFromStorage<T>(key: string): T | null {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return localStorageAdapter.read<T>(key);
|
||||
}
|
||||
|
||||
private saveToStorage(key: string, value: unknown): void {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error('Failed to save to storage:', e);
|
||||
}
|
||||
localStorageAdapter.write(key, value);
|
||||
}
|
||||
|
||||
private removeFromStorage(key: string): void {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore storage removal failures
|
||||
}
|
||||
localStorageAdapter.remove(key);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models'
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { localStorageAdapter } from '../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_PF_RECONNECT_CANCEL } from '../config/storageKeys';
|
||||
import { netcattyBridge } from './netcattyBridge';
|
||||
|
||||
export interface PortForwardingConnection {
|
||||
@@ -57,14 +59,12 @@ export const clearReconnectTimer = (ruleId: string): void => {
|
||||
// Cross-window reconnect cancellation via localStorage broadcast.
|
||||
// When one window deletes/replaces a rule, it writes to this key so
|
||||
// other windows (with pending reconnect timers) can cancel them.
|
||||
const RECONNECT_CANCEL_KEY = '__netcatty_pf_cancel_reconnect';
|
||||
|
||||
const broadcastReconnectCancel = (ruleId: string): void => {
|
||||
try {
|
||||
// Write then immediately remove so the storage event fires on
|
||||
// other windows without leaving stale data.
|
||||
window.localStorage.setItem(RECONNECT_CANCEL_KEY, ruleId);
|
||||
window.localStorage.removeItem(RECONNECT_CANCEL_KEY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_PF_RECONNECT_CANCEL, ruleId);
|
||||
localStorageAdapter.remove(STORAGE_KEY_PF_RECONNECT_CANCEL);
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
@@ -77,7 +77,7 @@ const broadcastReconnectCancel = (ruleId: string): void => {
|
||||
*/
|
||||
export const initReconnectCancelListener = (): (() => void) => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== RECONNECT_CANCEL_KEY || !e.newValue) return;
|
||||
if (e.key !== STORAGE_KEY_PF_RECONNECT_CANCEL || !e.newValue) return;
|
||||
const ruleId = e.newValue;
|
||||
clearReconnectTimer(ruleId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user