import { getErrorMessage } from "../../lib/error-message.js"; /* eslint-disable react-hooks/exhaustive-deps */ import { useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, } from "react"; import { createPortal } from "react-dom"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { RobustClipboardProvider } from "@/lib/clipboard-provider"; import { copyToClipboard, readFromClipboard } from "@/lib/clipboard"; import { resolveTerminalContextMenuAction, selectedTextToCopy, } from "@/features/terminal/terminal-clipboard-actions"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon } from "@xterm/addon-search"; import { useTranslation } from "react-i18next"; import { getBasePath } from "@/lib/base-path"; import { resolveConnectionOrigin, buildOriginWsUrl, } from "@/lib/connection-origin.ts"; import { getCookie, isElectron, logActivity, getSnippets, deleteCommandFromHistory, getCommandHistory, getHostPassword, } from "@/main-axios.ts"; import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx"; import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx"; import { TailscaleCheckDialog } from "@/ssh/dialogs/TailscaleCheckDialog.tsx"; import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx"; import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS, resolveTerminalFontFamily, } from "@/lib/terminal-themes.ts"; import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts"; import { useTheme } from "@/components/theme-provider.tsx"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts"; import { highlightTerminalOutput, updateControlStringMode, } from "@/lib/terminal-syntax-highlighter.ts"; import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts"; import { buildImageUploadFormData, type TerminalImageUploadSource, } from "@/features/terminal/terminal-image-upload.ts"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; import { TerminalSearchBar } from "./search/TerminalSearchBar.tsx"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { ConnectionLogProvider, useConnectionLog, } from "@/ssh/connection-log/ConnectionLogContext.tsx"; import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx"; import { toast } from "sonner"; import { Button } from "@/components/button"; import { Save } from "lucide-react"; import { authApi } from "@/main-axios.ts"; import { resolveTermixThemeColors } from "./terminal-theme.ts"; import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx"; import { TerminalToolbar } from "./TerminalToolbar.tsx"; import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; import { type Host, type Snippet, type TabType } from "@/types/ui-types"; import { getNextTerminalFontSize, getTerminalFontZoomDirection, } from "./terminal-font-zoom.ts"; import { isPhysicalShortcutKey, isTabKeyEvent } from "./terminal-key-event.ts"; import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts"; import { loadTouchInputSettings } from "./touch-input-settings-store.ts"; import { quoteTerminalImagePath } from "./terminal-image-path.ts"; import { hydrateLocalSharedHostAuth } from "@/lib/remote-server-api.ts"; import { getUserPreferences, parseCustomKeybindings, } from "@/api/open-tabs-api"; import { findMatchingKeybinding } from "@/lib/keybinding-match"; import { dispatchKeybindingAction, sendRawToSocket, } from "@/lib/keybinding-dispatch"; import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog"; import type { CustomKeybinding } from "@/types/keybindings"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import { TerminalLocalEcho, resolveLocalEchoMode, } from "@/lib/terminal-local-echo"; export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; type HostKeyVerificationData = Omit< React.ComponentProps, "isOpen" | "scenario" | "onAccept" | "onReject" | "backgroundColor" >; interface SSHTerminalProps { hostConfig: TerminalHostConfig; isVisible: boolean; title?: string; showTitle?: boolean; splitScreen?: boolean; onClose?: () => void; onTitleChange?: (title: string) => void; initialPath?: string; executeCommand?: string; /** Attach to this tmux session right after connecting (tmux monitor). */ tmuxAttachSession?: string; onOpenFileManager?: (path?: string) => void; onOpenFileInEditor?: (filePath: string) => void; previewTheme?: string | null; /** When true, suppress automatic focus on connect/visibility change. */ disableAutoFocus?: boolean; isQuickConnect?: boolean; onSaveQuickConnect?: () => Promise; /** Full host record, used to drive the context-aware terminal toolbar. */ host?: Host; onOpenTab?: (type: TabType) => void; /** False when this terminal sits in an unfocused split pane. */ isFocusedPane?: boolean; /** Fires when the backend reports the created session id (collab presenting). */ onSessionReady?: (sessionId: string) => void; } const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g; function updateAlternateScreenMode(output: string, currentMode: boolean) { ALTERNATE_SCREEN_SEQUENCE.lastIndex = 0; let isActive = currentMode; let sawSequence = false; let match: RegExpExecArray | null; while ((match = ALTERNATE_SCREEN_SEQUENCE.exec(output)) !== null) { sawSequence = true; isActive = match[2] === "h"; } return { isActive, sawSequence }; } const TerminalInner = forwardRef( function SSHTerminal( { hostConfig, isVisible, onSessionReady, splitScreen = false, onClose, onTitleChange, initialPath, executeCommand, tmuxAttachSession, onOpenFileManager, onOpenFileInEditor, previewTheme, disableAutoFocus = false, isQuickConnect = false, onSaveQuickConnect, host, onOpenTab, isFocusedPane = true, }, ref, ) { const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); const commandHistoryContext = useCommandHistory(); const { confirmWithToast } = useConfirmation(); const { theme: appTheme } = useTheme(); const { addLog } = useConnectionLog(); const { terminal: terminalDefaults } = useConnectionDefaults(); const outputListenersRef = useRef(new Set<(data: string) => void>()); const savedTheme = localStorage.getItem( `terminal_theme_host_${hostConfig.id}`, ); const config = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, theme: savedTheme || hostConfig.terminalConfig?.theme || terminalDefaults.theme || DEFAULT_TERMINAL_CONFIG.theme, }; const activeTheme = previewTheme || config.theme; const themeColors = resolveTermixThemeColors( activeTheme, appTheme, config.customThemeColors, ); const backgroundImage = config.backgroundImage || ""; const backgroundImageOpacity = config.backgroundImageOpacity ?? 0.15; const backgroundColor = backgroundImage ? "transparent" : themeColors.background; const fitAddonRef = useRef(null); const webSocketRef = useRef(null); const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null); const localEchoRef = useRef(null); const customKeybindingsRef = useRef([]); const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>( null, ); const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{ id: string; content: string; appendEnter: boolean; } | null>(null); const resizeTimeout = useRef(null); const wasDisconnectedBySSH = useRef(false); const pingIntervalRef = useRef(null); const pongReceivedRef = useRef(true); const pongTimeoutRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false); const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false); const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isFitted, setIsFitted] = useState(false); const [connectionError, setConnectionError] = useState(null); const connectionErrorRef = useRef(null); const [showDisconnectedOverlay, setShowDisconnectedOverlay] = useState(false); const updateConnectionError = useCallback((error: string | null) => { connectionErrorRef.current = error; setConnectionError(error); }, []); const [, setIsAuthenticated] = useState(false); const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); const [mfaPromptMode, setMfaPromptMode] = useState< "totp" | "password" | "menu" | "push" >("totp"); const [mfaWaiting, setMfaWaiting] = useState(false); const [showAuthDialog, setShowAuthDialog] = useState(false); const [authDialogReason, setAuthDialogReason] = useState< "no_keyboard" | "auth_failed" | "timeout" >("no_keyboard"); const [showPassphraseDialog, setShowPassphraseDialog] = useState(false); const [, setKeyboardInteractiveDetected] = useState(false); const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false); const [warpgateAuthUrl, setWarpgateAuthUrl] = useState(""); const [warpgateSecurityKey, setWarpgateSecurityKey] = useState(""); const warpgateTimeoutRef = useRef(null); const [opksshDialog, setOpksshDialog] = useState<{ isOpen: boolean; authUrl: string; requestId: string; stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; error?: string; providers?: Array<{ alias: string; issuer: string }>; /** Which issuer is asking (OPKSSH by default, "Step CA", ...). */ label?: string; } | null>(null); const opksshTimeoutRef = useRef(null); const [tailscaleCheckDialog, setTailscaleCheckDialog] = useState<{ isOpen: boolean; authUrl: string; message?: string; stage: "prompt" | "waiting"; } | null>(null); const tailscaleCheckTimeoutRef = useRef(null); const tailscaleCheckPendingRef = useRef(false); const opksshFailedRef = useRef(false); const currentHostIdRef = useRef(null); const currentHostConfigRef = useRef(null); // Vault SSH signer interactive OIDC flow const [vaultDialog, setVaultDialog] = useState<{ stage: "waiting" | "error"; error?: string; } | null>(null); const vaultFailedRef = useRef(false); const vaultPopupRef = useRef(null); const vaultTimeoutRef = useRef(null); const [hostKeyVerification, setHostKeyVerification] = useState<{ isOpen: boolean; scenario: "new" | "changed"; data: HostKeyVerificationData; } | null>(null); const sessionIdRef = useRef(null); const isAttachingSessionRef = useRef(false); // Consumed on first connectToHost call so retries don't re-attempt a stale session const pendingRestoredSessionIdRef = useRef( hostConfig.restoredSessionId ?? null, ); const [linkClickDialog, setLinkClickDialog] = useState<{ url: string; } | null>(null); useEffect(() => { if (!linkClickDialog) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); setLinkClickDialog(null); }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); }, [linkClickDialog]); const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{ sessions: Array<{ name: string; created: number; lastActivity: number; windows: number; attachedClients: number; }>; } | null>(null); const tmuxSessionNameRef = useRef(null); const [isTmuxAttached, setIsTmuxAttached] = useState(false); const tmuxCopyModeHintShownRef = useRef(false); const isVisibleRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); const maxReconnectAttempts = 8; const isUnmountingRef = useRef(false); const shouldNotReconnectRef = useRef(false); const isReconnectingRef = useRef(false); const isConnectingRef = useRef(false); const wasConnectedRef = useRef(false); const wasSessionExpiredRef = useRef(false); useEffect(() => { isUnmountingRef.current = false; shouldNotReconnectRef.current = false; isReconnectingRef.current = false; isConnectingRef.current = false; reconnectAttempts.current = 0; wasConnectedRef.current = false; isAttachingSessionRef.current = false; return () => {}; }, [hostConfig.id]); const connectionAttemptIdRef = useRef(0); const totpTimeoutRef = useRef(null); const connectionTimeoutRef = useRef(null); const activityLoggedRef = useRef(false); const commandHistoryTrackingEnabled = hostConfig.enableCommandHistory !== false; const { trackInput, getCurrentCommand, updateCurrentCommand } = useCommandTracker({ hostId: hostConfig.id, enabled: commandHistoryTrackingEnabled, onCommandExecuted: (command) => { if (!autocompleteHistory.current.includes(command)) { autocompleteHistory.current = [ command, ...autocompleteHistory.current, ]; } }, }); const getCurrentCommandRef = useRef(getCurrentCommand); const updateCurrentCommandRef = useRef(updateCurrentCommand); useEffect(() => { getCurrentCommandRef.current = getCurrentCommand; updateCurrentCommandRef.current = updateCurrentCommand; }, [getCurrentCommand, updateCurrentCommand]); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< string[] >([]); const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0); const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0, }); const autocompleteHistory = useRef([]); const currentAutocompleteCommand = useRef(""); const showAutocompleteRef = useRef(false); const autocompleteSuggestionsRef = useRef([]); const autocompleteSelectedIndexRef = useRef(0); const searchAddonRef = useRef(null); const searchInputRef = useRef(null); const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); const [searchWholeWord, setSearchWholeWord] = useState(false); const [searchRegex, setSearchRegex] = useState(false); const [searchResultIndex, setSearchResultIndex] = useState(-1); const [searchResultCount, setSearchResultCount] = useState(0); const showSearchRef = useRef(false); const searchQueryRef = useRef(""); const searchCaseSensitiveRef = useRef(false); const searchWholeWordRef = useRef(false); const searchRegexRef = useRef(false); const [showHistoryDialog] = useState(false); const [, setCommandHistory] = useState([]); const [, setIsLoadingHistory] = useState(false); const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading); const setCommandHistoryContextRef = useRef( commandHistoryContext.setCommandHistory, ); useEffect(() => { setIsLoadingRef.current = commandHistoryContext.setIsLoading; setCommandHistoryContextRef.current = commandHistoryContext.setCommandHistory; }, [ commandHistoryContext.setIsLoading, commandHistoryContext.setCommandHistory, ]); useEffect(() => { if (showHistoryDialog && hostConfig.id) { setIsLoadingHistory(true); setIsLoadingRef.current(true); getCommandHistory(hostConfig.id!) .then((history) => { setCommandHistory(history); setCommandHistoryContextRef.current(history); }) .catch((error) => { console.error("Failed to load command history:", error); setCommandHistory([]); setCommandHistoryContextRef.current([]); }) .finally(() => { setIsLoadingHistory(false); setIsLoadingRef.current(false); }); } }, [showHistoryDialog, hostConfig.id]); useEffect(() => { const autocompleteEnabled = localStorage.getItem("commandAutocomplete") === "true"; if (hostConfig.id && autocompleteEnabled) { getCommandHistory(hostConfig.id!) .then((history) => { autocompleteHistory.current = history; }) .catch((error) => { console.error("Failed to load autocomplete history:", error); autocompleteHistory.current = []; }); } else { autocompleteHistory.current = []; } }, [hostConfig.id]); useEffect(() => { showAutocompleteRef.current = showAutocomplete; }, [showAutocomplete]); useEffect(() => { autocompleteSuggestionsRef.current = autocompleteSuggestions; }, [autocompleteSuggestions]); useEffect(() => { autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; }, [autocompleteSelectedIndex]); useEffect(() => { showSearchRef.current = showSearch; }, [showSearch]); useEffect(() => { searchQueryRef.current = searchQuery; }, [searchQuery]); useEffect(() => { searchCaseSensitiveRef.current = searchCaseSensitive; }, [searchCaseSensitive]); useEffect(() => { searchWholeWordRef.current = searchWholeWord; }, [searchWholeWord]); useEffect(() => { searchRegexRef.current = searchRegex; }, [searchRegex]); useEffect(() => { if (showSearch) { searchInputRef.current?.focus(); searchInputRef.current?.select(); } }, [showSearch]); const activityLoggingRef = useRef(false); const passwordPromptShownRef = useRef(false); const passwordPromptBufferRef = useRef(""); const alternateScreenModeRef = useRef(false); const controlStringModeRef = useRef(false); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); const notifyTimerRef = useRef(null); const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>( null, ); const terminalFontSizeRef = useRef(config.fontSize); const DEBOUNCE_MS = 140; const logTerminalActivity = async () => { if ( !hostConfig.id || activityLoggedRef.current || activityLoggingRef.current ) { return; } activityLoggingRef.current = true; activityLoggedRef.current = true; try { const hostName = hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`; await logActivity("terminal", hostConfig.id, hostName); } catch (err) { console.warn("Failed to log terminal activity:", err); activityLoggedRef.current = false; } finally { activityLoggingRef.current = false; } }; useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]); useEffect(() => { // One-shot: historical code polled every 5s but only ever flipped false→true. setIsAuthenticated(true); }, []); function hardRefresh() { try { if ( terminal && typeof ( terminal as { refresh?: (start: number, end: number) => void } ).refresh === "function" ) { ( terminal as { refresh?: (start: number, end: number) => void } ).refresh(0, terminal.rows - 1); } } catch (error) { console.error("Terminal operation failed:", error); } } function performFit() { if ( !fitAddonRef.current || !terminal || !isVisible || isFittingRef.current ) { return; } isFittingRef.current = true; try { fitAddonRef.current.fit(); if (terminal && terminal.cols > 0 && terminal.rows > 0) { const lastSize = lastFittedSizeRef.current; if ( !lastSize || lastSize.cols !== terminal.cols || lastSize.rows !== terminal.rows ) { scheduleNotify(terminal.cols, terminal.rows); lastFittedSizeRef.current = { cols: terminal.cols, rows: terminal.rows, }; } } setIsFitted(true); } finally { isFittingRef.current = false; } } function changeTerminalFontSize(direction: -1 | 1) { const currentFontSize = terminal.options.fontSize ?? terminalFontSizeRef.current ?? DEFAULT_TERMINAL_CONFIG.fontSize; const nextFontSize = getNextTerminalFontSize(currentFontSize, direction); if (nextFontSize === currentFontSize) { return; } terminalFontSizeRef.current = nextFontSize; terminal.options.fontSize = nextFontSize; performFit(); hardRefresh(); } function getSearchOptions() { return { caseSensitive: searchCaseSensitiveRef.current, wholeWord: searchWholeWordRef.current, regex: searchRegexRef.current, incremental: true, decorations: { matchBackground: `${themeColors.yellow}55`, matchBorder: themeColors.yellow, matchOverviewRuler: themeColors.yellow, activeMatchBackground: `${themeColors.foreground}33`, activeMatchBorder: themeColors.foreground, activeMatchColorOverviewRuler: themeColors.foreground, }, }; } function runSearch(direction: "next" | "previous", term?: string) { const searchAddon = searchAddonRef.current; const query = term ?? searchQueryRef.current; if (!searchAddon || !query) return; if (direction === "next") { searchAddon.findNext(query, getSearchOptions()); } else { searchAddon.findPrevious(query, { ...getSearchOptions(), incremental: false, }); } } function openSearch() { setShowSearch(true); if (searchQueryRef.current) { runSearch("next", searchQueryRef.current); } } function closeSearch() { searchAddonRef.current?.clearDecorations(); setShowSearch(false); setSearchResultIndex(-1); setSearchResultCount(0); setTimeout(() => terminal?.focus(), 0); } function handleSearchQueryChange(value: string) { setSearchQuery(value); searchQueryRef.current = value; if (!value) { searchAddonRef.current?.clearDecorations(); setSearchResultIndex(-1); setSearchResultCount(0); return; } runSearch("next", value); } function toggleSearchCaseSensitive() { searchCaseSensitiveRef.current = !searchCaseSensitiveRef.current; setSearchCaseSensitive(searchCaseSensitiveRef.current); runSearch("next"); } function toggleSearchWholeWord() { searchWholeWordRef.current = !searchWholeWordRef.current; setSearchWholeWord(searchWholeWordRef.current); runSearch("next"); } function toggleSearchRegex() { searchRegexRef.current = !searchRegexRef.current; setSearchRegex(searchRegexRef.current); runSearch("next"); } function handleTotpSubmit(code: string) { const isPushMode = mfaPromptMode === "push"; if (webSocketRef.current && (code || isPushMode)) { webSocketRef.current.send( JSON.stringify({ type: isPasswordPrompt ? "password_response" : "totp_response", data: { code }, }), ); if (isPushMode) { // Server blocks until the phone approval completes; keep the dialog // open in a waiting state rather than closing it immediately. The // existing timeout continues running until "connected" or "error". setMfaWaiting(true); return; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } setTotpRequired(false); setTotpPrompt(""); setIsPasswordPrompt(false); } } function handleTotpCancel() { if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } setTotpRequired(false); setTotpPrompt(""); setIsPasswordPrompt(false); setMfaPromptMode("totp"); setMfaWaiting(false); if (onClose) onClose(); } function handleWarpgateContinue() { if (webSocketRef.current) { if (warpgateTimeoutRef.current) { clearTimeout(warpgateTimeoutRef.current); warpgateTimeoutRef.current = null; } webSocketRef.current.send( JSON.stringify({ type: "warpgate_auth_continue", data: {}, }), ); setWarpgateAuthRequired(false); setWarpgateAuthUrl(""); setWarpgateSecurityKey(""); } } function handleWarpgateCancel() { if (warpgateTimeoutRef.current) { clearTimeout(warpgateTimeoutRef.current); warpgateTimeoutRef.current = null; } setWarpgateAuthRequired(false); setWarpgateAuthUrl(""); setWarpgateSecurityKey(""); if (onClose) onClose(); } function handleWarpgateOpenUrl() { if (warpgateAuthUrl) { window.open(warpgateAuthUrl, "_blank", "noopener,noreferrer"); } } function handleAuthDialogSubmit(credentials: { password?: string; sshKey?: string; keyPassword?: string; }) { if (webSocketRef.current && terminal) { webSocketRef.current.send( JSON.stringify({ type: "reconnect_with_credentials", data: { cols: terminal.cols, rows: terminal.rows, password: credentials.password, sshKey: credentials.sshKey, keyPassword: credentials.keyPassword, hostConfig: { ...hostConfig, password: credentials.password, key: credentials.sshKey, keyPassword: credentials.keyPassword, }, }, }), ); setShowAuthDialog(false); setIsConnecting(true); } } function handleAuthDialogCancel() { setShowAuthDialog(false); if (onClose) onClose(); } function handlePassphraseSubmit(passphrase: string) { if (webSocketRef.current && terminal) { webSocketRef.current.send( JSON.stringify({ type: "reconnect_with_credentials", data: { cols: terminal.cols, rows: terminal.rows, keyPassword: passphrase, hostConfig: { ...hostConfig, keyPassword: passphrase, }, }, }), ); setShowPassphraseDialog(false); setIsConnecting(true); } } function handlePassphraseCancel() { setShowPassphraseDialog(false); if (onClose) onClose(); } function scheduleNotify(cols: number, rows: number) { if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); notifyTimerRef.current = setTimeout(() => { const next = pendingSizeRef.current; const last = lastSentSizeRef.current; if (!next) return; if (last && last.cols === next.cols && last.rows === next.rows) return; if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send( JSON.stringify({ type: "resize", data: next }), ); lastSentSizeRef.current = next; } }, DEBOUNCE_MS); } function formatTerminalOutput(output: string): string { const alternateScreen = updateAlternateScreenMode( output, alternateScreenModeRef.current, ); alternateScreenModeRef.current = alternateScreen.isActive; // Must run for every chunk, including ones we go on to skip, or the // control-string state stops tracking the stream. const controlString = updateControlStringMode( output, controlStringModeRef.current, ); controlStringModeRef.current = controlString.isActive; const syntaxHighlightingEnabled = hostConfig.terminalConfig?.syntaxHighlighting !== false; if ( !syntaxHighlightingEnabled || alternateScreen.sawSequence || alternateScreen.isActive || controlString.wasActive || controlString.isActive ) { return output; } return highlightTerminalOutput( output, hostConfig.terminalConfig?.syntaxHighlightingOptions, ); } async function resolvePasswordForPrompt(isSudoPrompt: boolean) { let passwordToFill = isSudoPrompt ? hostConfig.terminalConfig?.sudoPassword || hostConfig.password : hostConfig.password || hostConfig.terminalConfig?.sudoPassword; if (!passwordToFill && hostConfig.id) { passwordToFill = isSudoPrompt ? (await getHostPassword(hostConfig.id, "sudoPassword")) || (await getHostPassword(hostConfig.id, "password")) || undefined : (await getHostPassword(hostConfig.id, "password")) || (await getHostPassword(hostConfig.id, "sudoPassword")) || undefined; } return passwordToFill; } function maybeOfferPasswordFill(strippedData: string) { // PTY output can split a short prompt like "[sudo] password for user: " // across multiple WebSocket chunks, so match against a rolling buffer // of recent output rather than each chunk in isolation. const buffered = (passwordPromptBufferRef.current + strippedData).slice( -200, ); passwordPromptBufferRef.current = buffered; const passwordPromptPattern = /(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$|password:\s*$)/im; if (!passwordPromptPattern.test(buffered)) return; const hasStoredPassword = hostConfig.terminalConfig?.sudoPassword || hostConfig.password || hostConfig.hasSudoPassword || hostConfig.hasPassword; if (!hasStoredPassword || passwordPromptShownRef.current) return; passwordPromptShownRef.current = true; passwordPromptBufferRef.current = ""; const isSudoPrompt = /(?:\[sudo\]|sudo:)/i.test(buffered); confirmWithToast( t("terminal.passwordPromptFillTitle"), async () => { const passwordToFill = await resolvePasswordForPrompt(isSudoPrompt); if ( passwordToFill && webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN ) { webSocketRef.current.send( JSON.stringify({ type: "input", data: passwordToFill + "\n", }), ); } setTimeout(() => { passwordPromptShownRef.current = false; }, 3000); }, t("common.confirm"), t("common.cancel"), { confirmOnEnter: true }, ); setTimeout(() => { passwordPromptShownRef.current = false; }, 15000); } useImperativeHandle( ref, () => ({ disconnect: () => { isUnmountingRef.current = true; shouldNotReconnectRef.current = true; isReconnectingRef.current = false; if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current); pongTimeoutRef.current = null; } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } if (warpgateTimeoutRef.current) { clearTimeout(warpgateTimeoutRef.current); warpgateTimeoutRef.current = null; } if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify({ type: "disconnect" })); } sessionIdRef.current = null; webSocketRef.current?.close(); setIsConnected(false); setIsConnecting(false); }, reconnect: () => { isUnmountingRef.current = false; shouldNotReconnectRef.current = false; isReconnectingRef.current = false; isConnectingRef.current = false; reconnectAttempts.current = 0; wasDisconnectedBySSH.current = false; wasConnectedRef.current = false; updateConnectionError(null); setShowDisconnectedOverlay(false); if (terminal) { terminal.clear(); const cols = terminal.cols; const rows = terminal.rows; connectToHost(cols, rows); } }, isConnected: () => isConnected, fit: () => { if (!fitAddonRef.current || !terminal || isFittingRef.current) return; isFittingRef.current = true; try { fitAddonRef.current.fit(); if (terminal.cols > 0 && terminal.rows > 0) { const lastSize = lastFittedSizeRef.current; if ( !lastSize || lastSize.cols !== terminal.cols || lastSize.rows !== terminal.rows ) { scheduleNotify(terminal.cols, terminal.rows); lastFittedSizeRef.current = { cols: terminal.cols, rows: terminal.rows, }; } } setIsFitted(true); } finally { isFittingRef.current = false; } }, focus: () => terminal?.focus(), sendInput: (data: string) => { if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send(JSON.stringify({ type: "input", data })); } }, subscribeOutput: (listener: (data: string) => void) => { outputListenersRef.current.add(listener); return () => outputListenersRef.current.delete(listener); }, paste: (text: string) => { terminal?.paste(text); }, notifyResize: () => { try { const cols = terminal?.cols ?? undefined; const rows = terminal?.rows ?? undefined; if (typeof cols === "number" && typeof rows === "number") { scheduleNotify(cols, rows); hardRefresh(); } } catch (error) { console.error("Terminal operation failed:", error); } }, refresh: () => hardRefresh(), getApplicationCursorKeysMode: () => terminal?.modes?.applicationCursorKeysMode ?? false, openFileManager: () => { if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify({ type: "get_cwd" })); } else { onOpenFileManager?.("/"); } }, openShareModal: () => setShareModalOpen(true), canShare: () => isConnected && !isQuickConnect && !hostConfig.joinShareId && typeof hostConfig.id === "number", }), [ isConnected, terminal, isQuickConnect, hostConfig.joinShareId, hostConfig.id, ], ); function getUseRightClickCopyPaste() { return getCookie("rightClickCopyPaste") !== "false"; } function getCopyOnSelect() { return getCookie("copyOnSelect") === "true"; } function attemptReconnection() { if ( isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current || isConnectingRef.current || wasDisconnectedBySSH.current || reconnectTimeoutRef.current !== null ) { return; } if (reconnectAttempts.current >= maxReconnectAttempts) { setIsConnecting(false); shouldNotReconnectRef.current = true; setShowDisconnectedOverlay(true); addLog({ type: "error", stage: "connection", message: t("terminal.maxReconnectAttemptsReached"), }); return; } isReconnectingRef.current = true; if (terminal && !isAttachingSessionRef.current) { terminal.clear(); } reconnectAttempts.current++; addLog({ type: "info", stage: "connection", message: t("terminal.reconnecting", { attempt: reconnectAttempts.current, max: maxReconnectAttempts, }), }); const delay = Math.min( 2000 * Math.pow(2, reconnectAttempts.current - 1), 8000, ); reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = null; if ( isUnmountingRef.current || shouldNotReconnectRef.current || wasDisconnectedBySSH.current ) { isReconnectingRef.current = false; return; } if (reconnectAttempts.current > maxReconnectAttempts) { isReconnectingRef.current = false; return; } if (terminal && hostConfig) { if (!isAttachingSessionRef.current) { terminal.clear(); } const cols = terminal.cols; const rows = terminal.rows; connectToHost(cols, rows); } isReconnectingRef.current = false; }, delay); } // A persisted session that timed out reconnects to a fresh shell below. // Say so, and offer the setting that would have kept it alive. async function explainSessionExpiry() { const hostLabel = hostConfig.name || hostConfig.ip; let minutes: number | null = null; try { const { getTerminalSessionSettings } = await import("@/api/settings-api"); minutes = (await getTerminalSessionSettings()).timeoutMinutes; } catch { /* the notice still makes sense without the number */ } const notice = minutes ? t("terminal.sessionExpiredNotice", { host: hostLabel, minutes }) : t("terminal.sessionExpiredNoticeNoMinutes", { host: hostLabel }); addLog({ type: "warning", stage: "connection", message: notice }); const canEnable = typeof hostConfig.id === "number" && !hostConfig.terminalConfig?.autoTmux && !hostConfig.joinShareId; toast.warning(notice, { duration: 15000, ...(canEnable ? { action: { label: t("terminal.enableAutoTmuxAction"), onClick: () => { void import("@/api/host-terminal-config-api") .then(({ setHostAutoTmux }) => setHostAutoTmux(hostConfig.id as number, true), ) .then(() => { window.dispatchEvent( new CustomEvent("termix:hosts-changed"), ); toast.success( t("terminal.autoTmuxEnabled", { host: hostLabel }), ); }) .catch(() => toast.error(t("terminal.autoTmuxEnableFailed")), ); }, }, } : {}), }); } async function connectToHost(cols: number, rows: number) { if (isConnectingRef.current) { return; } isConnectingRef.current = true; connectionAttemptIdRef.current++; wasConnectedRef.current = false; if (!isReconnectingRef.current) { reconnectAttempts.current = 0; shouldNotReconnectRef.current = false; } const isDev = !isElectron() && process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === ""); let baseWsUrl: string; let wsProtocols: string[] = []; let outboundHostConfig = hostConfig; if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; } else if (isElectron()) { const origin = await resolveConnectionOrigin({ connectionType: "ssh", connectionOrigin: hostConfig.connectionOrigin as "local" | "remote" | null | undefined, }); const resolvedUrl = await buildOriginWsUrl({ origin, localPort: 30002, localPath: "", remotePath: "/ssh/websocket/", }); if (!resolvedUrl) { setIsConnected(false); setIsConnecting(false); updateConnectionError(t("errors.remoteServerRequired")); isConnectingRef.current = false; return; } if (origin === "local") { try { outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig); } catch (error) { const message = getErrorMessage( error, "Failed to load shared SSH authentication", ); setIsConnected(false); setIsConnecting(false); updateConnectionError(message); addLog({ type: "error", stage: "auth", message }); isConnectingRef.current = false; return; } } baseWsUrl = resolvedUrl.url; wsProtocols = resolvedUrl.protocols; } else { baseWsUrl = `${getBasePath()}/ssh/websocket/`; } if ( webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED ) { terminalInputDisposableRef.current?.dispose(); terminalInputDisposableRef.current = null; webSocketRef.current.close(); } if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } const ws = new WebSocket(baseWsUrl, wsProtocols); webSocketRef.current = ws; wasDisconnectedBySSH.current = false; updateConnectionError(null); shouldNotReconnectRef.current = false; isReconnectingRef.current = false; setIsConnecting(true); setupWebSocketListeners(ws, cols, rows, outboundHostConfig); } function setupWebSocketListeners( ws: WebSocket, cols: number, rows: number, outboundHostConfig: TerminalHostConfig, ) { ws.addEventListener("open", () => { alternateScreenModeRef.current = false; controlStringModeRef.current = false; connectionTimeoutRef.current = setTimeout(() => { if ( !isConnected && !totpRequired && !isPasswordPrompt && !tailscaleCheckPendingRef.current && !connectionErrorRef.current ) { if (terminal) { terminal.clear(); } const timeoutMessage = t("terminal.connectionTimeout"); updateConnectionError(timeoutMessage); addLog({ type: "error", stage: "connection", message: timeoutMessage, }); if (webSocketRef.current) { webSocketRef.current.close(); } if (reconnectAttempts.current > 0) { attemptReconnection(); } else { setIsConnecting(false); shouldNotReconnectRef.current = true; } } }, 35000); currentHostIdRef.current = hostConfig.id; currentHostConfigRef.current = hostConfig; // Consume the pending restored session ID once; retries get null so they create fresh connections const restoredSessionId = pendingRestoredSessionIdRef.current; pendingRestoredSessionIdRef.current = null; if (hostConfig.joinShareId) { isAttachingSessionRef.current = true; ws.send( JSON.stringify({ type: "joinSharedSession", data: { shareId: hostConfig.joinShareId, tabInstanceId: hostConfig.instanceId, }, }), ); } else if (restoredSessionId) { sessionIdRef.current = restoredSessionId; isAttachingSessionRef.current = true; ws.send( JSON.stringify({ type: "attachSession", data: { sessionId: restoredSessionId, cols, rows, tabInstanceId: hostConfig.instanceId, }, }), ); } else { isAttachingSessionRef.current = false; ws.send( JSON.stringify({ type: "connectToHost", data: { cols, rows, hostConfig: outboundHostConfig, initialPath, executeCommand, tmuxAttachSession, }, }), ); } terminalInputDisposableRef.current?.dispose(); localEchoRef.current = new TerminalLocalEcho( resolveLocalEchoMode( hostConfig.terminalConfig?.localEcho, localStorage.getItem("terminalLocalEchoMode"), ), ); terminalInputDisposableRef.current = terminal.onData((data) => { if (ws.readyState !== WebSocket.OPEN) return; if (data === "\r" || data === "\n") { const currentCmd = getCurrentCommand().trim(); const termixMatch = currentCmd.match(/^termix\s+(.+)$/); if (termixMatch && onOpenFileInEditor) { const filePath = termixMatch[1].trim(); trackInput(data); terminal.write("\r\n"); if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "open_file_in_editor", path: filePath, }), ); } return; } } trackInput(data); const predicted = localEchoRef.current?.handleInput(data); if (predicted) terminal.write(predicted); ws.send(JSON.stringify({ type: "input", data })); }); pongReceivedRef.current = true; pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { if (!pongReceivedRef.current) { console.warn( "[WebSocket] Pong timeout - connection appears dead, closing", ); ws.close(); return; } pongReceivedRef.current = false; ws.send(JSON.stringify({ type: "ping" })); } }, 30000); }); ws.addEventListener("message", (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "pong") { pongReceivedRef.current = true; return; } if (msg.type === "data") { if (typeof msg.data === "string") { outputListenersRef.current.forEach((listener) => listener(msg.data), ); if (showAutocompleteRef.current) { showAutocompleteRef.current = false; setShowAutocomplete(false); setAutocompleteSuggestions([]); currentAutocompleteCommand.current = ""; } const output = localEchoRef.current?.handleOutput(msg.data) ?? msg.data; terminal.write(formatTerminalOutput(output)); // Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+) // emit colored prompts with embedded escape sequences that break the regex. const strippedData = msg.data.replace( /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "", ); maybeOfferPasswordFill(strippedData); } else { const stringData = String(msg.data); const output = localEchoRef.current?.handleOutput(stringData) ?? stringData; terminal.write(formatTerminalOutput(output)); } } else if (msg.type === "error") { const errorMessage = msg.message || t("terminal.unknownError"); addLog({ type: "error", stage: "connection", message: errorMessage, }); if ( errorMessage.toLowerCase().includes("connection") || errorMessage.toLowerCase().includes("timeout") || errorMessage.toLowerCase().includes("network") ) { updateConnectionError(errorMessage); setIsConnected(false); if (terminal) { terminal.clear(); } setIsConnecting(false); wasDisconnectedBySSH.current = false; return; } if ( (errorMessage.toLowerCase().includes("auth") && errorMessage.toLowerCase().includes("failed")) || errorMessage.toLowerCase().includes("permission denied") || (errorMessage.toLowerCase().includes("invalid") && (errorMessage.toLowerCase().includes("password") || errorMessage.toLowerCase().includes("key"))) || errorMessage.toLowerCase().includes("incorrect password") ) { updateConnectionError(errorMessage); setIsConnecting(false); shouldNotReconnectRef.current = true; if (webSocketRef.current) { webSocketRef.current.close(); } return; } updateConnectionError(errorMessage); setIsConnecting(false); } else if (msg.type === "connected") { opksshFailedRef.current = false; vaultFailedRef.current = false; wasConnectedRef.current = true; setIsConnected(true); setIsConnecting(false); isConnectingRef.current = false; updateConnectionError(null); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (reconnectAttempts.current > 0) { addLog({ type: "success", stage: "connection", message: t("terminal.reconnected"), }); } else { addLog({ type: "success", stage: "connection", message: t("terminal.connected"), }); } reconnectAttempts.current = 0; isReconnectingRef.current = false; logTerminalActivity(); setTimeout(async () => { const terminalConfig = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, }; if ( terminalConfig.environmentVariables && terminalConfig.environmentVariables.length > 0 ) { for (const envVar of terminalConfig.environmentVariables) { if (envVar.key && envVar.value && ws.readyState === 1) { ws.send( JSON.stringify({ type: "input", data: `export ${envVar.key}="${envVar.value}"\n`, }), ); } } } if (terminalConfig.startupSnippetId) { try { const snippets = await getSnippets(); const snippet = snippets.find( (s: { id: number }) => s.id === terminalConfig.startupSnippetId, ); if (snippet && ws.readyState === 1) { ws.send( JSON.stringify({ type: "input", data: snippet.content + "\n", }), ); } } catch (err) { console.warn("Failed to execute startup snippet:", err); } } if (terminalConfig.autoMosh && ws.readyState === 1) { ws.send( JSON.stringify({ type: "input", data: terminalConfig.moshCommand + "\n", }), ); } }, 100); } else if (msg.type === "session_ended") { wasDisconnectedBySSH.current = true; setIsConnected(false); setIsConnecting(false); shouldNotReconnectRef.current = true; if (onClose) { onClose(); } } else if (msg.type === "disconnected") { wasDisconnectedBySSH.current = true; shouldNotReconnectRef.current = true; setIsConnected(false); setIsConnecting(false); if (msg.graceful) { wasConnectedRef.current = false; if (onClose) onClose(); } else if (wasConnectedRef.current) { wasConnectedRef.current = false; setShowDisconnectedOverlay(true); } else if (!connectionErrorRef.current) { updateConnectionError( msg.message || t("terminal.connectionRejected"), ); } } else if (msg.type === "totp_required") { setTotpRequired(true); setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel")); setIsPasswordPrompt(false); setMfaPromptMode("totp"); setMfaWaiting(false); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); } totpTimeoutRef.current = setTimeout(() => { setTotpRequired(false); if (webSocketRef.current) { webSocketRef.current.close(); } }, 180000); } else if (msg.type === "totp_retry") { // Existing prompt remains visible while the backend asks for another code. setMfaWaiting(false); } else if (msg.type === "password_required") { const promptText: string = msg.prompt || ""; const pushPromptPattern = /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i; const isPush = pushPromptPattern.test(promptText); const isMenu = !isPush && msg.echo === true; const mode: "menu" | "push" | "password" = isPush ? "push" : isMenu ? "menu" : "password"; setTotpRequired(true); setTotpPrompt(promptText || t("common.password")); setIsPasswordPrompt(true); setMfaPromptMode(mode); setMfaWaiting(false); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); } totpTimeoutRef.current = setTimeout( () => { setTotpRequired(false); if (webSocketRef.current) { webSocketRef.current.close(); } }, isPush ? 300000 : 180000, ); } else if (msg.type === "warpgate_auth_required") { setWarpgateAuthRequired(true); setWarpgateAuthUrl(msg.url || ""); setWarpgateSecurityKey(msg.securityKey || "N/A"); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (warpgateTimeoutRef.current) { clearTimeout(warpgateTimeoutRef.current); } warpgateTimeoutRef.current = setTimeout(() => { setWarpgateAuthRequired(false); if (webSocketRef.current) { webSocketRef.current.close(); } }, 300000); } else if (msg.type === "opkssh_auth_required") { if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (opksshFailedRef.current) { setOpksshDialog(null); if (opksshTimeoutRef.current) { clearTimeout(opksshTimeoutRef.current); opksshTimeoutRef.current = null; } updateConnectionError(t("terminal.opksshAuthFailed")); addLog({ type: "error", stage: "auth", message: t("terminal.opksshAuthFailed"), }); } else { opksshFailedRef.current = true; if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "opkssh_start_auth", data: { hostId: msg.hostId }, }), ); } } } else if (msg.type === "vault_auth_required") { if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (vaultFailedRef.current) { setVaultDialog({ stage: "error", error: t("terminal.vaultAuthFailed"), }); updateConnectionError(t("terminal.vaultAuthFailed")); addLog({ type: "error", stage: "auth", message: t("terminal.vaultAuthFailed"), }); } else { vaultFailedRef.current = true; webSocketRef.current?.send( JSON.stringify({ type: "vault_start_auth", data: { hostId: msg.hostId }, }), ); } } else if (msg.type === "vault_auth_url") { if (connectionErrorRef.current) return; try { vaultPopupRef.current = window.open( msg.url, "termix-vault-oidc", "width=540,height=720", ); } catch { vaultPopupRef.current = null; } setVaultDialog({ stage: "waiting" }); if (vaultTimeoutRef.current) clearTimeout(vaultTimeoutRef.current); vaultTimeoutRef.current = setTimeout(() => { setVaultDialog(null); webSocketRef.current?.close(); }, 300000); } else if (msg.type === "vault_completed") { if (vaultTimeoutRef.current) { clearTimeout(vaultTimeoutRef.current); vaultTimeoutRef.current = null; } try { vaultPopupRef.current?.close(); } catch { // popup may already be closed } setVaultDialog(null); if (webSocketRef.current && terminal) { webSocketRef.current.send( JSON.stringify({ type: "vault_auth_completed", data: { hostId: currentHostIdRef.current, cols: terminal.cols || 80, rows: terminal.rows || 24, hostConfig: currentHostConfigRef.current, }, }), ); } } else if (msg.type === "vault_error") { if (connectionErrorRef.current) return; vaultFailedRef.current = true; if (vaultTimeoutRef.current) { clearTimeout(vaultTimeoutRef.current); vaultTimeoutRef.current = null; } try { vaultPopupRef.current?.close(); } catch { // popup may already be closed } setVaultDialog({ stage: "error", error: msg.error }); setIsConnecting(false); } else if (msg.type === "opkssh_status") { if (connectionErrorRef.current) return; if (msg.stage === "chooser") { setOpksshDialog({ isOpen: true, authUrl: msg.url || "", requestId: msg.requestId || "", stage: "chooser", providers: msg.providers, label: typeof msg.label === "string" ? msg.label : undefined, }); if (opksshTimeoutRef.current) { clearTimeout(opksshTimeoutRef.current); } opksshTimeoutRef.current = setTimeout(() => { setOpksshDialog(null); if (webSocketRef.current) { webSocketRef.current.close(); } }, 300000); } else { setOpksshDialog((prev) => prev ? { ...prev, stage: msg.stage } : null, ); } } else if (msg.type === "opkssh_completed") { if (opksshTimeoutRef.current) { clearTimeout(opksshTimeoutRef.current); opksshTimeoutRef.current = null; } setOpksshDialog(null); if (webSocketRef.current && terminal) { webSocketRef.current.send( JSON.stringify({ type: "opkssh_auth_completed", data: { hostId: currentHostIdRef.current, cols: terminal.cols || 80, rows: terminal.rows || 24, hostConfig: currentHostConfigRef.current, }, }), ); } } else if (msg.type === "opkssh_error") { if (connectionErrorRef.current) return; opksshFailedRef.current = true; if (opksshDialog) { setOpksshDialog((prev) => prev ? { ...prev, stage: "error", error: msg.error } : null, ); } else { setOpksshDialog({ isOpen: true, authUrl: "", requestId: msg.requestId || "", stage: "error", error: msg.error, }); } setIsConnecting(false); } else if (msg.type === "opkssh_timeout") { if (connectionErrorRef.current) return; opksshFailedRef.current = true; if (opksshDialog) { setOpksshDialog((prev) => prev ? { ...prev, stage: "error", error: t("terminal.opksshTimeout"), } : null, ); } else { setOpksshDialog({ isOpen: true, authUrl: "", requestId: msg.requestId || "", stage: "error", error: t("terminal.opksshTimeout"), }); } setIsConnecting(false); } else if (msg.type === "opkssh_config_error") { setOpksshDialog({ isOpen: true, authUrl: "", requestId: msg.requestId || "", stage: "error", error: msg.instructions || msg.error, }); } else if (msg.type === "tailscale_check_required") { if (connectionErrorRef.current) return; tailscaleCheckPendingRef.current = true; // Tailscale holds the connection open while the user authenticates, // so the normal connect timeout must not fire during the wait. if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } setTailscaleCheckDialog({ isOpen: true, authUrl: msg.url || "", message: msg.message, stage: "prompt", }); if (tailscaleCheckTimeoutRef.current) { clearTimeout(tailscaleCheckTimeoutRef.current); } tailscaleCheckTimeoutRef.current = setTimeout(() => { tailscaleCheckPendingRef.current = false; setTailscaleCheckDialog(null); updateConnectionError(t("terminal.tailscaleCheckTimeout")); if (webSocketRef.current) { webSocketRef.current.close(); } }, 1800000); } else if (msg.type === "tailscale_check_completed") { tailscaleCheckPendingRef.current = false; if (tailscaleCheckTimeoutRef.current) { clearTimeout(tailscaleCheckTimeoutRef.current); tailscaleCheckTimeoutRef.current = null; } setTailscaleCheckDialog(null); } else if (msg.type === "keyboard_interactive_available") { setKeyboardInteractiveDetected(true); setIsConnecting(false); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } } else if (msg.type === "auth_method_not_available") { setAuthDialogReason("no_keyboard"); setShowAuthDialog(true); setIsConnecting(false); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } } else if (msg.type === "cwd") { onOpenFileManager?.(msg.path as string); } else if (msg.type === "open_file_in_editor") { onOpenFileInEditor?.(msg.path as string); } else if (msg.type === "passphrase_required") { setShowPassphraseDialog(true); setIsConnecting(false); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } } else if (msg.type === "host_key_verification_required") { setHostKeyVerification({ isOpen: true, scenario: "new", data: msg.data, }); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } } else if (msg.type === "host_key_changed") { setHostKeyVerification({ isOpen: true, scenario: "changed", data: msg.data, }); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } } else if (msg.type === "sessionCreated") { sessionIdRef.current = msg.sessionId; onSessionReady?.(msg.sessionId); if (hostConfig.instanceId) { import("@/main-axios").then(({ patchOpenTab }) => { patchOpenTab(hostConfig.instanceId!, { backendSessionId: msg.sessionId, }).catch(() => {}); }); } } else if (msg.type === "sessionAttached") { isAttachingSessionRef.current = false; opksshFailedRef.current = false; vaultFailedRef.current = false; wasConnectedRef.current = true; setIsConnected(true); setIsConnecting(false); isConnectingRef.current = false; shouldNotReconnectRef.current = false; updateConnectionError(null); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } reconnectAttempts.current = 0; isReconnectingRef.current = false; logTerminalActivity(); addLog({ type: "success", stage: "connection", message: t("terminal.reconnected"), }); } else if (msg.type === "sessionExpired") { isAttachingSessionRef.current = false; sessionIdRef.current = null; wasSessionExpiredRef.current = true; void explainSessionExpiry(); if (hostConfig.instanceId) { import("@/main-axios").then(({ patchOpenTab }) => { patchOpenTab(hostConfig.instanceId!, { backendSessionId: null, }).catch(() => {}); }); } if (webSocketRef.current) { webSocketRef.current.close(); } } else if (msg.type === "sessionTakenOver") { sessionIdRef.current = null; if (terminal) { terminal.clear(); } setIsConnected(false); setIsConnecting(true); addLog({ type: "warning", stage: "connection", message: t("terminal.sessionTakenOver"), }); const cols = terminal?.cols || 80; const rows = terminal?.rows || 24; connectToHost(cols, rows); } else if (msg.type === "tmux_sessions_available") { setTmuxSessionPicker({ sessions: msg.sessions, }); } else if ( msg.type === "tmux_session_created" || msg.type === "tmux_session_attached" ) { const sessionName = typeof msg.sessionName === "string" ? msg.sessionName : ""; tmuxSessionNameRef.current = sessionName || "(active)"; setIsTmuxAttached(true); addLog({ type: "info", stage: "connection", message: msg.type === "tmux_session_created" ? t("terminal.tmuxSessionCreated", { name: sessionName || "new", }) : t("terminal.tmuxSessionAttached", { name: sessionName, }), }); } else if (msg.type === "tmux_unavailable") { setTimeout(() => { toast.warning(t("terminal.tmuxUnavailable"), { duration: 8000, }); }, 500); addLog({ type: "warning", stage: "connection", message: t("terminal.tmuxUnavailable"), }); } else if (msg.type === "tmux_detached") { tmuxSessionNameRef.current = null; setIsTmuxAttached(false); toast.info(t("terminal.tmuxDetached"), { duration: 3000 }); } else if (msg.type === "connection_log") { if (msg.data) { addLog({ type: msg.data.level || "info", stage: msg.data.stage || "auth", message: msg.data.message, details: msg.data.details, }); } } } catch (error) { console.error("WebSocket message handler error:", error); } }); const currentAttemptId = connectionAttemptIdRef.current; ws.addEventListener("close", (event) => { if (currentAttemptId !== connectionAttemptIdRef.current) { return; } terminalInputDisposableRef.current?.dispose(); terminalInputDisposableRef.current = null; setIsConnected(false); isConnectingRef.current = false; if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current); pongTimeoutRef.current = null; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } tailscaleCheckPendingRef.current = false; if (tailscaleCheckTimeoutRef.current) { clearTimeout(tailscaleCheckTimeoutRef.current); tailscaleCheckTimeoutRef.current = null; } setTailscaleCheckDialog(null); if (wasSessionExpiredRef.current) { wasSessionExpiredRef.current = false; const cols = terminal?.cols || 80; const rows = terminal?.rows || 24; connectToHost(cols, rows); return; } if (event.code === 1006) { console.warn( "[WebSocket] Abnormal closure detected - attempting reconnection", ); addLog({ type: "warning", stage: "connection", message: t("terminal.websocketAbnormalClose"), }); if (wasConnectedRef.current) { attemptReconnection(); } else { updateConnectionError(t("terminal.websocketAbnormalClose")); setIsConnecting(false); } return; } if (event.code === 1008) { console.error("WebSocket authentication failed:", event.reason); addLog({ type: "error", stage: "auth", message: "Authentication failed - please re-login", }); updateConnectionError("Authentication failed - please re-login"); setIsConnecting(false); shouldNotReconnectRef.current = true; return; } if ( !wasConnectedRef.current && !isAttachingSessionRef.current && event.wasClean && (event.code === 1005 || event.code === 1000) ) { console.error("[WebSocket] Connection rejected by server"); addLog({ type: "error", stage: "connection", message: t("terminal.connectionRejected"), }); updateConnectionError(t("terminal.connectionRejected")); setIsConnecting(false); shouldNotReconnectRef.current = true; return; } const shouldAttemptReconnection = !wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current && !isConnectingRef.current; if (shouldAttemptReconnection) { wasDisconnectedBySSH.current = false; attemptReconnection(); } else { setIsConnecting(false); } }); ws.addEventListener("error", (event) => { if (currentAttemptId !== connectionAttemptIdRef.current) { return; } console.error("[WebSocket] Error:", event); setIsConnected(false); isConnectingRef.current = false; updateConnectionError(t("terminal.websocketError")); if (terminal) { terminal.clear(); } setIsConnecting(false); if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (totpTimeoutRef.current) { clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } }); } async function writeTextToClipboard(text: string): Promise { const ok = await copyToClipboard(text); if (!ok) toast.error(t("terminal.clipboardWriteFailed")); return ok; } async function readTextFromClipboard(): Promise { const text = await readFromClipboard(); if (!text && window.location.protocol !== "https:" && !isElectron()) { toast.error(t("terminal.clipboardHttpWarning")); } return text; } const handleSelectCommand = useCallback( (command: string) => { if (!terminal || !webSocketRef.current) return; for (const char of command) { webSocketRef.current.send( JSON.stringify({ type: "input", data: char }), ); } setTimeout(() => { terminal.focus(); }, 100); }, [terminal], ); useEffect(() => { commandHistoryContext.setOnSelectCommand(handleSelectCommand); }, [handleSelectCommand]); const handleAutocompleteSelect = useCallback( (selectedCommand: string) => { if (!webSocketRef.current) return; const currentCmd = currentAutocompleteCommand.current; const completion = selectedCommand.substring(currentCmd.length); for (const char of completion) { webSocketRef.current.send( JSON.stringify({ type: "input", data: char }), ); } updateCurrentCommand(selectedCommand); setShowAutocomplete(false); setAutocompleteSuggestions([]); currentAutocompleteCommand.current = ""; setTimeout(() => { terminal?.focus(); }, 50); }, [terminal, updateCurrentCommand], ); const handleDeleteCommand = useCallback( async (command: string) => { if (!hostConfig.id) return; try { await deleteCommandFromHistory(hostConfig.id, command); setCommandHistory((prev) => { const newHistory = prev.filter((cmd) => cmd !== command); setCommandHistoryContextRef.current(newHistory); return newHistory; }); autocompleteHistory.current = autocompleteHistory.current.filter( (cmd) => cmd !== command, ); } catch (error) { console.error("Failed to delete command from history:", error); } }, [hostConfig.id], ); useEffect(() => { commandHistoryContext.setOnDeleteCommand(handleDeleteCommand); }, [handleDeleteCommand]); // Separate theme and options updates to avoid terminal re-initialization flashes useEffect(() => { if (!terminal) return; const config = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, }; const activeTheme = previewTheme || config.theme; const themeColors = resolveTermixThemeColors( activeTheme, appTheme, config.customThemeColors, ); const fontFamily = resolveTerminalFontFamily(config.fontFamily); ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value); // Update terminal options individually to avoid re-initialization flashes terminal.options.cursorBlink = config.cursorBlink; terminal.options.cursorStyle = config.cursorStyle; terminal.options.scrollback = config.scrollback; terminal.options.fontSize = config.fontSize; terminalFontSizeRef.current = config.fontSize; terminal.options.fontFamily = fontFamily; terminal.options.rightClickSelectsWord = config.rightClickSelectsWord; terminal.options.macOptionIsMeta = config.macOptionIsMeta; terminal.options.fastScrollSensitivity = config.fastScrollSensitivity; terminal.options.minimumContrastRatio = config.minimumContrastRatio; terminal.options.letterSpacing = config.letterSpacing; terminal.options.lineHeight = config.lineHeight; terminal.options.theme = { background: config.backgroundImage ? "transparent" : themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, selectionBackground: themeColors.selectionBackground, selectionForeground: themeColors.selectionForeground, black: themeColors.black, red: themeColors.red, green: themeColors.green, yellow: themeColors.yellow, blue: themeColors.blue, magenta: themeColors.magenta, cyan: themeColors.cyan, white: themeColors.white, brightBlack: themeColors.brightBlack, brightRed: themeColors.brightRed, brightGreen: themeColors.brightGreen, brightYellow: themeColors.brightYellow, brightBlue: themeColors.brightBlue, brightMagenta: themeColors.brightMagenta, brightCyan: themeColors.brightCyan, brightWhite: themeColors.brightWhite, }; // Ensure terminal is correctly fitted if font-related options change if (fitAddonRef.current && isFitted) { performFit(); } // Refresh terminal to apply new theme colors to existing buffer content hardRefresh(); }, [ terminal, terminalDefaults, hostConfig.terminalConfig, previewTheme, appTheme, isFitted, ]); useEffect(() => { if (!terminal || !xtermRef.current) return; const config = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, }; const fontFamily = resolveTerminalFontFamily(config.fontFamily); ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value); const activeTheme = previewTheme || config.theme; const themeColors = resolveTermixThemeColors( activeTheme, appTheme, config.customThemeColors, ); // Set initial options before opening the terminal terminal.options = { cursorBlink: config.cursorBlink, cursorStyle: config.cursorStyle, scrollback: config.scrollback, fontSize: config.fontSize, fontFamily, allowTransparency: true, // MUST be set before open() convertEol: false, macOptionIsMeta: config.macOptionIsMeta, macOptionClickForcesSelection: false, rightClickSelectsWord: config.rightClickSelectsWord, fastScrollSensitivity: config.fastScrollSensitivity, allowProposedApi: true, minimumContrastRatio: config.minimumContrastRatio, letterSpacing: config.letterSpacing, lineHeight: config.lineHeight, theme: { background: config.backgroundImage ? "transparent" : themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, selectionBackground: themeColors.selectionBackground, selectionForeground: themeColors.selectionForeground, black: themeColors.black, red: themeColors.red, green: themeColors.green, yellow: themeColors.yellow, blue: themeColors.blue, magenta: themeColors.magenta, cyan: themeColors.cyan, white: themeColors.white, brightBlack: themeColors.brightBlack, brightRed: themeColors.brightRed, brightGreen: themeColors.brightGreen, brightYellow: themeColors.brightYellow, brightBlue: themeColors.brightBlue, brightMagenta: themeColors.brightMagenta, brightCyan: themeColors.brightCyan, brightWhite: themeColors.brightWhite, }, }; const fitAddon = new FitAddon(); const clipboardProvider = new RobustClipboardProvider(); const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); const unicode11Addon = new Unicode11Addon(); const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon((_event, uri) => { const url = uri.startsWith("http://") || uri.startsWith("https://") ? uri : `https://${uri}`; const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior; const globalBehavior = localStorage.getItem("terminalLinkClickBehavior") ?? "confirm"; const behavior = hostBehavior ?? globalBehavior; if (behavior === "direct") { window.open(url, "_blank"); } else { setLinkClickDialog({ url }); } }); fitAddonRef.current = fitAddon; searchAddonRef.current = searchAddon; terminal.loadAddon(fitAddon); terminal.loadAddon(clipboardAddon); terminal.loadAddon(unicode11Addon); terminal.loadAddon(webLinksAddon); terminal.loadAddon(searchAddon); searchAddon.onDidChangeResults(({ resultIndex, resultCount }) => { setSearchResultIndex(resultIndex); setSearchResultCount(resultCount); }); terminal.unicode.activeVersion = "11"; terminal.open(xtermRef.current); const xtermTextarea = xtermRef.current.querySelector("textarea"); if (xtermTextarea) { xtermTextarea.setAttribute("autocomplete", "off"); xtermTextarea.setAttribute("autocorrect", "off"); xtermTextarea.setAttribute("autocapitalize", "none"); xtermTextarea.setAttribute("spellcheck", "false"); xtermTextarea.setAttribute("data-gramm", "false"); xtermTextarea.setAttribute("data-gramm_editor", "false"); xtermTextarea.setAttribute("data-enable-grammarly", "false"); } terminal.onTitleChange((title) => { if (title) onTitleChange?.(title); }); document.fonts.ready.then(() => { terminal.refresh(0, terminal.rows - 1); fitAddon.fit(); }); terminal.attachCustomWheelEventHandler((ev) => { if (ev.ctrlKey || ev.metaKey) { changeTerminalFontSize(ev.deltaY < 0 ? 1 : -1); return false; } const cfg = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, }; const mod = cfg.fastScrollModifier; const modHeld = (mod === "alt" && ev.altKey) || (mod === "ctrl" && ev.ctrlKey) || (mod === "shift" && ev.shiftKey); if (modHeld) { const lines = Math.round( (Math.abs(ev.deltaY) / 100) * (cfg.fastScrollSensitivity ?? 5), ); terminal.scrollLines(ev.deltaY > 0 ? lines : -lines); return false; } return true; }); fitAddonRef.current?.fit(); // Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before // committing the fitted size, preventing the "terminal too short" glitch. requestAnimationFrame(() => { requestAnimationFrame(() => { fitAddonRef.current?.fit(); setIsFitted(true); }); }); // Send one-finger drags through xterm's real wheel DOM path. This keeps // scrollback, alternate-buffer/tmux, mouse reporting and wheel remainder // handling identical to desktop wheel input. let disposeTouchWheel = () => {}; let touchWheelDisposed = false; loadTouchInputSettings().then((settings) => { if (!touchWheelDisposed && xtermRef.current) { disposeTouchWheel = installTouchWheelCoordinator( xtermRef.current, undefined, settings, ); } }); const element = xtermRef.current; const handleContextMenu = (e: MouseEvent) => { if (e.ctrlKey && onOpenFileManager) { e.preventDefault(); e.stopPropagation(); onOpenFileManager(); return; } const action = resolveTerminalContextMenuAction({ rightClickCopyPaste: getUseRightClickCopyPaste(), copyOnSelect: getCopyOnSelect(), hasSelection: terminal.hasSelection(), }); if (action !== "native") { e.preventDefault(); e.stopPropagation(); if (action === "copy") { const text = terminal.getSelection(); writeTextToClipboard(text).then(() => terminal.clearSelection()); } else { readTextFromClipboard().then((text) => { if (text) terminal.paste(text); }); } return; } }; element?.addEventListener("contextmenu", handleContextMenu); const handleSelectionMouseUp = (e: MouseEvent) => { const text = selectedTextToCopy({ copyOnSelect: getCopyOnSelect(), button: e.button, selection: terminal.getSelection(), }); if (text) void writeTextToClipboard(text); }; element?.addEventListener("mouseup", handleSelectionMouseUp); const handleMiddleClick = (e: MouseEvent) => { if ( e.button !== 1 || !getCopyOnSelect() || !getUseRightClickCopyPaste() ) { return; } e.preventDefault(); e.stopPropagation(); readTextFromClipboard().then((text) => { if (text) terminal.paste(text); }); }; element?.addEventListener("auxclick", handleMiddleClick); const handlePaste = (e: ClipboardEvent) => { const text = e.clipboardData?.getData("text"); if (text) { e.preventDefault(); e.stopPropagation(); terminal.paste(text); } }; element?.addEventListener("paste", handlePaste); let tmuxDragTracking = false; const handleTmuxDragStart = (e: MouseEvent) => { if (e.button !== 0) return; if (!tmuxSessionNameRef.current) return; tmuxDragTracking = true; }; const handleTmuxDragMove = () => { if (!tmuxDragTracking) return; tmuxDragTracking = false; if (tmuxCopyModeHintShownRef.current) return; tmuxCopyModeHintShownRef.current = true; toast.info(t("terminal.tmuxCopyHint"), { duration: 5000 }); }; const handleTmuxDragEnd = () => { tmuxDragTracking = false; }; element?.addEventListener("mousedown", handleTmuxDragStart); element?.addEventListener("mousemove", handleTmuxDragMove); element?.addEventListener("mouseup", handleTmuxDragEnd); const handleBackspaceMode = (e: KeyboardEvent) => { if (e.key !== "Backspace") return; if (e.ctrlKey || e.metaKey || e.altKey) return; const config = { ...DEFAULT_TERMINAL_CONFIG, ...terminalDefaults, ...hostConfig.terminalConfig, }; if (config.backspaceMode !== "control-h") return; e.preventDefault(); e.stopPropagation(); if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send( JSON.stringify({ type: "input", data: "\x08" }), ); } return false; }; // On macOS Electron, Tab key events can be swallowed by Chromium's focus // traversal system before xterm.js sees them. Calling preventDefault() in // the capture phase blocks that traversal while still allowing the event to // reach xterm.js's internal handler (which fires our attachCustomKeyEventHandler). const handleTabCapture = (e: KeyboardEvent) => { if (isTabKeyEvent(e)) { e.preventDefault(); } }; element?.addEventListener("keydown", handleBackspaceMode, true); element?.addEventListener("keydown", handleTabCapture, true); const resizeObserver = new ResizeObserver(() => { // Background keep-alive tabs still observe layout; skip fit work. if (!isVisibleRef.current) return; if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { if (isVisibleRef.current) { performFit(); } }, 50); }); const observeTarget = xtermRef.current.parentElement ?? xtermRef.current; resizeObserver.observe(observeTarget); return () => { touchWheelDisposed = true; isFittingRef.current = false; resizeObserver.disconnect(); clipboardProvider.dispose(); element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("mouseup", handleSelectionMouseUp); element?.removeEventListener("auxclick", handleMiddleClick); element?.removeEventListener("paste", handlePaste); element?.removeEventListener("mousedown", handleTmuxDragStart); element?.removeEventListener("mousemove", handleTmuxDragMove); element?.removeEventListener("mouseup", handleTmuxDragEnd); element?.removeEventListener("keydown", handleBackspaceMode, true); element?.removeEventListener("keydown", handleTabCapture, true); disposeTouchWheel(); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current); }; }, [xtermRef, terminal]); const isMountedRef = useRef(false); useEffect( () => () => { terminalInputDisposableRef.current?.dispose(); terminalInputDisposableRef.current = null; }, [], ); useEffect(() => { isMountedRef.current = true; const currentHostId = hostConfig.id; return () => { if (!isMountedRef.current) { return; } if ( currentHostIdRef.current !== currentHostId && currentHostIdRef.current !== null ) { isUnmountingRef.current = true; shouldNotReconnectRef.current = true; isReconnectingRef.current = false; setIsConnecting(false); if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); if (connectionTimeoutRef.current) clearTimeout(connectionTimeoutRef.current); if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current); if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current); pongTimeoutRef.current = null; } if (webSocketRef.current) { webSocketRef.current.close(); } isMountedRef.current = false; } }; }, [hostConfig.id, hostConfig.instanceId]); useEffect(() => { let cancelled = false; const loadKeybindings = () => { cachedSnippetsRef.current = null; getUserPreferences() .then((prefs) => { if (!cancelled) { customKeybindingsRef.current = parseCustomKeybindings( prefs.customKeybindings, ).filter((kb) => kb.enabled); } }) .catch(() => { // keep previous/empty bindings on failure, non-fatal }); }; loadKeybindings(); window.addEventListener("customKeybindingsChanged", loadKeybindings); return () => { cancelled = true; window.removeEventListener("customKeybindingsChanged", loadKeybindings); }; }, []); useEffect(() => { if (!terminal) return; const getSnippetById = async ( id: string, ): Promise<{ content: string } | undefined> => { try { if (!cachedSnippetsRef.current) { cachedSnippetsRef.current = (await getSnippets()) as unknown as { id: number; content: string; }[]; } const numericId = Number(id); return cachedSnippetsRef.current?.find((s) => s.id === numericId); } catch { return undefined; } }; const handleCustomKey = (e: KeyboardEvent): boolean => { if (e.type !== "keydown") { return true; } // Custom user keybindings take priority over built-in defaults, but // never override autocomplete popup navigation while it is open. if (!showAutocompleteRef.current) { const matched = findMatchingKeybinding( e, customKeybindingsRef.current, ); if (matched) { e.preventDefault(); e.stopPropagation(); dispatchKeybindingAction(matched.action, { terminal, webSocketRef, writeTextToClipboard, readTextFromClipboard, getSnippetById, hostContext: { ip: hostConfig.ip, username: hostConfig.username, port: hostConfig.port, name: hostConfig.name, }, onSnippetNeedsInputs: (snippet) => setPendingKeybindingSnippet({ ...snippet, appendEnter: matched.action.appendEnter !== false, }), }); return false; } } if ( showSearchRef.current && e.key === "Escape" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey ) { e.preventDefault(); e.stopPropagation(); closeSearch(); return false; } if ( (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "f" ) { e.preventDefault(); e.stopPropagation(); openSearch(); return false; } if (navigator.userAgent.includes("Android")) { const sequence = getAndroidHardwareKeySequence( e, terminal.modes.applicationCursorKeysMode, hostConfig.terminalConfig?.backspaceMode, ); if (sequence) { e.preventDefault(); e.stopPropagation(); if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send( JSON.stringify({ type: "input", data: sequence }), ); } return false; } } // Forward global app shortcuts to AppShell directly — xterm swallows // all keydown events and synthetic re-dispatch is unreliable. // stopPropagation prevents the same event from also firing the window listener. if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { const globalCodes = [ "BracketRight", "BracketLeft", "Backslash", "Minus", ]; if (globalCodes.includes(e.code)) { e.stopPropagation(); globalShortcutHandler.current?.(e); return false; } } if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { const arrowCodes = [ "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", ]; if (arrowCodes.includes(e.code) || /^Digit[1-9]$/.test(e.code)) { e.stopPropagation(); globalShortcutHandler.current?.(e); return false; } } const fontZoomDirection = getTerminalFontZoomDirection(e); if (fontZoomDirection !== 0) { e.preventDefault(); e.stopPropagation(); changeTerminalFontSize(fontZoomDirection); return false; } if ( e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && isPhysicalShortcutKey(e, "KeyC", "c") && terminal.hasSelection() ) { const selection = terminal.getSelection(); if (selection) { e.preventDefault(); e.stopPropagation(); writeTextToClipboard(selection); terminal.clearSelection(); return false; } } if ( (e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey && isPhysicalShortcutKey(e, "KeyC", "c")) || (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert") ) { const selection = terminal.getSelection(); if (selection) { e.preventDefault(); e.stopPropagation(); writeTextToClipboard(selection); return false; } } if ( e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPhysicalShortcutKey(e, "KeyC", "c") ) { const selection = terminal.getSelection(); if (selection) { e.preventDefault(); e.stopPropagation(); writeTextToClipboard(selection); terminal.clearSelection(); return false; } } if ( e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPhysicalShortcutKey(e, "KeyV", "v") ) { e.preventDefault(); e.stopPropagation(); readTextFromClipboard().then((text) => { if (text) terminal.paste(text); }); return false; } if ( e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && isPhysicalShortcutKey(e, "KeyV", "v") ) { // Let the browser handle Ctrl+V natively, the paste event // listener will intercept the result without triggering the // clipboard permission popup return false; } if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) { const key = e.key.toLowerCase(); const blockedKeys = ["w", "t", "n", "q"]; if (blockedKeys.includes(key)) { e.preventDefault(); e.stopPropagation(); const ctrlCode = key.charCodeAt(0) - 96; if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send( JSON.stringify({ type: "input", data: String.fromCharCode(ctrlCode), }), ); } return false; } } if (showAutocompleteRef.current) { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowAutocomplete(false); setAutocompleteSuggestions([]); currentAutocompleteCommand.current = ""; return false; } if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); const currentIndex = autocompleteSelectedIndexRef.current; const suggestionsLength = autocompleteSuggestionsRef.current.length; if (e.key === "ArrowDown") { const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; setAutocompleteSelectedIndex(newIndex); } else if (e.key === "ArrowUp") { const newIndex = currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; setAutocompleteSelectedIndex(newIndex); } return false; } if ( e.key === "Enter" && autocompleteSuggestionsRef.current.length > 0 ) { e.preventDefault(); e.stopPropagation(); const selectedCommand = autocompleteSuggestionsRef.current[ autocompleteSelectedIndexRef.current ]; const currentCmd = currentAutocompleteCommand.current; const completion = selectedCommand.substring(currentCmd.length); if (webSocketRef.current?.readyState === 1) { for (const char of completion) { webSocketRef.current.send( JSON.stringify({ type: "input", data: char }), ); } } updateCurrentCommandRef.current(selectedCommand); setShowAutocomplete(false); setAutocompleteSuggestions([]); currentAutocompleteCommand.current = ""; return false; } if ( isTabKeyEvent(e) && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey ) { e.preventDefault(); e.stopPropagation(); const currentIndex = autocompleteSelectedIndexRef.current; const suggestionsLength = autocompleteSuggestionsRef.current.length; const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; setAutocompleteSelectedIndex(newIndex); return false; } setShowAutocomplete(false); setAutocompleteSuggestions([]); currentAutocompleteCommand.current = ""; return true; } if ( isTabKeyEvent(e) && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey ) { e.preventDefault(); e.stopPropagation(); if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send( JSON.stringify({ type: "input", data: "\x1b[Z" }), ); } return false; } if ( isTabKeyEvent(e) && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey ) { e.preventDefault(); e.stopPropagation(); const sendTabToShell = () => { if (webSocketRef.current?.readyState === 1) { webSocketRef.current.send( JSON.stringify({ type: "input", data: "\t" }), ); } }; const autocompleteEnabled = localStorage.getItem("commandAutocomplete") === "true"; if (!autocompleteEnabled) { sendTabToShell(); return false; } const currentCmd = getCurrentCommandRef.current().trim(); if (currentCmd.length === 0) { sendTabToShell(); return false; } if (webSocketRef.current?.readyState === 1) { const matches = autocompleteHistory.current .filter( (cmd) => cmd.startsWith(currentCmd) && cmd !== currentCmd && cmd.length > currentCmd.length, ) .slice(0, 5); if (matches.length === 1) { const completedCommand = matches[0]; const completion = completedCommand.substring(currentCmd.length); for (const char of completion) { webSocketRef.current.send( JSON.stringify({ type: "input", data: char }), ); } updateCurrentCommandRef.current(completedCommand); } else if (matches.length > 1) { currentAutocompleteCommand.current = currentCmd; setAutocompleteSuggestions(matches); setAutocompleteSelectedIndex(0); const cursorY = terminal.buffer.active.cursorY; const cursorX = terminal.buffer.active.cursorX; const rect = xtermRef.current?.getBoundingClientRect(); if (rect) { const cellHeight = terminal.rows > 0 ? rect.height / terminal.rows : 20; const cellWidth = terminal.cols > 0 ? rect.width / terminal.cols : 10; const itemHeight = 32; const footerHeight = 32; const maxMenuHeight = 240; const estimatedMenuHeight = Math.min( matches.length * itemHeight + footerHeight, maxMenuHeight, ); const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; const cursorTopY = rect.top + cursorY * cellHeight; const spaceBelow = window.innerHeight - cursorBottomY; const spaceAbove = cursorTopY; const showAbove = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; setAutocompletePosition({ top: showAbove ? Math.max(0, cursorTopY - estimatedMenuHeight) : cursorBottomY, left: Math.max(0, rect.left + cursorX * cellWidth), }); } setShowAutocomplete(true); } else { sendTabToShell(); } } return false; } return true; }; terminal.attachCustomKeyEventHandler(handleCustomKey); }, [terminal]); useEffect(() => { if (!terminal || !hostConfig || !isVisible) return; if (isConnected || isConnecting) return; if (isReconnectingRef.current || reconnectTimeoutRef.current !== null) { return; } if (shouldNotReconnectRef.current) { return; } if ( webSocketRef.current && (webSocketRef.current.readyState === WebSocket.OPEN || webSocketRef.current.readyState === WebSocket.CONNECTING) ) { return; } setIsConnecting(true); fitAddonRef.current?.fit(); requestAnimationFrame(() => { requestAnimationFrame(() => { fitAddonRef.current?.fit(); if (terminal.cols > 0 && terminal.rows > 0) { scheduleNotify(terminal.cols, terminal.rows); connectToHost(terminal.cols, terminal.rows); } }); }); }, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]); useEffect(() => { if (!terminal || !fitAddonRef.current) return; if (!isVisible) { lastFittedSizeRef.current = null; lastSentSizeRef.current = null; return; } const fitTimeoutId = setTimeout(() => { if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) { performFit(); if (!splitScreen && !isConnecting && !disableAutoFocus) { requestAnimationFrame(() => terminal.focus()); } } }, 50); return () => clearTimeout(fitTimeoutId); }, [terminal, isVisible, splitScreen, isConnecting]); const hasConnectionError = !!connectionError; function getImageUploadErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; const response = ( error as { response?: { data?: { error?: string; message?: string }; }; } )?.response; return ( response?.data?.error || response?.data?.message || "Image upload failed" ); } async function handleImageUpload( file: File, source: TerminalImageUploadSource, ) { if (file.type && !file.type.startsWith("image/")) { toast.error("Choose an image file"); return; } setIsImageUploading(true); try { const form = buildImageUploadFormData( file, hostConfig.instanceId ?? "", source, ); const response = await authApi.post("/terminal/image-upload", form, { headers: { "Content-Type": undefined }, }); const { shellPath } = response.data as { shellPath: string; }; const pathInserted = webSocketRef.current?.readyState === WebSocket.OPEN; if (pathInserted) { webSocketRef.current?.send( JSON.stringify({ type: "input", data: quoteTerminalImagePath(shellPath), }), ); toast.success(`Image uploaded: ${shellPath}`); } else { toast.warning( `Image uploaded, but the terminal was not available to paste it: ${shellPath}`, ); } } catch (error) { const response = (error as { response?: { data?: { code?: string } } }) ?.response; const code = response?.data?.code; const message = getImageUploadErrorMessage(error); toast.error(code ? `${message} (${code})` : message); } finally { setIsImageUploading(false); } } async function handleClipboardImage() { if (!navigator.clipboard?.read) { toast.error("Clipboard image access is not available in this browser"); return; } setIsImageUploading(true); try { const items = await navigator.clipboard.read(); for (const item of items) { const imageType = item.types.find((type) => type.startsWith("image/"), ); if (!imageType) continue; const blob = await item.getType(imageType); let clipboardFile = new File([blob], "clipboard-image.png", { type: imageType, }); // Preserve native PNG clipboard bytes. Some browser/platform // clipboard implementations decode transparent PNGs incorrectly // through canvas, producing an all-black/transparent re-encode. // Only rasterize formats that need conversion; Sharp validates the // resulting image server-side. if ( imageType !== "image/png" && typeof createImageBitmap === "function" ) { try { const bitmap = await createImageBitmap(blob); try { const canvas = document.createElement("canvas"); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas unavailable"); context.drawImage(bitmap, 0, 0); const png = await new Promise((resolve, reject) => { canvas.toBlob((result) => { if (result) resolve(result); else reject(new Error("Clipboard image conversion failed")); }, "image/png"); }); clipboardFile = new File([png], "clipboard-image.png", { type: "image/png", }); } finally { bitmap.close(); } } catch { // Fall back to the original clipboard blob. } } await handleImageUpload(clipboardFile, "clipboard"); return; } toast.error("No image found in the clipboard"); } catch (error) { toast.error(getErrorMessage(error, "Clipboard read failed")); } finally { setIsImageUploading(false); } } return (
{backgroundImage && (
)}
{ if (terminal && !splitScreen) { terminal.focus(); } }} /> {host && host.enableTerminalToolbar !== false && ( { if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send( JSON.stringify({ type: "tmux_detach" }), ); } }} isImageUploading={isImageUploading} onUploadImage={(file) => void handleImageUpload(file, "file")} onPasteImage={() => void handleClipboardImage()} onOpenTab={onOpenTab} onOpenFiles={() => { if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify({ type: "get_cwd" })); } else { onOpenFileManager?.("/"); } }} isFocused={isFocusedPane} /> )} {isQuickConnect && isConnected && !isQuickConnectSaved && onSaveQuickConnect && ( )} { setShowDisconnectedOverlay(false); isUnmountingRef.current = false; shouldNotReconnectRef.current = false; isReconnectingRef.current = false; isConnectingRef.current = false; reconnectAttempts.current = 0; wasDisconnectedBySSH.current = false; wasConnectedRef.current = false; updateConnectionError(null); if (terminal) { terminal.clear(); connectToHost(terminal.cols, terminal.rows); } }} extraActions={ onClose && ( ) } logPosition={hasConnectionError ? "top" : "bottom"} /> {opksshDialog?.isOpen && ( { if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "opkssh_cancel", data: { requestId: opksshDialog.requestId }, }), ); } setOpksshDialog(null); if (opksshTimeoutRef.current) { clearTimeout(opksshTimeoutRef.current); opksshTimeoutRef.current = null; } }} onOpenUrl={() => { window.open(opksshDialog.authUrl, "_blank"); if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "opkssh_browser_opened", data: { requestId: opksshDialog.requestId }, }), ); } }} onSelectProvider={(alias) => { if (!opksshDialog.authUrl) return; const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`; window.open(selectUrl, "_blank"); if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "opkssh_browser_opened", data: { requestId: opksshDialog.requestId }, }), ); } setOpksshDialog((prev) => prev ? { ...prev, stage: "waiting" } : null, ); }} backgroundColor={backgroundColor} /> )} {tailscaleCheckDialog?.isOpen && ( { tailscaleCheckPendingRef.current = false; if (tailscaleCheckTimeoutRef.current) { clearTimeout(tailscaleCheckTimeoutRef.current); tailscaleCheckTimeoutRef.current = null; } setTailscaleCheckDialog(null); if (webSocketRef.current) { webSocketRef.current.close(); } }} onOpenUrl={() => { window.open(tailscaleCheckDialog.authUrl, "_blank"); setTailscaleCheckDialog((prev) => prev ? { ...prev, stage: "waiting" } : null, ); }} backgroundColor={backgroundColor} /> )} {vaultDialog && (

{vaultDialog.stage === "error" ? t("terminal.vaultAuthFailed") : t("terminal.vaultAuthTitle")}

{vaultDialog.stage === "error" ? (

{vaultDialog.error || t("terminal.vaultAuthFailed")}

) : (

{t("terminal.vaultAuthDescription")}

)}
{vaultDialog.stage === "waiting" && ( )}
)} {hostKeyVerification?.isOpen && ( { if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "host_key_verification_response", data: { action: "accept" }, }), ); } setHostKeyVerification(null); }} onReject={() => { if (webSocketRef.current) { webSocketRef.current.send( JSON.stringify({ type: "host_key_verification_response", data: { action: "reject" }, }), ); } setHostKeyVerification(null); setIsConnecting(false); updateConnectionError(t("terminal.hostKeyRejected")); }} backgroundColor={backgroundColor} /> )} {pendingKeybindingSnippet && ( setPendingKeybindingSnippet(null)} onConfirm={(resolvedContent) => { const appendEnter = pendingKeybindingSnippet.appendEnter; setPendingKeybindingSnippet(null); const send = () => sendRawToSocket( webSocketRef, resolvedContent + (appendEnter ? "\r" : ""), ); const shouldConfirm = localStorage.getItem("confirmSnippetExecution") === "true"; if (shouldConfirm) { confirmWithToast( t("newUi.sidebar.snippets.confirmRunMessage", { name: t("newUi.sidebar.keybindings.actionRunSnippet"), }), send, t("newUi.sidebar.snippets.confirmRunButton"), t("newUi.sidebar.snippets.cancel"), { confirmOnEnter: true, duration: 6000 }, ); } else { send(); } }} /> )} {tmuxSessionPicker && ( { setTmuxSessionPicker(null); if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send( JSON.stringify({ type: "tmux_attach", data: { sessionName }, }), ); } setTimeout(() => terminal?.focus(), 50); }} onCreateNew={() => { setTmuxSessionPicker(null); if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send( JSON.stringify({ type: "tmux_attach", data: { sessionName: "" }, }), ); } setTimeout(() => terminal?.focus(), 50); }} onCancel={() => setTmuxSessionPicker(null)} backgroundColor={backgroundColor} /> )} runSearch("next")} onFindPrevious={() => runSearch("previous")} onClose={closeSearch} caseSensitive={searchCaseSensitive} onToggleCaseSensitive={toggleSearchCaseSensitive} wholeWord={searchWholeWord} onToggleWholeWord={toggleSearchWholeWord} regex={searchRegex} onToggleRegex={toggleSearchRegex} resultIndex={searchResultIndex} resultCount={searchResultCount} inputRef={searchInputRef} /> {linkClickDialog && createPortal(
setLinkClickDialog(null)} >
event.stopPropagation()} >

{t("terminal.linkDialogTitle")}

{linkClickDialog.url}

, document.body, )} {shareModalOpen && typeof hostConfig.id === "number" && ( setShareModalOpen(false)} hostId={hostConfig.id} sessionId={sessionIdRef.current} protocol="ssh" tabInstanceId={hostConfig.instanceId} /> )}
); }, ); export const Terminal = forwardRef( function Terminal(props, ref) { return ( ); }, );