Files
Netcatty/components/TerminalLayer.tsx
陈大猫 f5c3302329 feat: terminal rename, closeSession shortcut, and pane zoom (#1459)
* feat: auto-poll Docker capabilities while Docker tab is active

When the Docker tab is visible and hasDocker is not yet true,
poll refreshCapabilities() at the process refresh interval.
Stop polling once hasDocker becomes true, or when switching
to a different tab.

* fix: use resolvedTab instead of activeTab for Docker auto-poll condition

The auto-poll useEffect condition used activeTab, which stays stale
when Docker becomes unavailable. Changed to resolvedTab which reflects
the actual displayed tab. Also updated the dep array.

* fix: replace setInterval with setTimeout recursion in Docker tab probe

Replace setInterval-based polling with setTimeout recursion in the Docker
tab capability probe effect. This ensures the next probe only starts after
the previous one finishes, avoiding overlapping probes when SSH timeout
exceeds the polling interval.

- Add dockerPollTimerRef to track the timeout handle
- Use async pollOnce() that awaits refreshCapabilities() before scheduling next
- Use cancelled flag in cleanup to prevent scheduling after unmount
- Keep same dependency array for correctness

* fix: stabilize docker poll timer by using useRef for refreshCapabilities

refreshCapabilities() can return a new reference on every render, causing
the useEffect to re-run on every render — cleanup cancels the polling timer,
then the effect immediately calls pollOnce(), effectively bypassing the
configured timeout interval.

Fix: store refreshCapabilities in a useRef (refreshRef), use
refreshRef.current() inside pollOnce(), and replace refreshCapabilities
with refreshRef in the useEffect dependency array.

Closes #PR1456 Codex P2 review item.

* fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe

When switching to the Docker tab, two mechanisms were triggering probes:
1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities()
2. auto-poll effect: pollOnce() executing immediately on mount

This caused duplicate probes that waste SSH channel resources.

Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the
first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe
happens after one full interval. Subsequent probes continue at interval pace
via the setTimeout recursion in pollOnce itself.

The tab-switch effect still fires the immediate probe (the correct one),
so responsiveness on tab switch is preserved.

* fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling

The cancelledRef was set to true in the cleanup function when dependencies
changed, but never reset when the effect re-ran. This caused pollOnce to
always early-return on subsequent timer ticks, permanently halting
Docker capability probing after the first dependency change.

* fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation

Each effect generation now has its own  and  closure
variables instead of shared  / . This
prevents stale probes from surviving cleanup when the panel hides and
re-shows (Codex P2 review).

* fix: wrap refreshCapabilities in try/catch to keep polling on exception

If refreshCapabilities throws (instead of returning {success: false}),
the await would exit pollOnce without scheduling the next setTimeout,
silently killing Docker auto-detection polling.

* fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes

Add probingRef to track whether a capabilities probe is already in-flight.
- Tab-switch effect for Docker branch checks probingRef before starting a new probe
- Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe
- Tmux branch left unchanged as it has no auto-poll overlap risk

* fix: re-schedule next poll timer when probe is in-flight

When probingRef.current is true (tab-switch probe still running),
pollOnce was returning early without scheduling the next timer,
causing auto-poll to stop permanently afterward.

Now it schedules the next poll within the interval and returns,
so the polling loop keeps running until a slot where no probe is
active.

* fix: convert comments to ASCII-only English

- Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle'
- Line 113: replace em dash (U+2014) with ASCII dash

* feat: session inline rename, closeSession shortcut, pane zoom

* fix: sidebar inline rename with local state

* fix: add sessionDisplayName to terminalPropsAreEqual comparator

The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual),
but the comparator was missing a check for sessionDisplayName. After renaming
a session, the pane title bar would show the old name until some other prop
changed and triggered a re-render.

Add prev.sessionDisplayName === next.sessionDisplayName to the comparator
so that display name changes cause the Terminal to re-render immediately.

* fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props

* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring

The togglePaneZoom handler calls toggleWorkspaceViewMode() but it
wasn't destructured from getCtx(), causing a ReferenceError at runtime.

* fix: restore truncated ctx object in TerminalView render call

The TerminalView ctx object literal on line 1265 was truncated to
'showSele...' due to an editing tool truncation bug, causing
Parsing error: ',' expected on npm run lint / tsc --noEmit.

Restored the missing fields from the base commit:
showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef,
sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef,
terminalBackend, terminalContextActions, terminalCwdTracker,
terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem

Kept the PR's new additions (isVisible, onRename, sessionDisplayName)
intact.

* fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations

- Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx)
- Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать)

* fix: validate focusedSessionId before closing in closeSession hotkey

When closeSession hotkey fires, workspace.focusedSessionId may reference
a session that was already closed by another trigger (e.g., mouse click
on tab close button). Collect alive session IDs from the workspace root
and fall back to the first living pane if the stored focusedSessionId
is stale.

* fix(auto-poll): check useSessionCapabilities probing state in pollOnce

When auto-poll timer fires before the initial probe (from
useSessionCapabilities) completes, probingRef.current is still false
because the initial probe doesn't set it — causing a second overlapping
probe.

Add  check so that any in-flight probe from any path
(initial/auto-poll/tab-switch) prevents auto-poll overlap.

PR #1459

* fix: address remaining Codex review issues

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: add detach session from workspace with toolbar button and context menu

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: use customName in pane header display name for renamed sessions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: refine workspace terminal detach interactions

* fix: preserve workspace detach tab ordering

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:30:44 +08:00

1260 lines
46 KiB
TypeScript

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 { 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 { collectSessionIds } from '../domain/workspace';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import {
STORAGE_KEY_SIDE_PANEL_WIDTH,
} from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { applySessionFontSizeToHost } from '../domain/terminalAppearance';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { HistorySidePanel } from './HistorySidePanel';
import { useRemoteHistoryState } from '../application/state/useRemoteHistoryState';
import { resolveSnippetCommand } from './SnippetExecutionProvider';
import type { Snippet } from '../types';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { Button } from './ui/button';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
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 { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
ChunkedEscapeFilter,
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);
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
customGroups,
groupConfigs,
proxyProfiles,
keys,
identities,
snippets,
snippetPackages,
sessions,
workspaces,
knownHosts = [],
draggingSessionId,
terminalTheme,
followAppTerminalTheme = false,
accentMode = 'theme',
customAccent = '',
terminalSettings,
terminalFontFamilyId,
fontSize = 14,
hotkeyScheme = 'disabled',
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
onUpdateHost,
onAddKnownHost,
onCommandExecuted,
shellHistory = [],
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onCopySessionToNewWindow,
onSplitSession,
onConnectToHost,
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
updateSnippets,
updateSnippetPackages,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
showHostTreeSidebar = true,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
// Session rename props
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
}) => {
const { t } = useI18n();
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
const activeWorkspaceRef = useRef<Workspace | undefined>(undefined);
const activeSessionRef = useRef<TerminalSession | undefined>(undefined);
const focusedSessionIdRef = useRef<string | undefined>(undefined);
const terminalCwdRevisionRef = useRef(0);
const [terminalCwdRevision, setTerminalCwdRevision] = useState(0);
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
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) => {
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
terminalCwdRevisionRef.current += 1;
setTerminalCwdRevision(terminalCwdRevisionRef.current);
}, []);
// Stable callback references for Terminal components
const handleCloseSession = useCallback((sessionId: string) => {
sessionCapabilitiesStore.delete(sessionId);
onCloseSession(sessionId);
}, [onCloseSession]);
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
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,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(evt);
if (intent.kind === "closeSession") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onCloseSession, onUpdateSessionStatus]);
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<Map<string, SnippetExecutor>>(new Map());
const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => {
if (executor) {
snippetExecutorsRef.current.set(sessionId, executor);
return;
}
snippetExecutorsRef.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] = useState(false);
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
// Handle broadcast input - write to all other sessions in the source workspace.
const handleBroadcastInput = useCallback((data: string, sourceSessionId: string) => {
const sourceSession = sessionsRef.current.find((session) => session.id === sourceSessionId);
const workspaceId = sourceSession?.workspaceId;
if (!workspaceId) return;
for (const session of sessionsRef.current) {
if (session.workspaceId === workspaceId && session.id !== sourceSessionId) {
terminalBackend.writeToSession(session.id, data);
}
}
}, [terminalBackend]);
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
// 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<Map<string, SidePanelTab>>(new Map());
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
Map<string, { hostId: string; path: string }>
>(new Map());
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
Map<string, PendingSftpUpload>
>(new Map());
const [pendingTerminalSelectionForAI, setPendingTerminalSelectionForAI] =
useState<PendingTerminalSelectionForAI | null>(null);
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const handleToggleWorkspaceComposeBar = useCallback(() => {
setIsComposeBarOpen(prev => !prev);
}, []);
const handleOpenSftp = useCallback((host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], 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
// correct host.
if (sourceSessionId) {
const ws = activeWorkspaceRef.current;
if (ws && ws.focusedSessionId !== sourceSessionId) {
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
}
}
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;
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, 'sftp');
}
return next;
});
// Store or remove the host for this tab.
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
setSftpHostForTab(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, host);
}
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
if (initialPath) {
next.set(tabId, { hostId: host.id, path: initialPath });
} else {
next.delete(tabId);
}
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
if (isClosing || !pendingUploadEntries?.length) {
// Clear any stale pending upload on close or when opening without new files
next.delete(tabId);
} else {
next.set(tabId, {
requestId: crypto.randomUUID(),
hostId: host.id,
connectionKey: buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
targetPath: initialPath,
entries: pendingUploadEntries,
});
}
return next;
});
}, []);
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
setSftpPendingUploadsForTab(prev => {
const current = prev.get(tabId);
if (!current || current.requestId !== requestId) {
return prev;
}
const next = new Map(prev);
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;
});
}, []);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
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) => {
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
}),
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
);
// Pre-compute fallback hosts to avoid creating new objects on every render
const sessionHostsMap = useMemo(() => {
const map = new Map<string, Host>();
for (const session of sessions) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
const existingHost = materializeHostProxyProfile(
applyGroupDefaults(rawHost, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
let hostForSession: Host;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
hostForSession = existingHost;
} else {
hostForSession = {
...existingHost,
protocol,
port,
moshEnabled,
etEnabled,
};
}
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
} else {
// Create stable fallback host object
const fallbackProtocol = session.protocol ?? 'local' as const;
const fallbackHost: Host = {
id: session.hostId,
label: session.hostLabel || 'Local Terminal',
hostname: session.hostname || 'localhost',
username: session.username || 'local',
port: session.port ?? 22,
// Only local terminals adopt the client OS — unsaved serial
// sessions and orphaned remote sessions (whose host was deleted
// while the session lives on) also hit this fallback, and the
// non-local autocomplete path in Terminal.tsx trusts host.os, so
// a Windows-client 'windows' tag here would mis-shape POSIX
// remote/serial autocomplete (#1112 review).
os: fallbackProtocol === 'local'
? detectLocalOs(navigator.userAgent || navigator.platform)
: 'linux',
group: '',
tags: [],
protocol: fallbackProtocol,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
map.set(session.id, applySessionFontSizeToHost(fallbackHost, session));
}
}
return map;
}, [sessions, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
const host = sessionHostsMap.get(session.id);
if (!host?.hostChain?.hostIds?.length) continue;
map.set(
session.id,
host.hostChain.hostIds
.map((hostId) => {
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(rawChainHost, chainGroupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
const handleCommandSubmitted = useCallback((_command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
const tabId = activeTabIdRef.current;
if (!sftpFollowTerminalCwdRef.current || !tabId || sidePanelOpenTabsRef.current.get(tabId) !== 'sftp') return;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return;
const revisionAtCommand = terminalCwdRevisionRef.current;
const probeGeneration = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1;
cwdProbeGenerationRef.current.set(sessionId, probeGeneration);
cwdProbeCancelersRef.current.get(sessionId)?.();
const cancelProbe = scheduleBackendCwdProbeAfterCommand({
sessionId,
cwdRevisionAtCommand: revisionAtCommand,
getCwdRevision: () => terminalCwdRevisionRef.current,
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);
},
});
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
}, [handleTerminalCwdChange, terminalBackend]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
}, [onCommandExecuted]);
useEffect(() => () => {
for (const cancel of cwdProbeCancelersRef.current.values()) {
cancel();
}
cwdProbeCancelersRef.current.clear();
}, []);
const sessionSudoAutofillPasswordsMap = useMemo(() => {
const map = new Map<string, string | undefined>();
for (const session of sessions) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// 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: rawHost, keys, identities }));
}
}
return map;
}, [hostMap, sessions, keys, identities]);
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
const rawHost = hostMapRef.current.get(sessionHost.id);
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
if (usesGlobalFontSize) {
onUpdateTerminalFontSize?.(nextFontSize);
return;
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) ids.add(session.id);
for (const workspace of workspaces) ids.add(workspace.id);
return ids;
}, [sessions, workspaces]);
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<Map<string, () => void>>(new Map());
const splitVerticalHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleWorkspaceViewModeRef = useRef(onToggleWorkspaceViewMode);
onToggleWorkspaceViewModeRef.current = onToggleWorkspaceViewMode;
const workspaceFocusHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleBroadcastRef = useRef(onToggleBroadcast);
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
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));
}
}, []);
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 }): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
sessionId,
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
preferFreshBackend: options?.preferFreshBackend,
});
}, [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 handleCloseSidePanel = useCallback(() => {
const activeTabId = activeTabIdRef.current;
if (!activeTabId) return;
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
// Always clean up SFTP state (it may be mounted in the background
// while scripts/theme tab was active)
setSftpHostForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setSystemMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
refocusTerminalSession(sessionIdToRefocus);
}, [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;
}, []);
// Switch side panel to a specific tab (or toggle if already on that tab)
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 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;
});
}
// Note: When switching away from SFTP, we keep the SFTP host state
// so the SftpSidePanel stays mounted (hidden) and preserves connections.
// SFTP state is only cleaned up when the panel is fully closed.
markSidePanelSubTabOpened(tabId, tab);
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, tab);
return next;
});
});
}, [markSidePanelSubTabOpened, resolveSftpHostForTab]);
// 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]);
// 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<SidePanelTab>({ 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]);
// 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 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]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id;
if (!sessionId) return;
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
executor(command, noAutoRun);
return;
}
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// 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 remoteHistory = useRemoteHistoryState();
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 command = await resolveSnippetCommand(snippet);
if (command === null) return;
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
}, [handleSnippetClickForFocusedSession]);
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;
if (broadcastEnabled) {
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
for (const sid of allSessionIds) {
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
executor(text, false, { broadcast: false });
} else {
terminalBackend.writeToSession(sid, payload);
}
}
} 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 {
terminalBackend.writeToSession(targetId, payload);
}
}
}
}, [isBroadcastEnabled, terminalBackend]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt', timestampsEnabled: sessionLogsTimestampsEnabled }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled],
);
stableRef.current = {
accentMode,
activityTrackedSessions,
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
Array,
Button,
ChunkedEscapeFilter,
clearTerminalPreviewVars,
clearTopTabsPreviewVars,
FolderTree,
History,
HistorySidePanel,
MessageSquare,
Palette,
PanelLeft,
PanelRight,
cn,
collectSessionIds,
customAccent,
customGroups,
draggingSessionId,
editorWordWrap,
effectiveHosts,
filterTabsMap,
followAppTerminalTheme,
fontSize,
getSessionActivityIdsToClear,
getTerminalCwd,
handleAddKnownHost,
handleAddSelectionToAI,
handleBroadcastInput,
handleCloseSession,
handleCloseSidePanel,
handleCommandExecuted,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
handleHistoryRun,
handleOpenHistory,
handleOpenSftp,
handleOpenScripts,
handleOpenTheme,
handleOpenAI,
handleOpenSystem,
handleOsDetected,
handlePendingTerminalSelectionConsumed,
handlePendingUploadHandled,
handleSessionExit,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleSnippetExecutorChange,
handleStatusChange,
handleTerminalCwdChange,
handleTerminalDataCapture,
handleTerminalFontSizeChange,
handleToggleAiFromTopBar,
handleToggleScriptsSidePanel,
handleToggleSidePanel,
handleToggleSftpFromBar,
handleToggleWorkspaceComposeBar,
handleUpdateHost,
hasNotifiableTerminalOutput,
hostMap,
hosts,
hostsRef,
hotkeyScheme,
disableTerminalFontZoom,
identities,
isBroadcastEnabled,
isComposeBarOpen,
keyBindings,
keys,
knownHosts,
lastSidePanelTabRef,
mountedAiTabIds: aiMountedTabIds,
mountedSftpTabIds,
scriptsMountedTabIds,
systemMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onConnectToHost,
onCreateLocalTerminal,
onCreateWorkspaceFromSessions,
onHotkeyAction,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
onStartSessionDrag: onSetDraggingSessionId,
onEndSessionDrag: () => onSetDraggingSessionId(null),
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,
onToggleWorkspaceViewMode,
onToggleWorkspaceViewModeRef,
onUpdateHost,
onUpdateSplitSizes,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
onUpdateTerminalThemeId,
pendingTerminalSelectionForAI,
refocusActiveTerminalSession,
refocusTerminalSession,
remoteHistory,
shellHistory,
resolveSftpHostForTab,
ScriptsSidePanel,
sessionActivityStore,
sessionChainHostsMap,
sessionHostsMap,
sessionLogConfig,
sessionSudoAutofillPasswordsMap,
sessions,
sessionsRef,
setEditorWordWrap,
setIsComposeBarOpen,
setPendingTerminalSelectionForAI,
setAiMountedTabIds,
setScriptsMountedTabIds,
setSystemMountedTabIds,
setThemeMountedTabIds,
setSidePanelOpenTabs,
setSidePanelWidth,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setupMcpApprovalBridge,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelPosition,
sidePanelWidth,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
shouldMarkSessionActivity,
snippetExecutorsRef,
snippetPackages,
snippets,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
sshDebugLogsEnabled,
t,
TerminalComposeBar,
TerminalPanesHost,
terminalCwdRevision,
terminalFontFamilyId,
terminalRendererCwdBySessionRef,
terminalSettings,
terminalTheme,
ThemeSidePanel,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
Tooltip,
TooltipContent,
TooltipTrigger,
updateHosts,
updateSnippetPackages,
updateSnippets,
X,
Zap,
validAIScopeTargetIds,
validSessionActivityIds,
workspaceBroadcastHandlersRef,
workspaceById,
workspaceFocusHandlersRef,
workspaces,
workspacesRef,
activeTabIdRef,
activeWorkspaceRef,
activeSessionRef,
focusedSessionIdRef,
setSidePanelPosition,
};
return <TerminalLayerTabBridge stableRef={stableRef} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
TerminalLayer.displayName = 'TerminalLayer';