Files
Netcatty/components/terminalLayer/TerminalLayerSupport.tsx
T
陈大猫 c6baebd514 Support password in ssh:// deep links with ephemeral connect (#1963)
* Support password in ssh deep links with ephemeral connect

ssh://user:password@host URLs now connect directly using the URL
credentials without saving the host to the vault, covering bastion
one-time password flows. Password-less links keep the existing
match-or-prefill behavior.

Fixes #1890

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

* Keep ephemeral deep-link hosts out of vault update paths

Terminal-layer host updates (e.g. the SFTP follow-CWD toggle) map over
the merged terminal host list, which could persist an ephemeral host
and its plaintext password into the vault. Route terminal-layer host
updates through a splitter that applies ephemeral entries to in-memory
state only and forwards just the vault hosts to updateHosts.

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

* Exclude ephemeral deep-link sessions from session restore

Sessions backed by an in-memory ephemeral host cannot survive a
relaunch (the one-time password only lives in memory), so persisting
them would leak the ephemeral host metadata into restore storage and
restore a tab that can never reconnect. Mark ephemeral hosts and their
sessions, and filter them out of the session-restore payload.

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

* Treat ephemeral hosts as unsaved in appearance classification

The terminal theme/font panel and pane appearance resolution decided
"saved vault host" via hostMap membership. Ephemeral deep-link hosts
are now in that map, so theme/font changes were routed to the
persisted-host updater and silently dropped. Classify via
isSavedVaultHost (ephemeral flag aware) so those changes fall back to
the session/global setting path as before.

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

* Reuse saved host settings for password deep links and keep ephemeral marker on clones

Password deep links now run saved-host matching first: a uniquely
matched vault host contributes its non-credential settings (proxy,
jump chain, charset, group, ...) to the ephemeral host while the URL
credentials override vault identities and key references. Session
clones (split/copy/copy-to-new-window) now carry the ephemeralHost
marker so cloned tabs stay excluded from session restore.

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

* Block group credential inheritance on password deep-link ephemeral hosts

buildSshDeepLinkEphemeralHostFromSaved now takes the group-resolved
effective host and clears group/identityFilePaths in addition to
identity references, so a later resolveEffectiveHost pass cannot
re-inherit group default credentials (identity, key, password) over
the one-time URL password. Non-credential group defaults (proxy,
jump chain, charset, ...) are preserved via the effective host.

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

* Route ephemeral-host font zoom through the per-session font-size path

Ctrl+zoom on a non-workspace ephemeral deep-link session hit the
saved-host update path, where the vault updater silently dropped the
change (ephemeral ids are not in the persisted hosts array). Zoom and
the theme panel font-size slider/reset now store the size on the
session (rendered via applySessionFontSizeToHost) for ephemeral
hosts, matching the workspace-pane behavior.

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

* Fix ephemeral session font zoom routing

* Prevent ephemeral deep-link sudo autofill

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:56:32 +08:00

1582 lines
65 KiB
TypeScript

import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { activeTabStore } from '../../application/state/activeTabStore';
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 { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import { collectSessionIds, SplitDirection } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import { KeyBinding, TerminalSettings } from '../../domain/models';
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 Terminal from '../Terminal';
import { removePaneVisible, setPaneVisible } from '../terminal/paneVisibilityStore';
import type { TerminalBroadcastInputOptions } from '../terminal/terminalHelpers';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import {
getTerminalPaneRenderSnapshot,
parseTerminalPaneRenderSnapshot,
resolveHiddenTerminalPaneStyle,
type TerminalPaneHiddenSize,
} from '../terminalPaneVisibility';
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
export type SidePanelTab = 'sftp' | 'scripts' | 'history' | 'theme' | 'ai' | 'system' | 'notes';
const LazyAIChatSidePanel = lazy(() =>
import('../AIChatSidePanel').then((module) => ({ default: module.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;
targetPath?: string;
entries: DropEntry[];
};
export type SnippetExecutor = (
command: string,
noAutoRun?: boolean,
options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"] },
) => void;
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;
};
// eslint-disable-next-line no-control-regex
const TERMINAL_OSC_SEQUENCE_REGEX = new RegExp('\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)', 'g');
// eslint-disable-next-line no-control-regex
const TERMINAL_ESCAPE_SEQUENCE_REGEX = new RegExp('\\u001B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])', 'g');
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL_CHAR_REGEX = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F]', 'g');
// eslint-disable-next-line no-control-regex
const INCOMPLETE_ESCAPE_TAIL_REGEX = new RegExp('\\u001B(?:\\][^\\u0007\\u001B]*(?:\\u001B)?|\\[[0-?]*[ -/]*)?$');
const stripTerminalControlSequences = (data: string): string => {
return data
.replace(TERMINAL_OSC_SEQUENCE_REGEX, '')
.replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '')
.replace(TERMINAL_CONTROL_CHAR_REGEX, '');
};
export class ChunkedEscapeFilter {
private pending = '';
feed(chunk: string): string {
const data = this.pending + chunk;
const tailMatch = INCOMPLETE_ESCAPE_TAIL_REGEX.exec(data);
if (tailMatch) {
this.pending = tailMatch[0];
return stripTerminalControlSequences(data.slice(0, tailMatch.index));
}
this.pending = '';
return stripTerminalControlSequences(data);
}
}
export const hasNotifiableTerminalOutput = (filter: ChunkedEscapeFilter, chunk: string): boolean => {
return filter.feed(chunk).trim().length > 0;
};
export type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
}>;
};
function summarizeHostChain(
host: Host | undefined,
allHosts: Host[],
): AITerminalSessionInfo['hostChain'] | undefined {
if (!host?.hostChain?.hostIds?.length) return undefined;
return host.hostChain.hostIds.map((hostId) => {
const jumpHost = allHosts.find((entry) => entry.id === hostId);
return {
hostId,
label: jumpHost?.label,
hostname: jumpHost?.hostname,
};
});
}
export type AIPanelContext = {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds: string[];
scopeLabel: string;
terminalSessions: AITerminalSessionInfo[];
};
type AIStateValue = ReturnType<typeof useAIState>;
const AIStateContext = createContext<AIStateValue | null>(null);
export const buildAITerminalSessionInfo = (
session: TerminalSession | undefined,
host: Host | undefined,
localOs: 'linux' | 'macos' | 'windows',
options?: {
allHosts?: Host[];
portForwardingRules?: import('../../domain/models').PortForwardingRule[];
},
): AITerminalSessionInfo => {
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
const allHosts = options?.allHosts ?? (host ? [host] : []);
const hostChain = summarizeHostChain(host, allHosts);
const activePortForwards = host?.id && options?.portForwardingRules
? options.portForwardingRules
.filter((rule) => rule.hostId === host.id && (rule.status === 'active' || rule.status === 'connecting'))
.map((rule) => ({
ruleId: rule.id,
label: rule.label,
type: rule.type,
localPort: rule.localPort,
status: rule.status,
}))
: undefined;
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
// Suppress deviceType for Mosh / ET sessions — both require a shell-backed
// PTY and cannot connect to vendor CLIs, so network device mode doesn't apply.
deviceType: (session?.moshEnabled || host?.moshEnabled || session?.etEnabled || host?.etEnabled) ? undefined : host?.deviceType,
connected: session?.status === 'connected',
...(hostChain?.length ? { hostChain } : {}),
...(activePortForwards?.length ? { activePortForwards } : {}),
};
};
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;
}
interface AIStateMaintenanceHostProps {
validAIScopeTargetIds: Set<string>;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
return (
<AIStateContext.Provider value={aiState}>
{children}
</AIStateContext.Provider>
);
};
export const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
const { cleanupOrphanedSessions } = aiState;
useEffect(() => {
cleanupOrphanedSessions(validAIScopeTargetIds);
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
return null;
};
export const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
interface AISidePanelStateRootProps {
validAIScopeTargetIds: Set<string>;
children: React.ReactNode;
}
const AISidePanelStateRootInner: React.FC<AISidePanelStateRootProps> = ({
validAIScopeTargetIds,
children,
}) => (
<AIStateProvider>
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
{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 AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
contextsByTabId,
resolveExecutorContext,
pendingTerminalSelection,
onPendingTerminalSelectionConsumed,
notes,
hosts,
snippets,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
const {
activeSessionIdMap,
defaultAgentId,
panelViewByScope,
showDraftView,
updateDraft,
} = aiState;
useEffect(() => {
if (!pendingTerminalSelection) return;
const context = contextsByTabId.get(pendingTerminalSelection.tabId);
if (!context) return;
const attachment = createTerminalSelectionAttachment(pendingTerminalSelection.text);
if (!attachment) {
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
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],
}));
onPendingTerminalSelectionConsumed?.(pendingTerminalSelection.requestId);
}, [
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
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
draftsByScope={aiState.draftsByScope}
panelViewByScope={aiState.panelViewByScope}
setActiveSessionId={aiState.setActiveSessionId}
ensureDraftForScope={aiState.ensureDraftForScope}
updateDraft={aiState.updateDraft}
showDraftView={aiState.showDraftView}
showSessionView={aiState.showSessionView}
clearDraftForScope={aiState.clearDraftForScope}
addDraftFiles={aiState.addDraftFiles}
removeDraftFile={aiState.removeDraftFile}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
toolIntegrationMode={aiState.toolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
agentProviderMap={aiState.agentProviderMap}
setAgentProvider={aiState.setAgentProvider}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
commandTimeout={aiState.commandTimeout}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
quickMessages={aiState.quickMessages}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
scopeLabel={context.scopeLabel}
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[];
notes: VaultNote[];
noteGroups: 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;
shellHistory?: import('../../types').ShellHistoryEntry[];
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;
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;
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;
onToggleBroadcast?: (workspaceId: string) => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
updateSnippets?: (snippets: Snippet[]) => void;
updateSnippetPackages?: (packages: string[]) => void;
updateNotes: (notes: VaultNote[]) => void;
updateNoteGroups: (groups: 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?: string;
sessionLogsTimestampsEnabled?: boolean;
sshDebugLogsEnabled?: boolean;
showHostTreeSidebar?: boolean;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | 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;
workspaceById: Map<string, Workspace>;
workspaceRectsById: Map<string, Record<string, WorkspaceRect>>;
isTerminalLayerVisible: boolean;
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: true; directory: string; format: string; timestampsEnabled?: boolean };
sshDebugLogEnabled?: boolean;
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (sessionId: string, fontSize: number) => void;
onOpenSftp: (
host: Host,
initialPath?: string,
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onTerminalCwdChange: (sessionId: string, cwd: string | null, meta?: { source?: 'osc7' }) => 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;
shellHistory?: import('../../types').ShellHistoryEntry[];
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;
onBroadcastInput: (
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => 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 getPaneActiveWorkspaceRect = (props: Pick<TerminalPaneProps, 'session' | 'workspaceById' | 'workspaceRectsById'>): WorkspaceRect | null => {
const workspaceId = props.session.workspaceId;
if (!workspaceId) return null;
if (activeTabStore.getActiveTabId() !== workspaceId) return null;
const workspace = props.workspaceById.get(workspaceId);
if (!workspace || workspace.viewMode === 'focus') 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.workspaceById === next.workspaceById &&
workspaceRectsEqual(getPaneActiveWorkspaceRect(prev), getPaneActiveWorkspaceRect(next)) &&
prev.isTerminalLayerVisible === next.isTerminalLayerVisible &&
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 &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
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.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
);
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
session,
host,
sessionHostResolved,
chainHosts,
sudoAutofillPassword,
workspaceById,
workspaceRectsById,
isTerminalLayerVisible,
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,
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);
const activeWorkspaceId = paneState.workspaceId;
const isVisible = paneState.isVisible;
const paneElementRef = useRef<HTMLDivElement | null>(null);
const lastVisiblePaneSizeRef = useRef<TerminalPaneHiddenSize | null>(null);
// Publish visibility to the per-session store so TerminalServerStats /
// TerminalAutocomplete can self-subscribe — keeping isVisible out of the
// TerminalView ctx so visibility toggles don't re-render TerminalView.
useEffect(() => {
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 rect = activeWorkspaceId && isSplitViewVisible
? workspaceRectsById.get(activeWorkspaceId)?.[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 = isSplitViewVisible && 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 style: React.CSSProperties = { ...layoutStyle };
useLayoutEffect(() => {
if (!isVisible) return;
const element = paneElementRef.current;
if (!element) return;
const width = element.clientWidth;
const height = element.clientHeight;
if (width > 0 && height > 0) {
lastVisiblePaneSizeRef.current = { width, height };
}
}, [
isVisible,
layoutStyle.height,
layoutStyle.width,
activeWorkspaceId,
]);
if (!isVisible) {
Object.assign(style, resolveHiddenTerminalPaneStyle(style, lastVisiblePaneSizeRef.current));
}
const workspaceFocusHandler = activeWorkspaceId
? workspaceFocusHandlersRef.current.get(activeWorkspaceId)
: undefined;
const workspaceBroadcastHandler = activeWorkspaceId
? workspaceBroadcastHandlersRef.current.get(activeWorkspaceId)
: undefined;
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
const broadcastEnabled = activeWorkspaceId ? !!isBroadcastEnabled?.(activeWorkspaceId) : 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(() => {
if (activeWorkspaceId && !isFocusMode) {
onSetWorkspaceFocusedSession?.(activeWorkspaceId, session.id);
}
}, [activeWorkspaceId, isFocusMode, onSetWorkspaceFocusedSession, session.id]);
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 = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (!inActiveWorkspace || e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startPoint = { clientX: e.clientX, clientY: e.clientY };
const dragLabel = resolveSessionTabTitle(session, terminalSettings?.dynamicTabTitleMode);
let dragStarted = false;
let ghostEl: HTMLDivElement | null = null;
let insertEl: HTMLDivElement | null = null;
const ensureDragElements = () => {
if (!ghostEl) {
ghostEl = document.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';
document.body.appendChild(ghostEl);
}
if (!insertEl) {
insertEl = document.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';
document.body.appendChild(insertEl);
}
};
const removeDragElements = () => {
ghostEl?.remove();
insertEl?.remove();
ghostEl = null;
insertEl = null;
};
const updateDragElements = (event: PointerEvent) => {
ensureDragElements();
if (ghostEl) {
ghostEl.style.transform = `translate(${event.clientX + 12}px, ${event.clientY + 10}px)`;
}
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = getTopTabInsertionTarget(event, 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 = (event: PointerEvent) => {
if (dragStarted) return;
const dx = event.clientX - startPoint.clientX;
const dy = event.clientY - startPoint.clientY;
if (Math.hypot(dx, dy) < 4) return;
dragStarted = true;
onStartSessionDrag?.(session.id);
updateDragElements(event);
};
const cleanup = () => {
document.removeEventListener('pointermove', handlePointerMove, true);
document.removeEventListener('pointerup', handlePointerUp, true);
document.removeEventListener('pointercancel', handlePointerCancel, true);
removeDragElements();
if (dragStarted) onEndSessionDrag?.();
};
const handlePointerMove = (event: PointerEvent) => {
startDragIfNeeded(event);
if (dragStarted) updateDragElements(event);
};
const handlePointerCancel = () => {
cleanup();
};
const handlePointerUp = (event: PointerEvent) => {
startDragIfNeeded(event);
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = dragStarted ? updateDragElements(event) : null;
const shouldDetach = dragStarted && !!topTabsRoot && isPointInsideRect(event, 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,
]);
}
}
};
document.addEventListener('pointermove', handlePointerMove, true);
document.addEventListener('pointerup', handlePointerUp, true);
document.addEventListener('pointercancel', handlePointerCancel, true);
}, [
inActiveWorkspace,
onEndSessionDrag,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
session,
terminalSettings?.dynamicTabTitleMode,
workspaceById,
]);
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}
className={cn(
"absolute bg-background",
inActiveWorkspace && "workspace-pane",
isVisible && "z-10",
)}
style={style}
tabIndex={-1}
onClick={handlePaneClick}
>
<Terminal
host={host}
keys={keys}
identities={identities}
snippets={snippets}
chainHosts={chainHosts}
appearanceTheme={sessionAppearanceTheme}
knownHosts={knownHosts}
isVisible={isVisible}
paneLayoutKey={paneLayoutKey}
inWorkspace={inActiveWorkspace}
isResizing={isResizing}
isFocusMode={isFocusMode}
isFocused={isFocusedPane}
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
sessionId={session.id}
restoreState={session.restoreState}
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}
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 ? workspaceFocusHandler : undefined}
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={broadcastEnabled}
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : 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}
sessionDisplayName={resolveSessionTabTitle(session, terminalSettings?.dynamicTabTitleMode)}
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>;
resolvedSessionHostIds: Set<string>;
workspaceById: Map<string, Workspace>;
workspaceRectsById: Map<string, Record<string, WorkspaceRect>>;
isTerminalLayerVisible: boolean;
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: true; directory: string; format: string; 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;
shellHistory?: import('../../types').ShellHistoryEntry[];
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;
onBroadcastInput: (
data: string,
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => 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 => {
if (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.resolvedSessionHostIds !== next.resolvedSessionHostIds) return false;
if (prev.workspaceById !== next.workspaceById) return false;
if (prev.isTerminalLayerVisible !== next.isTerminalLayerVisible) 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;
if (prev.accentMode !== next.accentMode) return false;
if (prev.customAccent !== next.customAccent) return false;
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.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;
const activeTabId = activeTabStore.getActiveTabId();
const activeWorkspace = activeTabId ? next.workspaceById.get(activeTabId) : undefined;
if (!activeWorkspace || activeWorkspace.viewMode === 'focus') return true;
return prev.sessions.every((session) => {
if (session.workspaceId !== activeWorkspace.id) 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,
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)}
showSelectionAIAction={showSelectionAIAction}
{...sharedProps}
/>
);
})}
</>
);
}, terminalPanesHostPropsAreEqual);
TerminalPanesHost.displayName = 'TerminalPanesHost';