import { Copy, Minus, Square, Unplug, X } from 'lucide-react'; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { I18nProvider, useI18n } from '../application/i18n/I18nProvider'; import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow'; import { useVaultState } from '../application/state/useVaultState'; import { useWindowControls } from '../application/state/useWindowControls'; import { shouldCloseTerminalPopupOnExit, shouldRevealTerminalPopupOnExit, } from '../application/state/resolveTerminalSessionExitIntent'; import { upsertKnownHost } from '../domain/knownHosts'; import { isPluginHostProtocol, sanitizePluginConnection } from '../domain/pluginConnection'; import { resolveTerminalChainHosts, resolveTerminalSessionHost } from '../domain/terminalHostResolution'; import type { TerminalPopupPayload } from '../domain/systemManager/types'; import type { GroupConfig, Host, ProxyProfile, TerminalTheme } from '../domain/models'; import type { KnownHost } from '../types'; import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers'; import { detectLocalOs } from '../lib/localShell'; import { cn } from '../lib/utils'; import type { AppLockGateRenderContext } from './AppLockGate'; import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost'; const Terminal = lazy(() => import('./Terminal')); const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); const POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS = 900; const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500; const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000; type PopupThemeVars = React.CSSProperties & Record; type SettingsState = AppLockGateRenderContext["settings"]; const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => { const { colors } = theme; return { '--terminal-popup-bg': colors.background, '--terminal-popup-fg': colors.foreground, '--terminal-popup-muted': colors.foreground, '--terminal-popup-accent': colors.cursor, '--terminal-popup-control-hover': `color-mix(in srgb, ${colors.foreground} 10%, transparent)`, }; }; function TerminalPopupWindowControls({ mac, onClose }: { mac: boolean; onClose: () => void }) { const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls(); const [isWindowMaximized, setIsWindowMaximized] = useState(false); useEffect(() => { let cancelled = false; void fetchIsMaximized().then((value) => { if (!cancelled) setIsWindowMaximized(!!value); }); const handleResize = () => { void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value)); }; window.addEventListener('resize', handleResize); return () => { cancelled = true; window.removeEventListener('resize', handleResize); }; }, [fetchIsMaximized]); const handleMaximize = async () => { const value = await maximize(); setIsWindowMaximized(!!value); }; if (mac) return null; const buttonClass = 'app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-muted)] transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:text-[color:var(--terminal-popup-fg)]'; return (
); } function TerminalPopupSpinner() { return (
); } function TerminalPopupBlank() { return (
); } function TerminalPopupStartupError({ message, closeLabel, onClose, }: { message: string; closeLabel: string; onClose: () => void; }) { return (
{message}
); } function TerminalPopupTitleIcon({ icon }: { icon: TerminalPopupPayload['icon'] }) { if (!icon) return null; if (icon.kind !== 'image' || !icon.src) return null; return ( {icon.alt ); } function resolveHostProtocolFromSourceSession( source: TerminalPopupPayload['sourceSession'], attachExistingSession: boolean, ): Host['protocol'] { if ( source.protocol === 'local' || source.protocol === 'telnet' || isPluginHostProtocol(source.protocol) || (attachExistingSession && source.protocol === 'serial') ) { return source.protocol; } return 'ssh'; } function applySourceSessionConnectionOverrides( host: Host, source: TerminalPopupPayload['sourceSession'], attachExistingSession: boolean, ): Host { const protocol = resolveHostProtocolFromSourceSession(source, attachExistingSession); const pluginConnection = isPluginHostProtocol(protocol) ? sanitizePluginConnection(source.pluginConnection, protocol) ?? sanitizePluginConnection(host.pluginConnection, protocol) : undefined; const { pluginConnection: _pluginConnection, ...baseHost } = host; return { ...baseHost, hostname: source.hostname || host.hostname, username: source.username || host.username, port: source.port ?? (protocol === 'local' ? undefined : host.port), protocol, moshEnabled: source.moshEnabled === true, etEnabled: source.etEnabled === true, charset: source.charset ?? host.charset, ...(pluginConnection ? { pluginConnection } : {}), ...(protocol === 'serial' && source.serialConfig ? { serialConfig: source.serialConfig } : {}), }; } export function resolveTerminalPopupHost( config: TerminalPopupPayload, hosts: Host[], options: { groupConfigs?: GroupConfig[]; proxyProfiles?: ProxyProfile[]; localOs?: Host['os']; } = {}, ): Host { const resolvedHost = resolveTerminalSessionHost({ session: config.sourceSession, hosts, groupConfigs: options.groupConfigs ?? [], proxyProfiles: options.proxyProfiles ?? [], localOs: options.localOs ?? 'linux', }); return applySourceSessionConnectionOverrides( resolvedHost, config.sourceSession, Boolean(config.attachSessionId), ); } export function resolveTerminalPopupReuseId(config: TerminalPopupPayload): string | undefined { return config.sourceSession.reuseConnectionFromSessionId; } function TerminalPopupPageInner({ settings, allowTerminalStart = true, }: { settings: SettingsState; /** When false (app locked), keep config listeners mounted but do not start Terminal. */ allowTerminalStart?: boolean; }) { const { t } = useI18n(); const { close, markAttachClosePrepared, onPopupConfig, onPrepareClose, setWindowTitle, } = useTerminalPopupWindow(); const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls(); const { isInitialized: vaultInitialized, hosts, keys, identities, proxyProfiles, knownHosts, snippets, snippetPackages, groupConfigs, updateKnownHosts, deleteSelectedSnippets, } = useVaultState(); const [config, setConfig] = useState(null); const [terminalReady, setTerminalReady] = useState(false); const [startupError, setStartupError] = useState(null); const generatedSessionId = useMemo(() => crypto.randomUUID(), []); const attachSessionId = config?.attachSessionId; const attachAuthorization = config?.attachAuthorization; const isAttachMode = Boolean(attachSessionId); // Attach mode must reuse the live backend session id so input/output hit the same PTY. const sessionId = attachSessionId || generatedSessionId; const knownHostsRef = React.useRef(knownHosts); const attachClosePreparationRef = React.useRef<(() => Promise) | null>(null); const closePromiseRef = React.useRef | null>(null); const handleAttachClosePreparationChange = useCallback((prepare: (() => Promise) | null) => { attachClosePreparationRef.current = prepare; }, []); const handleClose = useCallback(() => { if (closePromiseRef.current) return closePromiseRef.current; const closePromise = (async () => { if (isAttachMode && attachSessionId && attachAuthorization) { try { const preparation = attachClosePreparationRef.current?.(); if (preparation) { await Promise.race([ preparation, new Promise((_, reject) => setTimeout( () => reject(new Error("Attach close preparation timed out")), 1500, )), ]); } await markAttachClosePrepared(attachSessionId, attachAuthorization); } catch { /* The main-process close handshake owns the final fallback. */ } } await close(); })(); closePromiseRef.current = closePromise; const clearClosePromise = () => { if (closePromiseRef.current === closePromise) closePromiseRef.current = null; }; void closePromise.then(clearClosePromise, clearClosePromise); return closePromise; }, [attachAuthorization, attachSessionId, close, isAttachMode, markAttachClosePrepared]); const effectiveKnownHosts = useMemo( () => getEffectiveKnownHosts(knownHosts) ?? [], [knownHosts], ); knownHostsRef.current = effectiveKnownHosts; const handleAddKnownHost = useCallback((knownHost: KnownHost) => { const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost); knownHostsRef.current = nextKnownHosts; updateKnownHosts(nextKnownHosts); }, [updateKnownHosts]); const popupThemeVars = useMemo( () => buildPopupThemeVars(settings.currentTerminalTheme), [settings.currentTerminalTheme], ); useEffect(() => { const unsubscribe = onPopupConfig((payload) => { setConfig(payload); if (payload.title) { void setWindowTitle(payload.title); } }); // Popup config may already be buffered by preload, so register the listener // before reporting renderer readiness or the buffered payload can be lost. notifyRendererReady(); return unsubscribe; }, [notifyRendererReady, onPopupConfig, setWindowTitle]); useEffect(() => { return onWindowCommandCloseRequested(() => { void handleClose(); }); }, [handleClose, onWindowCommandCloseRequested]); useEffect(() => { return onPrepareClose((payload) => { if (payload.sessionId !== attachSessionId || payload.authorization !== attachAuthorization) return; void handleClose(); }); }, [attachAuthorization, attachSessionId, handleClose, onPrepareClose]); const host = useMemo(() => { if (!config) return null; return resolveTerminalPopupHost(config, hosts, { groupConfigs, proxyProfiles, localOs: detectLocalOs(navigator.userAgent || navigator.platform), }); }, [config, groupConfigs, hosts, proxyProfiles]); const chainHosts = useMemo(() => resolveTerminalChainHosts({ host, hosts, groupConfigs, proxyProfiles, }), [groupConfigs, host, hosts, proxyProfiles]); const reuseId = useMemo(() => { if (!config) return undefined; return resolveTerminalPopupReuseId(config); }, [config]); // Defer *first* backend start while app-locked so startup commands/credentials // do not run behind the overlay. Once a session has started, keep it mounted // across re-locks so the popup is not torn down mid-session (Codex P2). const readyToStart = Boolean(config && host && vaultInitialized && allowTerminalStart); const [sessionStarted, setSessionStarted] = useState(false); useEffect(() => { if (readyToStart) setSessionStarted(true); }, [readyToStart]); const ready = sessionStarted || readyToStart; const startupRevealDelayMs = useMemo(() => { // Attach mode shows the live session immediately (no startup command). if (isAttachMode) return 0; if (!config?.startupCommand) return 0; const configuredDelay = settings.terminalSettings?.startupCommandDelayMs; const startupDelay = typeof configuredDelay === 'number' && Number.isFinite(configuredDelay) ? Math.max(0, configuredDelay) : 600; return Math.min( POPUP_STARTUP_REVEAL_MAX_DELAY_MS, Math.max(POPUP_STARTUP_REVEAL_MIN_DELAY_MS, startupDelay + POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS), ); }, [config?.startupCommand, isAttachMode, settings.terminalSettings?.startupCommandDelayMs]); const revealTerminal = useCallback(() => { setTerminalReady(true); }, []); useEffect(() => { setTerminalReady(false); setStartupError(null); }, [config?.popupId, sessionId]); useEffect(() => { if (!ready) return undefined; if (isAttachMode) { setTerminalReady(true); return undefined; } const timeout = window.setTimeout(() => setTerminalReady(true), startupRevealDelayMs); return () => window.clearTimeout(timeout); }, [config?.popupId, isAttachMode, ready, startupRevealDelayMs]); return (
{isMac &&
}
{config?.title ?? ''}
{!isMac && void handleClose()} />}
{!ready || !config || !host ? ( ) : startupError ? ( void handleClose()} /> ) : (
}> { void handleClose(); }} onSessionExit={(_closedSessionId, evt) => { if (shouldCloseTerminalPopupOnExit(evt, { autoCloseOnExit: settings.terminalSettings.autoCloseOnExit, isAttachMode, })) { void handleClose(); return; } if (shouldRevealTerminalPopupOnExit(evt, { autoCloseOnExit: settings.terminalSettings.autoCloseOnExit, isAttachMode, })) { revealTerminal(); return; } if (!terminalReady && config.startupCommand && !isAttachMode) { setStartupError(t('systemManager.popup.startupFailed')); } }} onStatusChange={(_changedSessionId, status) => { if (isAttachMode && status === 'connected') revealTerminal(); if (!config.startupCommand && status === 'connected') revealTerminal(); }} onTerminalDataCapture={revealTerminal} /> {!terminalReady && (
)}
)}
); } export default function TerminalPopupPage({ settings, allowTerminalStart = true, }: { settings: SettingsState; allowTerminalStart?: boolean; }) { return ( ); }