Files
NetMesh/components/terminalLayer/TerminalLayerSupport.tsx

1884 lines
78 KiB
TypeScript
Raw Permalink Normal View History

import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { activeTabStore } from '../../application/state/activeTabStore';
import {
applySessionPresentation,
usePresentedSession,
} from '../../application/state/sessionPresentationStore';
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
import { useAIState } from '../../application/state/useAIState';
import { useAISessionsStore } from '../../application/state/aiSessionsStore';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import {
buildAITerminalSessionInfo,
type AIPanelContext,
type AITerminalSessionInfo,
} from '../../domain/buildAITerminalSessionInfo';
export { buildAITerminalSessionInfo };
export type { AIPanelContext, AITerminalSessionInfo };
import { collectSessionIds, SplitDirection } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import { terminalPaneSessionsEqual } from '../../domain/terminalPaneSessionsEqual';
import {
resolveTerminalHibernateEnabled,
resolveTerminalHibernateEnabledForProtocol,
} from '../../domain/terminalHibernate';
import { KeyBinding, TerminalSettings } from '../../domain/models';
import type { TerminalCwdChangeMeta } from '../terminal/sftpCwd';
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
import { cn } from '../../lib/utils';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, VaultNote, Workspace } from '../../types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import type { AISession } from '../../infrastructure/ai/types';
import Terminal from '../Terminal';
import { removePaneVisible, setPaneVisible } from '../terminal/paneVisibilityStore';
import type { TerminalBroadcastInputOptions } from '../terminal/terminalHelpers';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import {
getTerminalPaneRenderSnapshot,
parseTerminalPaneRenderSnapshot,
resolveInactiveTerminalPaneStyle,
shouldUseTerminalPaneSplitLayout,
type TerminalPaneHiddenSize,
} from '../terminalPaneVisibility';
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
import {
resolvePaneMagnificationStyle,
type PaneMagnificationController,
type PaneMagnificationTarget,
} from '../../domain/paneMagnification';
export type SidePanelTab = SidePanelTool;
const LazyAIChatSidePanel = lazy(() =>
import('../AIChatSidePanel').then((module) => ({ default: module.AIChatSidePanel })),
);
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => {
void import('../AIChatSidePanel');
});
}
const AIChatSidePanelFallback = memo(function AIChatSidePanelFallback() {
return (
<div className="netcatty-lazy-fade-in h-full min-h-0 bg-background" aria-hidden="true" />
);
});
export type WorkspaceRect = { x: number; y: number; w: number; h: number };
export type SplitHint = {
direction: 'horizontal' | 'vertical';
position: 'left' | 'right' | 'top' | 'bottom';
targetSessionId?: string;
rect?: { x: number; y: number; w: number; h: number };
} | null;
export type ResizerHandle = {
id: string;
splitId: string;
index: number;
direction: 'vertical' | 'horizontal';
rect: { x: number; y: number; w: number; h: number };
splitArea: { w: number; h: number };
};
export type PendingSftpUpload = {
requestId: string;
hostId: string;
/** Full connection identity (id:hostname:port:protocol) for session-override awareness */
connectionKey: string;
/** Terminal session where the drop originated, including Mosh and ET. */
originSessionId?: string;
/** Terminal session whose active route must own the accepting SFTP connection. */
sourceSessionId?: string;
targetPath?: string;
entries: DropEntry[];
};
export type SnippetExecutor = (
command: string,
noAutoRun?: boolean,
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
focus?: boolean;
},
/**
* Returns true when the command was written to the session. False means the
* executor could not write and the caller may fall back to a direct backend
* write. May be async when the pane needs to wake from hibernation first.
*/
) => boolean | Promise<boolean>;
export type PendingTerminalSelectionForAI = {
requestId: string;
tabId: string;
text: string;
};
export function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
export function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
export function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
export type { TerminalThemePreviewState } from './terminalThemePreview';
export {
emptyTerminalThemePreview,
listThemePreviewSessionIds,
resolvePaneThemePreviewId,
} from './terminalThemePreview';
export const clearTerminalPreviewVars = (sessionId: string | null) => {
if (!sessionId || typeof document === 'undefined') return;
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
if (!pane) return;
pane.style.removeProperty('--terminal-preview-bg');
pane.style.removeProperty('--terminal-preview-fg');
pane.style.removeProperty('--terminal-preview-border');
pane.style.removeProperty('--terminal-preview-toolbar-btn');
pane.style.removeProperty('--terminal-preview-toolbar-btn-hover');
pane.style.removeProperty('--terminal-preview-toolbar-btn-active');
};
export const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const HOST_TREE_PREVIEW_PROPERTIES = [
'--terminal-host-tree-bg',
'--terminal-host-tree-fg',
'--terminal-host-tree-muted',
'--terminal-host-tree-separator',
'--terminal-host-tree-hover-bg',
'--terminal-host-tree-active-bg',
'--terminal-host-tree-drop-bg',
'--terminal-host-tree-folder-fg',
] as const;
const getHostTreePreviewRoots = (): HTMLElement[] => {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll<HTMLElement>(
'[data-section="app-host-tree-layer"], [data-section="terminal-host-tree-sidebar"]',
));
};
export const clearHostTreePreviewVars = () => {
const roots = getHostTreePreviewRoots();
for (const root of roots) {
for (const property of HOST_TREE_PREVIEW_PROPERTIES) {
removeStylePropertyIfSet(root, property);
}
}
};
export const clearTopTabsPreviewVars = () => {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
removeStylePropertyIfSet(tabsRoot, '--background');
removeStylePropertyIfSet(tabsRoot, '--foreground');
removeStylePropertyIfSet(tabsRoot, '--accent');
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
removeStylePropertyIfSet(tabsRoot, '--primary');
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
removeStylePropertyIfSet(tabsRoot, '--secondary');
removeStylePropertyIfSet(tabsRoot, '--border');
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
};
export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
let changed = false;
const next = new Map<string, T>();
for (const [id, value] of source) {
if (validIds.has(id)) {
next.set(id, value);
} else {
changed = true;
}
}
return changed ? next : source;
};
export { ChunkedEscapeFilter, hasNotifiableTerminalOutput } from './activityEscapeFilter';
/**
* Providers, permissions, agent config and the session mutators everything
* except `sessions` / `activeSessionIdMap` / `draftsByScope` /
* `panelViewByScope`, which `useAIState` deliberately keeps out of its return
* and publishes to `aiSessionsStore` instead. Because the hot slices are absent,
* a landing token cannot change this Context's identity, so provider and
* permission consumers stay put while a turn streams.
*/
export type AIConfigValue = ReturnType<typeof useAIState>;
const AIConfigContext = createContext<AIConfigValue | null>(null);
interface AIChatPanelsHostProps {
mountedTabIds: string[];
activeTabId: string | null;
activeSidePanelTab: SidePanelTab | null;
contextsByTabId: Map<string, AIPanelContext>;
resolveExecutorContext: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
pendingTerminalSelection?: PendingTerminalSelectionForAI | null;
onPendingTerminalSelectionConsumed?: (requestId: string) => void;
notes: VaultNote[];
hosts: Host[];
snippets: Snippet[];
onOpenVaultNoteFromChat?: (noteId: string) => void;
onOpenVaultHostFromChat?: (hostId: string) => void;
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
}
const EMPTY_WORKSPACES: Workspace[] = [];
interface AIStateMaintenanceHostProps {
validAIScopeTargetIds: Set<string>;
workspaces?: Workspace[] | null;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiConfig = useAIState();
return (
<AIConfigContext.Provider value={aiConfig}>
{children}
</AIConfigContext.Provider>
);
};
export const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
workspaces: workspacesProp,
}) => {
const aiConfig = useContext(AIConfigContext);
if (!aiConfig) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
// Guard missing prop so a wiring gap cannot crash the terminal shell.
const workspaces = workspacesProp ?? EMPTY_WORKSPACES;
const {
cleanupOrphanedSessions,
seedWorkspaceActiveSessionFromMembers,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
} = aiConfig;
const previousWorkspacesRef = useRef(workspaces);
const previousSessionWorkspaceRef = useRef(
new Map(workspaces.flatMap((workspace) => (
collectSessionIds(workspace.root).map((sessionId) => [sessionId, workspace.id] as const)
))),
);
useEffect(() => {
const previousWorkspaces = previousWorkspacesRef.current;
const previousIds = new Set(previousWorkspaces.map((workspace) => workspace.id));
const nextIds = new Set(workspaces.map((workspace) => workspace.id));
const previousSessionWorkspace = previousSessionWorkspaceRef.current;
for (const workspace of workspaces) {
if (previousIds.has(workspace.id)) continue;
const memberTerminalIds = collectSessionIds(workspace.root);
seedWorkspaceActiveSessionFromMembers({
workspaceId: workspace.id,
memberTerminalIds,
preferredTerminalId: workspace.focusedSessionId,
});
}
for (const workspace of workspaces) {
for (const sessionId of collectSessionIds(workspace.root)) {
const previousWorkspaceId = previousSessionWorkspace.get(sessionId);
if (previousWorkspaceId === workspace.id) continue;
// Member newly joined this workspace — seed only fills an empty map.
// Prefer the workspace focused pane so we don't pin the joiner's chat
// ahead of the pane the user is already looking at.
seedWorkspaceActiveSessionFromMembers({
workspaceId: workspace.id,
memberTerminalIds: collectSessionIds(workspace.root),
preferredTerminalId: workspace.focusedSessionId,
});
break;
}
}
for (const workspace of workspaces) {
if (!previousIds.has(workspace.id)) continue;
const previousWorkspace = previousWorkspaces.find((entry) => entry.id === workspace.id);
if (!previousWorkspace) continue;
const previousMemberIds = collectSessionIds(previousWorkspace.root);
const currentMemberIds = collectSessionIds(workspace.root);
if (previousMemberIds.every((sessionId) => currentMemberIds.includes(sessionId))) continue;
retargetWorkspaceActiveChatForMemberLoss({
workspaceId: workspace.id,
previousMemberTerminalIds: previousMemberIds,
currentMemberTerminalIds: currentMemberIds,
preferredTerminalId: workspace.focusedSessionId,
});
}
for (const workspace of previousWorkspaces) {
if (nextIds.has(workspace.id)) continue;
const memberTerminalIds = collectSessionIds(workspace.root);
handoffDissolvedWorkspaceScope({
workspaceId: workspace.id,
terminalIds: memberTerminalIds.filter((sessionId) => validAIScopeTargetIds.has(sessionId)),
preferredTerminalId: workspace.focusedSessionId,
});
}
previousWorkspacesRef.current = workspaces;
previousSessionWorkspaceRef.current = new Map(workspaces.flatMap((workspace) => (
collectSessionIds(workspace.root).map((sessionId) => [sessionId, workspace.id] as const)
)));
cleanupOrphanedSessions(validAIScopeTargetIds);
}, [
cleanupOrphanedSessions,
handoffDissolvedWorkspaceScope,
retargetWorkspaceActiveChatForMemberLoss,
seedWorkspaceActiveSessionFromMembers,
validAIScopeTargetIds,
workspaces,
]);
return null;
};
export const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
interface AISidePanelStateRootProps {
validAIScopeTargetIds: Set<string>;
workspaces?: Workspace[] | null;
children: React.ReactNode;
}
const AISidePanelStateRootInner: React.FC<AISidePanelStateRootProps> = ({
validAIScopeTargetIds,
workspaces,
children,
}) => (
<AIStateProvider>
<AIStateMaintenanceHost
validAIScopeTargetIds={validAIScopeTargetIds}
workspaces={workspaces}
/>
{children}
</AIStateProvider>
);
export const AISidePanelStateRoot = memo(AISidePanelStateRootInner);
AISidePanelStateRoot.displayName = 'AISidePanelStateRoot';
function aiChatPanelsHostAreEqual(
prev: AIChatPanelsHostProps,
next: AIChatPanelsHostProps,
): boolean {
if (prev.mountedTabIds !== next.mountedTabIds) return false;
if (prev.contextsByTabId !== next.contextsByTabId) return false;
if (prev.activeSidePanelTab !== next.activeSidePanelTab) return false;
if (prev.pendingTerminalSelection !== next.pendingTerminalSelection) return false;
if (prev.onPendingTerminalSelectionConsumed !== next.onPendingTerminalSelectionConsumed) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
if (prev.snippets !== next.snippets) return false;
if (prev.onOpenVaultNoteFromChat !== next.onOpenVaultNoteFromChat) return false;
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
if (prev.activeTabId === next.activeTabId) return true;
for (let i = 0; i < prev.mountedTabIds.length; i += 1) {
const tabId = prev.mountedTabIds[i];
const prevAiVisible = prev.activeTabId === tabId && prev.activeSidePanelTab === 'ai';
const nextAiVisible = next.activeTabId === tabId && next.activeSidePanelTab === 'ai';
if (prevAiVisible !== nextAiVisible) return false;
}
return true;
}
const consumedTerminalSelectionRequestIds = new Set<string>();
const CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT = 64;
function markTerminalSelectionRequestConsumed(requestId: string): void {
consumedTerminalSelectionRequestIds.add(requestId);
if (consumedTerminalSelectionRequestIds.size <= CONSUMED_TERMINAL_SELECTION_REQUEST_ID_LIMIT) {
return;
}
const oldest = consumedTerminalSelectionRequestIds.values().next().value;
if (oldest !== undefined) consumedTerminalSelectionRequestIds.delete(oldest);
}
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
contextsByTabId,
resolveExecutorContext,
pendingTerminalSelection,
onPendingTerminalSelectionConsumed,
notes,
hosts,
snippets,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
}) => {
const aiConfig = useContext(AIConfigContext);
if (!aiConfig) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
const {
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
} = useAISessionsStore();
const {
defaultAgentId,
showDraftView,
updateDraft,
} = aiConfig;
useEffect(() => {
if (!pendingTerminalSelection) return;
if (consumedTerminalSelectionRequestIds.has(pendingTerminalSelection.requestId)) {
return;
}
const context = contextsByTabId.get(pendingTerminalSelection.tabId);
if (!context) return;
const attachment = createTerminalSelectionAttachment(pendingTerminalSelection.text);
markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId);
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
if (!attachment) return;
const scopeKey = `${context.scopeType}:${context.scopeTargetId ?? ''}`;
const isSessionView =
panelViewByScope[scopeKey]?.mode === 'session'
|| activeSessionIdMap[scopeKey] != null;
if (!isSessionView) {
showDraftView(scopeKey);
}
updateDraft(scopeKey, defaultAgentId, (draft) => ({
...draft,
attachments: [...draft.attachments, attachment],
}));
}, [
activeSessionIdMap,
contextsByTabId,
defaultAgentId,
onPendingTerminalSelectionConsumed,
panelViewByScope,
pendingTerminalSelection,
showDraftView,
updateDraft,
]);
return (
<>
{mountedTabIds.map((tabId) => {
const context = contextsByTabId.get(tabId);
if (!context) return null;
const isVisible = activeTabId === tabId && activeSidePanelTab === 'ai';
return (
<div
key={tabId}
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
>
<LazyLoadBoundary name="AI side panel" resetKey={tabId}>
<Suspense fallback={<AIChatSidePanelFallback />}>
<LazyAIChatSidePanel
// Full list keeps fuzzy history ranking; panel areEqual only
// compares exact-scope session object refs for stream isolation.
sessions={sessions as AISession[]}
activeSessionIdMap={activeSessionIdMap as Record<string, string | null>}
draftsByScope={draftsByScope}
panelViewByScope={panelViewByScope}
setActiveSessionId={aiConfig.setActiveSessionId}
ensureDraftForScope={aiConfig.ensureDraftForScope}
updateDraft={aiConfig.updateDraft}
showDraftView={aiConfig.showDraftView}
showSessionView={aiConfig.showSessionView}
clearDraftForScope={aiConfig.clearDraftForScope}
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
addMessageToSession={aiConfig.addMessageToSession}
updateLastMessage={aiConfig.updateLastMessage}
updateMessageById={aiConfig.updateMessageById}
persistContextCompaction={aiConfig.persistContextCompaction}
providers={aiConfig.providers}
activeProviderId={aiConfig.activeProviderId}
activeModelId={aiConfig.activeModelId}
defaultAgentId={aiConfig.defaultAgentId}
toolIntegrationMode={aiConfig.toolIntegrationMode}
externalAgents={aiConfig.externalAgents}
setExternalAgents={aiConfig.setExternalAgents}
agentModelMap={aiConfig.agentModelMap}
setAgentModel={aiConfig.setAgentModel}
agentProviderMap={aiConfig.agentProviderMap}
setAgentProvider={aiConfig.setAgentProvider}
agentThinkingMap={aiConfig.agentThinkingMap}
setAgentThinking={aiConfig.setAgentThinking}
updateProvider={aiConfig.updateProvider}
globalPermissionMode={aiConfig.globalPermissionMode}
setGlobalPermissionMode={aiConfig.setGlobalPermissionMode}
commandBlocklist={aiConfig.commandBlocklist}
commandTimeout={aiConfig.commandTimeout}
responseIdleTimeout={aiConfig.responseIdleTimeout}
maxIterations={aiConfig.maxIterations}
webSearchConfig={aiConfig.webSearchConfig}
quickMessages={aiConfig.quickMessages}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
scopeLabel={context.scopeLabel}
focusedSessionId={context.focusedSessionId}
terminalSessions={context.terminalSessions}
resolveExecutorContext={resolveExecutorContext}
isVisible={isVisible}
notes={notes}
hosts={hosts}
snippets={snippets}
onOpenVaultNote={onOpenVaultNoteFromChat}
onOpenVaultHost={onOpenVaultHostFromChat}
onOpenVaultSection={onOpenVaultSectionFromChat}
onOpenVaultSnippet={onOpenVaultSnippetFromChat}
/>
</Suspense>
</LazyLoadBoundary>
</div>
);
})}
</>
);
};
export const AIChatPanelsHost = memo(AIChatPanelsHostInner, aiChatPanelsHostAreEqual);
AIChatPanelsHost.displayName = 'AIChatPanelsHost';
export interface TerminalLayerProps {
hosts: Host[];
portForwardingRules?: import('../../domain/models').PortForwardingRule[];
customGroups: string[];
groupConfigs: GroupConfig[];
proxyProfiles: ProxyProfile[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
snippetPackages: string[];
openNoteRequest?: { tabId: string; noteId: string; requestId: number } | null;
onOpenVaultNoteFromChat?: (noteId: string) => void;
onOpenVaultHostFromChat?: (hostId: string) => void;
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
sessions: TerminalSession[];
workspaces: Workspace[];
knownHosts?: KnownHost[];
draggingSessionId: string | null;
terminalTheme: TerminalTheme;
terminalThemeId?: string;
followAppTerminalTheme?: boolean;
pickTerminalTheme?: (themeId: string) => void;
clearThemeIntent?: () => void;
settleManualThemeIntent?: () => void;
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
terminalFontFamilyId: string;
fontSize?: number;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
restoreTerminalCwd?: boolean;
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
onUpdateSessionRestoreCwd?: (sessionId: string, cwd: string | null) => void;
onUpdateSessionDynamicTitle?: (sessionId: string, title: string | null) => void;
onUpdateSessionCodingCliProvider?: (sessionId: string, providerId: import('../../domain/codingCliProviders').CodingCliProviderId | null) => void;
onClearSessionFontSizeOverride?: (sessionId: string) => void;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
onUpdateHostDistro: (hostId: string, distro: string) => void;
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onDeleteShellHistoryEntry?: (entryId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
onSetDraggingSessionId: (id: string | null) => void;
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: (
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
onConnectToHost: (host: Host) => string | void;
onCreateLocalTerminal?: () => void;
// Broadcast mode
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleBroadcast?: (workspaceId: string) => void;
onToggleGlobalBroadcast?: () => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
updateSnippets?: (snippets: Snippet[]) => void;
updateSnippetPackages?: (packages: string[]) => void;
sftpDefaultViewMode: 'list' | 'tree';
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
terminalSidePanelAutoOpen?: boolean;
terminalSidePanelAutoOpenTab?: TerminalSidePanelAutoOpenTab;
sftpFollowTerminalCwd: boolean;
setSftpFollowTerminalCwd: (enabled: boolean) => void;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
// Session log settings for real-time streaming
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: "txt" | "raw" | "html";
sessionLogsTimestampsEnabled?: boolean;
sshDebugLogsEnabled?: boolean;
showHostTreeSidebar?: boolean;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
paneMagnificationRef?: React.MutableRefObject<PaneMagnificationController | null>;
// Session rename
onStartSessionRename?: (sessionId: string) => void;
onSubmitSessionRename?: (sessionId?: string, name?: string) => void;
}
interface TerminalPaneProps {
session: TerminalSession;
host: Host;
sessionHostResolved: boolean;
chainHosts?: Host[];
sudoAutofillPassword?: string;
sudoAutofillCandidates?: import("../terminal/runtime/terminalSudoAutofill").SudoPasswordAutofillCandidate[];
workspaceById: Map<string, Workspace>;
workspaceRectsById: Map<string, Record<string, WorkspaceRect>>;
isTerminalLayerVisible: boolean;
magnifiedPane: { tabId: string; target: PaneMagnificationTarget } | null;
onMagnifyTerminalPane: (tabId: string, sessionId: string) => void;
onTerminalPaneInteraction: (tabId: string, sessionId: string) => void;
workspaceFocusHandlersRef: React.MutableRefObject<Map<string, () => void>>;
workspaceBroadcastHandlersRef: React.MutableRefObject<Map<string, () => void>>;
splitHorizontalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
splitVerticalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
resolveSessionAppearance: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
hostMap: Map<string, Host>;
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
knownHosts: KnownHost[];
terminalFontFamilyId: string;
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
restoreTerminalCwd?: boolean;
keyBindings?: KeyBinding[];
isResizing: boolean;
isComposeBarOpen: boolean;
sessionLog?: { enabled: boolean; directory: string; format: "txt" | "raw" | "html"; timestampsEnabled?: boolean };
sshDebugLogEnabled?: boolean;
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (sessionId: string, fontSize: number) => void;
onOpenSftp: (
host: Host,
initialPath?: string,
pendingUploadEntries?: DropEntry[],
originSessionId?: string,
sourceSessionId?: string,
) => void;
onTerminalCwdChange: (sessionId: string, cwd: string | null, meta?: TerminalCwdChangeMeta) => void;
onTerminalTitleChange?: (sessionId: string, title: string | null) => void;
onTerminalBell?: (sessionId: string) => void;
onTerminalOutput?: (sessionId: string, chunk: string) => void;
onTerminalContextReaderChange?: (sessionId: string, reader: TerminalContextReader | null) => void;
onOpenScripts: () => void;
onOpenHistory?: () => void;
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandSubmitted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => string[] | void;
onToggleWorkspaceComposeBar: () => void;
onBroadcastInterruptPriorityChange: (
sessionId: string,
prioritize: (() => void) | null,
) => void;
onSnippetExecutorChange: (
sessionId: string,
executor: SnippetExecutor | null,
) => void;
onProgrammaticCommandLogRewriteChange: (
sessionId: string,
queueRewrite: ((rewrite: ProgrammaticCommandLogRewrite) => void) | null,
) => void;
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
showSelectionAIAction: boolean;
onStartSessionRename?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: (
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
}
const getPaneAppearanceThemeId = (props: TerminalPaneProps): string => {
const isEphemeral = !isSavedVaultHost(props.hostMap.get(props.host.id));
return props.resolveSessionAppearance({ host: props.host, isEphemeral }).themeId;
};
const getPaneWorkspaceRect = (props: Pick<TerminalPaneProps, 'session' | 'workspaceRectsById'>): WorkspaceRect | null => {
const workspaceId = props.session.workspaceId;
if (!workspaceId) return null;
return props.workspaceRectsById.get(workspaceId)?.[props.session.id] ?? null;
};
const getPaneRenderedWorkspaceRect = (props: Pick<TerminalPaneProps, 'session' | 'host' | 'workspaceById' | 'workspaceRectsById' | 'terminalSettings'>): WorkspaceRect | null => {
const workspaceId = props.session.workspaceId;
if (!workspaceId) return null;
const workspace = props.workspaceById.get(workspaceId);
if (!workspace) return null;
if (resolveTerminalHibernateEnabledForProtocol(props.terminalSettings, props.host.protocol)
&& activeTabStore.getActiveTabId() !== workspaceId) {
return null;
}
if (workspace.viewMode === 'focus' && workspace.focusedSessionId === props.session.id) {
return null;
}
return props.workspaceRectsById.get(workspaceId)?.[props.session.id] ?? null;
};
const workspaceRectsEqual = (a: WorkspaceRect | null, b: WorkspaceRect | null): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
};
const terminalPanePropsAreEqual = (
prev: TerminalPaneProps,
next: TerminalPaneProps,
): boolean => (
prev.session === next.session &&
prev.host === next.host &&
prev.sessionHostResolved === next.sessionHostResolved &&
prev.chainHosts === next.chainHosts &&
prev.sudoAutofillPassword === next.sudoAutofillPassword &&
prev.sudoAutofillCandidates === next.sudoAutofillCandidates &&
prev.workspaceById === next.workspaceById &&
workspaceRectsEqual(getPaneRenderedWorkspaceRect(prev), getPaneRenderedWorkspaceRect(next)) &&
prev.isTerminalLayerVisible === next.isTerminalLayerVisible &&
prev.magnifiedPane === next.magnifiedPane &&
prev.onMagnifyTerminalPane === next.onMagnifyTerminalPane &&
prev.onTerminalPaneInteraction === next.onTerminalPaneInteraction &&
prev.workspaceFocusHandlersRef === next.workspaceFocusHandlersRef &&
prev.workspaceBroadcastHandlersRef === next.workspaceBroadcastHandlersRef &&
prev.splitHorizontalHandlersRef === next.splitHorizontalHandlersRef &&
prev.splitVerticalHandlersRef === next.splitVerticalHandlersRef &&
getPaneAppearanceThemeId(prev) === getPaneAppearanceThemeId(next) &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.snippets === next.snippets &&
prev.knownHosts === next.knownHosts &&
prev.terminalFontFamilyId === next.terminalFontFamilyId &&
prev.fontSize === next.fontSize &&
prev.terminalTheme === next.terminalTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
// accentMode / customAccent intentionally omitted — Terminal reads appearanceChromeStore.
prev.terminalSettings === next.terminalSettings &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.disableTerminalFontZoom === next.disableTerminalFontZoom &&
prev.restoreTerminalCwd === next.restoreTerminalCwd &&
prev.keyBindings === next.keyBindings &&
prev.isResizing === next.isResizing &&
prev.isComposeBarOpen === next.isComposeBarOpen &&
prev.sessionLog === next.sessionLog &&
prev.sshDebugLogEnabled === next.sshDebugLogEnabled &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onTerminalFontSizeChange === next.onTerminalFontSizeChange &&
prev.onOpenSftp === next.onOpenSftp &&
prev.onTerminalCwdChange === next.onTerminalCwdChange &&
prev.onTerminalTitleChange === next.onTerminalTitleChange &&
prev.onTerminalBell === next.onTerminalBell &&
prev.onTerminalOutput === next.onTerminalOutput &&
prev.onTerminalContextReaderChange === next.onTerminalContextReaderChange &&
prev.onOpenScripts === next.onOpenScripts &&
prev.onOpenHistory === next.onOpenHistory &&
prev.onOpenTheme === next.onOpenTheme &&
prev.onOpenSystem === next.onOpenSystem &&
prev.onCloseSession === next.onCloseSession &&
prev.onStatusChange === next.onStatusChange &&
prev.onSessionExit === next.onSessionExit &&
prev.onTerminalDataCapture === next.onTerminalDataCapture &&
prev.onOsDetected === next.onOsDetected &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onCommandExecuted === next.onCommandExecuted &&
prev.onCommandSubmitted === next.onCommandSubmitted &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onBroadcastInput === next.onBroadcastInput &&
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
prev.showSelectionAIAction === next.showSelectionAIAction &&
prev.onStartSessionRename === next.onStartSessionRename &&
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
prev.onReorderTabs === next.onReorderTabs &&
prev.onStartSessionDrag === next.onStartSessionDrag &&
prev.onEndSessionDrag === next.onEndSessionDrag
);
type WorkspaceDetachPointerDragOptions = {
inActiveWorkspace: boolean;
session: TerminalSession;
terminalSettings?: TerminalSettings;
workspaceById: Map<string, Workspace>;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
onReorderTabs?: TerminalPaneProps['onReorderTabs'];
};
export function useWorkspaceDetachPointerDrag({
inActiveWorkspace,
session,
terminalSettings,
workspaceById,
onStartSessionDrag,
onEndSessionDrag,
onRemoveSessionFromWorkspace,
onReorderTabs,
}: WorkspaceDetachPointerDragOptions): (event: React.PointerEvent<HTMLElement>) => void {
const activeDragCleanupRef = useRef<(() => void) | null>(null);
const cleanupActiveDrag = useCallback(() => {
const cleanup = activeDragCleanupRef.current;
activeDragCleanupRef.current = null;
cleanup?.();
}, []);
useEffect(() => cleanupActiveDrag, [cleanupActiveDrag, session.id]);
return useCallback((event: React.PointerEvent<HTMLElement>) => {
if (!inActiveWorkspace || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
cleanupActiveDrag();
const ownerDocument = event.currentTarget.ownerDocument;
const ownerWindow = ownerDocument.defaultView;
const startPoint = { clientX: event.clientX, clientY: event.clientY };
const dragLabel = resolveSessionTabTitle(
applySessionPresentation(session),
terminalSettings?.dynamicTabTitleMode,
);
let dragStarted = false;
let cleanupFinished = false;
let ghostEl: HTMLDivElement | null = null;
let insertEl: HTMLDivElement | null = null;
const ensureDragElements = () => {
if (!ghostEl) {
ghostEl = ownerDocument.createElement('div');
ghostEl.textContent = dragLabel;
ghostEl.style.position = 'fixed';
ghostEl.style.left = '0';
ghostEl.style.top = '0';
ghostEl.style.zIndex = '2147483647';
ghostEl.style.pointerEvents = 'none';
ghostEl.style.maxWidth = '220px';
ghostEl.style.padding = '5px 10px';
ghostEl.style.borderRadius = '7px';
ghostEl.style.border = '1px solid color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 60%, transparent)';
ghostEl.style.background = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 90%, transparent)';
ghostEl.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
ghostEl.style.boxShadow = '0 12px 28px rgba(0, 0, 0, 0.28)';
ghostEl.style.fontSize = '12px';
ghostEl.style.fontWeight = '600';
ghostEl.style.whiteSpace = 'nowrap';
ghostEl.style.overflow = 'hidden';
ghostEl.style.textOverflow = 'ellipsis';
ownerDocument.body.appendChild(ghostEl);
}
if (!insertEl) {
insertEl = ownerDocument.createElement('div');
insertEl.style.position = 'fixed';
insertEl.style.zIndex = '2147483646';
insertEl.style.pointerEvents = 'none';
insertEl.style.width = '2px';
insertEl.style.borderRadius = '999px';
insertEl.style.background = 'var(--top-tabs-accent, hsl(var(--accent)))';
insertEl.style.boxShadow = '0 0 10px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 70%, transparent)';
insertEl.style.display = 'none';
ownerDocument.body.appendChild(insertEl);
}
};
const removeDragElements = () => {
ghostEl?.remove();
insertEl?.remove();
ghostEl = null;
insertEl = null;
};
const updateDragElements = (pointerEvent: PointerEvent) => {
ensureDragElements();
if (ghostEl) {
ghostEl.style.transform = `translate(${pointerEvent.clientX + 12}px, ${pointerEvent.clientY + 10}px)`;
}
const topTabsRoot = ownerDocument.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = getTopTabInsertionTarget(pointerEvent, topTabsRoot);
if (!topTabsRoot || !insertionTarget || !insertEl) {
if (insertEl) insertEl.style.display = 'none';
return insertionTarget;
}
const targetTab = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
.find((tab) => tab.dataset.tabId === insertionTarget.tabId);
if (!targetTab) {
insertEl.style.display = 'none';
return insertionTarget;
}
const targetRect = targetTab.getBoundingClientRect();
const rootRect = topTabsRoot.getBoundingClientRect();
const lineX = insertionTarget.position === 'before' ? targetRect.left : targetRect.right;
insertEl.style.display = 'block';
insertEl.style.left = `${lineX - 1}px`;
insertEl.style.top = `${Math.max(rootRect.top + 5, targetRect.top + 3)}px`;
insertEl.style.height = `${Math.max(18, Math.min(rootRect.bottom - rootRect.top - 8, targetRect.height - 4))}px`;
return insertionTarget;
};
const resolveStableInsertionTarget = (insertionTarget: ReturnType<typeof getTopTabInsertionTarget>) => {
if (!insertionTarget || insertionTarget.tabId !== session.workspaceId) return insertionTarget;
const sourceWorkspace = session.workspaceId ? workspaceById.get(session.workspaceId) : undefined;
if (!sourceWorkspace) return insertionTarget;
const remainingSessionIds = collectSessionIds(sourceWorkspace.root)
.filter((candidateId) => candidateId !== session.id);
if (remainingSessionIds.length !== 1) return insertionTarget;
return {
tabId: remainingSessionIds[0],
position: insertionTarget.position,
};
};
const startDragIfNeeded = (pointerEvent: PointerEvent) => {
if (cleanupFinished || dragStarted) return;
const dx = pointerEvent.clientX - startPoint.clientX;
const dy = pointerEvent.clientY - startPoint.clientY;
if (Math.hypot(dx, dy) < 4) return;
dragStarted = true;
onStartSessionDrag?.(session.id);
updateDragElements(pointerEvent);
};
const cleanup = () => {
if (cleanupFinished) return;
cleanupFinished = true;
ownerDocument.removeEventListener('pointermove', handlePointerMove, true);
ownerDocument.removeEventListener('pointerup', handlePointerUp, true);
ownerDocument.removeEventListener('pointercancel', handlePointerCancel, true);
ownerWindow?.removeEventListener('blur', handleWindowBlur);
removeDragElements();
const shouldEndDrag = dragStarted;
dragStarted = false;
if (activeDragCleanupRef.current === cleanup) {
activeDragCleanupRef.current = null;
}
if (shouldEndDrag) onEndSessionDrag?.();
};
const handlePointerMove = (pointerEvent: PointerEvent) => {
startDragIfNeeded(pointerEvent);
if (dragStarted) updateDragElements(pointerEvent);
};
const handlePointerCancel = () => {
cleanup();
};
const handleWindowBlur = () => {
cleanup();
};
const handlePointerUp = (pointerEvent: PointerEvent) => {
startDragIfNeeded(pointerEvent);
const topTabsRoot = ownerDocument.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = dragStarted ? updateDragElements(pointerEvent) : null;
const shouldDetach = dragStarted
&& !!topTabsRoot
&& isPointInsideRect(pointerEvent, topTabsRoot.getBoundingClientRect());
cleanup();
if (shouldDetach) {
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
if (onRemoveSessionFromWorkspace) {
onRemoveSessionFromWorkspace(
session.id,
stableInsertionTarget
? {
tabId: stableInsertionTarget.tabId,
position: stableInsertionTarget.position,
additionalTabIds: [session.id, stableInsertionTarget.tabId],
}
: undefined,
);
} else if (stableInsertionTarget) {
onReorderTabs?.(session.id, stableInsertionTarget.tabId, stableInsertionTarget.position, [
session.id,
stableInsertionTarget.tabId,
]);
}
}
};
activeDragCleanupRef.current = cleanup;
ownerDocument.addEventListener('pointermove', handlePointerMove, true);
ownerDocument.addEventListener('pointerup', handlePointerUp, true);
ownerDocument.addEventListener('pointercancel', handlePointerCancel, true);
ownerWindow?.addEventListener('blur', handleWindowBlur);
}, [
cleanupActiveDrag,
inActiveWorkspace,
onEndSessionDrag,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
session,
terminalSettings?.dynamicTabTitleMode,
workspaceById,
]);
}
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
session,
host,
sessionHostResolved,
chainHosts,
sudoAutofillPassword,
sudoAutofillCandidates,
workspaceById,
workspaceRectsById,
isTerminalLayerVisible,
magnifiedPane,
onMagnifyTerminalPane,
onTerminalPaneInteraction,
workspaceFocusHandlersRef,
workspaceBroadcastHandlersRef,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
resolveSessionAppearance,
hostMap,
keys,
identities,
snippets,
knownHosts,
terminalFontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
terminalSettings,
hotkeyScheme,
disableTerminalFontZoom,
restoreTerminalCwd,
keyBindings,
isResizing,
isComposeBarOpen,
sessionLog,
sshDebugLogEnabled,
onHotkeyAction,
onTerminalFontSizeChange,
onOpenSftp,
onTerminalCwdChange,
onTerminalTitleChange,
onTerminalBell,
onTerminalOutput,
onTerminalContextReaderChange,
onOpenScripts,
onOpenHistory,
onOpenTheme,
onOpenSystem,
onCloseSession,
onStatusChange,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
onUpdateHost,
onAddKnownHost,
onCommandExecuted,
onCommandSubmitted,
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onBroadcastInput,
onBroadcastInterruptPriorityChange,
onToggleWorkspaceComposeBar,
onSnippetExecutorChange,
onProgrammaticCommandLogRewriteChange,
onAddSelectionToAI,
showSelectionAIAction,
onStartSessionRename,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
const getRenderSnapshot = useCallback(
() => getTerminalPaneRenderSnapshot({
activeTabId: activeTabStore.getActiveTabId(),
sessionId: session.id,
sessionWorkspaceId: session.workspaceId,
workspaceById,
isTerminalLayerVisible,
}),
[isTerminalLayerVisible, session.id, session.workspaceId, workspaceById],
);
const renderSnapshot = useSyncExternalStore(activeTabStore.subscribe, getRenderSnapshot, getRenderSnapshot);
const { paneState, isFocusedPane } = parseTerminalPaneRenderSnapshot(renderSnapshot);
// Live titles/icons are store-driven; per-session snapshot so other panes do
// not re-render when only a sibling title changes.
const presentedSession = usePresentedSession(session);
const sessionDisplayName = resolveSessionTabTitle(
presentedSession,
terminalSettings?.dynamicTabTitleMode,
);
const activeWorkspaceId = paneState.workspaceId;
const isVisible = paneState.isVisible;
const paneElementRef = useRef<HTMLDivElement | null>(null);
const lastVisiblePaneSizeRef = useRef<TerminalPaneHiddenSize | null>(null);
const [, bumpHiddenPaneSizeVersion] = useState(0);
// Publish visibility before paint so hibernate / write-path readers see the
// new value in the same frame as the CSS hide/show (#1985).
useLayoutEffect(() => {
setPaneVisible(session.id, isVisible);
}, [session.id, isVisible]);
useEffect(() => () => removePaneVisible(session.id), [session.id]);
const inActiveWorkspace = !!activeWorkspaceId;
const isFocusMode = paneState.mode === 'focus';
const isSplitViewVisible = paneState.mode === 'split';
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(terminalSettings, host.protocol);
const layoutWorkspaceId = activeWorkspaceId ?? (!hibernateHiddenTabs ? session.workspaceId : undefined);
const layoutWorkspace = layoutWorkspaceId ? workspaceById.get(layoutWorkspaceId) : undefined;
const keepsWorkspacePresentation = !!layoutWorkspace;
const usesSplitLayout = shouldUseTerminalPaneSplitLayout({
workspace: layoutWorkspace,
sessionId: session.id,
isVisible,
hibernateHiddenTabs,
});
const rect = layoutWorkspaceId && usesSplitLayout
? workspaceRectsById.get(layoutWorkspaceId)?.[session.id] ?? null
: null;
const layoutStyle = rect
? {
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
}
: { left: 0, top: 0, width: '100%', height: '100%' };
const livePaneLayoutKey = usesSplitLayout && rect
? `${Math.round(rect.w)}x${Math.round(rect.h)}`
: 'full';
const paneLayoutKeyRef = useRef(livePaneLayoutKey);
const [, bumpPaneLayoutKeyVersion] = useState(0);
const shouldCommitLayoutImmediately =
!deferPaneLayoutUpdate && (!isSplitViewVisible || isFocusMode || isFocusedPane);
if (shouldCommitLayoutImmediately && paneLayoutKeyRef.current !== livePaneLayoutKey) {
paneLayoutKeyRef.current = livePaneLayoutKey;
}
useEffect(() => {
if (deferPaneLayoutUpdate || !isVisible || !isSplitViewVisible || isFocusMode || isFocusedPane) return;
if (paneLayoutKeyRef.current === livePaneLayoutKey) return;
let cancelled = false;
const commitDeferredLayout = () => {
if (cancelled || !isVisible) return;
if (paneLayoutKeyRef.current === livePaneLayoutKey) return;
paneLayoutKeyRef.current = livePaneLayoutKey;
bumpPaneLayoutKeyVersion((version) => version + 1);
};
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(commitDeferredLayout, { timeout: 500 });
return () => {
cancelled = true;
cancelIdleCallback(idleId);
};
}
const timerId = setTimeout(commitDeferredLayout, 350);
return () => {
cancelled = true;
clearTimeout(timerId);
};
}, [deferPaneLayoutUpdate, isFocusedPane, isFocusMode, isSplitViewVisible, isVisible, livePaneLayoutKey]);
const paneLayoutKey = paneLayoutKeyRef.current;
const isMagnified = isVisible
&& magnifiedPane?.target.kind === 'terminal'
&& magnifiedPane.target.sessionId === session.id
&& magnifiedPane.tabId === (activeWorkspaceId ?? session.id);
const isCoveredByMagnification = isVisible
&& magnifiedPane?.tabId === (activeWorkspaceId ?? session.id)
&& !isMagnified;
const [magnifiedSurfaceBounds, setMagnifiedSurfaceBounds] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
useLayoutEffect(() => {
if (!isMagnified) {
setMagnifiedSurfaceBounds(null);
return undefined;
}
const pane = paneElementRef.current;
const surface = pane?.closest<HTMLElement>('[data-section="terminal-workspace"]');
if (!surface) return undefined;
const update = () => {
const bounds = surface.getBoundingClientRect();
setMagnifiedSurfaceBounds({
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
});
};
update();
if (typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(update);
observer.observe(surface);
return () => observer.disconnect();
}, [isMagnified]);
const style: React.CSSProperties = resolvePaneMagnificationStyle(
layoutStyle,
isMagnified && !!magnifiedSurfaceBounds,
magnifiedSurfaceBounds,
);
useLayoutEffect(() => {
const element = paneElementRef.current;
if (!element) return;
const capturePaneSize = () => {
const width = element.clientWidth;
const height = element.clientHeight;
if (width <= 0 || height <= 0) return false;
lastVisiblePaneSizeRef.current = { width, height };
return true;
};
// Only full-size layouts may feed the hidden-pane pinned size. Split rects
// must never be cached: after a split -> focus viewMode switch the cached
// fragment size would pin the hidden pane (and its PTY) narrow again,
// recreating the \r progress-refresh scrollback spam (#3046).
if (isVisible && !rect) {
capturePaneSize();
const observer = new ResizeObserver(() => {
capturePaneSize();
});
observer.observe(element);
return () => observer.disconnect();
}
if (isVisible) {
// A split-visible pane invalidates the cached full-size measurement:
// after an intervening viewport resize the stale pixels would pin later
// hidden full-size layouts at the wrong width (#3046). The hidden
// initialization below re-measures the current area instead.
lastVisiblePaneSizeRef.current = null;
return;
}
const initializeHiddenFullSize = !hibernateHiddenTabs
&& !rect
&& !lastVisiblePaneSizeRef.current;
if (!initializeHiddenFullSize) return;
if (capturePaneSize()) {
bumpHiddenPaneSizeVersion((version) => version + 1);
return;
}
const observer = new ResizeObserver(() => {
if (!capturePaneSize()) return;
observer.disconnect();
bumpHiddenPaneSizeVersion((version) => version + 1);
});
observer.observe(element);
return () => observer.disconnect();
}, [
hibernateHiddenTabs,
isVisible,
layoutStyle.height,
layoutStyle.width,
activeWorkspaceId,
rect,
]);
if (!isVisible) {
Object.assign(style, resolveInactiveTerminalPaneStyle(
style,
lastVisiblePaneSizeRef.current,
hibernateHiddenTabs,
!rect,
));
}
const workspaceBroadcastHandler = activeWorkspaceId
? workspaceBroadcastHandlersRef.current.get(activeWorkspaceId)
: undefined;
const workspaceFocusHandler = activeWorkspaceId
? workspaceFocusHandlersRef.current.get(activeWorkspaceId)
: undefined;
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
const broadcastEnabled = activeWorkspaceId
? !!isBroadcastEnabled?.(activeWorkspaceId)
: (isGlobalBroadcastEnabled ?? false);
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
const sessionAppearance = useMemo(
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
[resolveSessionAppearance, host, isHostEphemeral],
);
const sessionAppearanceTheme = sessionAppearance.theme;
const handlePaneClick = useCallback(() => {
const tabId = activeWorkspaceId ?? session.id;
onTerminalPaneInteraction(tabId, session.id);
if (activeWorkspaceId && !isFocusMode) {
onSetWorkspaceFocusedSession?.(activeWorkspaceId, session.id);
}
}, [activeWorkspaceId, isFocusMode, onSetWorkspaceFocusedSession, onTerminalPaneInteraction, session.id]);
const handleTogglePaneMagnification = useCallback(() => {
onMagnifyTerminalPane(activeWorkspaceId ?? session.id, session.id);
}, [activeWorkspaceId, onMagnifyTerminalPane, session.id]);
const handleExpandToFocus = useCallback(() => {
if (isMagnified) {
handleTogglePaneMagnification();
}
workspaceFocusHandler?.();
}, [handleTogglePaneMagnification, isMagnified, workspaceFocusHandler]);
const handleOpenSystemForPane = useCallback(() => {
if (activeWorkspaceId && !isFocusMode) {
onSetWorkspaceFocusedSession?.(activeWorkspaceId, session.id);
}
onOpenSystem?.();
}, [activeWorkspaceId, isFocusMode, onOpenSystem, onSetWorkspaceFocusedSession, session.id]);
const handleRename = useCallback(() => {
onStartSessionRename?.(session.id);
}, [onStartSessionRename, session.id]);
const handleDetach = useCallback(() => {
onRemoveSessionFromWorkspace?.(session.id);
}, [onRemoveSessionFromWorkspace, session.id]);
const handleDetachDragStart = useCallback((e: React.DragEvent) => {
if (!inActiveWorkspace) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(WORKSPACE_SESSION_DRAG_TYPE, session.id);
e.dataTransfer.setData('session-id', session.id);
e.dataTransfer.setData('text/plain', session.id);
onStartSessionDrag?.(session.id);
}, [inActiveWorkspace, onStartSessionDrag, session.id]);
const handleDetachDragEnd = useCallback(() => {
onEndSessionDrag?.();
}, [onEndSessionDrag]);
const handleDetachPointerDown = useWorkspaceDetachPointerDrag({
inActiveWorkspace,
session,
terminalSettings,
workspaceById,
onStartSessionDrag,
onEndSessionDrag,
onRemoveSessionFromWorkspace,
onReorderTabs,
});
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
onTerminalFontSizeChange?.(session.id, nextFontSize);
}, [onTerminalFontSizeChange, session.id]);
return (
<div
ref={paneElementRef}
data-session-id={session.id}
data-section="terminal-split-pane"
data-focused={isFocusedPane ? 'true' : undefined}
inert={isVisible && !isCoveredByMagnification ? undefined : true}
className={cn(
"absolute bg-background",
inActiveWorkspace && "workspace-pane",
isVisible && "z-10",
isMagnified && magnifiedSurfaceBounds && "animate-in fade-in zoom-in-95 duration-150",
)}
style={style}
tabIndex={-1}
onClick={handlePaneClick}
onFocusCapture={handlePaneClick}
>
<Terminal
host={host}
keys={keys}
identities={identities}
snippets={snippets}
chainHosts={chainHosts}
appearanceTheme={sessionAppearanceTheme}
knownHosts={knownHosts}
isVisible={isVisible}
paneLayoutKey={paneLayoutKey}
inWorkspace={keepsWorkspacePresentation}
isResizing={isResizing}
isFocusMode={layoutWorkspace?.viewMode === 'focus'}
isPaneMagnified={isMagnified}
isFocused={isFocusedPane}
isFocusedPane={isSplitViewVisible ? isFocusedPane : undefined}
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
sessionId={session.id}
workspaceId={session.workspaceId}
restoreState={session.restoreState}
pendingInitialCwd={session.pendingInitialCwd}
shellType={session.shellType}
lastCwd={session.lastCwd}
restoreTerminalCwd={restoreTerminalCwd && sessionHostResolved}
startupCommand={session.startupCommand}
noAutoRun={session.noAutoRun}
multiLineRunMode={session.multiLineRunMode}
pendingScriptId={session.pendingScriptId}
pendingScript={session.pendingScript}
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
requireFreshConnection={session.requireFreshConnection}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={disableTerminalFontZoom}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
onOpenSftp={onOpenSftp}
onTerminalCwdChange={onTerminalCwdChange}
onTerminalTitleChange={onTerminalTitleChange}
onTerminalBell={onTerminalBell}
onTerminalOutput={onTerminalOutput}
onTerminalContextReaderChange={onTerminalContextReaderChange}
onOpenScripts={onOpenScripts}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme}
onOpenSystem={handleOpenSystemForPane}
onCloseSession={onCloseSession}
onStatusChange={onStatusChange}
onSessionExit={onSessionExit}
onTerminalDataCapture={onTerminalDataCapture}
onOsDetected={onOsDetected}
onUpdateHost={onUpdateHost}
onAddKnownHost={onAddKnownHost}
onCommandExecuted={onCommandExecuted}
onCommandSubmitted={onCommandSubmitted}
onExpandToFocus={inActiveWorkspace && !isFocusMode ? handleExpandToFocus : undefined}
onTogglePaneMagnification={inActiveWorkspace && (!isFocusMode || isMagnified) ? handleTogglePaneMagnification : undefined}
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={broadcastEnabled}
onToggleBroadcast={
inActiveWorkspace
? workspaceBroadcastHandler
: (canUseGlobalBroadcast ? onToggleGlobalBroadcast : undefined)
}
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
onSnippetExecutorChange={onSnippetExecutorChange}
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
sessionLog={sessionLog}
sshDebugLogEnabled={sshDebugLogEnabled}
sudoAutofillPassword={sudoAutofillPassword}
sudoAutofillCandidates={sudoAutofillCandidates}
sessionDisplayName={sessionDisplayName}
showSelectionAIAction={showSelectionAIAction}
onAddSelectionToAI={onAddSelectionToAI}
onRename={handleRename}
onDetach={inActiveWorkspace ? handleDetach : undefined}
onStartSessionDrag={inActiveWorkspace ? onStartSessionDrag : undefined}
onEndSessionDrag={inActiveWorkspace ? onEndSessionDrag : undefined}
onDetachPointerDown={inActiveWorkspace ? handleDetachPointerDown : undefined}
onDetachDragStart={inActiveWorkspace ? handleDetachDragStart : undefined}
onDetachDragEnd={inActiveWorkspace ? handleDetachDragEnd : undefined}
/>
</div>
);
}, terminalPanePropsAreEqual);
TerminalPane.displayName = 'TerminalPane';
interface TerminalPanesHostProps {
sessions: TerminalSession[];
sessionHostsMap: Map<string, Host>;
sessionChainHostsMap: Map<string, Host[]>;
sessionSudoAutofillPasswordsMap: Map<string, string | undefined>;
sessionSudoAutofillCandidatesMap: Map<
string,
import("../terminal/runtime/terminalSudoAutofill").SudoPasswordAutofillCandidate[] | undefined
>;
resolvedSessionHostIds: Set<string>;
workspaceById: Map<string, Workspace>;
workspaceRectsById: Map<string, Record<string, WorkspaceRect>>;
isTerminalLayerVisible: boolean;
magnifiedPane: { tabId: string; target: PaneMagnificationTarget } | null;
onMagnifyTerminalPane: (tabId: string, sessionId: string) => void;
onTerminalPaneInteraction: (tabId: string, sessionId: string) => void;
workspaceFocusHandlersRef: React.MutableRefObject<Map<string, () => void>>;
workspaceBroadcastHandlersRef: React.MutableRefObject<Map<string, () => void>>;
splitHorizontalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
splitVerticalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
resolveSessionAppearance: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
hostMap: Map<string, Host>;
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
knownHosts: KnownHost[];
terminalFontFamilyId: string;
fontSize: number;
terminalTheme: TerminalTheme;
followAppTerminalTheme?: boolean;
accentMode?: 'theme' | 'custom';
customAccent?: string;
terminalSettings?: TerminalSettings;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
disableTerminalFontZoom?: boolean;
restoreTerminalCwd?: boolean;
keyBindings?: KeyBinding[];
isResizing: boolean;
isComposeBarOpen: boolean;
sessionLog?: { enabled: boolean; directory: string; format: "txt" | "raw" | "html"; timestampsEnabled?: boolean };
sshDebugLogEnabled?: boolean;
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: TerminalPaneProps['onTerminalFontSizeChange'];
onOpenSftp: TerminalPaneProps['onOpenSftp'];
onTerminalCwdChange: TerminalPaneProps['onTerminalCwdChange'];
onTerminalTitleChange?: TerminalPaneProps['onTerminalTitleChange'];
onTerminalBell?: TerminalPaneProps['onTerminalBell'];
onTerminalOutput?: TerminalPaneProps['onTerminalOutput'];
onTerminalContextReaderChange?: TerminalPaneProps['onTerminalContextReaderChange'];
onOpenScripts: () => void;
onOpenHistory?: () => void;
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onCommandSubmitted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => string[] | void;
onToggleWorkspaceComposeBar: () => void;
onBroadcastInterruptPriorityChange: (
sessionId: string,
prioritize: (() => void) | null,
) => void;
onSnippetExecutorChange: (
sessionId: string,
executor: SnippetExecutor | null,
) => void;
onProgrammaticCommandLogRewriteChange: TerminalPaneProps['onProgrammaticCommandLogRewriteChange'];
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
onStartSessionRename?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
}
const terminalPanesHostPropsAreEqual = (
prev: TerminalPanesHostProps,
next: TerminalPanesHostProps,
): boolean => {
// Ignore TopTabs-only presentation fields on sessions (dynamicTitle, coding CLI).
if (!terminalPaneSessionsEqual(prev.sessions, next.sessions)) return false;
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
if (prev.sessionChainHostsMap !== next.sessionChainHostsMap) return false;
if (prev.sessionSudoAutofillPasswordsMap !== next.sessionSudoAutofillPasswordsMap) return false;
if (prev.sessionSudoAutofillCandidatesMap !== next.sessionSudoAutofillCandidatesMap) return false;
if (prev.resolvedSessionHostIds !== next.resolvedSessionHostIds) return false;
if (prev.workspaceById !== next.workspaceById) return false;
if (prev.isTerminalLayerVisible !== next.isTerminalLayerVisible) return false;
if (prev.magnifiedPane !== next.magnifiedPane) return false;
if (prev.onMagnifyTerminalPane !== next.onMagnifyTerminalPane) return false;
if (prev.onTerminalPaneInteraction !== next.onTerminalPaneInteraction) return false;
if (prev.workspaceFocusHandlersRef !== next.workspaceFocusHandlersRef) return false;
if (prev.workspaceBroadcastHandlersRef !== next.workspaceBroadcastHandlersRef) return false;
if (prev.splitHorizontalHandlersRef !== next.splitHorizontalHandlersRef) return false;
if (prev.splitVerticalHandlersRef !== next.splitVerticalHandlersRef) return false;
if (prev.resolveSessionAppearance !== next.resolveSessionAppearance) return false;
if (prev.hostMap !== next.hostMap) return false;
if (prev.keys !== next.keys) return false;
if (prev.identities !== next.identities) return false;
if (prev.snippets !== next.snippets) return false;
if (prev.knownHosts !== next.knownHosts) return false;
if (prev.terminalFontFamilyId !== next.terminalFontFamilyId) return false;
if (prev.fontSize !== next.fontSize) return false;
if (prev.terminalTheme !== next.terminalTheme) return false;
if (prev.followAppTerminalTheme !== next.followAppTerminalTheme) return false;
// accentMode / customAccent intentionally omitted — Terminal reads appearanceChromeStore.
if (prev.terminalSettings !== next.terminalSettings) return false;
if (prev.hotkeyScheme !== next.hotkeyScheme) return false;
if (prev.disableTerminalFontZoom !== next.disableTerminalFontZoom) return false;
if (prev.restoreTerminalCwd !== next.restoreTerminalCwd) return false;
if (prev.keyBindings !== next.keyBindings) return false;
if (prev.isResizing !== next.isResizing) return false;
if (prev.isComposeBarOpen !== next.isComposeBarOpen) return false;
if (prev.sessionLog !== next.sessionLog) return false;
if (prev.sshDebugLogEnabled !== next.sshDebugLogEnabled) return false;
if (prev.onHotkeyAction !== next.onHotkeyAction) return false;
if (prev.onTerminalFontSizeChange !== next.onTerminalFontSizeChange) return false;
if (prev.onOpenSftp !== next.onOpenSftp) return false;
if (prev.onTerminalCwdChange !== next.onTerminalCwdChange) return false;
if (prev.onTerminalTitleChange !== next.onTerminalTitleChange) return false;
if (prev.onTerminalBell !== next.onTerminalBell) return false;
if (prev.onTerminalOutput !== next.onTerminalOutput) return false;
if (prev.onTerminalContextReaderChange !== next.onTerminalContextReaderChange) return false;
if (prev.onOpenScripts !== next.onOpenScripts) return false;
if (prev.onOpenHistory !== next.onOpenHistory) return false;
if (prev.onOpenTheme !== next.onOpenTheme) return false;
if (prev.onOpenSystem !== next.onOpenSystem) return false;
if (prev.onCloseSession !== next.onCloseSession) return false;
if (prev.onStatusChange !== next.onStatusChange) return false;
if (prev.onSessionExit !== next.onSessionExit) return false;
if (prev.onTerminalDataCapture !== next.onTerminalDataCapture) return false;
if (prev.onOsDetected !== next.onOsDetected) return false;
if (prev.onUpdateHost !== next.onUpdateHost) return false;
if (prev.onAddKnownHost !== next.onAddKnownHost) return false;
if (prev.onCommandExecuted !== next.onCommandExecuted) return false;
if (prev.onCommandSubmitted !== next.onCommandSubmitted) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onSplitSession !== next.onSplitSession) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
if (prev.onSnippetExecutorChange !== next.onSnippetExecutorChange) return false;
if (prev.onProgrammaticCommandLogRewriteChange !== next.onProgrammaticCommandLogRewriteChange) return false;
if (prev.onAddSelectionToAI !== next.onAddSelectionToAI) return false;
if (prev.onStartSessionRename !== next.onStartSessionRename) return false;
if (prev.onRemoveSessionFromWorkspace !== next.onRemoveSessionFromWorkspace) return false;
if (prev.onReorderTabs !== next.onReorderTabs) return false;
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
if (!resolveTerminalHibernateEnabled(next.terminalSettings)) {
return prev.sessions.every((session) => workspaceRectsEqual(
getPaneWorkspaceRect({ session, workspaceRectsById: prev.workspaceRectsById }),
getPaneWorkspaceRect({ session, workspaceRectsById: next.workspaceRectsById }),
));
}
const activeTabId = activeTabStore.getActiveTabId();
const activeWorkspace = activeTabId ? next.workspaceById.get(activeTabId) : undefined;
return prev.sessions.every((session) => {
const isLocalTerminal = next.sessionHostsMap.get(session.id)?.protocol === 'local';
const isInVisibleSplitWorkspace = activeWorkspace?.viewMode !== 'focus'
&& session.workspaceId === activeWorkspace?.id;
if (!isLocalTerminal && !isInVisibleSplitWorkspace) return true;
return workspaceRectsEqual(
getPaneWorkspaceRect({ session, workspaceRectsById: prev.workspaceRectsById }),
getPaneWorkspaceRect({ session, workspaceRectsById: next.workspaceRectsById }),
);
});
};
export const TerminalPanesHost: React.FC<TerminalPanesHostProps> = memo(({
sessions,
sessionHostsMap,
sessionChainHostsMap,
sessionSudoAutofillPasswordsMap,
sessionSudoAutofillCandidatesMap,
resolvedSessionHostIds,
...sharedProps
}) => {
const [showSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
return (
<>
{sessions.map((session) => {
const host = sessionHostsMap.get(session.id);
if (!host) return null;
return (
<TerminalPane
key={session.id}
session={session}
host={host}
sessionHostResolved={resolvedSessionHostIds.has(session.id)}
chainHosts={sessionChainHostsMap.get(session.id)}
sudoAutofillPassword={sessionSudoAutofillPasswordsMap.get(session.id)}
sudoAutofillCandidates={sessionSudoAutofillCandidatesMap.get(session.id)}
showSelectionAIAction={showSelectionAIAction}
{...sharedProps}
/>
);
})}
</>
);
}, terminalPanesHostPropsAreEqual);
TerminalPanesHost.displayName = 'TerminalPanesHost';