import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react'; import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { activeTabStore } from '../application/state/activeTabStore'; import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts'; import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse'; import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent'; import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots'; import { getSessionActivityIdsToClear, getValidSessionActivityIds, shouldMarkSessionActivity, } from '../application/state/sessionActivity'; import { sessionActivityStore } from '../application/state/sessionActivityStore'; import { sessionCapabilitiesStore } from '../application/state/sessionCapabilitiesStore'; import { useTerminalBackend } from '../application/state/useTerminalBackend'; import { useCodingCliSessionSignals, } from '../application/state/codingCliSessionSignalController'; import { collectSessionIds } from '../domain/workspace'; import { isPluginHostProtocol } from '../domain/pluginConnection'; import { cn, normalizeLineEndings } from '../lib/utils'; import { detectLocalOs } from '../lib/localShell'; import { useStoredString } from '../application/state/useStoredString'; import { useStoredNumber } from '../application/state/useStoredNumber'; import { useStoredBoolean } from '../application/state/useStoredBoolean'; import { STORAGE_KEY_SIDE_PANEL_WIDTH, STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN, } from '../infrastructure/config/storageKeys'; import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache'; import { getSftpReopenMemoryKey, resolveSftpOpenLocation, type SftpRememberedLocation, } from '../application/state/sftp/sftpReopenLocation'; import type { DropEntry } from '../lib/sftpFileUtils'; import { Host, KnownHost, TerminalSession, Workspace } from '../types'; import { applySessionFontSizeToHost } from '../domain/terminalAppearance'; import { resolveHostAutofillPassword } from '../domain/sshAuth'; import { listPasswordPromptFillCandidates } from '../domain/passwordPromptAssist'; import { isTerminalSensitiveInputActive } from './terminal/runtime/terminalSensitiveInputRegistry'; import { resolveEffectiveTerminalHost, resolveTerminalChainHosts, resolveTerminalSessionHost, } from '../domain/terminalHostResolution'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { toast } from './ui/toast'; import { useI18n } from '../application/i18n/I18nProvider'; import { SftpSidePanel } from './SftpSidePanel'; import { ScriptsSidePanel } from './ScriptsSidePanel'; import { HistorySidePanel } from './HistorySidePanel'; import { NotesManager } from './notes/NotesManager'; import { terminalCwdStore } from '../application/state/terminalCwdStore'; import { resolveSnippetCommand } from './SnippetExecutionProvider'; import type { Snippet } from '../types'; import { isScriptSnippet } from '../domain/snippetScript.ts'; import { pauseScriptRun, resumeScriptRun, runAutomationScript, stopScriptRun, waitForScriptRun, } from '../application/state/scriptAutomationCoordinator'; import { ThemeSidePanel } from './terminal/ThemeSidePanel'; import { focusTerminalSessionInput } from './terminal/focusTerminalSession'; import { TerminalComposeBar } from './terminal/TerminalComposeBar'; import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate'; import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast'; import { AUTO_RUN_SNIPPET_LINE_DELAY_MS, shouldDelayAutoRunSnippetInput, type TerminalBroadcastInputOptions, } from './terminal/terminalHelpers'; import { dispatchKittyKeyboardBroadcastInput } from './terminal/runtime/kittyKeyboardBroadcast'; import { Button } from './ui/button'; import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent'; import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent'; import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent'; import { terminalLayerAreEqual } from './terminalLayerMemo'; import { TerminalLayerTabBridge } from './terminalLayer/TerminalLayerTabBridge'; import { clearTerminalSessionRuntimeState, pruneTerminalTabMemoryState, pruneTerminalSessionRuntimeState, } from './terminalLayer/useTerminalLayerEffects'; import { sftpTransferCenterStore } from '../application/state/sftpTransferCenterStore'; import { SFTP_TRANSFER_HISTORY_RETENTION_MS, listInvalidSftpPanelTabIds, listTerminalTabIdsWithRetainingTransfers, resolveSftpActiveTransfersCount, shouldCloseSftpSidePanel, shouldClearSftpPanelAfterTransferChange, shouldKeepSftpMountedAfterClose, shouldMarkSftpPaneClosed, shouldScheduleSftpRetainedPanelCleanup, terminalSftpTransferOwnerId, } from './terminalLayer/sftpPanelLifecycle'; import { resolveAiNoteArtifactPanelIntent } from './terminalLayer/aiNoteArtifactPanelIntent'; import { canUseDirectSessionWriteFallback, } from './terminalLayer/terminalLayerSessionRouting'; import { DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, resolveTerminalSidePanelAutoOpen, } from '../domain/terminalSidePanelAutoOpen'; import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe'; import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand, type RendererCwdSource, type TerminalCwdChangeMeta, } from './terminal/sftpCwd'; import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host'; import { collectSidePanelPanes, sidePanelLayoutHasTool, type SidePanelSplitDirection, } from '../domain/sidePanelLayout'; import { isPaneMagnificationSelectionValid, resolvePaneMagnificationCandidate, type PaneMagnificationController, type PaneMagnificationTarget, } from '../domain/paneMagnification'; import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState'; import { TERMINAL_SIDE_PANEL_MAX_WIDTH, TERMINAL_SIDE_PANEL_MIN_WIDTH, } from '../application/state/terminalSidePanelWidth'; import { AIChatPanelsHost, AISidePanelStateRoot, AIStateMaintenanceHost, AIStateProvider, ChunkedEscapeFilter, clearHostTreePreviewVars, TerminalPanesHost, clearTerminalPreviewVars, clearTopTabsPreviewVars, filterTabsMap, hasNotifiableTerminalOutput, type PendingSftpUpload, type PendingTerminalSelectionForAI, type SidePanelTab, type SnippetExecutor, type TerminalLayerProps, } from './terminalLayer/TerminalLayerSupport'; const addMountedSidePanelTabId = ( tabIds: string[], tabId: string, ): string[] => (tabIds.includes(tabId) ? tabIds : [...tabIds, tabId]); const removeMountedSidePanelTabId = ( tabIds: string[], tabId: string, ): string[] => tabIds.filter((id) => id !== tabId); function buildScriptSessionMeta( sessionId: string, sessions: TerminalSession[], hosts: Host[], ) { const session = sessions.find((entry) => entry.id === sessionId); if (!session) return undefined; const host = hosts.find((entry) => entry.id === session.hostId); return { connected: session.status === 'connected', name: session.customName || session.hostLabel || host?.label, hostname: host?.hostname ?? session.hostname, username: host?.username ?? session.username, }; } const TerminalLayerInner: React.FC = ({ hosts, portForwardingRules = [], customGroups, groupConfigs, proxyProfiles, keys, identities, snippets, snippetPackages, openNoteRequest, onOpenVaultNoteFromChat, onOpenVaultHostFromChat, onOpenVaultSectionFromChat, onOpenVaultSnippetFromChat, sessions, workspaces, knownHosts = [], draggingSessionId, terminalTheme, terminalThemeId = terminalTheme.id, followAppTerminalTheme = false, pickTerminalTheme, clearThemeIntent, settleManualThemeIntent, resolveSessionAppearance, accentMode = 'theme', customAccent = '', terminalSettings, terminalFontFamilyId, fontSize = 14, hotkeyScheme = 'disabled', disableTerminalFontZoom = false, restoreTerminalCwd = false, keyBindings = [], onHotkeyAction, onUpdateTerminalThemeId, onUpdateTerminalFontFamilyId, onUpdateTerminalFontSize, onUpdateTerminalFontWeight, onUpdateSessionFontSize, onUpdateSessionRestoreCwd, onUpdateSessionDynamicTitle, onUpdateSessionCodingCliProvider, onClearSessionFontSizeOverride, onCloseSession, onUpdateSessionStatus, onUpdateHostDistro, onUpdateHost, onAddKnownHost, onCommandExecuted, onDeleteShellHistoryEntry, onTerminalDataCapture, onCreateWorkspaceFromSessions, onAddSessionToWorkspace, onRequestAddToWorkspace, onAppendHostToWorkspace, onUpdateSplitSizes, onSetDraggingSessionId, onToggleWorkspaceViewMode, onSetWorkspaceFocusedSession, onReorderWorkspaceSessions, onReorderTabs, onCopySession, onDuplicateSession, onCopySessionToNewWindow, onSplitSession, onConnectToHost, onCreateLocalTerminal, isBroadcastEnabled, onToggleBroadcast, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, updateHosts, updateSnippets, updateSnippetPackages, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, terminalSidePanelAutoOpen = false, terminalSidePanelAutoOpenTab = DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, editorWordWrap, setEditorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, showHostTreeSidebar = true, toggleScriptsSidePanelRef, toggleSidePanelRef, paneMagnificationRef, // Session rename props onStartSessionRename, onSubmitSessionRename, onRemoveSessionFromWorkspace, }) => { const { t } = useI18n(); const [magnifiedPane, setMagnifiedPane] = useState<{ tabId: string; target: PaneMagnificationTarget; } | null>(null); const magnifiedPaneRef = useRef(magnifiedPane); magnifiedPaneRef.current = magnifiedPane; const lastInteractedPaneRef = useRef>(new Map()); // Side panel state must be initialized before any callbacks reference its // setters or refs in their dependency arrays. const { sidePanelOpenTabs, setSidePanelOpenTabs, sidePanelOpenTabsRef, sidePanelLayouts, setSidePanelLayouts, sidePanelLayoutsRef, focusPane: focusSidePanelPaneForTab, splitPane: splitSidePanelPaneForTab, closePane: closeSidePanelPaneForTab, resizeSplit: resizeSidePanelSplitForTab, } = useTerminalSidePanelLayoutState(); useEffect(() => { setMagnifiedPane((current) => { if (!current) return current; const terminalPanes = sessions.map((session) => ({ tabId: session.workspaceId ?? session.id, sessionId: session.id, })); const sidePanelPanes = Array.from(sidePanelLayouts.entries()).flatMap(([tabId, layout]) => ( collectSidePanelPanes(layout.root).map((pane) => ({ tabId, paneId: pane.id })) )); return isPaneMagnificationSelectionValid(current, terminalPanes, sidePanelPanes) ? current : null; }); }, [sessions, sidePanelLayouts]); const terminalRendererCwdBySessionRef = useRef>(new Map()); const terminalRendererCwdSourceBySessionRef = useRef>(new Map()); const stableRef = useRef>({}); const activeTabIdRef = useRef(activeTabStore.getActiveTabId()); const activeWorkspaceRef = useRef(undefined); const activeSessionRef = useRef(undefined); const focusedSessionIdRef = useRef(undefined); const terminalOsc7SignalBySessionRef = useRef>(new Map()); const cwdProbeCancelersRef = useRef void>>(new Map()); const cwdProbeGenerationRef = useRef>(new Map()); useEffect(() => { const liveSessionIds = new Set(sessions.map((session) => session.id)); pruneTerminalSessionRuntimeState({ terminalRendererCwdBySessionRef, terminalRendererCwdSourceBySessionRef, terminalOsc7SignalBySessionRef, cwdProbeGenerationRef, cwdProbeCancelersRef, }, liveSessionIds); terminalCwdStore.prune(liveSessionIds); }, [sessions]); useEffect(() => { const runPrewarm = () => prewarmAIStateStorageSnapshots(); if (typeof window.requestIdleCallback === 'function') { const idleId = window.requestIdleCallback(runPrewarm, { timeout: 2500 }); return () => window.cancelIdleCallback(idleId); } const timeoutId = window.setTimeout(runPrewarm, 500); return () => window.clearTimeout(timeoutId); }, []); const handleTerminalCwdChange = useCallback(( sessionId: string, cwd: string | null, meta?: TerminalCwdChangeMeta, ) => { if (meta?.source === 'osc7') { // Bump on every OSC 7 report, even when the decoded path is unchanged. // PROMPT_COMMAND/precmd emits OSC 7 after each command; skipping the // backend pwd probe in that case prevents SFTP follow from toggling // between OSC 7 cwd and login-shell fallback pwd (notably after sudo). const nextSignal = (terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0) + 1; terminalOsc7SignalBySessionRef.current.set(sessionId, nextSignal); } const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null; const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null; const nextCwdSource: RendererCwdSource = meta?.source ?? 'backend'; if (currentCwd === nextCwd) { if (nextCwd) { terminalRendererCwdSourceBySessionRef.current.set(sessionId, nextCwdSource); } // Heal store drift and publish provenance changes even when the path is unchanged. terminalCwdStore.setCwd(sessionId, nextCwd, nextCwd ? nextCwdSource : undefined); return; } if (nextCwd) { terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd); terminalRendererCwdSourceBySessionRef.current.set(sessionId, nextCwdSource); } else { terminalRendererCwdBySessionRef.current.delete(sessionId); terminalRendererCwdSourceBySessionRef.current.delete(sessionId); } onUpdateSessionRestoreCwd?.(sessionId, nextCwd); // External store: side-panel live snapshot subscribers update without // re-rendering TerminalLayerInner. terminalCwdStore.setCwd(sessionId, nextCwd, nextCwd ? nextCwdSource : undefined); }, [onUpdateSessionRestoreCwd]); // Stable while only presentation fields (title/provider) change. const codingCliSessionIdsKey = sessions.map((session) => session.id).join('\0'); const codingCliSessionIds = useMemo( () => (codingCliSessionIdsKey ? codingCliSessionIdsKey.split('\0') : []), [codingCliSessionIdsKey], ); const codingCliSignalController = useCodingCliSessionSignals({ dynamicTabTitleMode: terminalSettings?.dynamicTabTitleMode ?? 'agent', sessionIds: codingCliSessionIds, getSession: (sessionId) => sessionsRef.current.find((candidate) => candidate.id === sessionId), onUpdateSessionCodingCliProvider, onUpdateSessionDynamicTitle, }); const handleTerminalTitleChange = codingCliSignalController.handleTerminalTitleChange; const handleTerminalOutput = codingCliSignalController.handleTerminalOutput; const handleTerminalBell = useCallback((sessionId: string) => { const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); if (!session) return; if (!shouldMarkSessionActivity(activeTabStore.getActiveTabId(), session)) return; sessionActivityStore.setTabActive(sessionId, true); }, []); // Stable callback references for Terminal components const handleCloseSession = useCallback((sessionId: string) => { setMagnifiedPane((current) => ( current?.target.kind === 'terminal' && current.target.sessionId === sessionId ? null : current )); for (const [tabId, target] of lastInteractedPaneRef.current) { if (target.kind === 'terminal' && target.sessionId === sessionId) { lastInteractedPaneRef.current.delete(tabId); } } clearTerminalSessionRuntimeState({ terminalRendererCwdBySessionRef, terminalOsc7SignalBySessionRef, cwdProbeGenerationRef, cwdProbeCancelersRef, }, sessionId); codingCliSignalController.forgetSession(sessionId); sessionCapabilitiesStore.delete(sessionId); onCloseSession(sessionId); }, [codingCliSignalController, onCloseSession]); const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar); sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar; const terminalSidePanelAutoOpenRef = useRef(terminalSidePanelAutoOpen); terminalSidePanelAutoOpenRef.current = terminalSidePanelAutoOpen; const terminalSidePanelAutoOpenTabRef = useRef(terminalSidePanelAutoOpenTab); terminalSidePanelAutoOpenTabRef.current = terminalSidePanelAutoOpenTab; const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd); sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd; const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => { onUpdateSessionStatus(sessionId, status); if (status !== 'connected') return; const session = sessionsRef.current.find(s => s.id === sessionId); if (!session) return; const proto = session.protocol; const sftpAvailable = proto === 'ssh' || proto === 'mosh'; const tabId = session.workspaceId || sessionId; if (sidePanelOpenTabsRef.current.has(tabId)) return; const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null; const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({ enabled: terminalSidePanelAutoOpenRef.current, selectedTab: terminalSidePanelAutoOpenTabRef.current, sftpAvailable, }); const targetPanel = autoOpenTarget ?? (sftpAutoOpenSidebarRef.current && sftpAvailable ? 'sftp' : null); if (!targetPanel) return; lastSidePanelTabRef.current.set(tabId, targetPanel); if (targetPanel === 'sftp') { sftpPaneClosedTabIdsRef.current.delete(tabId); const host = hostsRef.current.find(h => h.id === session.hostId); const hostWithOverrides: Host = host ? { ...host, protocol: session.protocol ?? host.protocol, port: session.port ?? host.port, moshEnabled: session.moshEnabled ?? host.moshEnabled, etEnabled: session.etEnabled ?? host.etEnabled, } : { id: session.hostId || sessionId, hostname: session.hostname, username: session.username, port: session.port ?? 22, protocol: proto, label: session.label || session.hostname, } as Host; setSftpHostForTab(prev => { const next = new Map(prev); next.set(tabId, hostWithOverrides); return next; }); } else if (targetPanel === 'ai') { setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } else if (targetPanel === 'scripts') { setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } else if (targetPanel === 'theme') { setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } else if (targetPanel === 'system') { setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } else if (targetPanel === 'notes') { setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } setSidePanelOpenTabs(prev => { const next = new Map(prev); next.set(tabId, targetPanel); return next; }); }, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]); const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => { const intent = resolveTerminalSessionExitIntent( evt, terminalSettings?.autoCloseOnExit ?? true, ); if (intent.kind === "closeSession") { handleCloseSession(sessionId); } else { onUpdateSessionStatus(sessionId, 'disconnected'); } }, [handleCloseSession, onUpdateSessionStatus, terminalSettings?.autoCloseOnExit]); const handleOsDetected = useCallback((hostId: string, distro: string) => { onUpdateHostDistro(hostId, distro); }, [onUpdateHostDistro]); const handleUpdateHost = useCallback((host: Host) => { onUpdateHost(host); }, [onUpdateHost]); const handleAddKnownHost = useCallback((knownHost: KnownHost) => { onAddKnownHost?.(knownHost); }, [onAddKnownHost]); const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => { onTerminalDataCapture?.(sessionId, data); }, [onTerminalDataCapture]); // Terminal backend for broadcast writes const terminalBackend = useTerminalBackend(); const snippetExecutorsRef = useRef>(new Map()); const broadcastInterruptPrioritizersRef = useRef void>>(new Map()); const programmaticCommandLogRewriteHandlersRef = useRef void>>(new Map()); const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => { if (executor) { snippetExecutorsRef.current.set(sessionId, executor); return; } snippetExecutorsRef.current.delete(sessionId); }, []); const handleBroadcastInterruptPriorityChange = useCallback(( sessionId: string, prioritize: (() => void) | null, ) => { if (prioritize) { broadcastInterruptPrioritizersRef.current.set(sessionId, prioritize); return; } broadcastInterruptPrioritizersRef.current.delete(sessionId); }, []); const handleProgrammaticCommandLogRewriteChange = useCallback(( sessionId: string, queueRewrite: ((rewrite: ProgrammaticCommandLogRewrite) => void) | null, ) => { if (queueRewrite) { programmaticCommandLogRewriteHandlersRef.current.set(sessionId, queueRewrite); return; } programmaticCommandLogRewriteHandlersRef.current.delete(sessionId); }, []); const onSessionData = terminalBackend.onSessionData; const workspaceById = useMemo( () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces], ); // Workspace-level compose bar state const [isComposeBarOpen, setIsComposeBarOpen] = useStoredBoolean( STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN, false, ); const sessionsRef = useRef(sessions); sessionsRef.current = sessions; const workspacesRef = useRef(workspaces); workspacesRef.current = workspaces; const hostsRef = useRef(hosts); hostsRef.current = hosts; const portForwardingRulesRef = useRef(portForwardingRules); portForwardingRulesRef.current = portForwardingRules; const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession); onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession; // Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP). const [aiMountedTabIds, setAiMountedTabIds] = useState([]); const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState([]); const [systemMountedTabIds, setSystemMountedTabIds] = useState([]); const [themeMountedTabIds, setThemeMountedTabIds] = useState([]); const [notesMountedTabIds, setNotesMountedTabIds] = useState([]); const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber( STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH }, ); const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>( 'netcatty_side_panel_position', 'left', (v): v is 'left' | 'right' => v === 'left' || v === 'right', ); // Remember the last sub-panel shown per tab so the toggle shortcut can // restore it after a close. Overwritten on open, never cleared on close. const lastSidePanelTabRef = useRef>(new Map()); const notesReturnTabRef = useRef>(new Map()); // The host to pass to the SFTP panel - stored when the user opens SFTP const [sftpHostForTab, setSftpHostForTab] = useState>(new Map()); const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState< Map >(new Map()); const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState< Map >(new Map()); const [pendingTerminalSelectionForAI, setPendingTerminalSelectionForAI] = useState(null); const [notesOpenNoteByTab, setNotesOpenNoteByTab] = useState>(new Map()); const notesOpenRequestIdRef = useRef(0); const sftpHostForTabRef = useRef(sftpHostForTab); sftpHostForTabRef.current = sftpHostForTab; const sftpActiveTransfersByTabRef = useRef>(new Map()); const sftpActiveExternalEditsByTabRef = useRef>(new Map()); const sftpRetainedAfterCloseTabIdsRef = useRef>(new Set()); const sftpPaneClosedTabIdsRef = useRef>(new Set()); const sftpOpeningTabIdsRef = useRef>(new Set()); const sftpRetainedCleanupTimersRef = useRef>(new Map()); const sftpLastPathForSourceRef = useRef>(new Map()); const resolveSftpOpenTarget = useCallback((params: { tabId: string; host: Host; initialPath?: string; pendingUploadEntries?: DropEntry[]; originSessionId?: string | null; }) => { const { tabId, host, initialPath, pendingUploadEntries, originSessionId } = params; const connectionKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol); const memoryKey = getSftpReopenMemoryKey({ tabId, sourceSessionId: originSessionId }); const effectiveInitialPath = resolveSftpOpenLocation({ hostId: host.id, connectionKey, terminalCwd: initialPath, explicitTargetPath: pendingUploadEntries?.length ? initialPath : undefined, hasPendingUpload: !!pendingUploadEntries?.length, remembered: sftpLastPathForSourceRef.current.get(memoryKey), }); return { connectionKey, effectiveInitialPath }; }, []); const clearSftpPanelState = useCallback((tabId: string) => { const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId); if (cleanupTimer !== undefined) { window.clearTimeout(cleanupTimer); sftpRetainedCleanupTimersRef.current.delete(tabId); } sftpActiveTransfersByTabRef.current.delete(tabId); sftpActiveExternalEditsByTabRef.current.delete(tabId); sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); sftpPaneClosedTabIdsRef.current.delete(tabId); sftpOpeningTabIdsRef.current.delete(tabId); setSftpHostForTab(prev => { if (!prev.has(tabId)) return prev; const next = new Map(prev); next.delete(tabId); return next; }); setSftpPendingUploadsForTab(prev => { if (!prev.has(tabId)) return prev; const next = new Map(prev); next.delete(tabId); return next; }); setSftpInitialLocationForTab(prev => { if (!prev.has(tabId)) return prev; const next = new Map(prev); next.delete(tabId); return next; }); }, []); // Panel-reported count can lag a tick; the global store is the authority for // unfinished work under this tab's transfer owner (terminal:). const resolveTabActiveTransfersCount = useCallback((tabId: string, reportedCount?: number) => { return resolveSftpActiveTransfersCount({ reportedCount: reportedCount ?? sftpActiveTransfersByTabRef.current.get(tabId) ?? 0, storeTasks: sftpTransferCenterStore.getSnapshot().tasks, ownerId: terminalSftpTransferOwnerId(tabId), }); }, []); const resolveTabActiveExternalEditCount = useCallback((tabId: string, reportedCount?: number) => { return Math.max(0, reportedCount ?? sftpActiveExternalEditsByTabRef.current.get(tabId) ?? 0); }, []); const evaluateSftpPanelRetentionAfterActivity = useCallback((tabId: string, params: { activeTransfersCount: number; activeExternalEditCount: number; }) => { const { activeTransfersCount, activeExternalEditCount } = params; if (activeTransfersCount > 0 || activeExternalEditCount > 0) { const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId); if (cleanupTimer !== undefined) { window.clearTimeout(cleanupTimer); sftpRetainedCleanupTimersRef.current.delete(tabId); } return; } const lifecycle = { activeTransfersCount, activeExternalEditCount, panelOpen: sidePanelOpenTabsRef.current.has(tabId) || sftpOpeningTabIdsRef.current.has(tabId), retainedAfterClose: sftpRetainedAfterCloseTabIdsRef.current.has(tabId), }; if (shouldClearSftpPanelAfterTransferChange(lifecycle)) { clearSftpPanelState(tabId); return; } if ( shouldScheduleSftpRetainedPanelCleanup({ activeTransfersCount: lifecycle.activeTransfersCount, activeExternalEditCount: lifecycle.activeExternalEditCount, retainedAfterClose: lifecycle.retainedAfterClose, }) && !sftpRetainedCleanupTimersRef.current.has(tabId) ) { const cleanupTimer = window.setTimeout(() => { sftpRetainedCleanupTimersRef.current.delete(tabId); const currentTransfers = resolveTabActiveTransfersCount(tabId); const currentExternalEdits = resolveTabActiveExternalEditCount(tabId); if ( currentTransfers <= 0 && currentExternalEdits <= 0 && !sidePanelOpenTabsRef.current.has(tabId) && !sftpOpeningTabIdsRef.current.has(tabId) && sftpRetainedAfterCloseTabIdsRef.current.has(tabId) ) { clearSftpPanelState(tabId); } }, SFTP_TRANSFER_HISTORY_RETENTION_MS); sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer); } }, [ clearSftpPanelState, resolveTabActiveExternalEditCount, resolveTabActiveTransfersCount, sidePanelOpenTabsRef, ]); const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => { const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count)); if (activeTransfersCount > 0) { sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount); } else { sftpActiveTransfersByTabRef.current.delete(tabId); } evaluateSftpPanelRetentionAfterActivity(tabId, { activeTransfersCount, activeExternalEditCount: resolveTabActiveExternalEditCount(tabId), }); }, [ evaluateSftpPanelRetentionAfterActivity, resolveTabActiveExternalEditCount, resolveTabActiveTransfersCount, ]); const handleSftpActiveExternalEditsChange = useCallback((tabId: string, count: number) => { const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId, Math.max(0, count)); if (activeExternalEditCount > 0) { sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount); } else { sftpActiveExternalEditsByTabRef.current.delete(tabId); } evaluateSftpPanelRetentionAfterActivity(tabId, { activeTransfersCount: resolveTabActiveTransfersCount(tabId), activeExternalEditCount, }); }, [ evaluateSftpPanelRetentionAfterActivity, resolveTabActiveExternalEditCount, resolveTabActiveTransfersCount, ]); const closeTerminalSidePanelTab = useCallback((tabId: string) => { setSidePanelOpenTabs((previous) => { const next = new Map(previous); next.delete(tabId); return next; }); sftpOpeningTabIdsRef.current.delete(tabId); const activeTransfersCount = resolveTabActiveTransfersCount(tabId); const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId); if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) { sftpRetainedAfterCloseTabIdsRef.current.add(tabId); if (activeTransfersCount > 0) { sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount); } if (activeExternalEditCount > 0) { sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount); } } else { clearSftpPanelState(tabId); } setAiMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId)); setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId)); setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId)); setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId)); setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId)); setNotesOpenNoteByTab((previous) => { const next = new Map(previous); next.delete(tabId); return next; }); notesReturnTabRef.current.delete(tabId); }, [ clearSftpPanelState, resolveTabActiveExternalEditCount, resolveTabActiveTransfersCount, setSidePanelOpenTabs, ]); const handleToggleWorkspaceComposeBar = useCallback(() => { setIsComposeBarOpen(prev => !prev); }, [setIsComposeBarOpen]); const handleOpenSftp = useCallback(( host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], originSessionId?: string, sourceSessionId?: string, ) => { const tabId = activeTabIdRef.current; if (!tabId) return; // When SFTP is opened from a non-focused workspace pane (toolbar click // or drag-drop), switch focus first so the SFTP panel binds to the // originating terminal. if (originSessionId) { const ws = activeWorkspaceRef.current; if (ws && ws.focusedSessionId !== originSessionId) { onSetWorkspaceFocusedSessionRef.current?.(ws.id, originSessionId); } } const currentPanel = sidePanelOpenTabsRef.current.get(tabId); const isOpen = currentPanel === 'sftp'; const currentHost = sftpHostForTabRef.current.get(tabId); const shouldKeepOpen = !!pendingUploadEntries?.length; // Compare full endpoint identity so that session-time overrides // (different port/protocol for the same host ID) trigger a switch // instead of toggling the panel closed. const isSameEndpoint = currentHost && currentHost.id === host.id && currentHost.hostname === host.hostname && currentHost.port === host.port && currentHost.protocol === host.protocol && currentHost.username === host.username && currentHost.sftpSudo === host.sftpSudo && (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto"); const currentLayout = sidePanelLayoutsRef.current.get(tabId); const paneCount = currentLayout ? collectSidePanelPanes(currentLayout.root).length : 1; const isClosing = shouldCloseSftpSidePanel({ shouldKeepOpen, isOpen, isSameEndpoint: !!isSameEndpoint, paneCount, }); if (isClosing) { closeTerminalSidePanelTab(tabId); return; } sftpOpeningTabIdsRef.current.add(tabId); const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId); if (cleanupTimer !== undefined) { window.clearTimeout(cleanupTimer); sftpRetainedCleanupTimersRef.current.delete(tabId); } sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); sftpPaneClosedTabIdsRef.current.delete(tabId); setSidePanelOpenTabs(prev => { const next = new Map(prev); next.set(tabId, 'sftp'); return next; }); setSftpHostForTab(prev => { const next = new Map(prev); next.set(tabId, host); return next; }); const { connectionKey, effectiveInitialPath } = resolveSftpOpenTarget({ tabId, host, initialPath, pendingUploadEntries, originSessionId, }); setSftpInitialLocationForTab(prev => { const next = new Map(prev); if (effectiveInitialPath) { next.set(tabId, { hostId: host.id, path: effectiveInitialPath }); } else { next.delete(tabId); } return next; }); setSftpPendingUploadsForTab(prev => { if (!pendingUploadEntries?.length) { // Opening the panel without new files must not discard drops that are // still queued and waiting to upload; leave the queue untouched. // Stale entries are cancelled by the panel itself (with a toast) when // their host/connection no longer matches. return prev; } const next = new Map(prev); // Queue the drop behind any earlier pending request for this tab so a // second drop that arrives before the first has started uploading // cannot silently discard the first batch. const queue = prev.get(tabId) ?? []; next.set(tabId, [ ...queue, { requestId: crypto.randomUUID(), hostId: host.id, connectionKey, originSessionId, sourceSessionId, targetPath: initialPath, entries: pendingUploadEntries, }, ]); return next; }); }, [closeTerminalSidePanelTab, resolveSftpOpenTarget, setSidePanelOpenTabs, sidePanelLayoutsRef, sidePanelOpenTabsRef]); const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => { setSftpPendingUploadsForTab(prev => { const queue = prev.get(tabId); if (!queue?.length) return prev; const index = queue.findIndex(item => item.requestId === requestId); if (index === -1) return prev; const next = new Map(prev); const remaining = queue.filter((_, itemIndex) => itemIndex !== index); if (remaining.length) { next.set(tabId, remaining); } else { next.delete(tabId); } return next; }); }, []); const handleSftpInitialLocationApplied = useCallback((tabId: string, location: { hostId: string; path: string }) => { setSftpInitialLocationForTab(prev => { const current = prev.get(tabId); if (!current || current.hostId !== location.hostId || current.path !== location.path) { return prev; } const next = new Map(prev); next.delete(tabId); return next; }); }, []); const handleSftpCurrentPathChange = useCallback((memoryKey: string, location: SftpRememberedLocation) => { if (!memoryKey || !location.path) return; sftpLastPathForSourceRef.current.set(memoryKey, location); }, []); // Pre-compute host lookup map for O(1) access const hostMap = useMemo(() => { const map = new Map(); for (const h of hosts) map.set(h.id, h); return map; }, [hosts]); const hostMapRef = useRef(hostMap); hostMapRef.current = hostMap; const proxyProfileIdSet = useMemo( () => new Set(proxyProfiles.map((profile) => profile.id)), [proxyProfiles], ); const effectiveHosts = useMemo( () => hosts.map((host) => resolveEffectiveTerminalHost({ host, groupConfigs, proxyProfiles, validProxyProfileIds: proxyProfileIdSet, })), [groupConfigs, hosts, proxyProfileIdSet, proxyProfiles], ); // Pre-compute fallback hosts to avoid creating new objects on every render const sessionHostsMap = useMemo(() => { const map = new Map(); for (const session of sessions) { const hostForSession = resolveTerminalSessionHost({ session, hosts, groupConfigs, proxyProfiles, localOs: detectLocalOs(navigator.userAgent || navigator.platform), }); map.set(session.id, applySessionFontSizeToHost(hostForSession, session)); } return map; }, [sessions, hosts, groupConfigs, proxyProfiles]); const resolvedSessionHostIds = useMemo(() => { const ids = new Set(); for (const session of sessions) { if (hostMap.has(session.hostId) || session.protocol === 'local') ids.add(session.id); } return ids; }, [hostMap, sessions]); const sessionChainHostsMap = useMemo(() => { const map = new Map(); for (const session of sessions) { const host = sessionHostsMap.get(session.id); const chainHosts = resolveTerminalChainHosts({ host, hosts, groupConfigs, proxyProfiles, validProxyProfileIds: proxyProfileIdSet, }); if (chainHosts.length > 0) map.set(session.id, chainHosts); } return map; }, [sessions, sessionHostsMap, hosts, groupConfigs, proxyProfileIdSet, proxyProfiles]); const sessionHostsMapRef = useRef(sessionHostsMap); sessionHostsMapRef.current = sessionHostsMap; // Resolve the active broadcast mode, then write to its target sessions. const handleBroadcastInput = useCallback(( data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions, ) => { const targetSessionIds = resolveTerminalBroadcastTargetIds({ sessions: sessionsRef.current, sourceSessionId, globalBroadcastEnabled: isGlobalBroadcastEnabled, directTargetSessionIds: options?.kittyKeyboardTargetSessionIds, }); if (targetSessionIds.length === 0) return []; const targetSessionIdSet = new Set(targetSessionIds); const deliveredSessionIds: string[] = []; for (const session of sessionsRef.current) { if (!targetSessionIdSet.has(session.id)) continue; if (!canUseDirectSessionWriteFallback(session)) continue; if (options?.kittyKeyboardInput) { dispatchKittyKeyboardBroadcastInput(session.id, options.kittyKeyboardInput, { beforeUrgentInterrupt: () => { broadcastInterruptPrioritizersRef.current.get(session.id)?.(); }, }); deliveredSessionIds.push(session.id); continue; } const lineDelayMs = options?.lineDelayMs; if (data === "\x03" && isPluginHostProtocol(session.protocol) && terminalBackend.signalPluginConnection) { broadcastInterruptPrioritizersRef.current.get(session.id)?.(); void terminalBackend.signalPluginConnection(session.id, "interrupt").catch(() => { terminalBackend.interruptSession(session.id); }); deliveredSessionIds.push(session.id); continue; } if (data === "\x03" && terminalBackend.interruptSession) { broadcastInterruptPrioritizersRef.current.get(session.id)?.(); terminalBackend.interruptSession(session.id); continue; } if (isTerminalSensitiveInputActive(session.id)) continue; terminalBackend.writeToSession(session.id, data, { automated: true, sensitive: false, ...(lineDelayMs ? { lineDelayMs } : {}), }); deliveredSessionIds.push(session.id); } return deliveredSessionIds; }, [terminalBackend, isGlobalBroadcastEnabled]); const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => { codingCliSignalController.handleCommandSubmitted(sessionId, command); const tabId = activeTabIdRef.current; const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); if (!session || !canReuseTerminalConnection(session)) return; const sessionHost = sessionHostsMapRef.current.get(sessionId); const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp') ? sftpHostForTabRef.current.get(tabId) ?? null : null; if (!shouldProbeCommandCwd({ restoreTerminalCwd, visibleSftpHost, sessionHost, globalSftpFollowTerminalCwd: sftpFollowTerminalCwdRef.current, })) return; const osc7SignalAtCommand = terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0; const probeGeneration = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1; cwdProbeGenerationRef.current.set(sessionId, probeGeneration); cwdProbeCancelersRef.current.get(sessionId)?.(); const cancelProbe = scheduleBackendCwdProbeAfterCommand({ sessionId, osc7SignalAtCommand, getOsc7Signal: () => terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0, getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options), canProbe: async () => { if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false; const host = sessionHostsMapRef.current.get(sessionId); if (!host) return false; const detectedDeviceClass = classifyDistroId(host.distro); const isNetworkDevice = host.deviceType === 'network' || detectedDeviceClass === 'network-device'; const info = await terminalBackend.getSessionRemoteInfo?.(sessionId); return shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion, }); }, onProbedCwd: (cwd) => { if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return; const existing = terminalRendererCwdBySessionRef.current.get(sessionId); if (existing === cwd) return; handleTerminalCwdChange(sessionId, cwd, { source: 'backend-strict' }); }, }); cwdProbeCancelersRef.current.set(sessionId, cancelProbe); }, [codingCliSignalController, handleTerminalCwdChange, restoreTerminalCwd, sidePanelLayoutsRef, terminalBackend]); const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => { onCommandExecuted?.(command, hostId, hostLabel, sessionId); }, [onCommandExecuted]); useEffect(() => () => { pruneTerminalSessionRuntimeState({ terminalRendererCwdBySessionRef, terminalRendererCwdSourceBySessionRef, terminalOsc7SignalBySessionRef, cwdProbeGenerationRef, cwdProbeCancelersRef, }, new Set()); }, []); const sessionSudoAutofillPasswordsMap = useMemo(() => { const map = new Map(); for (const session of sessions) { // Use the effective session host (group defaults / proxy materialization // applied) so a password inherited from group settings is available. const sessionHost = sessionHostsMap.get(session.id); if (sessionHost) { // Resolve through identity references too (host.identityId), not just // host.password, so a password stored in a Keychain identity is filled // (issue #1284) — same resolution SSH login uses. map.set(session.id, resolveHostAutofillPassword({ host: sessionHost, keys, identities })); } } return map; }, [sessionHostsMap, sessions, keys, identities]); const sessionSudoAutofillCandidatesMap = useMemo(() => { const map = new Map< string, ReturnType | undefined >(); for (const session of sessions) { const sessionHost = sessionHostsMap.get(session.id); if (sessionHost) { map.set( session.id, listPasswordPromptFillCandidates({ host: sessionHost, keys, identities }), ); } } return map; }, [sessionHostsMap, sessions, keys, identities]); const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => { const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); const sessionHost = sessionHostsMapRef.current.get(sessionId); const rawHost = sessionHost ? hostMapRef.current.get(sessionHost.id) : null; const target = resolveTerminalFontSizeUpdateTarget({ session, sessionHost, rawHost }); if (target.kind === 'none') return; if (target.kind === 'session') { onUpdateSessionFontSize?.(sessionId, nextFontSize); } else if (target.kind === 'global') { onUpdateTerminalFontSize?.(nextFontSize); } else { onUpdateHost({ ...target.host, fontSize: nextFontSize, fontSizeOverride: true }); } }, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]); const validAIScopeTargetIds = useMemo(() => { const ids = new Set(); for (const session of sessions) ids.add(session.id); for (const workspace of workspaces) ids.add(workspace.id); return ids; }, [sessions, workspaces]); useEffect(() => { pruneTerminalTabMemoryState({ lastSidePanelTabRef, notesReturnTabRef, sftpLastPathForSourceRef, }, validAIScopeTargetIds); }, [validAIScopeTargetIds]); useEffect(() => { const storeActiveTransferTabIds = listTerminalTabIdsWithRetainingTransfers( sftpTransferCenterStore.getSnapshot().tasks, ); // Keep owners with unfinished transfers or external-editor temps mounted // even after the terminal tab id drops out of the valid set. const activeTransferTabIds = new Set([ ...sftpActiveTransfersByTabRef.current.keys(), ...sftpActiveExternalEditsByTabRef.current.keys(), ...storeActiveTransferTabIds, ]); const invalidTabIds = listInvalidSftpPanelTabIds({ mountedTabIds: sftpHostForTab.keys(), activeTransferTabIds, retainedTabIds: sftpRetainedAfterCloseTabIdsRef.current, openingTabIds: sftpOpeningTabIdsRef.current, cleanupTimerTabIds: sftpRetainedCleanupTimersRef.current.keys(), validTabIds: validAIScopeTargetIds, }); for (const tabId of invalidTabIds) { clearSftpPanelState(tabId); } }, [clearSftpPanelState, sftpHostForTab, validAIScopeTargetIds]); useEffect(() => { for (const tabId of sftpOpeningTabIdsRef.current) { if (sidePanelOpenTabs.get(tabId) === 'sftp') { sftpOpeningTabIdsRef.current.delete(tabId); } } }, [sidePanelOpenTabs]); useEffect(() => () => { for (const cleanupTimer of sftpRetainedCleanupTimersRef.current.values()) { window.clearTimeout(cleanupTimer); } sftpRetainedCleanupTimersRef.current.clear(); }, []); const validSessionActivityIds = useMemo(() => { return getValidSessionActivityIds(sessions); }, [sessions]); const activityTrackedSessions = useMemo( () => sessions.filter( (session) => session.status !== 'disconnected', ), [sessions], ); const onSplitSessionRef = useRef(onSplitSession); onSplitSessionRef.current = onSplitSession; const splitHorizontalHandlersRef = useRef void>>(new Map()); const splitVerticalHandlersRef = useRef void>>(new Map()); const onToggleWorkspaceViewModeRef = useRef(onToggleWorkspaceViewMode); onToggleWorkspaceViewModeRef.current = onToggleWorkspaceViewMode; const workspaceFocusHandlersRef = useRef void>>(new Map()); const onToggleBroadcastRef = useRef(onToggleBroadcast); onToggleBroadcastRef.current = onToggleBroadcast; const workspaceBroadcastHandlersRef = useRef void>>(new Map()); const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast); onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast; const mountedSftpTabIds = useMemo( () => Array.from(sftpHostForTab.keys()), [sftpHostForTab], ); const markSidePanelSubTabOpened = useCallback((tabId: string, panel: SidePanelTab) => { if (panel === 'ai') { setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); return; } if (panel === 'scripts') { setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); return; } if (panel === 'theme') { setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); return; } if (panel === 'system') { setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); return; } if (panel === 'notes') { setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); } }, []); const getActiveTerminalSessionId = useCallback((): string | null => { const activeWorkspace = activeWorkspaceRef.current; const activeSession = activeSessionRef.current; if (!activeWorkspace) return activeSession?.id ?? null; const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root)); const focusedId = activeWorkspace.focusedSessionId; if (focusedId && workspaceSessionIdSet.has(focusedId) && sessionsRef.current.some((session) => session.id === focusedId)) { return focusedId; } return sessionsRef.current.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null; }, []); const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => { const activeWorkspace = activeWorkspaceRef.current; if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return; onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId); }, [onSetWorkspaceFocusedSession]); // Get the focused terminal's current working directory const getTerminalCwd = useCallback(async (options?: { preferFreshBackend?: boolean; allowRendererFallback?: boolean; requireActiveShellCwd?: boolean; }): Promise => { const sessionId = getActiveTerminalSessionId(); return resolvePreferredTerminalCwd({ rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined, rendererCwdSource: sessionId ? terminalRendererCwdSourceBySessionRef.current.get(sessionId) : undefined, sessionId, getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options), preferFreshBackend: options?.preferFreshBackend, allowRendererFallback: options?.allowRendererFallback, requireActiveShellCwd: options?.requireActiveShellCwd, }); }, [getActiveTerminalSessionId, terminalBackend]); const refocusTerminalSession = useCallback((sessionId?: string | null) => { focusTerminalSessionInput(sessionId); }, []); const refocusActiveTerminalSession = useCallback(() => { const sessionId = getActiveTerminalSessionId(); syncWorkspaceFocusIfNeeded(sessionId); refocusTerminalSession(sessionId); }, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]); // Close the entire side panel for the current tab const handleEndSessionDrag = useCallback(() => { onSetDraggingSessionId(null); }, [onSetDraggingSessionId]); const handleCloseSidePanel = useCallback(() => { const activeTabId = activeTabIdRef.current; if (!activeTabId) return; setMagnifiedPane((current) => ( current?.tabId === activeTabId && current.target.kind === 'side-panel' ? null : current )); const sessionIdToRefocus = getActiveTerminalSessionId(); syncWorkspaceFocusIfNeeded(sessionIdToRefocus); closeTerminalSidePanelTab(activeTabId); refocusTerminalSession(sessionIdToRefocus); }, [closeTerminalSidePanelTab, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]); // Resolve the SFTP host for a tab: a previously-stored host, otherwise the // host of the workspace's focused session or the active session. null = none. const resolveSftpHostForTab = useCallback((tabId: string): Host | null => { const stored = sftpHostForTabRef.current.get(tabId); if (stored) return stored; const currentWorkspace = activeWorkspaceRef.current; const currentFocusedSessionId = focusedSessionIdRef.current; const currentActiveSession = activeSessionRef.current; const currentSessionHosts = sessionHostsMapRef.current; if (currentWorkspace && currentFocusedSessionId) { return currentSessionHosts.get(currentFocusedSessionId) ?? null; } if (currentActiveSession) { return currentSessionHosts.get(currentActiveSession.id) ?? null; } return null; }, []); const prepareSidePanelTool = useCallback((tabId: string, tab: SidePanelTab): boolean => { if (tab === 'sftp') { sftpOpeningTabIdsRef.current.add(tabId); const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId); if (cleanupTimer !== undefined) { window.clearTimeout(cleanupTimer); sftpRetainedCleanupTimersRef.current.delete(tabId); } sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); sftpPaneClosedTabIdsRef.current.delete(tabId); } // If switching to SFTP and no host is stored yet, resolve it if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) { const host = resolveSftpHostForTab(tabId); if (!host) return; setSftpHostForTab(prev => { const next = new Map(prev); next.set(tabId, host); return next; }); const sourceSessionId = getActiveTerminalSessionId(); const { effectiveInitialPath } = resolveSftpOpenTarget({ tabId, host, originSessionId: sourceSessionId, }); setSftpInitialLocationForTab(prev => { const next = new Map(prev); if (effectiveInitialPath) { next.set(tabId, { hostId: host.id, path: effectiveInitialPath }); } else { next.delete(tabId); } return next; }); } // Keep the hidden SFTP panel mounted while switching sub-panels. A panel // closed during a transfer is also retained until the user reopens it and // explicitly closes the now-idle panel, preserving the completed history. markSidePanelSubTabOpened(tabId, tab); return true; }, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]); // Switch only the focused pane. If the tool is already open elsewhere in // the tree, the domain helper focuses that pane instead of duplicating it. const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => { const tabId = activeTabIdRef.current; if (!tabId) return; const currentPanel = sidePanelOpenTabsRef.current.get(tabId); // If already on this tab, do nothing — user must click X to close if (currentPanel === tab) return; if (!prepareSidePanelTool(tabId, tab)) return; startTransition(() => { setSidePanelOpenTabs(prev => { const next = new Map(prev); next.set(tabId, tab); return next; }); }); }, [prepareSidePanelTool, setSidePanelOpenTabs, sidePanelOpenTabsRef]); const handleFocusSidePanelPane = useCallback((paneId: string) => { const tabId = activeTabIdRef.current; if (!tabId) return; const layout = sidePanelLayoutsRef.current.get(tabId); const pane = layout ? collectSidePanelPanes(layout.root).find((candidate) => candidate.id === paneId) : undefined; if (pane) { lastInteractedPaneRef.current.set(tabId, { kind: 'side-panel', paneId: pane.id, tool: pane.tool, }); } focusSidePanelPaneForTab(tabId, paneId); }, [focusSidePanelPaneForTab, sidePanelLayoutsRef]); const handleTerminalPaneInteraction = useCallback((tabId: string, sessionId: string) => { lastInteractedPaneRef.current.set(tabId, { kind: 'terminal', sessionId }); }, []); const handleMagnifyTerminalPane = useCallback((tabId: string, sessionId: string) => { const target: PaneMagnificationTarget = { kind: 'terminal', sessionId }; lastInteractedPaneRef.current.set(tabId, target); setMagnifiedPane((current) => ( current?.tabId === tabId && current.target.kind === 'terminal' && current.target.sessionId === sessionId ? null : { tabId, target } )); }, []); const handleMagnifySidePanelPane = useCallback((paneId: string) => { const tabId = activeTabIdRef.current; if (!tabId) return; const layout = sidePanelLayoutsRef.current.get(tabId); const pane = layout ? collectSidePanelPanes(layout.root).find((candidate) => candidate.id === paneId) : undefined; if (!pane) return; const target: PaneMagnificationTarget = { kind: 'side-panel', paneId: pane.id, tool: pane.tool, }; lastInteractedPaneRef.current.set(tabId, target); setMagnifiedPane((current) => ( current?.tabId === tabId && current.target.kind === 'side-panel' && current.target.paneId === paneId ? null : { tabId, target } )); }, [sidePanelLayoutsRef]); const handleRestoreMagnifiedPane = useCallback(() => { const tabId = activeTabIdRef.current; setMagnifiedPane((current) => current?.tabId === tabId ? null : current); }, []); useEffect(() => { if (!paneMagnificationRef) return undefined; const getTarget = (): { tabId: string; target: PaneMagnificationTarget; focused: boolean } | null => { const tabId = activeTabStore.getActiveTabId(); if (!tabId) return null; const workspace = workspacesRef.current.find((candidate) => candidate.id === tabId); const hasCurrentMagnification = magnifiedPaneRef.current?.tabId === tabId; if (workspace?.viewMode === 'focus' && !hasCurrentMagnification) return null; const workspaceSessionIds = workspace ? collectSessionIds(workspace.root) : []; const standaloneSession = sessionsRef.current.find((candidate) => ( candidate.id === tabId && !candidate.workspaceId )); const terminalSessionIds = workspaceSessionIds.length > 0 ? workspaceSessionIds : standaloneSession ? [standaloneSession.id] : []; const layout = sidePanelLayoutsRef.current.get(tabId); const sidePanelPanes = layout ? collectSidePanelPanes(layout.root) : []; const focusedSidePanelPane = sidePanelPanes.find((candidate) => ( candidate.id === layout?.focusedPaneId )); const candidate = resolvePaneMagnificationCandidate({ tabId, terminalSessionIds, focusedSessionId: workspace?.focusedSessionId, sidePanelPanes: focusedSidePanelPane ? [focusedSidePanelPane, ...sidePanelPanes.filter((pane) => pane !== focusedSidePanelPane)] : sidePanelPanes, lastTarget: lastInteractedPaneRef.current.get(tabId), current: magnifiedPaneRef.current, }); return candidate ? { tabId, ...candidate } : null; }; const controller: PaneMagnificationController = { getState: () => { const target = getTarget(); return target ? (target.focused ? 'focused' : 'focusable') : 'unavailable'; }, focus: () => { const target = getTarget(); if (!target || target.focused) return false; setMagnifiedPane({ tabId: target.tabId, target: target.target }); return true; }, restore: () => { const target = getTarget(); if (!target?.focused) return false; setMagnifiedPane(null); return true; }, toggle: () => { const target = getTarget(); if (!target) return false; setMagnifiedPane(target.focused ? null : { tabId: target.tabId, target: target.target }); return true; }, }; paneMagnificationRef.current = controller; return () => { if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null; }; }, [paneMagnificationRef, sidePanelLayoutsRef]); const handleSplitSidePanelPane = useCallback(( tool: SidePanelTab, direction: SidePanelSplitDirection, availableAxisLength: number, ) => { const tabId = activeTabIdRef.current; if (!tabId) return; if (!sidePanelLayoutsRef.current.has(tabId) || !prepareSidePanelTool(tabId, tool)) return; splitSidePanelPaneForTab( tabId, tool, direction, { paneId: crypto.randomUUID(), splitId: crypto.randomUUID() }, availableAxisLength, ); }, [prepareSidePanelTool, sidePanelLayoutsRef, splitSidePanelPaneForTab]); const handleCloseSidePanelPane = useCallback((paneId: string) => { const tabId = activeTabIdRef.current; if (!tabId) return; const layout = sidePanelLayoutsRef.current.get(tabId); const closingPane = layout ? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId) : undefined; setMagnifiedPane((current) => ( current?.tabId === tabId && current.target.kind === 'side-panel' && current.target.paneId === paneId ? null : current )); const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId); if (closesWholePanel) { handleCloseSidePanel(); return; } if (shouldMarkSftpPaneClosed({ closingPaneTool: closingPane?.tool, closesWholePanel: closesWholePanel, })) { sftpPaneClosedTabIdsRef.current.add(tabId); } }, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]); const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => { const tabId = activeTabIdRef.current; if (!tabId) return; resizeSidePanelSplitForTab(tabId, splitId, sizes); }, [resizeSidePanelSplitForTab]); // Toggle SFTP from activity bar header const handleToggleSftpFromBar = useCallback(() => { handleSwitchSidePanelTab('sftp'); }, [handleSwitchSidePanelTab]); // Open scripts side panel (called from Terminal toolbar) const handleOpenScripts = useCallback(() => { handleSwitchSidePanelTab('scripts'); }, [handleSwitchSidePanelTab]); const handleToggleScriptsSidePanel = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const intent = resolveScriptsSidePanelShortcutIntent( sidePanelOpenTabsRef.current.get(tabId) ?? null, ); if (intent.kind === 'closeTerminalSidePanel') { handleCloseSidePanel(); return; } markSidePanelSubTabOpened(tabId, 'scripts'); startTransition(() => { setSidePanelOpenTabs(prev => { const next = new Map(prev); next.set(tabId, 'scripts'); return next; }); }); }, [handleCloseSidePanel, markSidePanelSubTabOpened, setSidePanelOpenTabs, sidePanelOpenTabsRef]); // Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if // closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is // available) or scripts. const handleToggleSidePanel = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const isOpen = sidePanelOpenTabsRef.current.has(tabId); const sftpAvailable = !!resolveSftpHostForTab(tabId); const fallbackTab: SidePanelTab = sftpAvailable ? 'sftp' : 'scripts'; const lastTab = lastSidePanelTabRef.current.get(tabId) ?? null; const intent = resolveSidePanelToggleIntent({ isOpen, lastTab, fallbackTab }); if (intent.kind === 'close') { handleCloseSidePanel(); return; } // If the remembered panel is SFTP but no host is resolvable, use scripts. const target: SidePanelTab = intent.tab === 'sftp' && !sftpAvailable ? 'scripts' : intent.tab; handleSwitchSidePanelTab(target); }, [handleCloseSidePanel, handleSwitchSidePanelTab, resolveSftpHostForTab, sidePanelOpenTabsRef]); // Open theme side panel (called from Terminal toolbar) const handleOpenTheme = useCallback(() => { handleSwitchSidePanelTab('theme'); }, [handleSwitchSidePanelTab]); const handleOpenHistory = useCallback(() => { handleSwitchSidePanelTab('history'); }, [handleSwitchSidePanelTab]); // Open AI chat side panel (side-panel rail button: a plain switch that is a // no-op when AI is already the active sub-panel, matching the other rail tabs) const handleOpenAI = useCallback(() => { handleSwitchSidePanelTab('ai'); }, [handleSwitchSidePanelTab]); const handleOpenSystem = useCallback(() => { handleSwitchSidePanelTab('system'); }, [handleSwitchSidePanelTab]); const handleOpenNotes = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const currentPanel = sidePanelOpenTabsRef.current.get(tabId) ?? null; if (currentPanel && currentPanel !== 'notes') { notesReturnTabRef.current.set(tabId, currentPanel); } handleSwitchSidePanelTab('notes'); }, [handleSwitchSidePanelTab, sidePanelOpenTabsRef]); const handleBackFromNotes = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const fallback: SidePanelTab = resolveSftpHostForTab(tabId) ? 'sftp' : 'scripts'; const remembered = notesReturnTabRef.current.get(tabId) ?? fallback; const target = remembered === 'notes' ? fallback : (remembered === 'sftp' && !resolveSftpHostForTab(tabId) ? 'scripts' : remembered); handleSwitchSidePanelTab(target); }, [handleSwitchSidePanelTab, resolveSftpHostForTab]); const openNotesPanelForSourceNote = useCallback((tabId: string, noteId: string) => { notesOpenRequestIdRef.current += 1; const requestId = notesOpenRequestIdRef.current; setNotesOpenNoteByTab((prev) => { const next = new Map(prev); next.set(tabId, { noteId, requestId }); return next; }); setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId)); setSidePanelOpenTabs((prev) => { const next = new Map(prev); next.set(tabId, 'notes'); return next; }); }, [setSidePanelOpenTabs]); const handleOpenHostFromNotes = useCallback((host: Host, source?: { noteId?: string }) => { const sourceNoteId = source?.noteId; const previousTabId = activeTabStore.getActiveTabId(); const connectedTabId = onConnectToHost(host); if (!sourceNoteId) return; if (typeof connectedTabId === 'string' && connectedTabId) { openNotesPanelForSourceNote(connectedTabId, sourceNoteId); return; } const openWhenTabIsReady = (attempt = 0) => { const tabId = activeTabStore.getActiveTabId(); if (tabId && tabId !== previousTabId) { openNotesPanelForSourceNote(tabId, sourceNoteId); return; } if (attempt >= 8) { if (tabId) openNotesPanelForSourceNote(tabId, sourceNoteId); return; } window.setTimeout(() => openWhenTabIsReady(attempt + 1), 16); }; openWhenTabIsReady(); }, [onConnectToHost, openNotesPanelForSourceNote]); useEffect(() => { if (!openNoteRequest) return; openNotesPanelForSourceNote(openNoteRequest.tabId, openNoteRequest.noteId); }, [openNoteRequest, openNotesPanelForSourceNote]); const handleOpenVaultNoteFromAiPanel = useCallback((noteId: string) => { const intent = resolveAiNoteArtifactPanelIntent({ activeTabId: activeTabIdRef.current, currentPanel: activeTabIdRef.current ? sidePanelOpenTabsRef.current.get(activeTabIdRef.current) ?? null : null, noteId, }); if (intent.kind === 'fallback') { onOpenVaultNoteFromChat?.(intent.noteId); return; } if (intent.returnPanel) { notesReturnTabRef.current.set(intent.tabId, intent.returnPanel); } openNotesPanelForSourceNote(intent.tabId, intent.noteId); }, [onOpenVaultNoteFromChat, openNotesPanelForSourceNote, sidePanelOpenTabsRef]); const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => { const text = selection.trim(); if (!text) return; const tabId = activeTabIdRef.current; if (!tabId) return; const ws = activeWorkspaceRef.current; if (ws && ws.focusedSessionId !== sourceSessionId) { onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId); } setPendingTerminalSelectionForAI({ requestId: crypto.randomUUID(), tabId, text, }); handleSwitchSidePanelTab('ai'); }, [handleSwitchSidePanelTab]); const handlePendingTerminalSelectionConsumed = useCallback((requestId: string) => { setPendingTerminalSelectionForAI((current) => ( current?.requestId === requestId ? null : current )); }, []); // Toggle the AI chat side panel from the top-bar button: open it (or switch // to it from another sub-panel), and close the side panel when AI is already // the open sub-panel. Unlike handleOpenAI (the rail switch), a second click // here dismisses the panel. const handleToggleAiFromTopBar = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const intent = resolveAiSidePanelToggleIntent( sidePanelOpenTabsRef.current.get(tabId) ?? null, ); if (intent.kind === 'closeTerminalSidePanel') { handleCloseSidePanel(); return; } handleSwitchSidePanelTab('ai'); }, [handleCloseSidePanel, handleSwitchSidePanelTab, sidePanelOpenTabsRef]); // Toggle the System Manager side panel from the top-bar button. const handleToggleSystemFromTopBar = useCallback(() => { const tabId = activeTabIdRef.current; if (!tabId) return; const currentTab = sidePanelOpenTabsRef.current.get(tabId); if (currentTab === 'system') { handleCloseSidePanel(); } else { handleSwitchSidePanelTab('system'); } }, [handleCloseSidePanel, handleSwitchSidePanelTab, sidePanelOpenTabsRef]); // Execute snippet on the focused terminal session const handleSnippetClickForFocusedSession = useCallback(( command: string, noAutoRun?: boolean, options?: { multiLineRunMode?: Snippet["multiLineRunMode"] }, ) => { const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id; if (!sessionId) return; const executor = snippetExecutorsRef.current.get(sessionId); if (executor) { executor(command, noAutoRun, options); return; } const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); if (!session || !canUseDirectSessionWriteFallback(session)) return; let data = normalizeLineEndings(command); if (!noAutoRun) data = `${data}\r`; const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun, multiLineRunMode: options?.multiLineRunMode, }) ? AUTO_RUN_SNIPPET_LINE_DELAY_MS : undefined; terminalBackend.writeToSession(sessionId, data, { automated: true, sensitive: isTerminalSensitiveInputActive(sessionId), ...(lineDelayMs ? { lineDelayMs } : {}), }); // Re-focus the terminal so the user can interact immediately const pane = document.querySelector(`[data-session-id="${sessionId}"]`); const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null; textarea?.focus(); }, [terminalBackend]); const handleHistoryPaste = useCallback( (command: string) => handleSnippetClickForFocusedSession(command, true), [handleSnippetClickForFocusedSession], ); const handleHistoryRun = useCallback( (command: string) => handleSnippetClickForFocusedSession(command, false), [handleSnippetClickForFocusedSession], ); const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => { const sessionId = getActiveTerminalSessionId(); if (!sessionId) return; if (isScriptSnippet(snippet)) { try { await runAutomationScript({ snippet, sessionId, sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); } return; } const command = await resolveSnippetCommand(snippet); if (command === null) return; handleSnippetClickForFocusedSession(command, snippet.noAutoRun, { multiLineRunMode: snippet.multiLineRunMode, }); }, [getActiveTerminalSessionId, handleSnippetClickForFocusedSession, hosts, t]); const handleRunScriptFromPanel = useCallback(async (snippet: Snippet) => { const sessionId = getActiveTerminalSessionId(); if (!sessionId) { toast.error(t('scripts.recording.noSession')); return; } try { await runAutomationScript({ snippet, sessionId, sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); } }, [getActiveTerminalSessionId, hosts, t]); const sendCommandSnippetToSession = useCallback(async ( sessionId: string, command: string, snippet: Snippet, options?: { focus?: boolean }, ): Promise => { // Never inject into a peer tab sitting on a password/sensitive prompt. if (isTerminalSensitiveInputActive(sessionId)) return false; const executor = snippetExecutorsRef.current.get(sessionId); if (executor) { // Executor may wake a hibernated pane first so bracketed-paste mode and // encoding match the normal single-pane path. const wrote = await executor(command, snippet.noAutoRun, { multiLineRunMode: snippet.multiLineRunMode, broadcast: false, // Multi-tab fan-out must not call term.focus() on every peer. focus: options?.focus !== false, }); // Wake can surface a password prompt from buffered output — recheck. if (isTerminalSensitiveInputActive(sessionId)) return false; if (wrote) return true; } // Recheck before last-resort backend write as well. if (isTerminalSensitiveInputActive(sessionId)) return false; const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); if (!session || !canUseDirectSessionWriteFallback(session)) return false; // Last-resort fallback when the executor could not write (rare). Prefer the // executor path so terminal modes (bracketed paste) stay authoritative. // Do not invent bracketed-paste markers without live term.modes. let data = normalizeLineEndings(command); const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun: snippet.noAutoRun, multiLineRunMode: snippet.multiLineRunMode, }) ? AUTO_RUN_SNIPPET_LINE_DELAY_MS : undefined; if (!snippet.noAutoRun) data = `${data}\r`; terminalBackend.writeToSession(sessionId, data, { automated: true, sensitive: false, ...(lineDelayMs ? { lineDelayMs } : {}), }); return true; }, [terminalBackend]); const handleRunScriptOnWorkspace = useCallback(async ( snippet: Snippet, mode: 'sequential' | 'parallel' = 'parallel', ) => { const workspace = activeWorkspaceRef.current; if (!workspace) { // Single terminal tab (no workspace): fall back to focused session. const sessionId = getActiveTerminalSessionId(); if (!sessionId) { toast.error(t('scripts.recording.noSession')); return; } if (isTerminalSensitiveInputActive(sessionId)) { toast.info(t('scripts.actions.skippedSensitiveSessions', { count: 1 })); return; } if (!isScriptSnippet(snippet)) { const command = await resolveSnippetCommand(snippet); if (command === null) return; handleSnippetClickForFocusedSession(command, snippet.noAutoRun, { multiLineRunMode: snippet.multiLineRunMode, }); return; } try { await runAutomationScript({ snippet, sessionId, sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); } return; } const workspaceSessions = sessionsRef.current.filter((session) => session.workspaceId === workspace.id); const sessionIds = workspaceSessions .filter((session) => session.status === 'connected') .map((session) => session.id); const skippedConnecting = workspaceSessions.filter((session) => session.status === 'connecting').length; if (sessionIds.length === 0) { if (skippedConnecting > 0) { toast.info(t('scripts.actions.skippedConnectingSessions', { count: skippedConnecting })); } else { toast.error(t('scripts.recording.noSession')); } return; } if (skippedConnecting > 0) { toast.info(t('scripts.actions.skippedConnectingSessions', { count: skippedConnecting })); } // Code snippets: paste/send the resolved command to every connected tab. // Sequential vs parallel only affects automation scripts (which await run // completion); plain snippets are fire-and-forget writes. if (!isScriptSnippet(snippet)) { const command = await resolveSnippetCommand(snippet); if (command === null) return; const focusedBefore = workspace.focusedSessionId ?? getActiveTerminalSessionId() ?? null; let sent = 0; let skippedSensitive = 0; for (const sid of sessionIds) { if (isTerminalSensitiveInputActive(sid)) { skippedSensitive += 1; continue; } // Never steal focus from peer panes during fan-out; restore below. if (await sendCommandSnippetToSession(sid, command, snippet, { focus: false })) sent += 1; } if (skippedSensitive > 0) { toast.info(t('scripts.actions.skippedSensitiveSessions', { count: skippedSensitive })); } if (sent === 0 && skippedSensitive === 0) { toast.error(t('scripts.recording.noSession')); } else if (focusedBefore) { focusTerminalSessionInput(focusedBefore); } return; } const runnableSessionIds = sessionIds.filter((sid) => !isTerminalSensitiveInputActive(sid)); const skippedSensitive = sessionIds.length - runnableSessionIds.length; if (skippedSensitive > 0) { toast.info(t('scripts.actions.skippedSensitiveSessions', { count: skippedSensitive })); } if (runnableSessionIds.length === 0) { // Connected sessions all sensitive — do not claim "no session". return; } try { const runOnSession = (sid: string) => runAutomationScript({ snippet, sessionId: sid, sessionMeta: buildScriptSessionMeta(sid, sessionsRef.current, hosts), }); if (mode === 'sequential') { for (const sid of runnableSessionIds) { const { runId } = await runOnSession(sid); await waitForScriptRun(runId); } } else { await Promise.all(runnableSessionIds.map((sid) => runOnSession(sid))); } } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); } }, [getActiveTerminalSessionId, handleSnippetClickForFocusedSession, hosts, sendCommandSnippetToSession, t]); useEffect(() => { const handler = (event: Event) => { const snippet = (event as CustomEvent<{ snippet: Snippet }>).detail?.snippet; if (!snippet) return; void handleRunScriptFromPanel(snippet); }; window.addEventListener('netcatty:scripts:run-on-focused', handler); return () => window.removeEventListener('netcatty:scripts:run-on-focused', handler); }, [handleRunScriptFromPanel]); const handleStartRecordingFromPanel = useCallback(() => { const sessionId = getActiveTerminalSessionId(); if (!sessionId) { toast.error(t('scripts.recording.noSession')); return; } const recording = getScriptRecordingSnapshot(); if (recording.sessionId === sessionId) { window.dispatchEvent(new CustomEvent('netcatty:script:recording:stop', { detail: { sessionId } })); return; } if (recording.sessionId) { toast.error(t('scripts.recording.alreadyActive')); return; } window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } })); toast.info(t('scripts.recording.started')); }, [getActiveTerminalSessionId, t]); const handleComposeSend = useCallback((text: string) => { const activeWorkspace = activeWorkspaceRef.current; if (!activeWorkspace) return; const payload = text + '\r'; const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id); const focusedSessionId = activeWorkspace.focusedSessionId; const focusedSensitive = focusedSessionId ? isTerminalSensitiveInputActive(focusedSessionId) : false; if (broadcastEnabled && !focusedSensitive) { const allSessionIds = sessionsRef.current .filter((session) => session.workspaceId === activeWorkspace.id) .map((session) => session.id); for (const sid of allSessionIds) { if (isTerminalSensitiveInputActive(sid)) continue; const executor = snippetExecutorsRef.current.get(sid); if (executor) { executor(text, false, { broadcast: false }); } else { const session = sessionsRef.current.find((candidate) => candidate.id === sid); if (!session || !canUseDirectSessionWriteFallback(session)) continue; terminalBackend.writeToSession(sid, payload, { sensitive: false }); } } } else { const workspaceSessions = sessionsRef.current.filter((session) => session.workspaceId === activeWorkspace.id); const validFocusedId = focusedSessionId && workspaceSessions.some((session) => session.id === focusedSessionId) ? focusedSessionId : undefined; const targetId = validFocusedId ?? workspaceSessions[0]?.id; if (targetId) { const executor = snippetExecutorsRef.current.get(targetId); if (executor) { executor(text, false); } else { const session = sessionsRef.current.find((candidate) => candidate.id === targetId); if (!session || !canUseDirectSessionWriteFallback(session)) return; terminalBackend.writeToSession(targetId, payload, { sensitive: isTerminalSensitiveInputActive(targetId), }); } } } }, [isBroadcastEnabled, terminalBackend]); const sessionLogConfig = useMemo( () => ({ enabled: Boolean(sessionLogsEnabled && sessionLogsDir), directory: sessionLogsDir, format: sessionLogsFormat || 'txt', timestampsEnabled: sessionLogsTimestampsEnabled, }), [sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled], ); stableRef.current = { accentMode, activityTrackedSessions, AIChatPanelsHost, AISidePanelStateRoot, AIStateMaintenanceHost, AIStateProvider, Array, Button, ChunkedEscapeFilter, clearHostTreePreviewVars, clearTerminalPreviewVars, clearTopTabsPreviewVars, FolderTree, History, HistorySidePanel, MessageSquare, NotesManager, Palette, PanelLeft, PanelRight, cn, collectSessionIds, customAccent, customGroups, draggingSessionId, editorWordWrap, effectiveHosts, filterTabsMap, followAppTerminalTheme, pickTerminalTheme, clearThemeIntent, settleManualThemeIntent, resolveSessionAppearance, fontSize, getSessionActivityIdsToClear, getTerminalCwd, handleAddKnownHost, handleAddSelectionToAI, handleBroadcastInput, handleBroadcastInterruptPriorityChange, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleHistoryDelete: onDeleteShellHistoryEntry, handleCommandSubmitted, handleComposeSend, handleHistoryPaste, handleHistoryRun, handleOpenHistory, handleOpenSftp, handleOpenScripts, handleOpenTheme, handleOpenAI, handleOpenSystem, handleOpenNotes, handleFocusSidePanelPane, handleMagnifySidePanelPane, handleRestoreMagnifiedPane, handleMagnifyTerminalPane, handleTerminalPaneInteraction, handleSplitSidePanelPane, handleCloseSidePanelPane, handleResizeSidePanelSplit, handleBackFromNotes, handleOpenHostFromNotes, handleOsDetected, handlePendingTerminalSelectionConsumed, handlePendingUploadHandled, handleSessionExit, handleSftpCurrentPathChange, handleSftpActiveTransfersChange, handleSftpActiveExternalEditsChange, handleSftpInitialLocationApplied, persistSidePanelWidth, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleRunScriptFromPanel, handleRunScriptOnWorkspace, handleStartRecordingFromPanel, // scriptRuns live in scriptRunsStore; Scripts side panel subscribes directly. handleStopScriptRun: stopScriptRun, handlePauseScriptRun: pauseScriptRun, handleResumeScriptRun: resumeScriptRun, handleSnippetExecutorChange, handleProgrammaticCommandLogRewriteChange, handleStatusChange, handleTerminalCwdChange, handleTerminalTitleChange, handleTerminalBell, handleTerminalOutput, handleTerminalDataCapture, handleTerminalFontSizeChange, handleToggleAiFromTopBar, handleToggleSystemFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, hasNotifiableTerminalOutput, hostMap, hosts, hostsRef, portForwardingRules, portForwardingRulesRef, hotkeyScheme, disableTerminalFontZoom, restoreTerminalCwd, identities, isBroadcastEnabled, isGlobalBroadcastEnabled, canUseGlobalBroadcast, isComposeBarOpen, keyBindings, keys, knownHosts, lastSidePanelTabRef, mountedAiTabIds: aiMountedTabIds, mountedSftpTabIds, magnifiedPane, notesMountedTabIds, notesOpenNoteByTab, scriptsMountedTabIds, systemMountedTabIds, themeMountedTabIds, onAddSessionToWorkspace, onConnectToHost, onCreateLocalTerminal, onCreateWorkspaceFromSessions, onHotkeyAction, onReorderWorkspaceSessions, onReorderTabs, onCopySession, onDuplicateSession, onCopySessionToNewWindow, onRequestAddToWorkspace, onAppendHostToWorkspace, onSessionData, onSetDraggingSessionId, onSetWorkspaceFocusedSession, onStartSessionRename, onSubmitSessionRename, onRemoveSessionFromWorkspace, onStartSessionDrag: onSetDraggingSessionId, onEndSessionDrag: handleEndSessionDrag, onSplitSession, onSplitSessionRef, onToggleBroadcastRef, onToggleGlobalBroadcastRef, onToggleWorkspaceViewMode, onToggleWorkspaceViewModeRef, onUpdateHost, onUpdateSplitSizes, onUpdateTerminalFontFamilyId, onUpdateTerminalFontSize, onUpdateTerminalFontWeight, onUpdateSessionFontSize, onUpdateSessionRestoreCwd, onUpdateSessionDynamicTitle, onUpdateSessionCodingCliProvider, onClearSessionFontSizeOverride, onUpdateTerminalThemeId, pendingTerminalSelectionForAI, refocusActiveTerminalSession, refocusTerminalSession, resolveSftpHostForTab, ScriptsSidePanel, sessionActivityStore, sessionChainHostsMap, sessionHostsMap, resolvedSessionHostIds, sessionLogConfig, sessionSudoAutofillPasswordsMap, sessionSudoAutofillCandidatesMap, sessions, sessionsRef, setEditorWordWrap, setIsComposeBarOpen, setPendingTerminalSelectionForAI, setAiMountedTabIds, setNotesMountedTabIds, setNotesOpenNoteByTab, setScriptsMountedTabIds, setSystemMountedTabIds, setThemeMountedTabIds, setSidePanelOpenTabs, setSidePanelLayouts, setSidePanelWidth, setSftpFollowTerminalCwd, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, showHostTreeSidebar, sidePanelOpenTabs, sidePanelLayouts, sidePanelPosition, sidePanelWidth, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpFollowTerminalCwd, sftpHostForTab, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpPaneClosedTabIdsRef, sftpRetainedAfterCloseTabIdsRef, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, shouldMarkSessionActivity, snippetExecutorsRef, programmaticCommandLogRewriteHandlersRef, snippetPackages, snippets, onOpenVaultHostFromChat, onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel, onOpenVaultSectionFromChat, onOpenVaultSnippetFromChat, splitHorizontalHandlersRef, splitVerticalHandlersRef, sshDebugLogsEnabled, t, TerminalComposeBar, TerminalPanesHost, terminalFontFamilyId, terminalRendererCwdBySessionRef, terminalRendererCwdSourceBySessionRef, terminalSettings, terminalTheme, terminalThemeId, ThemeSidePanel, toggleScriptsSidePanelRef, toggleSidePanelRef, Tooltip, TooltipContent, TooltipTrigger, updateHosts, updateSnippetPackages, updateSnippets, X, Zap, validAIScopeTargetIds, validSessionActivityIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaces, workspacesRef, activeTabIdRef, activeWorkspaceRef, activeSessionRef, focusedSessionIdRef, setSidePanelPosition, }; return ; }; export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual); TerminalLayer.displayName = 'TerminalLayer';