Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df11beff8c | ||
|
|
c14da33e5b | ||
|
|
f1ce541885 | ||
|
|
07e003fe43 | ||
|
|
81f53c9a7f | ||
|
|
2d8cea2e7d | ||
|
|
b724cfc775 | ||
|
|
10ff2cc092 | ||
|
|
4124c03b80 | ||
|
|
56a3994a52 | ||
|
|
e1e730e439 | ||
|
|
bb17647954 | ||
|
|
56a0baebeb | ||
|
|
d2a6c67e4e | ||
|
|
56f70d015d | ||
|
|
cf9f84767c | ||
|
|
3a862cbd0c | ||
|
|
6af2a99680 | ||
|
|
b3d37d134a | ||
|
|
a9e561ee51 | ||
|
|
e808b1709e | ||
|
|
d75b58e4d8 | ||
|
|
e2430cdcab | ||
|
|
8e6ac8de10 | ||
|
|
5495877e5a | ||
|
|
5078b3776e | ||
|
|
f5d6b8b4d8 | ||
|
|
1c560dbc16 | ||
|
|
4b8b0ed74c | ||
|
|
308d825db7 | ||
|
|
af074c5704 | ||
|
|
c60afdd8fe | ||
|
|
a1d05ca5b3 | ||
|
|
327ca3806a | ||
|
|
2f71dd3927 | ||
|
|
3844edd49f | ||
|
|
8f97a7e81d | ||
|
|
5daf1f0d6f | ||
|
|
b1a5b92ce4 | ||
|
|
c99a70831a | ||
|
|
4b0468b0d2 | ||
|
|
f32078f270 | ||
|
|
a525c073b9 | ||
|
|
afceb92a55 | ||
|
|
4822894efb | ||
|
|
d9b51c3a50 | ||
|
|
15b1dba558 | ||
|
|
fd6b3930c1 | ||
|
|
53cb160a6e | ||
|
|
bb590f140d | ||
|
|
945992b80e |
@@ -1687,6 +1687,17 @@ const en: Messages = {
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
|
||||
@@ -1694,6 +1694,17 @@ const zhCN: Messages = {
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_IMMERSIVE_MODE,
|
||||
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -340,20 +340,11 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (stored === null || stored === '') {
|
||||
// Persist default so collectSyncableSettings() can include it
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
|
||||
return true;
|
||||
}
|
||||
return stored === 'true';
|
||||
});
|
||||
const setImmersiveMode = useCallback((enabled: boolean) => {
|
||||
setImmersiveModeState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
// Immersive mode is always enabled — the toggle has been removed from settings
|
||||
const immersiveMode = true;
|
||||
const setImmersiveMode = useCallback((_enabled: boolean) => {
|
||||
// no-op: immersive mode is always on
|
||||
}, []);
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
@@ -465,14 +456,6 @@ export const useSettingsState = () => {
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
|
||||
// Immersive mode
|
||||
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
|
||||
if (storedImmersive === 'true' || storedImmersive === 'false') {
|
||||
const val = storedImmersive === 'true';
|
||||
setImmersiveModeState(val);
|
||||
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
|
||||
}
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
|
||||
@@ -625,9 +608,6 @@ export const useSettingsState = () => {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
|
||||
setImmersiveModeState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -849,13 +829,6 @@ export const useSettingsState = () => {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync immersive mode from other windows
|
||||
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.immersiveMode) {
|
||||
setImmersiveModeState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AgentModelPreset,
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
@@ -425,15 +440,62 @@ 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(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
if (!isCopilotExternalAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
const knownModelIds = new Set(result.models.map((model) => model.id));
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: result.models ?? [],
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
@@ -593,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
|
||||
@@ -1263,18 +1263,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-8 w-28">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1286,6 +1288,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
@@ -1294,113 +1401,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -44,6 +44,8 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -500,6 +502,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isVisible,
|
||||
});
|
||||
|
||||
const zmodem = useZmodemTransfer(sessionId);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (zmodem.active) {
|
||||
zmodemToastedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (zmodemToastedRef.current) return;
|
||||
if (zmodem.error) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.error(zmodem.error, 'ZMODEM');
|
||||
} else if (zmodem.filename) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.success(
|
||||
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
|
||||
'ZMODEM',
|
||||
);
|
||||
}
|
||||
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -1092,6 +1115,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => clearTimeout(timer);
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
// When search bar opens/closes, re-fit terminal and maintain scroll position
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !fitAddonRef.current) return;
|
||||
const buffer = term.buffer.active;
|
||||
const wasAtBottom = buffer.viewportY >= buffer.baseY;
|
||||
const prevViewportY = buffer.viewportY;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
requestAnimationFrame(() => {
|
||||
if (wasAtBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
term.scrollToLine(prevViewportY);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
|
||||
if (shouldAutoFocus) {
|
||||
@@ -1549,7 +1592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
@@ -1963,6 +2006,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
@@ -2047,6 +2091,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ZMODEM transfer progress indicator */}
|
||||
{zmodem.active && (
|
||||
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
|
||||
<ZmodemProgressIndicator
|
||||
transferType={zmodem.transferType}
|
||||
filename={zmodem.filename}
|
||||
transferred={zmodem.transferred}
|
||||
total={zmodem.total}
|
||||
fileIndex={zmodem.fileIndex}
|
||||
fileCount={zmodem.fileCount}
|
||||
finalizing={zmodem.finalizing}
|
||||
onCancel={zmodem.cancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -1460,9 +1460,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
|
||||
|
||||
useEffect(() => {
|
||||
const panelOpen = activeSidePanelTab === 'theme' && !!previewTargetSessionId;
|
||||
const shouldKeepPreview =
|
||||
activeSidePanelTab === 'theme' &&
|
||||
!!previewTargetSessionId &&
|
||||
panelOpen &&
|
||||
!!themePreview.targetSessionId &&
|
||||
!!themePreview.themeId;
|
||||
|
||||
@@ -1473,8 +1473,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
clearTerminalPreviewVars(appliedSessionId);
|
||||
appliedPreviewSessionRef.current = null;
|
||||
}
|
||||
clearTopTabsPreviewVars();
|
||||
|
||||
if (themePreview.targetSessionId || themePreview.themeId) {
|
||||
setThemePreview({ targetSessionId: null, themeId: null });
|
||||
}
|
||||
|
||||
124
components/ThemeList.tsx
Normal file
124
components/ThemeList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
|
||||
// Memoized theme item component
|
||||
export const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalTheme;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeListProps {
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
import React from 'react';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
} from './ui/aside-panel';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ThemeList } from './ThemeList';
|
||||
|
||||
interface ThemeSelectPanelProps {
|
||||
open: boolean;
|
||||
@@ -18,40 +15,6 @@ interface ThemeSelectPanelProps {
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
// Mini terminal preview component
|
||||
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
|
||||
theme,
|
||||
isSelected
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
|
||||
isSelected ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span style={{ color: theme.colors.cyan }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-wrap">
|
||||
<span style={{ color: theme.colors.blue }}>dir/</span>
|
||||
<span style={{ color: theme.colors.green }}>file</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span
|
||||
className="inline-block w-1 h-1.5"
|
||||
style={{ backgroundColor: theme.colors.cursor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
open,
|
||||
selectedThemeId,
|
||||
@@ -60,51 +23,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
}) => {
|
||||
// Reserved for future hover preview feature
|
||||
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// All themes combined
|
||||
const allThemes = useMemo(() => {
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-secondary/50"
|
||||
)}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onMouseEnter={() => setHoveredThemeId(theme.id)}
|
||||
onMouseLeave={() => setHoveredThemeId(null)}
|
||||
>
|
||||
<TerminalPreview theme={theme} isSelected={isSelected} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isSelected && "text-primary"
|
||||
)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<div className="text-xs text-muted-foreground">Light mode</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={open}
|
||||
@@ -116,8 +34,10 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
<AsidePanelContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="py-2">
|
||||
{/* All themes in a single list */}
|
||||
{allThemes.map(renderThemeItem)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId || ''}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanelContent>
|
||||
|
||||
@@ -522,7 +522,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{activeTabId === session.id && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'hsl(var(--primary))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
@@ -621,7 +621,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'hsl(var(--primary))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
|
||||
@@ -11,6 +11,7 @@ type AgentLike = {
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'copilot'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
@@ -20,7 +21,7 @@ type AgentIconKey =
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
copilot: {
|
||||
src: '/ai/agents/copilot.svg',
|
||||
badgeClassName: 'border-zinc-300 bg-white',
|
||||
imageClassName: 'object-contain brightness-0',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('copilot'))) {
|
||||
return 'copilot';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const iconKey = getAgentIconKey(agent);
|
||||
const visual = AGENT_ICON_VISUALS[iconKey];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
(da) => {
|
||||
if (isSettingsManagedDiscoveredAgent(da)) {
|
||||
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
||||
}
|
||||
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
||||
},
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
{/* Pending tool calls from the *last* assistant message are rendered
|
||||
after all tool-result messages (see below) for chronological order.
|
||||
Unresolved tool calls from earlier or cancelled messages are shown
|
||||
inline — as interrupted, or with approval controls if still pending. */}
|
||||
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pending tool calls from the last assistant message — rendered here
|
||||
(after all tool-result messages) so they appear at the bottom. */}
|
||||
{lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
|
||||
@@ -112,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpListModels?: (
|
||||
acpCommand: string,
|
||||
acpArgs?: string[],
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
* Host Chain Sub-Panel
|
||||
* Panel for configuring SSH jump host chain
|
||||
*/
|
||||
import { ArrowDown,Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ArrowDown,Plus,Search,X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { AsidePanel } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface ChainPanelProps {
|
||||
@@ -38,6 +39,14 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return availableHostsForChain;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return availableHostsForChain.filter(
|
||||
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableHostsForChain, searchQuery]);
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
@@ -52,16 +61,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
|
||||
</p>
|
||||
<Button className="w-full h-10" onClick={() => { }}>
|
||||
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 space-y-4 w-0 min-w-full">
|
||||
{/* Chain visualization */}
|
||||
<div className="space-y-2">
|
||||
{chainedHosts.map((host, index) => (
|
||||
@@ -73,7 +73,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -110,11 +110,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
{availableHostsForChain.length > 0 && (
|
||||
<Card className="p-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{availableHostsForChain.map((host) => (
|
||||
{filteredHosts.map((host) => (
|
||||
<button
|
||||
key={host.id}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
|
||||
onClick={() => onAddHost(host.id)}
|
||||
>
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
|
||||
@@ -3,55 +3,12 @@
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../../application/state/customThemeStore';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
import { ThemeList } from '../ThemeList';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
@@ -16,8 +16,12 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
} from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
|
||||
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
|
||||
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: storedPath ? prevAgents : otherAgents,
|
||||
defaultAgentId: storedPath
|
||||
? defaultAgentId
|
||||
: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = {
|
||||
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
|
||||
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
|
||||
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
defaultAgentIdRef.current = defaultAgentId;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
const setInfo = agentKey === "codex"
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
? setClaudePathInfo
|
||||
: setCopilotPathInfo;
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
? setIsResolvingClaude
|
||||
: setIsResolvingCopilot;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
// Consolidate managed agent entries using the callback form of
|
||||
// setExternalAgents so we never depend on externalAgents directly.
|
||||
// All three agents resolve concurrently on mount — React runs
|
||||
// state updater callbacks sequentially, so updating the ref inside
|
||||
// ensures later calls see earlier defaultAgentId changes.
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
return null;
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
}, [setExternalAgents, setDefaultAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
}, [resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
const customPath = agentKey === "codex"
|
||||
? codexCustomPath
|
||||
: agentKey === "claude"
|
||||
? claudeCustomPath
|
||||
: copilotCustomPath;
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
isResolvingPath={isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
isResolvingPath={isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- GitHub Copilot CLI Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
|
||||
</div>
|
||||
|
||||
<CopilotCliCard
|
||||
pathInfo={copilotPathInfo}
|
||||
isResolvingPath={isResolvingCopilot}
|
||||
customPath={copilotCustomPath}
|
||||
onCustomPathChange={setCopilotCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("copilot")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
@@ -258,19 +258,6 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.immersiveMode")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.immersiveMode")}
|
||||
description={t("settings.appearance.immersiveMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={!!isImmersive}
|
||||
onChange={() => onToggleImmersive?.()}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.copilot.detecting')
|
||||
: found
|
||||
? t('ai.copilot.detected')
|
||||
: t('ai.copilot.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.copilot.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.copilot.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.copilot.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.copilot.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { CopilotCliCard } from "./CopilotCliCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
|
||||
@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
export type SettingsIconId = AIProviderId | "claude" | "copilot";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
copilot: "/ai/agents/copilot.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
@@ -124,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
copilot: "border border-zinc-300 bg-white",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
|
||||
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
79
components/terminal/ZmodemProgressIndicator.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ZmodemProgressIndicatorProps {
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
|
||||
transferType,
|
||||
filename,
|
||||
transferred,
|
||||
total,
|
||||
fileIndex,
|
||||
fileCount,
|
||||
finalizing,
|
||||
onCancel,
|
||||
}) => {
|
||||
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
|
||||
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
|
||||
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
|
||||
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{filename || label}{fileInfo}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-50 mt-0.5">
|
||||
{formatBytes(transferred)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
|
||||
title="Cancel transfer (Ctrl+C)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
|
||||
onRequestReposition?: () => void;
|
||||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||||
searchBarOffset?: number;
|
||||
/** Called when user clicks outside the popup to dismiss it */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||||
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
containerRef,
|
||||
onRequestReposition,
|
||||
searchBarOffset: _searchBarOffset = 30,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
};
|
||||
}, [containerRef, onRequestReposition, visible]);
|
||||
|
||||
// Dismiss popup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!visible || !onDismiss) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
const bg = themeColors?.background ?? "#1e1e2e";
|
||||
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${clampedLeft}px`,
|
||||
|
||||
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
102
components/terminal/hooks/useZmodemTransfer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface ZmodemTransferState {
|
||||
active: boolean;
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ZmodemTransferState = {
|
||||
active: false,
|
||||
transferType: null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
finalizing: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onZmodemEvent) return;
|
||||
|
||||
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
|
||||
switch (event.type) {
|
||||
case 'detect':
|
||||
setState({
|
||||
active: true,
|
||||
transferType: event.transferType ?? null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
error: null,
|
||||
});
|
||||
break;
|
||||
case 'progress':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: true,
|
||||
transferType: event.transferType ?? prev.transferType,
|
||||
filename: event.filename ?? prev.filename,
|
||||
transferred: event.transferred ?? prev.transferred,
|
||||
total: event.total ?? prev.total,
|
||||
fileIndex: event.fileIndex ?? prev.fileIndex,
|
||||
fileCount: event.fileCount ?? prev.fileCount,
|
||||
finalizing: !!((event as Record<string, unknown>).finalizing),
|
||||
}));
|
||||
break;
|
||||
case 'complete':
|
||||
setState((prev) => ({ ...prev, active: false }));
|
||||
break;
|
||||
case 'error':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
active: false,
|
||||
error: event.error ?? 'Unknown error',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
setState(initialState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
}
|
||||
@@ -172,7 +172,7 @@ const attachSessionToTerminal = (
|
||||
term: XTerm,
|
||||
id: string,
|
||||
opts?: {
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
|
||||
onExitMessage?: (evt: { exitCode?: number; signal?: number; error?: string; reason?: string }) => string;
|
||||
onConnected?: () => void;
|
||||
// For serial: convert lone LF to CRLF to avoid "staircase effect"
|
||||
convertLfToCrlf?: boolean;
|
||||
@@ -209,6 +209,9 @@ const attachSessionToTerminal = (
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.updateStatus("disconnected");
|
||||
if (evt.error) {
|
||||
ctx.setError(evt.error);
|
||||
}
|
||||
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { existsSync, statSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// ── ANSI / URL regexes ──
|
||||
@@ -93,7 +93,11 @@ function normalizeCliPathForPlatform(filePath) {
|
||||
if (!normalized) return null;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return existsSync(normalized) ? normalized : null;
|
||||
// Reject directories (e.g. /Applications/Codex.app) — must be a file
|
||||
try {
|
||||
if (existsSync(normalized) && statSync(normalized).isFile()) return normalized;
|
||||
} catch { /* stat failed */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
|
||||
const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const fs = require("node:fs");
|
||||
const { existsSync } = fs;
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
@@ -60,7 +62,6 @@ const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpPendingCancelRequests = new Set();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
|
||||
}
|
||||
|
||||
function cleanupAcpProvider(chatSessionId) {
|
||||
// Clean up temporary COPILOT_HOME directory regardless of whether a
|
||||
// provider entry exists — prepareCopilotHome may have succeeded before
|
||||
// provider creation failed.
|
||||
try {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
|
||||
if (existsSync(tempCopilotHome)) {
|
||||
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
const entry = acpProviders.get(chatSessionId);
|
||||
if (!entry) return;
|
||||
const rootPid = entry.provider?.model?.agentProcess?.pid;
|
||||
cleanupAcpProviderInstance(entry.provider, chatSessionId);
|
||||
acpProviders.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
|
||||
if (!provider) return;
|
||||
const rootPid = provider?.model?.agentProcess?.pid;
|
||||
const childPids = getChildProcessTreePids(rootPid);
|
||||
try {
|
||||
if (typeof entry.provider.forceCleanup === "function") {
|
||||
entry.provider.forceCleanup();
|
||||
} else if (typeof entry.provider.cleanup === "function") {
|
||||
entry.provider.cleanup();
|
||||
if (typeof provider.forceCleanup === "function") {
|
||||
provider.forceCleanup();
|
||||
} else if (typeof provider.cleanup === "function") {
|
||||
provider.cleanup();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
|
||||
}
|
||||
killTrackedProcessTree(rootPid, childPids);
|
||||
acpProviders.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function isActiveAcpRun(chatSessionId, requestId) {
|
||||
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
|
||||
return Boolean(activeRun && activeRun.requestId === requestId);
|
||||
}
|
||||
|
||||
function isUnsupportedLoadSessionError(err) {
|
||||
function shouldRetryFreshSession(err) {
|
||||
const message = String(err?.message || err || "").toLowerCase();
|
||||
return message.includes("method not found") && message.includes("session/load");
|
||||
return (message.includes("method not found") && message.includes("session/load"))
|
||||
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
|
||||
}
|
||||
|
||||
function getChildProcessTreePids(rootPid) {
|
||||
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMcpServersForDebug(mcpServers) {
|
||||
if (!Array.isArray(mcpServers)) return [];
|
||||
return mcpServers.map((server) => ({
|
||||
name: server?.name || "",
|
||||
type: server?.type || "",
|
||||
command: server?.command || "",
|
||||
args: Array.isArray(server?.args) ? server.args : [],
|
||||
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
|
||||
url: server?.url || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function logAcpDebug(agentLabel, message, details) {
|
||||
const prefix = `[ACP DEBUG][${agentLabel}]`;
|
||||
if (details === undefined) {
|
||||
console.log(prefix, message);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(prefix, message, JSON.stringify(details));
|
||||
} catch {
|
||||
console.log(prefix, message, details);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgentCommandName(command) {
|
||||
if (typeof command !== "string" || !command) return "";
|
||||
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
|
||||
}
|
||||
|
||||
function matchesAgentCommand(command, expectedName) {
|
||||
if (typeof command !== "string" || typeof expectedName !== "string") return false;
|
||||
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
|
||||
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
|
||||
}
|
||||
|
||||
function envPairsToObject(entries) {
|
||||
if (!Array.isArray(entries)) return {};
|
||||
const result = {};
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry.name !== "string") continue;
|
||||
result[entry.name] = entry.value == null ? "" : String(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapMcpServerToCopilotConfig(server) {
|
||||
if (!server || typeof server !== "object" || !server.name) return null;
|
||||
|
||||
if (server.type === "stdio" || server.type === "local") {
|
||||
return {
|
||||
type: "local",
|
||||
command: server.command || "",
|
||||
args: Array.isArray(server.args) ? server.args : [],
|
||||
env: envPairsToObject(server.env),
|
||||
tools: ["*"],
|
||||
};
|
||||
}
|
||||
|
||||
if (server.type === "http" || server.type === "sse") {
|
||||
return {
|
||||
type: server.type,
|
||||
url: server.url || "",
|
||||
headers: envPairsToObject(server.headers),
|
||||
tools: ["*"],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
|
||||
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
|
||||
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
|
||||
|
||||
try {
|
||||
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
|
||||
}
|
||||
|
||||
fs.mkdirSync(tempCopilotHome, { recursive: true });
|
||||
|
||||
if (realCopilotHome && existsSync(realCopilotHome)) {
|
||||
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
|
||||
}
|
||||
|
||||
const configPath = path.join(tempCopilotHome, "mcp-config.json");
|
||||
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
|
||||
const mergedServers = { ...(baseConfig.mcpServers || {}) };
|
||||
|
||||
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
|
||||
const mapped = mapMcpServerToCopilotConfig(server);
|
||||
if (!mapped) continue;
|
||||
mergedServers[server.name] = mapped;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
return {
|
||||
copilotHome: tempCopilotHome,
|
||||
configPath,
|
||||
serverNames: Object.keys(mergedServers),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a streaming HTTP request and forward SSE events back to renderer
|
||||
*/
|
||||
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
resolveAcp: resolveCodexAcpBinaryPath,
|
||||
},
|
||||
{
|
||||
command: "copilot",
|
||||
name: "GitHub Copilot CLI",
|
||||
icon: "copilot",
|
||||
description: "GitHub's coding agent CLI",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
args: ["-p", "{prompt}"],
|
||||
},
|
||||
];
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
@@ -1303,12 +1453,16 @@ function registerHandlers(ipcMain) {
|
||||
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
// --version failed: not a valid CLI executable (e.g. .app bundle)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!version) continue;
|
||||
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agentInfo,
|
||||
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
|
||||
path: resolvedPath,
|
||||
version,
|
||||
available: true,
|
||||
@@ -1327,7 +1481,9 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
if (customPath) {
|
||||
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath);
|
||||
// Fall back to PATH search if the stored path no longer exists
|
||||
// (e.g. CLI reinstalled to a different location).
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
|
||||
} else {
|
||||
resolvedPath = resolveCliFromPath(command, shellEnv);
|
||||
}
|
||||
@@ -1341,7 +1497,12 @@ function registerHandlers(ipcMain) {
|
||||
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
|
||||
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
|
||||
} catch {
|
||||
version = "";
|
||||
// --version failed: not a valid CLI executable
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return { path: resolvedPath, version: null, available: false };
|
||||
}
|
||||
|
||||
return { path: resolvedPath, version, available: true };
|
||||
@@ -1521,6 +1682,7 @@ function registerHandlers(ipcMain) {
|
||||
const ALLOWED_AGENT_COMMANDS = new Set([
|
||||
"claude", "claude-agent-acp",
|
||||
"codex", "codex-acp",
|
||||
"copilot",
|
||||
]);
|
||||
|
||||
// Spawn an external agent process
|
||||
@@ -1730,6 +1892,102 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── ACP (Agent Client Protocol) streaming ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
let provider = null;
|
||||
let copilotConfigInfo = null;
|
||||
try {
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const shellEnv = await getShellEnv();
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
|
||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
const agentEnv = { ...shellEnv };
|
||||
if (apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
|
||||
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
|
||||
provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
args: resolvedArgs,
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const sessionInfo = await provider.initSession();
|
||||
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
|
||||
? sessionInfo.models.availableModels
|
||||
: [];
|
||||
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Fetched session models", {
|
||||
chatSessionId: chatSessionId || null,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
|
||||
copilotHome: copilotConfigInfo?.copilotHome || null,
|
||||
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
models: availableModels.map((modelInfo) => ({
|
||||
id: modelInfo?.modelId,
|
||||
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
|
||||
description: modelInfo?.description || undefined,
|
||||
})).filter((modelInfo) => Boolean(modelInfo.id)),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to list models:", err?.message || err);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
try {
|
||||
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
|
||||
} catch {
|
||||
// Ignore cleanup failures for transient model-discovery providers.
|
||||
}
|
||||
// Clean up transient COPILOT_HOME created for model listing
|
||||
if (copilotConfigInfo?.copilotHome) {
|
||||
try {
|
||||
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
@@ -1771,8 +2029,10 @@ function registerHandlers(ipcMain) {
|
||||
const shellEnv = await getShellEnv();
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
|
||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||
|
||||
// Resolve API key from providerId (decrypted in main process only)
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
@@ -1811,6 +2071,13 @@ function registerHandlers(ipcMain) {
|
||||
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
||||
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
|
||||
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
|
||||
chatSessionId,
|
||||
scopedIds,
|
||||
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||
}
|
||||
@@ -1821,9 +2088,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
!shouldForceProviderReset &&
|
||||
providerEntry &&
|
||||
providerEntry.acpCommand === acpCommand &&
|
||||
providerEntry.cwd === sessionCwd &&
|
||||
@@ -1840,6 +2105,11 @@ function registerHandlers(ipcMain) {
|
||||
if (apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
let copilotConfigInfo = null;
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
@@ -1850,6 +2120,7 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
|
||||
|
||||
const provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
@@ -1857,15 +2128,31 @@ function registerHandlers(ipcMain) {
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
mcpServers: sessionMcpServers,
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
persistSession: true,
|
||||
});
|
||||
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Creating ACP provider", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
cwd: sessionCwd,
|
||||
resolvedCommand,
|
||||
resolvedArgs,
|
||||
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
|
||||
copilotHome: copilotConfigInfo?.copilotHome || null,
|
||||
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
|
||||
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
|
||||
});
|
||||
}
|
||||
|
||||
providerEntry = {
|
||||
provider,
|
||||
acpCommand,
|
||||
@@ -1877,15 +2164,21 @@ function registerHandlers(ipcMain) {
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "ACP session initialized", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
toolNames: Object.keys(providerEntry.provider.tools || {}),
|
||||
});
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1901,13 +2194,22 @@ function registerHandlers(ipcMain) {
|
||||
args: fallbackClaudeAcp
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
env: (() => {
|
||||
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
|
||||
if (isCopilotAgent) {
|
||||
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
||||
}
|
||||
return fallbackEnv;
|
||||
})(),
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
persistSession: true,
|
||||
});
|
||||
@@ -1924,6 +2226,14 @@ function registerHandlers(ipcMain) {
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
toolNames: Object.keys(providerEntry.provider.tools || {}),
|
||||
});
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
@@ -2042,6 +2352,9 @@ function registerHandlers(ipcMain) {
|
||||
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
|
||||
hasContent = true;
|
||||
}
|
||||
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
|
||||
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:acp:event", {
|
||||
requestId,
|
||||
event: serialized,
|
||||
@@ -2057,6 +2370,13 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// If stream completed with zero content, likely an auth or connection issue
|
||||
if (!hasContent && !abortController.signal.aborted) {
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Stream completed with no content", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
});
|
||||
}
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -2095,9 +2415,6 @@ function registerHandlers(ipcMain) {
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
acpChatRuns.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
@@ -2127,10 +2444,6 @@ function registerHandlers(ipcMain) {
|
||||
acpPendingCancelRequests.add(effectiveRequestId);
|
||||
cancelled = true;
|
||||
}
|
||||
if (effectiveChatSessionId) {
|
||||
acpForceProviderReset.add(effectiveChatSessionId);
|
||||
cleanupAcpProvider(effectiveChatSessionId);
|
||||
}
|
||||
// Preserve the ACP provider session on stop so the next user message can
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
@@ -2143,7 +2456,6 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
return { ok: true };
|
||||
|
||||
@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
return;
|
||||
}
|
||||
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
|
||||
// Wrong token or wrong method — reject and close
|
||||
const response = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@@ -629,6 +630,22 @@ function handleExec(params) {
|
||||
|
||||
// ── MCP Server Config Builder ──
|
||||
|
||||
function resolveMcpServerRuntimeCommand() {
|
||||
const runtimeCommand = process.execPath;
|
||||
const runtimeEnv = [];
|
||||
|
||||
if (runtimeCommand && existsSync(runtimeCommand)) {
|
||||
const basename = path.basename(runtimeCommand).toLowerCase();
|
||||
const isNodeBinary = basename === "node" || basename.startsWith("node.");
|
||||
if (!isNodeBinary) {
|
||||
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
|
||||
}
|
||||
return { command: runtimeCommand, env: runtimeEnv };
|
||||
}
|
||||
|
||||
return { command: "node", env: runtimeEnv };
|
||||
}
|
||||
|
||||
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
|
||||
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
|
||||
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
const runtimePath = toUnpackedAsarPath(
|
||||
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
|
||||
);
|
||||
const runtime = resolveMcpServerRuntimeCommand();
|
||||
|
||||
const env = [
|
||||
...runtime.env,
|
||||
{ name: "NETCATTY_MCP_PORT", value: String(port) },
|
||||
];
|
||||
|
||||
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
return {
|
||||
name: "netcatty-remote-hosts",
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
command: runtime.command,
|
||||
args: [runtimePath],
|
||||
env,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -410,9 +411,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// 0 = disabled (no keepalive packets sent)
|
||||
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
|
||||
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
@@ -680,9 +681,9 @@ async function startSSHSession(event, options) {
|
||||
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
|
||||
readyTimeout: 20000, // Fast failure for non-interactive auth
|
||||
// Use user-configured keepalive interval (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// 0 = disabled (no keepalive packets sent)
|
||||
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 0,
|
||||
keepaliveCountMax: options.keepaliveInterval > 0 ? 3 : 0,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
@@ -1246,15 +1247,36 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
};
|
||||
|
||||
const sshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(buf);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return stream.write(buf); } catch { return true; /* ignore */ }
|
||||
},
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
label: "SSH",
|
||||
});
|
||||
session.zmodemSentry = sshZmodemSentry;
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(data);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
|
||||
// In normal mode, sentry's onData callback handles decoding and buffering.
|
||||
sshZmodemSentry.consume(data);
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
// stderr is not used for ZMODEM — decode normally
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
@@ -1294,6 +1316,7 @@ async function startSSHSession(event, options) {
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1362,6 +1385,7 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1382,6 +1406,7 @@ async function startSSHSession(event, options) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1412,6 +1437,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
@@ -14,6 +14,7 @@ const { SerialPort } = require("serialport");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -286,6 +287,7 @@ function startLocalSession(event, payload) {
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
cwd,
|
||||
encoding: null, // Return Buffer for ZMODEM binary support
|
||||
});
|
||||
|
||||
const session = {
|
||||
@@ -329,11 +331,40 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
session.flushPendingData = flushLocal;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
// On Windows, node-pty ignores encoding: null and still emits UTF-8
|
||||
// strings, making raw-byte ZMODEM impossible for local PTY sessions.
|
||||
// Only wire up the sentry on platforms where encoding: null works.
|
||||
if (process.platform !== "win32") {
|
||||
const localDecoder = new StringDecoder("utf8");
|
||||
const zmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = localDecoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferLocalData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return proc.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Local",
|
||||
});
|
||||
session.zmodemSentry = zmodemSentry;
|
||||
|
||||
proc.onData((data) => {
|
||||
zmodemSentry.consume(data);
|
||||
});
|
||||
} else {
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
}
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
@@ -535,19 +566,57 @@ async function startTelnetSession(event, options) {
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
const telnetZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = telnetDecoder.write(buf);
|
||||
if (!decoded) return;
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
// Escape 0xFF bytes as 0xFF 0xFF per Telnet spec so binary
|
||||
// ZMODEM data passes through without being treated as IAC.
|
||||
try {
|
||||
let hasFF = false;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
if (buf[i] === 0xff) { hasFF = true; break; }
|
||||
}
|
||||
if (hasFF) {
|
||||
const escaped = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
escaped.push(buf[i]);
|
||||
if (buf[i] === 0xff) escaped.push(0xff);
|
||||
}
|
||||
return socket.write(Buffer.from(escaped));
|
||||
} else {
|
||||
return socket.write(buf);
|
||||
}
|
||||
} catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(telnetWebContentsId);
|
||||
},
|
||||
label: "Telnet",
|
||||
});
|
||||
// Attach sentry to session once created (connect callback runs after this)
|
||||
const attachTelnetSentry = () => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) session.zmodemSentry = telnetZmodemSentry;
|
||||
};
|
||||
socket.once('connect', attachTelnetSentry);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// Always run Telnet negotiation — even during ZMODEM, the Telnet
|
||||
// layer still escapes 0xFF as IAC IAC and sends control sequences.
|
||||
const cleanData = handleTelnetNegotiation(data);
|
||||
|
||||
if (cleanData.length > 0) {
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
telnetZmodemSentry.consume(cleanData);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -562,6 +631,7 @@ async function startTelnetSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.zmodemSentry?.cancel();
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
@@ -577,6 +647,7 @@ async function startTelnetSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.zmodemSentry?.cancel();
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
@@ -645,6 +716,7 @@ async function startMoshSession(event, options) {
|
||||
rows,
|
||||
env,
|
||||
cwd: os.homedir(),
|
||||
encoding: null, // Return Buffer for ZMODEM binary support
|
||||
});
|
||||
|
||||
const session = {
|
||||
@@ -682,11 +754,37 @@ async function startMoshSession(event, options) {
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
const moshDecoder = new StringDecoder("utf8");
|
||||
const moshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const str = moshDecoder.write(buf);
|
||||
if (!str) return;
|
||||
trackSessionIdlePrompt(session, str);
|
||||
bufferMoshData(str);
|
||||
sessionLogStreamManager.appendData(sessionId, str);
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return proc.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Mosh",
|
||||
});
|
||||
session.zmodemSentry = moshZmodemSentry;
|
||||
|
||||
proc.onData((data) => {
|
||||
moshZmodemSentry.consume(data);
|
||||
});
|
||||
} else {
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
}
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
@@ -790,17 +888,33 @@ async function startSerialSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const decoded = serialDecoder.write(data);
|
||||
if (decoded) {
|
||||
const serialZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
onData(buf) {
|
||||
const decoded = serialDecoder.write(buf);
|
||||
if (!decoded) return;
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
},
|
||||
writeToRemote(buf) {
|
||||
try { return serialPort.write(buf); } catch { return true; }
|
||||
},
|
||||
getWebContents() {
|
||||
return electronModule.webContents.fromId(session.webContentsId);
|
||||
},
|
||||
label: "Serial",
|
||||
});
|
||||
session.zmodemSentry = serialZmodemSentry;
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
// data is already Buffer from serialport — feed to sentry
|
||||
serialZmodemSentry.consume(data);
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
session.zmodemSentry?.cancel();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
@@ -809,6 +923,7 @@ async function startSerialSession(event, options) {
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
session.zmodemSentry?.cancel();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
@@ -830,7 +945,15 @@ async function startSerialSession(event, options) {
|
||||
function writeToSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
|
||||
// During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer)
|
||||
if (session.zmodemSentry?.isActive()) {
|
||||
if (payload.data === '\x03') {
|
||||
session.zmodemSentry.cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (session.stream) {
|
||||
session.stream.write(payload.data);
|
||||
@@ -887,6 +1010,7 @@ function closeSession(event, payload) {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
session.flushPendingData?.();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
@@ -999,6 +1123,7 @@ function cleanupAllSessions() {
|
||||
console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`);
|
||||
for (const [sessionId, session] of sessions) {
|
||||
try {
|
||||
session.zmodemSentry?.cancel();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
session.conn?.end();
|
||||
|
||||
@@ -675,6 +675,11 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
@@ -917,6 +922,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: "netcatty Settings",
|
||||
width: settingsWidth,
|
||||
height: settingsHeight,
|
||||
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
|
||||
@@ -1042,6 +1048,9 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
settingsWindow = null;
|
||||
});
|
||||
|
||||
// Prevent HTML <title> from overriding the window title
|
||||
win.on('page-title-updated', (e) => { e.preventDefault(); });
|
||||
|
||||
// Load the settings page
|
||||
const settingsPath = '/#/settings';
|
||||
|
||||
|
||||
794
electron/bridges/zmodemHelper.cjs
Normal file
794
electron/bridges/zmodemHelper.cjs
Normal file
@@ -0,0 +1,794 @@
|
||||
/**
|
||||
* ZMODEM Helper - Provides ZMODEM file transfer support for terminal sessions.
|
||||
*
|
||||
* Architecture: ZMODEM detection and transfer runs entirely in the main process.
|
||||
* The Sentry wraps the raw data stream and routes data either to the normal
|
||||
* string-based terminal pipeline (via `to_terminal`) or to the ZMODEM protocol
|
||||
* handler. This avoids any changes to the IPC / preload / renderer data path.
|
||||
*
|
||||
* The renderer is only notified for progress display via lightweight IPC events.
|
||||
*/
|
||||
|
||||
const Zmodem = require("zmodem.js");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Lazy-load electron to avoid issues when requiring from non-electron contexts
|
||||
let _electron = null;
|
||||
function getElectron() {
|
||||
if (!_electron) _electron = require("electron");
|
||||
return _electron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||||
*
|
||||
* All raw data from the PTY / SSH stream / socket should be fed into
|
||||
* `consume()`. The sentry transparently calls `onData(str)` for normal
|
||||
* terminal output and handles ZMODEM transfers internally.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sessionId
|
||||
* @param {(data: Buffer) => void} opts.onData
|
||||
* Called with raw bytes during normal (non-ZMODEM) operation.
|
||||
* The caller is responsible for charset-aware decoding (UTF-8, iconv, etc.).
|
||||
* @param {(buf: Buffer) => void} opts.writeToRemote
|
||||
* Write raw bytes back to the remote side (PTY / SSH stream / socket).
|
||||
* @param {() => import('electron').WebContents | null} opts.getWebContents
|
||||
* Returns the Electron WebContents for sending progress IPC events.
|
||||
* @param {string} [opts.label]
|
||||
* Human-readable label for log messages (e.g. "Local", "SSH").
|
||||
* @returns {ZmodemSentryWrapper}
|
||||
*/
|
||||
function createZmodemSentry(opts) {
|
||||
const {
|
||||
sessionId,
|
||||
onData,
|
||||
writeToRemote,
|
||||
getWebContents,
|
||||
interruptRemote,
|
||||
label = "Session",
|
||||
} = opts;
|
||||
|
||||
let active = false;
|
||||
let currentZSession = null;
|
||||
let _needsDrain = false;
|
||||
const pendingEchoes = [];
|
||||
let pendingTerminalSuppression = null;
|
||||
let cancelInterruptTimer = null;
|
||||
let ignoreDetectionUntil = 0;
|
||||
// After aborting, suppress incoming data briefly so residual ZMODEM
|
||||
// protocol bytes from the remote don't flood the terminal as garbage.
|
||||
let cooldownUntil = 0;
|
||||
const COOLDOWN_MS = 2000;
|
||||
const ECHO_TTL_MS = 1500;
|
||||
const ECHO_MAX_BYTES = 256;
|
||||
|
||||
function prunePendingEchoes(now = Date.now()) {
|
||||
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
|
||||
pendingEchoes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function rememberOutgoingEcho(octets) {
|
||||
const buf = Buffer.from(octets);
|
||||
if (!buf.length || buf.length > ECHO_MAX_BYTES) return;
|
||||
prunePendingEchoes();
|
||||
pendingEchoes.push({
|
||||
buf,
|
||||
expiresAt: Date.now() + ECHO_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function stripEchoedOutgoingData(data) {
|
||||
if (!pendingEchoes.length) return data;
|
||||
|
||||
prunePendingEchoes();
|
||||
|
||||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
let mutated = false;
|
||||
|
||||
while (pendingEchoes.length && buf.length) {
|
||||
const nextEcho = pendingEchoes[0].buf;
|
||||
if (buf.length < nextEcho.length) break;
|
||||
if (!buf.subarray(0, nextEcho.length).equals(nextEcho)) break;
|
||||
|
||||
mutated = true;
|
||||
buf = buf.subarray(nextEcho.length);
|
||||
pendingEchoes.shift();
|
||||
}
|
||||
|
||||
return mutated ? buf : data;
|
||||
}
|
||||
|
||||
function stripPendingTerminalSuppression(data) {
|
||||
if (!pendingTerminalSuppression?.length) return data;
|
||||
|
||||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const fullMatchAt = buf.indexOf(pendingTerminalSuppression);
|
||||
if (fullMatchAt !== -1) {
|
||||
buf = Buffer.concat([
|
||||
buf.subarray(0, fullMatchAt),
|
||||
buf.subarray(fullMatchAt + pendingTerminalSuppression.length),
|
||||
]);
|
||||
pendingTerminalSuppression = null;
|
||||
return buf;
|
||||
}
|
||||
|
||||
const maxMatch = Math.min(pendingTerminalSuppression.length, buf.length);
|
||||
let matchLen = 0;
|
||||
while (matchLen < maxMatch && buf[matchLen] === pendingTerminalSuppression[matchLen]) {
|
||||
matchLen += 1;
|
||||
}
|
||||
|
||||
if (!matchLen) return buf;
|
||||
|
||||
buf = buf.subarray(matchLen);
|
||||
pendingTerminalSuppression = matchLen === pendingTerminalSuppression.length
|
||||
? null
|
||||
: pendingTerminalSuppression.subarray(matchLen);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
function stripVisibleZmodemHeaders(data) {
|
||||
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < buf.length) {
|
||||
const prefixAt = buf.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42]), searchFrom);
|
||||
if (prefixAt === -1) break;
|
||||
|
||||
const minHeaderLength = 20;
|
||||
if (buf.length - prefixAt < minHeaderLength) break;
|
||||
|
||||
let isHexHeader = true;
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const byte = buf[prefixAt + 4 + i];
|
||||
const isHexDigit =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66);
|
||||
if (!isHexDigit) {
|
||||
isHexHeader = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHexHeader) {
|
||||
searchFrom = prefixAt + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let headerLength = 18;
|
||||
if (buf[prefixAt + 18] === 0x0d && buf[prefixAt + 19] === 0x0a) {
|
||||
headerLength = 20;
|
||||
if (buf[prefixAt + 20] === 0x11) {
|
||||
headerLength = 21;
|
||||
}
|
||||
}
|
||||
|
||||
buf = Buffer.concat([
|
||||
buf.subarray(0, prefixAt),
|
||||
buf.subarray(prefixAt + headerLength),
|
||||
]);
|
||||
searchFrom = prefixAt;
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
function looksLikeResidualZmodemData(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (!buf.length) return true;
|
||||
|
||||
for (const byte of buf) {
|
||||
const isResidualControl =
|
||||
byte === 0x18 || // CAN / ZDLE
|
||||
byte === 0x08 || // backspace from abort sequence
|
||||
byte === 0x11 || // XON
|
||||
byte === 0x13 || // XOFF
|
||||
byte === 0x0d ||
|
||||
byte === 0x0a;
|
||||
if (isResidualControl) continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendExtraAbortBytes() {
|
||||
try {
|
||||
writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRemoteInterruptAfterCancel(transferRole) {
|
||||
if (cancelInterruptTimer) {
|
||||
clearTimeout(cancelInterruptTimer);
|
||||
cancelInterruptTimer = null;
|
||||
}
|
||||
|
||||
if (transferRole !== "send") return;
|
||||
ignoreDetectionUntil = Date.now() + 300;
|
||||
|
||||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||||
|
||||
// Some rz builds (notably Debian's lrzsz) can stay attached to the tty
|
||||
// after a protocol cancel. Follow up with Ctrl+C so the remote shell
|
||||
// reliably regains control. If rz is already gone, this just refreshes
|
||||
// the prompt like a normal interactive interrupt.
|
||||
cancelInterruptTimer = setTimeout(() => {
|
||||
cancelInterruptTimer = null;
|
||||
try { interruptRemote?.(); } catch { /* ignore */ }
|
||||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function isIgnorableSendKeepaliveError(errMsg) {
|
||||
return Boolean(
|
||||
active &&
|
||||
currentZSession?.type === "send" &&
|
||||
!currentZSession?._sending_file &&
|
||||
errMsg.includes("Unhandled header: ZRINIT")
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnorableSendResumePingError(errMsg) {
|
||||
return Boolean(
|
||||
active &&
|
||||
currentZSession?.type === "send" &&
|
||||
!currentZSession?._sending_file &&
|
||||
currentZSession?._next_header_handler?.ZRINIT &&
|
||||
errMsg.includes("Unhandled header: ZRPOS")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const sentry = new Zmodem.Sentry({
|
||||
to_terminal(octets) {
|
||||
// Normal data – pass raw bytes to the caller for charset-aware decoding.
|
||||
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
|
||||
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
|
||||
if (!sanitizedOctets.length) return;
|
||||
onData(sanitizedOctets);
|
||||
},
|
||||
|
||||
sender(octets) {
|
||||
// ZMODEM protocol bytes – send raw to remote.
|
||||
rememberOutgoingEcho(octets);
|
||||
const ok = writeToRemote(Buffer.from(octets));
|
||||
// Track backpressure: if stream.write() returned false, the
|
||||
// kernel TCP buffer is full. The upload loop should pause.
|
||||
if (ok === false) _needsDrain = true;
|
||||
},
|
||||
|
||||
on_detect(detection) {
|
||||
if (active) {
|
||||
console.warn(`[ZMODEM][${label}] Detection while transfer active; denying`);
|
||||
detection.deny();
|
||||
return;
|
||||
}
|
||||
if (Date.now() < ignoreDetectionUntil) {
|
||||
console.log(`[ZMODEM][${label}] Ignoring stray detection during cancel grace window`);
|
||||
detection.deny();
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
const zsession = detection.confirm();
|
||||
currentZSession = zsession;
|
||||
pendingTerminalSuppression = zsession.type === "receive"
|
||||
? Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex())
|
||||
: zsession._last_ZRINIT?.to_hex
|
||||
? Buffer.from(zsession._last_ZRINIT.to_hex())
|
||||
: null;
|
||||
|
||||
const contents = getWebContents();
|
||||
const transferType = zsession.type === "send" ? "upload" : "download";
|
||||
|
||||
console.log(`[ZMODEM][${label}] Detected ${transferType} for session ${sessionId}`);
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:detect", {
|
||||
sessionId,
|
||||
transferType,
|
||||
});
|
||||
|
||||
// Provide a drain helper so the upload loop can pause when the
|
||||
// underlying transport's write buffer is full.
|
||||
const transferOpts = {
|
||||
...opts,
|
||||
waitForDrain: () => {
|
||||
if (!_needsDrain) return Promise.resolve();
|
||||
_needsDrain = false;
|
||||
// Yield to the event loop so Node can flush buffered writes to
|
||||
// the kernel. Using setImmediate (not setTimeout) avoids any
|
||||
// fixed delay — we resume as soon as the I/O phase completes.
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
handleTransfer(zsession, transferType, transferOpts)
|
||||
.then(() => {
|
||||
// Only act if this is still the active session (not replaced by a new one)
|
||||
if (currentZSession !== zsession) return;
|
||||
console.log(`[ZMODEM][${label}] Transfer completed for session ${sessionId}`);
|
||||
safeSend(contents, "netcatty:zmodem:complete", { sessionId });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (currentZSession !== zsession) return;
|
||||
console.error(`[ZMODEM][${label}] Transfer error:`, err.message || err);
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
safeSend(contents, "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: String(err.message || err),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
// Only clear state if this is still the active session
|
||||
if (currentZSession === zsession) {
|
||||
active = false;
|
||||
currentZSession = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
on_retract() {
|
||||
// False positive – sentry automatically resumes passthrough.
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Feed raw bytes from the session into the sentry.
|
||||
* @param {Buffer|Uint8Array} data
|
||||
*/
|
||||
consume(data) {
|
||||
// During cooldown after abort, unconditionally suppress all incoming
|
||||
// data. sz can stream large amounts of file data that's still in
|
||||
// SSH/TCP buffers after we send CAN; checking content doesn't help
|
||||
// because the residual data contains arbitrary printable bytes.
|
||||
if (cooldownUntil) {
|
||||
const now = Date.now();
|
||||
if (now < cooldownUntil) {
|
||||
// Keep sending CAN in case earlier ones were lost in the flood
|
||||
if (now - (cooldownUntil - COOLDOWN_MS) > 200) {
|
||||
sendExtraAbortBytes();
|
||||
}
|
||||
return; // drop everything during cooldown
|
||||
}
|
||||
cooldownUntil = 0;
|
||||
// After cooldown, let this chunk through — it's likely the shell prompt
|
||||
}
|
||||
|
||||
try {
|
||||
const sanitizedData = stripEchoedOutgoingData(data);
|
||||
if (!sanitizedData.length) return;
|
||||
sentry.consume(sanitizedData);
|
||||
} catch (err) {
|
||||
const errMsg = String(err.message || err);
|
||||
console.error(`[ZMODEM][${label}] Sentry consume error:`, errMsg);
|
||||
|
||||
const wasActive = active;
|
||||
|
||||
// lrzsz's `rz` may resend ZRINIT while we're waiting for the user
|
||||
// to choose files. zmodem.js doesn't model that pre-offer keepalive,
|
||||
// but the repeated header is harmless, so ignore it and keep waiting.
|
||||
if (isIgnorableSendKeepaliveError(errMsg)) {
|
||||
console.log(`[ZMODEM][${label}] Ignoring repeated pre-offer ZRINIT`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Some receivers emit a final ZRPOS ping right before they send the
|
||||
// post-file ZRINIT. If that ping is processed a beat late, zmodem.js
|
||||
// complains even though the transfer can continue normally.
|
||||
if (isIgnorableSendResumePingError(errMsg)) {
|
||||
console.log(`[ZMODEM][${label}] Ignoring late post-file ZRPOS`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ZFIN/OO mismatch: the file transfer completed (ZFIN exchanged)
|
||||
// but the shell prompt arrived before the "OO" end marker. This
|
||||
// is common over SSH because sz exits and the shell resumes before
|
||||
// the "OO" acknowledgement is sent. Treat as successful transfer.
|
||||
// Do NOT abort() here — that sends CAN bytes to the remote shell.
|
||||
// Instead, manually clean up the sentry's internal session state.
|
||||
if (wasActive && errMsg.includes("ZFIN") && errMsg.includes("OO")) {
|
||||
console.log(`[ZMODEM][${label}] ZFIN/OO mismatch — treating as success`);
|
||||
if (currentZSession) {
|
||||
try { currentZSession._on_session_end(); } catch { /* ignore */ }
|
||||
}
|
||||
active = false;
|
||||
currentZSession = null;
|
||||
safeSend(getWebContents(), "netcatty:zmodem:complete", { sessionId });
|
||||
try { sentry.consume(data); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other errors, abort and send extra CAN sequences to
|
||||
// ensure the remote rz/sz process stops transmitting.
|
||||
if (currentZSession) {
|
||||
try { currentZSession.abort(); } catch { /* ignore */ }
|
||||
}
|
||||
sendExtraAbortBytes();
|
||||
// Follow up with Ctrl+C after a short delay to kill rz/sz on
|
||||
// Debian and other systems where it stays attached after CAN.
|
||||
setTimeout(() => {
|
||||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||||
}, 150);
|
||||
|
||||
active = false;
|
||||
currentZSession = null;
|
||||
// Enter cooldown: discard incoming data briefly while the remote
|
||||
// processes our CAN sequence and stops sending ZMODEM frames.
|
||||
cooldownUntil = Date.now() + COOLDOWN_MS;
|
||||
|
||||
if (wasActive) {
|
||||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Whether a ZMODEM transfer is currently in progress. */
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
|
||||
/** Cancel the current ZMODEM transfer. */
|
||||
cancel() {
|
||||
if (currentZSession) {
|
||||
const transferRole = currentZSession.type;
|
||||
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
|
||||
try { currentZSession.abort(); } catch { /* ignore */ }
|
||||
sendExtraAbortBytes();
|
||||
active = false;
|
||||
currentZSession = null;
|
||||
cooldownUntil = Date.now() + COOLDOWN_MS;
|
||||
scheduleRemoteInterruptAfterCancel(transferRole);
|
||||
safeSend(getWebContents(), "netcatty:zmodem:error", {
|
||||
sessionId,
|
||||
error: "Transfer cancelled",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (module-level, usable from handleUpload / handleDownload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Race a promise against a timeout. If the promise doesn't settle within
|
||||
* `ms`, resolve with undefined instead of hanging forever. This prevents
|
||||
* zmodem.js internal promises (xfer.end, zsession.close) from blocking
|
||||
* indefinitely after cancel/abort.
|
||||
*/
|
||||
function withTimeout(promise, ms) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("ZMODEM handshake timeout")), ms);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CAN bytes + delayed Ctrl-C to kill the remote rz/sz process.
|
||||
* Used from dialog-cancel paths that run outside the sentry closure.
|
||||
*/
|
||||
function abortRemoteProcess(writeToRemote) {
|
||||
try { writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18])); } catch { /* ignore */ }
|
||||
setTimeout(() => {
|
||||
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfer handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleTransfer(zsession, transferType, opts) {
|
||||
if (transferType === "upload") {
|
||||
await handleUpload(zsession, opts);
|
||||
} else {
|
||||
await handleDownload(zsession, opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files to the remote (remote executed `rz`).
|
||||
*/
|
||||
async function handleUpload(zsession, opts) {
|
||||
const { sessionId, getWebContents } = opts;
|
||||
const contents = getWebContents();
|
||||
const { BrowserWindow, dialog } = getElectron();
|
||||
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||||
const result = await dialog.showOpenDialog(win || undefined, {
|
||||
properties: ["openFile", "multiSelections"],
|
||||
title: "Select files to upload (ZMODEM)",
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
if (!xfer) {
|
||||
// Receiver skipped this file
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read and send in chunks
|
||||
const CHUNK_SIZE = 64 * 1024; // Leave room for inbound ZMODEM control frames
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(CHUNK_SIZE);
|
||||
let sent = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buf, 0, CHUNK_SIZE);
|
||||
if (bytesRead === 0) break;
|
||||
|
||||
// zmodem.js send() is synchronous and triggers writeToRemote via
|
||||
// the sentry's sender callback. Yield after each chunk so the
|
||||
// event loop can flush buffered writes and process inbound control
|
||||
// frames, preventing unbounded memory growth on slow links.
|
||||
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, bytesRead));
|
||||
sent += bytesRead;
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
// Wait for transport to drain if its buffer is full, then yield
|
||||
// so inbound ZMODEM control frames can be processed.
|
||||
if (opts.waitForDrain) await opts.waitForDrain();
|
||||
await yieldToIO();
|
||||
}
|
||||
// All data written to Node.js buffer — but TCP may still be
|
||||
// flushing to the remote. Show "finalizing" state while we
|
||||
// wait for the remote to acknowledge.
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
await withTimeout(xfer.end(), 120000);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(zsession.close(), 120000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download files from the remote (remote executed `sz <file>`).
|
||||
*/
|
||||
async function handleDownload(zsession, opts) {
|
||||
const { sessionId, getWebContents } = opts;
|
||||
const contents = getWebContents();
|
||||
const { BrowserWindow, dialog } = getElectron();
|
||||
|
||||
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
|
||||
let fileIndex = 0;
|
||||
const pendingStreams = [];
|
||||
const pendingOffers = [];
|
||||
let lastProgressTime = 0;
|
||||
let downloadDir = null;
|
||||
let rejectSession = () => {};
|
||||
|
||||
const processOffer = (xfer, reject) => {
|
||||
if (!downloadDir) {
|
||||
pendingOffers.push(xfer);
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = xfer.get_details();
|
||||
// Sanitize filename to prevent path traversal attacks
|
||||
const rawName = detail.name || `untitled_${Date.now()}`;
|
||||
const name = path.basename(rawName);
|
||||
const size = detail.size || 0;
|
||||
const savePath = path.join(downloadDir, name);
|
||||
const currentIndex = fileIndex++;
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: 0,
|
||||
total: size,
|
||||
fileIndex: currentIndex,
|
||||
fileCount: -1, // unknown total until session ends
|
||||
transferType: "download",
|
||||
});
|
||||
|
||||
// Avoid overwriting existing files — append (1), (2), etc.
|
||||
let finalPath = savePath;
|
||||
if (fs.existsSync(savePath)) {
|
||||
const ext = path.extname(name);
|
||||
const base = path.basename(name, ext);
|
||||
let n = 1;
|
||||
do {
|
||||
finalPath = path.join(downloadDir, `${base} (${n})${ext}`);
|
||||
n++;
|
||||
} while (fs.existsSync(finalPath));
|
||||
}
|
||||
|
||||
const ws = fs.createWriteStream(finalPath);
|
||||
let received = 0;
|
||||
let writeAborted = false;
|
||||
|
||||
// Track pending write streams (and paths) for cleanup at session end
|
||||
pendingStreams.push({ stream: ws, path: finalPath, completed: false });
|
||||
|
||||
ws.on("error", (err) => {
|
||||
writeAborted = true;
|
||||
console.error(`[ZMODEM] Write stream error for ${name}:`, err.message);
|
||||
ws.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
xfer.accept({
|
||||
on_input(payload) {
|
||||
if (writeAborted) return;
|
||||
const chunk = Buffer.from(payload);
|
||||
ws.write(chunk);
|
||||
received += chunk.length;
|
||||
|
||||
// Throttle progress IPC to ~10 updates/sec to avoid
|
||||
// overwhelming the renderer on fast links.
|
||||
const now = Date.now();
|
||||
if (now - lastProgressTime >= 100) {
|
||||
lastProgressTime = now;
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
filename: name,
|
||||
transferred: received,
|
||||
total: size,
|
||||
fileIndex: currentIndex,
|
||||
fileCount: -1,
|
||||
transferType: "download",
|
||||
});
|
||||
}
|
||||
},
|
||||
}).catch((err) => {
|
||||
ws.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
xfer.on("complete", () => {
|
||||
const entry = pendingStreams.find((e) => e.stream === ws);
|
||||
if (entry) entry.completed = true;
|
||||
ws.end();
|
||||
});
|
||||
};
|
||||
|
||||
const sessionPromise = new Promise((resolve, reject) => {
|
||||
rejectSession = reject;
|
||||
zsession.on("offer", (xfer) => {
|
||||
try {
|
||||
processOffer(xfer, reject);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all write streams to finish flushing before resolving.
|
||||
// If a stream never received end() (e.g. transfer was cancelled),
|
||||
// destroy it so the fd is released and finish/close can fire.
|
||||
zsession.on("session_end", async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingStreams.map((entry) => {
|
||||
const { stream: s, path: filePath, completed } = entry;
|
||||
if (s.writableFinished) {
|
||||
// Delete partial files that never completed
|
||||
if (!completed) {
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!s.writableEnded) s.destroy();
|
||||
return new Promise((r) => {
|
||||
s.on("close", () => {
|
||||
// Clean up partial downloads
|
||||
if (!completed) {
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
r();
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch { /* ignore — error handler already called reject */ }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Start the session BEFORE showing the dialog so lrzsz doesn't
|
||||
// time out waiting for ZRINIT while the user browses for a folder.
|
||||
zsession.start();
|
||||
|
||||
const result = await dialog.showOpenDialog(win || undefined, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
title: "Select download directory (ZMODEM)",
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
void sessionPromise.catch(() => {});
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
downloadDir = result.filePaths[0];
|
||||
while (pendingOffers.length) {
|
||||
processOffer(pendingOffers.shift(), rejectSession);
|
||||
}
|
||||
|
||||
await sessionPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function safeSend(contents, channel, data) {
|
||||
try {
|
||||
if (contents && !contents.isDestroyed()) {
|
||||
contents.send(channel, data);
|
||||
}
|
||||
} catch {
|
||||
// WebContents may have been destroyed between the check and the send
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createZmodemSentry };
|
||||
@@ -318,8 +318,8 @@ function registerAppProtocol() {
|
||||
|
||||
function focusMainWindow() {
|
||||
try {
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
const win = wins && wins.length ? wins[0] : null;
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
const win = mainWin && !mainWin.isDestroyed?.() ? mainWin : null;
|
||||
if (!win) return false;
|
||||
|
||||
// Check if the webContents has crashed or been destroyed
|
||||
@@ -505,6 +505,14 @@ const registerBridges = (win) => {
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
|
||||
// ZMODEM cancel handler
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (session?.zmodemSentry) {
|
||||
session.zmodemSentry.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
|
||||
ipcMain.handle("netcatty:figspec:list", async () => {
|
||||
try {
|
||||
@@ -1066,12 +1074,11 @@ if (!gotLock) {
|
||||
} catch {}
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const zmodemListeners = new Map();
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
@@ -109,6 +110,28 @@ function _deliverToListeners(sessionId, data) {
|
||||
});
|
||||
}
|
||||
|
||||
// ZMODEM file transfer events
|
||||
ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "progress", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:complete", (_event, payload) => {
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "complete", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
const set = zmodemListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
@@ -153,6 +176,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -569,6 +593,14 @@ const api = {
|
||||
},
|
||||
setSessionEncoding: (sessionId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
||||
onZmodemEvent: (sessionId, cb) => {
|
||||
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
|
||||
zmodemListeners.get(sessionId).add(cb);
|
||||
return () => zmodemListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
},
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
@@ -1207,6 +1239,9 @@ const api = {
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
|
||||
},
|
||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
||||
},
|
||||
aiAcpCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
|
||||
17
global.d.ts
vendored
17
global.d.ts
vendored
@@ -263,6 +263,23 @@ declare global {
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
// ZMODEM file transfer
|
||||
onZmodemEvent?(
|
||||
sessionId: string,
|
||||
cb: (event: {
|
||||
type: 'detect' | 'progress' | 'complete' | 'error';
|
||||
sessionId: string;
|
||||
transferType?: 'upload' | 'download';
|
||||
filename?: string;
|
||||
transferred?: number;
|
||||
total?: number;
|
||||
fileIndex?: number;
|
||||
fileCount?: number;
|
||||
finalizing?: boolean;
|
||||
error?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
@@ -337,7 +337,7 @@ body {
|
||||
|
||||
/* Dim terminal text in unfocused workspace panes (default) */
|
||||
.workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 0.65;
|
||||
opacity: 0.82;
|
||||
}
|
||||
/* Border-style focus indicator (opt-in via data attribute) */
|
||||
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {
|
||||
|
||||
69
infrastructure/ai/managedAgents.ts
Normal file
69
infrastructure/ai/managedAgents.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
|
||||
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
|
||||
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
|
||||
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
|
||||
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
|
||||
};
|
||||
|
||||
function getCommandBasename(command: string | undefined): string {
|
||||
const normalized = String(command || '').trim();
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts.pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
function isPathLikeCommand(command: string | undefined): boolean {
|
||||
const normalized = String(command || '').trim();
|
||||
return normalized.includes('/') || normalized.includes('\\');
|
||||
}
|
||||
|
||||
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
|
||||
const basename = getCommandBasename(command);
|
||||
return basename === agentKey || basename.startsWith(`${agentKey}.`);
|
||||
}
|
||||
|
||||
export function isSettingsManagedDiscoveredAgent(
|
||||
agent: Pick<DiscoveredAgent, 'command'>,
|
||||
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
|
||||
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
|
||||
}
|
||||
|
||||
export function matchesManagedAgentConfig(
|
||||
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
|
||||
agentKey: ManagedAgentKey,
|
||||
): boolean {
|
||||
const meta = MANAGED_AGENT_META[agentKey];
|
||||
const basename = getCommandBasename(agent.command);
|
||||
return (
|
||||
agent.id === `discovered_${agentKey}` ||
|
||||
agent.acpCommand === meta.acpCommand ||
|
||||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedAgentStoredPath(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): string | null {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const preferredAgent = agents.find(
|
||||
(agent) =>
|
||||
agent.id === managedId &&
|
||||
isPathLikeCommand(agent.command) &&
|
||||
matchesPrimaryCliBasename(agent.command, agentKey),
|
||||
);
|
||||
if (preferredAgent) {
|
||||
return preferredAgent.command;
|
||||
}
|
||||
|
||||
const fallbackAgent = agents.find(
|
||||
(agent) =>
|
||||
matchesManagedAgentConfig(agent, agentKey) &&
|
||||
isPathLikeCommand(agent.command) &&
|
||||
matchesPrimaryCliBasename(agent.command, agentKey),
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
@@ -1677,5 +1677,329 @@ export const TERMINAL_THEMES: TerminalTheme[] = [
|
||||
brightCyan: '#83c092',
|
||||
brightWhite: '#5c6d64'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github-dark',
|
||||
name: 'GitHub Dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#2f81f7',
|
||||
selection: '#264f78',
|
||||
black: '#484f58',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ffa198',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github-light',
|
||||
name: 'GitHub Light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
foreground: '#1f2328',
|
||||
cursor: '#0969da',
|
||||
selection: '#add6ff',
|
||||
black: '#24292f',
|
||||
red: '#cf222e',
|
||||
green: '#116329',
|
||||
yellow: '#4d2d00',
|
||||
blue: '#0969da',
|
||||
magenta: '#8250df',
|
||||
cyan: '#1b7c83',
|
||||
white: '#6e7781',
|
||||
brightBlack: '#57606a',
|
||||
brightRed: '#a40e26',
|
||||
brightGreen: '#1a7f37',
|
||||
brightYellow: '#633c01',
|
||||
brightBlue: '#218bff',
|
||||
brightMagenta: '#a475f9',
|
||||
brightCyan: '#3192aa',
|
||||
brightWhite: '#8c959f',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ubuntu',
|
||||
name: 'Ubuntu',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#300a24',
|
||||
foreground: '#eeeeec',
|
||||
cursor: '#bbbbbb',
|
||||
selection: '#b5d5ff',
|
||||
black: '#2e3436',
|
||||
red: '#cc0000',
|
||||
green: '#4e9a06',
|
||||
yellow: '#c4a000',
|
||||
blue: '#3465a4',
|
||||
magenta: '#75507b',
|
||||
cyan: '#06989a',
|
||||
white: '#d3d7cf',
|
||||
brightBlack: '#555753',
|
||||
brightRed: '#ef2929',
|
||||
brightGreen: '#8ae234',
|
||||
brightYellow: '#fce94f',
|
||||
brightBlue: '#729fcf',
|
||||
brightMagenta: '#ad7fa8',
|
||||
brightCyan: '#34e2e2',
|
||||
brightWhite: '#eeeeec',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'one-dark-pro',
|
||||
name: 'One Dark Pro',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
selection: '#3e4452',
|
||||
black: '#3f4451',
|
||||
red: '#e05561',
|
||||
green: '#8cc265',
|
||||
yellow: '#d18f52',
|
||||
blue: '#4aa5f0',
|
||||
magenta: '#c162de',
|
||||
cyan: '#42b3c2',
|
||||
white: '#d7dae0',
|
||||
brightBlack: '#4f5666',
|
||||
brightRed: '#ff616e',
|
||||
brightGreen: '#a5e075',
|
||||
brightYellow: '#f0a45d',
|
||||
brightBlue: '#4dc4ff',
|
||||
brightMagenta: '#de73ff',
|
||||
brightCyan: '#4cd1e0',
|
||||
brightWhite: '#e6e6e6',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'horizon-dark',
|
||||
name: 'Horizon',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#1c1e26',
|
||||
foreground: '#d5d8da',
|
||||
cursor: '#6c6f93',
|
||||
selection: '#6c6f93',
|
||||
black: '#16161c',
|
||||
red: '#e95678',
|
||||
green: '#29d398',
|
||||
yellow: '#fab795',
|
||||
blue: '#26bbd9',
|
||||
magenta: '#ee64ac',
|
||||
cyan: '#59e1e3',
|
||||
white: '#d5d8da',
|
||||
brightBlack: '#6c6f93',
|
||||
brightRed: '#ec6a88',
|
||||
brightGreen: '#3fdaa4',
|
||||
brightYellow: '#fbc3a7',
|
||||
brightBlue: '#3fc4de',
|
||||
brightMagenta: '#f075b5',
|
||||
brightCyan: '#6be4e6',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'palenight',
|
||||
name: 'Palenight',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#292d3e',
|
||||
foreground: '#bfc7d5',
|
||||
cursor: '#ffcc00',
|
||||
selection: '#7580b8',
|
||||
black: '#292d3e',
|
||||
red: '#ff5572',
|
||||
green: '#a9c77d',
|
||||
yellow: '#ffcb6b',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#89ddff',
|
||||
white: '#d0d0d0',
|
||||
brightBlack: '#676e95',
|
||||
brightRed: '#ff5572',
|
||||
brightGreen: '#c3e88d',
|
||||
brightYellow: '#ffcb6b',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#89ddff',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'panda',
|
||||
name: 'Panda',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#292a2b',
|
||||
foreground: '#e6e6e6',
|
||||
cursor: '#ff4b82',
|
||||
selection: '#454647',
|
||||
black: '#757575',
|
||||
red: '#ff2c6d',
|
||||
green: '#19f9d8',
|
||||
yellow: '#ffb86c',
|
||||
blue: '#45a9f9',
|
||||
magenta: '#ff75b5',
|
||||
cyan: '#b084eb',
|
||||
white: '#cdcdcd',
|
||||
brightBlack: '#757575',
|
||||
brightRed: '#ff2c6d',
|
||||
brightGreen: '#19f9d8',
|
||||
brightYellow: '#ffcc95',
|
||||
brightBlue: '#6fc1ff',
|
||||
brightMagenta: '#ff9ac1',
|
||||
brightCyan: '#bcaafe',
|
||||
brightWhite: '#e6e6e6',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'snazzy',
|
||||
name: 'Snazzy',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#1e1f29',
|
||||
foreground: '#ebece6',
|
||||
cursor: '#e4e4e4',
|
||||
selection: '#81aec6',
|
||||
black: '#000000',
|
||||
red: '#fc4346',
|
||||
green: '#50fb7c',
|
||||
yellow: '#f0fb8c',
|
||||
blue: '#49baff',
|
||||
magenta: '#fc4cb4',
|
||||
cyan: '#8be9fe',
|
||||
white: '#ededec',
|
||||
brightBlack: '#555555',
|
||||
brightRed: '#fc4346',
|
||||
brightGreen: '#50fb7c',
|
||||
brightYellow: '#f0fb8c',
|
||||
brightBlue: '#49baff',
|
||||
brightMagenta: '#fc4cb4',
|
||||
brightCyan: '#8be9fe',
|
||||
brightWhite: '#ededec',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'synthwave-84',
|
||||
name: "Synthwave '84",
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#262335',
|
||||
foreground: '#f0eff1',
|
||||
cursor: '#72f1b8',
|
||||
selection: '#463465',
|
||||
black: '#241b30',
|
||||
red: '#fe4450',
|
||||
green: '#72f1b8',
|
||||
yellow: '#fede5d',
|
||||
blue: '#03edf9',
|
||||
magenta: '#ff7edb',
|
||||
cyan: '#03edf9',
|
||||
white: '#f0eff1',
|
||||
brightBlack: '#7f7094',
|
||||
brightRed: '#fe4450',
|
||||
brightGreen: '#72f1b8',
|
||||
brightYellow: '#f9f972',
|
||||
brightBlue: '#aa54f9',
|
||||
brightMagenta: '#ff7edb',
|
||||
brightCyan: '#03edf9',
|
||||
brightWhite: '#f2f2e3',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vesper',
|
||||
name: 'Vesper',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#101010',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#acb1ab',
|
||||
selection: '#988049',
|
||||
black: '#101010',
|
||||
red: '#f5a191',
|
||||
green: '#90b99f',
|
||||
yellow: '#e6b99d',
|
||||
blue: '#aca1cf',
|
||||
magenta: '#e29eca',
|
||||
cyan: '#ea83a5',
|
||||
white: '#a0a0a0',
|
||||
brightBlack: '#7e7e7e',
|
||||
brightRed: '#ff8080',
|
||||
brightGreen: '#99ffe4',
|
||||
brightYellow: '#ffc799',
|
||||
brightBlue: '#b9aeda',
|
||||
brightMagenta: '#ecaad6',
|
||||
brightCyan: '#f591b2',
|
||||
brightWhite: '#ffffff',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'kanso-dark',
|
||||
name: 'Kanso Dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
background: '#090e13',
|
||||
foreground: '#c5c9c7',
|
||||
cursor: '#c5c9c7',
|
||||
selection: '#393b44',
|
||||
black: '#0d0c0c',
|
||||
red: '#c4746e',
|
||||
green: '#8a9a7b',
|
||||
yellow: '#c4b28a',
|
||||
blue: '#8ba4b0',
|
||||
magenta: '#a292a3',
|
||||
cyan: '#8ea4a2',
|
||||
white: '#c8c093',
|
||||
brightBlack: '#a4a7a4',
|
||||
brightRed: '#e46876',
|
||||
brightGreen: '#87a987',
|
||||
brightYellow: '#e6c384',
|
||||
brightBlue: '#7fbbb3',
|
||||
brightMagenta: '#938aa9',
|
||||
brightCyan: '#7aa89f',
|
||||
brightWhite: '#c5c9c7',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'kanso-light',
|
||||
name: 'Kanso Light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
background: '#f2f1ef',
|
||||
foreground: '#22262d',
|
||||
cursor: '#22262d',
|
||||
selection: '#e2e1df',
|
||||
black: '#22262d',
|
||||
red: '#c84053',
|
||||
green: '#6f894e',
|
||||
yellow: '#77713f',
|
||||
blue: '#4d699b',
|
||||
magenta: '#b35b79',
|
||||
cyan: '#597b75',
|
||||
white: '#545464',
|
||||
brightBlack: '#6d6f6e',
|
||||
brightRed: '#d7474b',
|
||||
brightGreen: '#6e915f',
|
||||
brightYellow: '#836f4a',
|
||||
brightBlue: '#6693bf',
|
||||
brightMagenta: '#624c83',
|
||||
brightCyan: '#5e857a',
|
||||
brightWhite: '#43436c',
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -8282,6 +8283,18 @@
|
||||
"buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
@@ -16276,6 +16289,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zmodem.js": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
|
||||
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0",
|
||||
"zmodem.js": "^0.1.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1
public/ai/agents/copilot.svg
Normal file
1
public/ai/agents/copilot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user