* feat: auto-poll Docker capabilities while Docker tab is active
When the Docker tab is visible and hasDocker is not yet true,
poll refreshCapabilities() at the process refresh interval.
Stop polling once hasDocker becomes true, or when switching
to a different tab.
* fix: use resolvedTab instead of activeTab for Docker auto-poll condition
The auto-poll useEffect condition used activeTab, which stays stale
when Docker becomes unavailable. Changed to resolvedTab which reflects
the actual displayed tab. Also updated the dep array.
* fix: replace setInterval with setTimeout recursion in Docker tab probe
Replace setInterval-based polling with setTimeout recursion in the Docker
tab capability probe effect. This ensures the next probe only starts after
the previous one finishes, avoiding overlapping probes when SSH timeout
exceeds the polling interval.
- Add dockerPollTimerRef to track the timeout handle
- Use async pollOnce() that awaits refreshCapabilities() before scheduling next
- Use cancelled flag in cleanup to prevent scheduling after unmount
- Keep same dependency array for correctness
* fix: stabilize docker poll timer by using useRef for refreshCapabilities
refreshCapabilities() can return a new reference on every render, causing
the useEffect to re-run on every render — cleanup cancels the polling timer,
then the effect immediately calls pollOnce(), effectively bypassing the
configured timeout interval.
Fix: store refreshCapabilities in a useRef (refreshRef), use
refreshRef.current() inside pollOnce(), and replace refreshCapabilities
with refreshRef in the useEffect dependency array.
Closes #PR1456 Codex P2 review item.
* fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe
When switching to the Docker tab, two mechanisms were triggering probes:
1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities()
2. auto-poll effect: pollOnce() executing immediately on mount
This caused duplicate probes that waste SSH channel resources.
Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the
first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe
happens after one full interval. Subsequent probes continue at interval pace
via the setTimeout recursion in pollOnce itself.
The tab-switch effect still fires the immediate probe (the correct one),
so responsiveness on tab switch is preserved.
* fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling
The cancelledRef was set to true in the cleanup function when dependencies
changed, but never reset when the effect re-ran. This caused pollOnce to
always early-return on subsequent timer ticks, permanently halting
Docker capability probing after the first dependency change.
* fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation
Each effect generation now has its own and closure
variables instead of shared / . This
prevents stale probes from surviving cleanup when the panel hides and
re-shows (Codex P2 review).
* fix: wrap refreshCapabilities in try/catch to keep polling on exception
If refreshCapabilities throws (instead of returning {success: false}),
the await would exit pollOnce without scheduling the next setTimeout,
silently killing Docker auto-detection polling.
* fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes
Add probingRef to track whether a capabilities probe is already in-flight.
- Tab-switch effect for Docker branch checks probingRef before starting a new probe
- Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe
- Tmux branch left unchanged as it has no auto-poll overlap risk
* fix: re-schedule next poll timer when probe is in-flight
When probingRef.current is true (tab-switch probe still running),
pollOnce was returning early without scheduling the next timer,
causing auto-poll to stop permanently afterward.
Now it schedules the next poll within the interval and returns,
so the polling loop keeps running until a slot where no probe is
active.
* fix: convert comments to ASCII-only English
- Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle'
- Line 113: replace em dash (U+2014) with ASCII dash
* feat: session inline rename, closeSession shortcut, pane zoom
* fix: sidebar inline rename with local state
* fix: add sessionDisplayName to terminalPropsAreEqual comparator
The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual),
but the comparator was missing a check for sessionDisplayName. After renaming
a session, the pane title bar would show the old name until some other prop
changed and triggered a re-render.
Add prev.sessionDisplayName === next.sessionDisplayName to the comparator
so that display name changes cause the Terminal to re-render immediately.
* fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props
* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring
The togglePaneZoom handler calls toggleWorkspaceViewMode() but it
wasn't destructured from getCtx(), causing a ReferenceError at runtime.
* fix: restore truncated ctx object in TerminalView render call
The TerminalView ctx object literal on line 1265 was truncated to
'showSele...' due to an editing tool truncation bug, causing
Parsing error: ',' expected on npm run lint / tsc --noEmit.
Restored the missing fields from the base commit:
showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef,
sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef,
terminalBackend, terminalContextActions, terminalCwdTracker,
terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem
Kept the PR's new additions (isVisible, onRename, sessionDisplayName)
intact.
* fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations
- Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx)
- Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать)
* fix: validate focusedSessionId before closing in closeSession hotkey
When closeSession hotkey fires, workspace.focusedSessionId may reference
a session that was already closed by another trigger (e.g., mouse click
on tab close button). Collect alive session IDs from the workspace root
and fall back to the first living pane if the stored focusedSessionId
is stale.
* fix(auto-poll): check useSessionCapabilities probing state in pollOnce
When auto-poll timer fires before the initial probe (from
useSessionCapabilities) completes, probingRef.current is still false
because the initial probe doesn't set it — causing a second overlapping
probe.
Add check so that any in-flight probe from any path
(initial/auto-poll/tab-switch) prevents auto-poll overlap.
PR #1459
* fix: address remaining Codex review issues
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: add detach session from workspace with toolbar button and context menu
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use customName in pane header display name for renamed sessions
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: refine workspace terminal detach interactions
* fix: preserve workspace detach tab ordering
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
1278 lines
55 KiB
TypeScript
1278 lines
55 KiB
TypeScript
import { Terminal as XTerm } from "@xterm/xterm";
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
import { SearchAddon } from "@xterm/addon-search";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
|
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { detectLocalOs } from "../lib/localShell";
|
|
import { logger } from "../lib/logger";
|
|
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
|
import {
|
|
Host,
|
|
Snippet,
|
|
TerminalSession,
|
|
} from "../types";
|
|
import { resolveSnippetCommand } from "./SnippetExecutionProvider";
|
|
import {
|
|
shouldEnableNativeUserInputAutoScroll,
|
|
shouldScrollOnTerminalInput,
|
|
} from "../domain/terminalScroll";
|
|
import {
|
|
applyCustomAccentToTerminalTheme,
|
|
resolveHostTerminalThemeId,
|
|
type TerminalHostUpdate,
|
|
} from "../domain/terminalAppearance";
|
|
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
|
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
|
import { resolveHostAuth } from "../domain/sshAuth";
|
|
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
|
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
|
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
|
import { Button } from "./ui/button";
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { toast } from "./ui/toast";
|
|
import { useAvailableFonts } from "../application/state/fontStore";
|
|
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
|
|
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
|
import { useCustomThemes } from "../application/state/customThemeStore";
|
|
|
|
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
|
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
|
|
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
|
|
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
|
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
|
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
|
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
|
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
|
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
|
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
|
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
|
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
|
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
|
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
|
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
|
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
|
import {
|
|
createPromptLineBreakState,
|
|
type PromptLineBreakState,
|
|
} from "./terminal/runtime/promptLineBreak";
|
|
import {
|
|
prepareSudoAutofillInput,
|
|
type SudoPasswordAutofill,
|
|
} from "./terminal/runtime/terminalSudoAutofill";
|
|
import {
|
|
recordTerminalCommandExecution,
|
|
shouldRecordShellHistory,
|
|
} from "./terminal/runtime/terminalCommandExecution";
|
|
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
|
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
|
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
|
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
|
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
|
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
|
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
|
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
|
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
|
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
|
import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
|
import { TerminalView } from "./terminal/TerminalView";
|
|
import {
|
|
forceSyncRenderAfterResize,
|
|
MAX_CONNECTION_LOG_DATA_CHARS,
|
|
shouldHideConnectingDialogForConnectionReuse,
|
|
shouldShowTerminalConnectionDialog,
|
|
type TerminalProps,
|
|
} from "./terminal/terminalHelpers";
|
|
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
|
|
|
|
const TerminalComponent: React.FC<TerminalProps> = ({
|
|
host,
|
|
keys,
|
|
identities,
|
|
snippets,
|
|
snippetPackages = [],
|
|
compactToolbar = false,
|
|
lineTimestampsAvailable = true,
|
|
chainHosts = [],
|
|
themePreviewId,
|
|
knownHosts = [],
|
|
isVisible,
|
|
paneLayoutKey,
|
|
inWorkspace,
|
|
isResizing,
|
|
isFocusMode,
|
|
isFocused,
|
|
fontFamilyId,
|
|
fontSize,
|
|
terminalTheme,
|
|
followAppTerminalTheme = false,
|
|
accentMode = "theme",
|
|
customAccent = "",
|
|
terminalSettings,
|
|
sessionId,
|
|
startupCommand,
|
|
noAutoRun,
|
|
reuseConnectionFromSessionId,
|
|
serialConfig,
|
|
hotkeyScheme = "disabled",
|
|
disableTerminalFontZoom = false,
|
|
keyBindings = [],
|
|
onHotkeyAction,
|
|
onTerminalFontSizeChange,
|
|
onStatusChange,
|
|
onSessionExit,
|
|
onTerminalDataCapture,
|
|
onOsDetected,
|
|
onCloseSession,
|
|
onUpdateHost,
|
|
onAddKnownHost,
|
|
onExpandToFocus,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
onSplitHorizontal,
|
|
onSplitVertical,
|
|
onOpenSftp,
|
|
onTerminalCwdChange,
|
|
onOpenScripts,
|
|
onOpenHistory,
|
|
onOpenTheme,
|
|
onOpenSystem,
|
|
isBroadcastEnabled,
|
|
onToggleBroadcast,
|
|
onToggleComposeBar,
|
|
isWorkspaceComposeBarOpen,
|
|
onBroadcastInput,
|
|
onSnippetExecutorChange,
|
|
sessionLog,
|
|
sshDebugLogEnabled,
|
|
sudoAutofillPassword,
|
|
showSelectionAIAction = true,
|
|
onAddSelectionToAI,
|
|
sessionDisplayName,
|
|
onRename,
|
|
onDetach,
|
|
onStartSessionDrag,
|
|
onEndSessionDrag,
|
|
onDetachPointerDown,
|
|
onDetachDragStart,
|
|
onDetachDragEnd,
|
|
}) => {
|
|
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
|
const deferTerminalResize = isResizing || layoutSuppressActive;
|
|
const deferTerminalResizeRef = useRef(deferTerminalResize);
|
|
deferTerminalResizeRef.current = deferTerminalResize;
|
|
|
|
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
|
const CONNECTION_TIMEOUT = 120000;
|
|
const { t } = useI18n();
|
|
const availableFonts = useAvailableFonts();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const termRef = useRef<XTerm | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
|
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
|
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
|
const knownCwdRef = useRef<string | undefined>(undefined);
|
|
const disposeDataRef = useRef<(() => void) | null>(null);
|
|
const disposeExitRef = useRef<(() => void) | null>(null);
|
|
const sessionRef = useRef<string | null>(null);
|
|
const isBootActiveRef = useRef(false);
|
|
const hasConnectedRef = useRef(false);
|
|
const hasRunStartupCommandRef = useRef(false);
|
|
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
|
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
|
|
// chained xterm.write callbacks verify the token before proceeding so a
|
|
// cancelled retry can't fire a startNewSession after the fact.
|
|
const retryTokenRef = useRef<symbol | null>(null);
|
|
const terminalDataCapturedRef = useRef(false);
|
|
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
|
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
|
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
|
const commandBufferRef = useRef<string>("");
|
|
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
|
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
|
const mouseTrackingRef = useRef(false);
|
|
const serialLineBufferRef = useRef<string>("");
|
|
|
|
const terminalSettingsRef = useRef(terminalSettings);
|
|
terminalSettingsRef.current = terminalSettings;
|
|
const handleUpdateHostFromTerminal = useCallback((hostUpdate: TerminalHostUpdate) => {
|
|
onUpdateHost?.(hostUpdate as Host);
|
|
}, [onUpdateHost]);
|
|
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
|
const isVisibleRef = useRef(isVisible);
|
|
isVisibleRef.current = isVisible;
|
|
const pendingOutputScrollRef = useRef(false);
|
|
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
|
const fontWeightFixupDoneRef = useRef(false);
|
|
|
|
const captureTerminalLogData = useCallback((data: string) => {
|
|
const replaySafeData = terminalLogSanitizerRef.current.append(data);
|
|
if (!replaySafeData) return;
|
|
connectionLogBufferRef.current.append(replaySafeData);
|
|
}, []);
|
|
|
|
const finalizeTerminalLogData = useCallback(() => {
|
|
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
|
if (replaySafeData) {
|
|
connectionLogBufferRef.current.append(replaySafeData);
|
|
}
|
|
return connectionLogBufferRef.current.toString();
|
|
}, []);
|
|
|
|
const writeLocalTerminalData = useCallback((data: string) => {
|
|
if (!data) return;
|
|
captureTerminalLogData(data);
|
|
termRef.current?.write(data);
|
|
}, [captureTerminalLogData]);
|
|
|
|
const hotkeySchemeRef = useRef(hotkeyScheme);
|
|
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
|
|
const keyBindingsRef = useRef(keyBindings);
|
|
const onHotkeyActionRef = useRef(onHotkeyAction);
|
|
hotkeySchemeRef.current = hotkeyScheme;
|
|
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
|
|
keyBindingsRef.current = keyBindings;
|
|
onHotkeyActionRef.current = onHotkeyAction;
|
|
|
|
const isBroadcastEnabledRef = useRef(isBroadcastEnabled);
|
|
const onBroadcastInputRef = useRef(onBroadcastInput);
|
|
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
|
onBroadcastInputRef.current = onBroadcastInput;
|
|
|
|
// Snippets ref for shortkey support in terminal
|
|
const snippetsRef = useRef(snippets);
|
|
snippetsRef.current = snippets;
|
|
|
|
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
|
// xterm runtime (and a few effects here) can drive the hook without making
|
|
// Terminal re-render on every suggestion update.
|
|
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
|
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
|
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
|
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
|
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
|
|
|
const terminalBackend = useTerminalBackend();
|
|
const {
|
|
resizeSession,
|
|
receiveSerialYmodem,
|
|
selectDirectory,
|
|
selectDirectoryAvailable,
|
|
selectFile,
|
|
selectFileAvailable,
|
|
sendSerialYmodem,
|
|
serialYmodemAvailable,
|
|
serialYmodemReceiveAvailable,
|
|
setSessionEncoding,
|
|
} = terminalBackend;
|
|
|
|
|
|
|
|
// isScriptsOpen state removed - scripts now handled by side panel
|
|
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const lastToastedErrorRef = useRef<string | null>(null);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const [progressLogs, setProgressLogs] = useState<string[]>([]);
|
|
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
const [showSFTP, setShowSFTP] = useState(false);
|
|
const [progressValue, setProgressValue] = useState(15);
|
|
const [hasSelection, setHasSelection] = useState(false);
|
|
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<{ left: number; top: number } | null>(null);
|
|
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
|
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
|
|
|
const statusRef = useRef<TerminalSession["status"]>(status);
|
|
statusRef.current = status;
|
|
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
|
|
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
|
|
sudoAutofillPasswordRef.current = sudoAutofillPassword;
|
|
|
|
const [chainProgress, setChainProgress] = useState<{
|
|
currentHop: number;
|
|
totalHops: number;
|
|
currentHostLabel: string;
|
|
} | null>(null);
|
|
|
|
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
|
|
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
|
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
|
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
|
return 'utf-8';
|
|
});
|
|
const terminalEncodingRef = useRef(terminalEncoding);
|
|
terminalEncodingRef.current = terminalEncoding;
|
|
// True only after the user actively picks an encoding from the toolbar.
|
|
// onSessionAttached uses this to decide whether to override the backend's
|
|
// initial charset for telnet/serial reconnects — on a first attach we
|
|
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
|
|
// that the UI's two-value state can't represent.
|
|
const userPickedEncodingRef = useRef(false);
|
|
|
|
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
|
const {
|
|
isSearchOpen,
|
|
setIsSearchOpen,
|
|
searchMatchCount,
|
|
handleToggleSearch,
|
|
handleSearch,
|
|
handleFindNext,
|
|
handleFindPrevious,
|
|
handleCloseSearch,
|
|
} = terminalSearch;
|
|
|
|
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
|
if (
|
|
statusRef.current !== "connected" ||
|
|
(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
|
) {
|
|
return data;
|
|
}
|
|
const pastedCommand = data.match(/^([^\r\n]+)(\r\n|\r|\n)$/);
|
|
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
|
|
return data;
|
|
}
|
|
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
|
return data;
|
|
}, []);
|
|
|
|
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
|
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
|
autocompleteAcceptTextRef.current = (text: string) => {
|
|
const id = sessionRef.current;
|
|
if (id && text) {
|
|
let textToWrite = text;
|
|
let handledSubmittedInput = false;
|
|
if (
|
|
host.protocol !== "serial" &&
|
|
statusRef.current === "connected" &&
|
|
!(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
|
) {
|
|
const preparedText = prepareProgrammaticSudoInput(text);
|
|
handledSubmittedInput = preparedText !== text;
|
|
textToWrite = preparedText;
|
|
}
|
|
|
|
// Serial line mode: buffer text and handle local echo instead of direct send
|
|
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
|
for (const ch of text) {
|
|
if (ch === "\r") {
|
|
const line = serialLineBufferRef.current + "\r";
|
|
terminalBackend.writeToSession(id, line);
|
|
serialLineBufferRef.current = "";
|
|
if (serialConfig?.localEcho) writeLocalTerminalData("\r\n");
|
|
} else if (ch === "\x15") {
|
|
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
|
writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length));
|
|
}
|
|
serialLineBufferRef.current = "";
|
|
} else if (ch === "\b" || ch === "\x7f") {
|
|
if (serialLineBufferRef.current.length > 0) {
|
|
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
|
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
|
|
}
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
serialLineBufferRef.current += ch;
|
|
if (serialConfig?.localEcho) writeLocalTerminalData(ch);
|
|
}
|
|
}
|
|
// Still update commandBuffer and broadcast for serial line mode
|
|
// (fall through to shared bookkeeping below — don't return early)
|
|
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
|
// Serial character mode with local echo: echo accepted text locally
|
|
terminalBackend.writeToSession(id, textToWrite);
|
|
for (const ch of text) {
|
|
if (ch === "\r") {
|
|
writeLocalTerminalData("\r\n");
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
writeLocalTerminalData(ch);
|
|
}
|
|
}
|
|
} else {
|
|
terminalBackend.writeToSession(id, textToWrite);
|
|
}
|
|
|
|
// Broadcast to other sessions if broadcast mode is enabled
|
|
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(text, sessionId);
|
|
}
|
|
|
|
// Update command buffer for onCommandExecuted tracking
|
|
for (const ch of text) {
|
|
if (handledSubmittedInput) {
|
|
commandBufferRef.current = "";
|
|
break;
|
|
} else if (ch === "\r" || ch === "\n") {
|
|
const rawCommand = commandBufferRef.current;
|
|
recordTerminalCommandExecution(rawCommand, {
|
|
host,
|
|
sessionId,
|
|
onCommandExecuted,
|
|
onCommandSubmitted,
|
|
commandBufferRef,
|
|
promptLineBreakStateRef,
|
|
}, termRef.current);
|
|
} else if (ch === "\x15") {
|
|
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
|
commandBufferRef.current = "";
|
|
} else if (ch === "\b" || ch === "\x7f") {
|
|
// Backspace: remove last character (Windows fuzzy replacement uses \b)
|
|
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
|
} else if (ch.charCodeAt(0) >= 32) {
|
|
commandBufferRef.current += ch;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
|
// its state updates don't re-render this component (see render below).
|
|
// For local protocol the effective OS is the client OS: synthetic fallback
|
|
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
|
|
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
|
|
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
|
|
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
|
|
? detectLocalOs(navigator.userAgent || navigator.platform)
|
|
: (host.os || "linux");
|
|
const autocompleteSettings = terminalSettings ? {
|
|
enabled: terminalSettings.autocompleteEnabled ?? true,
|
|
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
|
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
|
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
|
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
|
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
|
} : undefined;
|
|
|
|
const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | undefined> => {
|
|
const cwd = await resolvePreferredTerminalCwd({
|
|
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
|
sessionId: sessionRef.current,
|
|
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
|
preferFreshBackend: options?.preferFreshBackend,
|
|
});
|
|
return cwd ?? undefined;
|
|
}, [terminalBackend, terminalCwdTracker]);
|
|
|
|
const clearTerminalCwd = useCallback(() => {
|
|
terminalCwdTracker.clearRendererCwd();
|
|
knownCwdRef.current = undefined;
|
|
onTerminalCwdChange?.(sessionId, null);
|
|
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
|
|
|
// Classify the host's device family from the *detected* distro and the
|
|
// explicit deviceType only. This intentionally bypasses
|
|
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
|
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
|
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
|
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
|
// Several features gate on this — the working-directory probe below, the
|
|
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
|
// because each opens an extra exec channel that strict network-device
|
|
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
|
// whole session (#1043).
|
|
const detectedDeviceClass = classifyDistroId(host.distro);
|
|
const isNetworkDevice =
|
|
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
|
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
|
|
|
|
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
|
const isLocalConnection = host.protocol === "local";
|
|
const isSerialConnection = host.protocol === "serial";
|
|
const supportsRemoteImagePaste =
|
|
!isLocalConnection &&
|
|
!isSerialConnection &&
|
|
host.protocol !== "telnet" &&
|
|
host.protocol !== "mosh" &&
|
|
!host.moshEnabled &&
|
|
host.protocol !== "et" &&
|
|
!host.etEnabled;
|
|
|
|
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
|
// network devices. See isNetworkDevice above for why the gating uses the
|
|
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
|
// #674 covers the AAA-log-flood motivation for stats specifically.
|
|
const isSupportedOs =
|
|
!isNetworkDevice &&
|
|
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
|
const isSystemSidebarEligible =
|
|
!!onOpenSystem &&
|
|
isSupportedOs &&
|
|
!isLocalConnection &&
|
|
!isSerialConnection &&
|
|
host.protocol !== 'telnet';
|
|
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
|
|
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
|
|
// terminal. We just forward `isSupportedOs` via ctx.
|
|
|
|
const zmodem = useZmodemTransfer(sessionId);
|
|
|
|
const zmodemToastedRef = useRef(false);
|
|
|
|
const pendingAuthRef = useRef<PendingAuth>(null);
|
|
useEffect(() => {
|
|
sudoAutofillRef.current?.updatePassword(sudoAutofillPassword);
|
|
}, [sudoAutofillPassword]);
|
|
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
|
const auth = useTerminalAuthState({
|
|
host,
|
|
pendingAuthRef,
|
|
termRef,
|
|
onUpdateHost: handleUpdateHostFromTerminal,
|
|
onStartSession: (term) => {
|
|
const starters = sessionStartersRef.current;
|
|
if (!starters) return;
|
|
if (host.moshEnabled) {
|
|
starters.startMosh(term);
|
|
return;
|
|
}
|
|
if (host.etEnabled) {
|
|
starters.startEt(term);
|
|
return;
|
|
}
|
|
starters.startSSH(term);
|
|
},
|
|
setStatus: (next) => setStatus(next),
|
|
setProgressLogs,
|
|
});
|
|
|
|
const [needsHostKeyVerification, setNeedsHostKeyVerification] = useState(false);
|
|
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
|
const [pendingHostKeyRequestId, setPendingHostKeyRequestId] = useState<string | null>(null);
|
|
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
|
|
|
// OSC-52 clipboard read prompt
|
|
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
|
|
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
|
|
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
|
|
// Reject if terminal is not visible (background tab) — user can't see the prompt
|
|
if (!isVisibleRef.current) return Promise.resolve(false);
|
|
// Reject if another prompt is already pending (avoid resolver overwrite)
|
|
if (osc52ReadResolverRef.current) return Promise.resolve(false);
|
|
return new Promise((resolve) => {
|
|
osc52ReadResolverRef.current = resolve;
|
|
setOsc52ReadPromptVisible(true);
|
|
});
|
|
}, []);
|
|
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
|
|
setOsc52ReadPromptVisible(false);
|
|
osc52ReadResolverRef.current?.(allowed);
|
|
osc52ReadResolverRef.current = null;
|
|
// Restore focus to terminal
|
|
termRef.current?.focus();
|
|
}, []);
|
|
|
|
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) return;
|
|
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
|
e.preventDefault();
|
|
}, []);
|
|
|
|
// Subscribe to custom theme changes so editing triggers re-render
|
|
const customThemes = useCustomThemes();
|
|
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
|
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
|
|
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
|
|
const effectiveFontSize = useMemo(
|
|
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
|
[fontSize, hasFontSizeOverride, host.fontSize],
|
|
);
|
|
const effectiveFontWeight = useMemo(
|
|
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
|
|
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
|
|
);
|
|
const resolvedFontFamily = useMemo(() => {
|
|
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
|
? host.fontFamily
|
|
: fontFamilyId;
|
|
const resolvedFontId = hostFontId || "menlo";
|
|
const selectedFont = availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0];
|
|
const platform: SupportedPlatform =
|
|
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform)
|
|
? "darwin"
|
|
: typeof navigator !== "undefined" && /Win/i.test(navigator.platform)
|
|
? "win32"
|
|
: "linux";
|
|
return composeFontFamilyStack({
|
|
primaryFamily: selectedFont.family,
|
|
userFallback: terminalSettings?.fallbackFont ?? "",
|
|
latinFontId: resolvedFontId,
|
|
platform,
|
|
});
|
|
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
|
|
|
|
const effectiveTheme = useMemo(() => {
|
|
// When "Follow Application Theme" is on and there's no active
|
|
// preview, skip per-host overrides — all terminals should use the
|
|
// UI-matched theme passed via terminalTheme prop.
|
|
if (followAppTerminalTheme && !themePreviewId) {
|
|
return applyCustomAccentToTerminalTheme(terminalTheme, accentMode, customAccent);
|
|
}
|
|
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
|
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
|
terminalTheme.id,
|
|
);
|
|
let baseTheme = terminalTheme;
|
|
if (themeId) {
|
|
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
|
|| customThemes.find((t) => t.id === themeId);
|
|
if (hostTheme) baseTheme = hostTheme;
|
|
}
|
|
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
|
}, [accentMode, customAccent, customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
|
|
|
const resolvedChainHosts =
|
|
chainHosts;
|
|
|
|
const updateStatus = (next: TerminalSession["status"]) => {
|
|
setStatus(next);
|
|
hasConnectedRef.current = next === "connected";
|
|
onStatusChange?.(sessionId, next);
|
|
};
|
|
|
|
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
|
|
const captureHandler = onTerminalDataCaptureRef.current;
|
|
if (!captureHandler || terminalDataCapturedRef.current) return;
|
|
terminalDataCapturedRef.current = true;
|
|
const replaySafeLogData = finalizeTerminalLogData();
|
|
const capturedData = replaySafeLogData || data;
|
|
captureHandler(capturedSessionId, capturedData);
|
|
}, [finalizeTerminalLogData]);
|
|
|
|
const cleanupSession = () => {
|
|
disposeDataRef.current?.();
|
|
disposeDataRef.current = null;
|
|
disposeExitRef.current?.();
|
|
disposeExitRef.current = null;
|
|
|
|
if (sessionRef.current) {
|
|
try {
|
|
terminalBackend.closeSession(sessionRef.current);
|
|
} catch (err) {
|
|
logger.warn("Failed to close SSH session", err);
|
|
}
|
|
}
|
|
sessionRef.current = null;
|
|
};
|
|
|
|
const teardown = () => {
|
|
isBootActiveRef.current = false;
|
|
retryTokenRef.current = null;
|
|
cleanupSession();
|
|
xtermRuntimeRef.current?.dispose();
|
|
xtermRuntimeRef.current = null;
|
|
termRef.current = null;
|
|
fitAddonRef.current = null;
|
|
serializeAddonRef.current = null;
|
|
searchAddonRef.current = null;
|
|
};
|
|
|
|
const sessionStarters = createTerminalSessionStarters({
|
|
host,
|
|
keys,
|
|
identities,
|
|
knownHosts,
|
|
resolvedChainHosts,
|
|
sessionId,
|
|
reuseConnectionFromSessionId,
|
|
startupCommand,
|
|
noAutoRun,
|
|
terminalSettings,
|
|
terminalSettingsRef,
|
|
terminalBackend,
|
|
serialConfig,
|
|
isVisibleRef,
|
|
isBootActiveRef,
|
|
pendingOutputScrollRef,
|
|
sessionRef,
|
|
hasConnectedRef,
|
|
hasRunStartupCommandRef,
|
|
disposeDataRef,
|
|
disposeExitRef,
|
|
fitAddonRef,
|
|
serializeAddonRef,
|
|
pendingAuthRef,
|
|
promptLineBreakStateRef,
|
|
sudoAutofillRef,
|
|
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
|
updateStatus,
|
|
setStatus,
|
|
setError,
|
|
setNeedsAuth: auth.setNeedsAuth,
|
|
setAuthRetryMessage: auth.setAuthRetryMessage,
|
|
setAuthPassword: auth.setAuthPassword,
|
|
setProgressLogs,
|
|
setProgressValue,
|
|
setChainProgress,
|
|
t,
|
|
onSessionAttached: (id: string) => {
|
|
clearTerminalCwd();
|
|
// SSH: always sync. Its backend starts in utf-8 regardless of
|
|
// host.charset, so the push is what keeps the UI state aligned
|
|
// across reconnects — including localhost SSH targets, hence
|
|
// hostname isn't in the gate.
|
|
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
|
|
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
|
|
const isTelnet = host.protocol === 'telnet';
|
|
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
|
|
const isEt = host.protocol === 'et' || host.etEnabled;
|
|
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
|
|
if (isSSH) {
|
|
setSessionEncoding(id, terminalEncodingRef.current);
|
|
return;
|
|
}
|
|
// Telnet / serial: the backend already applied host.charset
|
|
// (including arbitrary iconv labels like latin1 / shift_jis that
|
|
// the UI's two-value state can't represent) through start*Session
|
|
// options, so don't clobber it on first attach. Only re-sync once
|
|
// the user has explicitly picked from the toolbar menu — that's
|
|
// the signal they want the UI choice to win on reconnect.
|
|
if ((isTelnet || isSerial) && userPickedEncodingRef.current) {
|
|
setSessionEncoding(id, terminalEncodingRef.current);
|
|
}
|
|
},
|
|
onSessionExit: (closedSessionId, evt) => {
|
|
clearTerminalCwd();
|
|
onSessionExit?.(closedSessionId, evt);
|
|
},
|
|
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
|
onTerminalLogData: captureTerminalLogData,
|
|
onOsDetected,
|
|
onCommandExecuted,
|
|
sessionLog,
|
|
sshDebugLogEnabled,
|
|
sudoAutofillPassword,
|
|
sudoAutofillPasswordRef,
|
|
});
|
|
sessionStartersRef.current = sessionStarters;
|
|
|
|
useEffect(() => {
|
|
setConnectionReuseFellBack(false);
|
|
if (!reuseConnectionFromSessionId) return undefined;
|
|
|
|
return terminalBackend.onConnectionReuseFallback?.((fallbackSessionId) => {
|
|
if (fallbackSessionId === sessionId) {
|
|
setConnectionReuseFellBack(true);
|
|
}
|
|
});
|
|
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
|
|
|
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
|
const fitAddon = fitAddonRef.current;
|
|
if (!fitAddon) return;
|
|
if (options?.requireVisible && !isVisibleRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
if (width <= 0 || height <= 0) {
|
|
// Terminal is hidden — invalidate the cached size so that when it
|
|
// becomes visible again, a non-forced fit won't be suppressed by a
|
|
// stale size match (e.g. after font metrics changed while hidden).
|
|
lastFittedSizeRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (!options?.force) {
|
|
const lastSize = lastFittedSizeRef.current;
|
|
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
|
autocompleteRepositionRef.current?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const runFit = () => {
|
|
try {
|
|
const term = termRef.current;
|
|
if (!term) return;
|
|
|
|
const dimensions = fitAddon.proposeDimensions();
|
|
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
|
|
|
lastFittedSizeRef.current = { width, height };
|
|
// addon-fit 0.11 clears the renderer before resizing, which can show
|
|
// as a one-frame WebGL blink during layout changes. Resize directly
|
|
// using the proposed dimensions to preserve the existing behavior
|
|
// without forcing a blank intermediate frame.
|
|
if (term.cols !== dimensions.cols || term.rows !== dimensions.rows) {
|
|
term.resize(dimensions.cols, dimensions.rows);
|
|
forceSyncRenderAfterResize(term);
|
|
}
|
|
if (typeof requestAnimationFrame === "function") {
|
|
requestAnimationFrame(() => {
|
|
autocompleteRepositionRef.current?.();
|
|
});
|
|
} else {
|
|
autocompleteRepositionRef.current?.();
|
|
}
|
|
} catch (err) {
|
|
logger.warn("Fit failed", err);
|
|
}
|
|
};
|
|
|
|
if (
|
|
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
|
|
typeof requestAnimationFrame === "function"
|
|
) {
|
|
requestAnimationFrame(runFit);
|
|
} else {
|
|
runFit();
|
|
}
|
|
};
|
|
|
|
const prevIsResizingRef = useRef(isResizing);
|
|
|
|
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
|
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
|
|
|
// True only while createXTermRuntime is programmatically restoring the
|
|
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
|
// copy-on-select skip a redundant clipboard write that would otherwise
|
|
// clobber whatever the user copied elsewhere in the meantime.
|
|
const isRestoringSelectionRef = useRef(false);
|
|
|
|
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
|
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
|
|
|
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
|
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
|
termRef.current.scrollToBottom();
|
|
}
|
|
}, []);
|
|
|
|
const broadcastUserPasteData = useCallback((data: string) => {
|
|
if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(data, sessionId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [sessionId]);
|
|
|
|
const executeSnippetCommand = useCallback((
|
|
command: string,
|
|
noAutoRun?: boolean,
|
|
options?: { broadcast?: boolean },
|
|
) => {
|
|
const term = termRef.current;
|
|
const id = sessionRef.current;
|
|
if (!term || !id) return;
|
|
|
|
let data = normalizeLineEndings(command);
|
|
const isMultiLine = data.includes('\n');
|
|
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
|
// outside the paste markers — otherwise shells treat it as pasted text
|
|
// instead of a submit action.
|
|
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
|
data = wrapBracketedPaste(data);
|
|
}
|
|
if (!noAutoRun) data = `${data}\r`;
|
|
|
|
// Broadcast the exact bytes the active session receives so peers mirror it,
|
|
// including the bracketed-paste wrapping and the auto-run \r. Broadcasting
|
|
// the raw (un-wrapped) form would let a multi-line noAutoRun snippet run
|
|
// line-by-line on peers, since handleBroadcastInput writes bytes directly
|
|
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
|
// broadcast mode would clear peer input (the clear keystrokes already go
|
|
// through the broadcast-aware path) but never send the command.
|
|
if (options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
|
onBroadcastInputRef.current(data, sessionId);
|
|
}
|
|
|
|
data = prepareProgrammaticSudoInput(data);
|
|
terminalBackend.writeToSession(id, data);
|
|
scrollToBottomAfterProgrammaticInput(data);
|
|
term.focus();
|
|
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
|
|
|
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
|
const command = await resolveSnippetCommand(snippet);
|
|
if (command === null) return;
|
|
executeSnippetCommand(command, snippet.noAutoRun);
|
|
}, [executeSnippetCommand]);
|
|
|
|
const onSnippetShortkeyRef = useRef(executeSnippet);
|
|
onSnippetShortkeyRef.current = executeSnippet;
|
|
|
|
const terminalContextActions = useTerminalContextActions({
|
|
termRef,
|
|
sourceSessionId: sessionId,
|
|
sessionRef,
|
|
onHasSelectionChange: setHasSelection,
|
|
scrollOnPasteRef,
|
|
isBroadcastEnabledRef,
|
|
onBroadcastInputRef,
|
|
isLocalConnection,
|
|
supportsRemoteImagePaste,
|
|
terminalBackend,
|
|
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
|
scrollToBottomAfterProgrammaticInput,
|
|
});
|
|
// Kept fresh on every render so the mouseTracking capture handler at
|
|
// handleContextMenuCapture (which is bound once per sessionId) can
|
|
// still invoke the latest paste / select-word callbacks without
|
|
// re-binding on every action identity change. See #941.
|
|
const terminalContextActionsRef = useRef(terminalContextActions);
|
|
terminalContextActionsRef.current = terminalContextActions;
|
|
|
|
const handleAddSelectionToAI = useCallback(() => {
|
|
const selection = termRef.current?.getSelection() ?? "";
|
|
if (!selection.trim()) return;
|
|
onAddSelectionToAI?.(sessionId, selection);
|
|
}, [onAddSelectionToAI, sessionId]);
|
|
|
|
const handleSetTerminalEncoding = useCallback((encoding: 'utf-8' | 'gb18030') => {
|
|
setTerminalEncoding(encoding);
|
|
userPickedEncodingRef.current = true;
|
|
if (sessionRef.current) {
|
|
setSessionEncoding(sessionRef.current, encoding);
|
|
}
|
|
}, [setSessionEncoding]);
|
|
|
|
const handleOpenSFTP = useCallback(async () => {
|
|
if (onOpenSftp) {
|
|
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
|
const initialPath = await resolveSftpInitialPath();
|
|
onOpenSftp(host, initialPath, undefined, sessionId);
|
|
return;
|
|
}
|
|
|
|
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
|
|
if (showSFTP) {
|
|
setShowSFTP(false);
|
|
return;
|
|
}
|
|
setShowSFTP(true);
|
|
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
|
|
|
const handleSendYmodem = useCallback(async () => {
|
|
if (!isSerialConnection || statusRef.current !== "connected") return;
|
|
if (!selectFileAvailable() || !serialYmodemAvailable()) {
|
|
toast.error(t("terminal.ymodem.unavailable"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const filePath = await selectFile(
|
|
t("terminal.ymodem.selectFile"),
|
|
undefined,
|
|
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
|
);
|
|
if (!filePath) return;
|
|
|
|
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
|
toast.info(t("terminal.ymodem.started", { fileName }));
|
|
const result = await sendSerialYmodem(sessionRef.current || sessionId, filePath);
|
|
if (result.success) {
|
|
toast.success(t("terminal.ymodem.complete", { fileName: result.fileName || fileName }));
|
|
} else {
|
|
toast.error(result.error || t("terminal.ymodem.failed"));
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t("terminal.ymodem.failed"));
|
|
}
|
|
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
|
|
|
|
const handleReceiveYmodem = useCallback(async () => {
|
|
if (!isSerialConnection || statusRef.current !== "connected") return;
|
|
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
|
|
toast.error(t("terminal.ymodem.unavailable"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
|
|
if (!destinationDir) return;
|
|
|
|
toast.info(t("terminal.ymodem.receiveStarted"));
|
|
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
|
|
if (result.success) {
|
|
if (result.fileCount && result.fileCount > 1) {
|
|
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
|
|
} else if (result.fileName) {
|
|
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
|
|
} else {
|
|
toast.success(t("terminal.ymodem.receiveEmpty"));
|
|
}
|
|
} else {
|
|
toast.error(t("terminal.ymodem.receiveFailed"));
|
|
}
|
|
} catch {
|
|
toast.error(t("terminal.ymodem.receiveFailed"));
|
|
}
|
|
}, [
|
|
isSerialConnection,
|
|
receiveSerialYmodem,
|
|
selectDirectory,
|
|
selectDirectoryAvailable,
|
|
serialYmodemReceiveAvailable,
|
|
sessionId,
|
|
t,
|
|
]);
|
|
|
|
const handleCancelConnect = () => {
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
|
}
|
|
retryTokenRef.current = null;
|
|
setIsCancelling(true);
|
|
auth.setNeedsAuth(false);
|
|
auth.setAuthRetryMessage(null);
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
setError("Connection cancelled");
|
|
setProgressLogs((prev) => [...prev, "Cancelled by user."]);
|
|
cleanupSession();
|
|
updateStatus("disconnected");
|
|
setChainProgress(null);
|
|
setTimeout(() => setIsCancelling(false), 600);
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
const handleDismissDisconnectedDialog = () => {
|
|
setIsDisconnectedDialogDismissed(true);
|
|
};
|
|
|
|
const handleCloseDisconnectedSession = () => {
|
|
retryTokenRef.current = null;
|
|
onCloseSession?.(sessionId);
|
|
};
|
|
|
|
const handleHostKeyClose = () => {
|
|
setNeedsHostKeyVerification(false);
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
handleCancelConnect();
|
|
};
|
|
|
|
const handleHostKeyContinue = () => {
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, false);
|
|
}
|
|
setNeedsHostKeyVerification(false);
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
};
|
|
|
|
const handleHostKeyAddAndContinue = () => {
|
|
if (pendingHostKeyInfo && onAddKnownHost) {
|
|
onAddKnownHost(createKnownHostFromHostKeyInfo(pendingHostKeyInfo, host));
|
|
}
|
|
if (pendingHostKeyRequestId) {
|
|
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, true, true);
|
|
}
|
|
setNeedsHostKeyVerification(false);
|
|
if (pendingConnectionRef.current) {
|
|
pendingConnectionRef.current();
|
|
pendingConnectionRef.current = null;
|
|
}
|
|
setPendingHostKeyInfo(null);
|
|
setPendingHostKeyRequestId(null);
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
if (!termRef.current) return;
|
|
cleanupSession();
|
|
const term = termRef.current;
|
|
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
|
// kicks off another retry while the chained writes below are still
|
|
// queued, the token will be invalidated and our callbacks will abort
|
|
// before opening a ghost backend session with no owning UI.
|
|
const retryToken = Symbol("retry");
|
|
retryTokenRef.current = retryToken;
|
|
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
|
|
|
|
auth.resetForRetry();
|
|
terminalDataCapturedRef.current = false;
|
|
hasRunStartupCommandRef.current = false;
|
|
setIsDisconnectedDialogDismissed(false);
|
|
setConnectionReuseFellBack(false);
|
|
setStatus("connecting");
|
|
setError(null);
|
|
setProgressLogs(["Retrying secure channel..."]);
|
|
setShowLogs(true);
|
|
|
|
const startNewSession = () => {
|
|
if (!retryStillActive()) return;
|
|
if (host.protocol === "serial") {
|
|
sessionStarters.startSerial(term);
|
|
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
|
sessionStarters.startLocal(term);
|
|
} else if (host.protocol === "telnet") {
|
|
sessionStarters.startTelnet(term);
|
|
} else if (host.moshEnabled) {
|
|
sessionStarters.startMosh(term);
|
|
} else if (host.etEnabled) {
|
|
sessionStarters.startEt(term);
|
|
} else {
|
|
sessionStarters.startSSH(term);
|
|
}
|
|
};
|
|
|
|
// Chain the whole preparation through xterm.write callbacks so everything
|
|
// lands in strict order — see #695. xterm.write is async, so without
|
|
// chaining, a fast reconnect path (local/serial especially) can interleave
|
|
// the new session's first bytes with our reset sequence, corrupting the
|
|
// first screen.
|
|
//
|
|
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
|
|
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
|
|
// we must be on the normal buffer before preserving.
|
|
term.write('\x1b[?1049l', () => {
|
|
if (!retryStillActive()) return;
|
|
// 2. Push the previous session's viewport into scrollback so the user
|
|
// can still read it after reconnect.
|
|
preserveTerminalViewportInScrollback(term);
|
|
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
|
|
// full-screen apps may have left on — DECCKM (otherwise arrow keys
|
|
// emit SS3 and break readline history), keypad mode, SGR,
|
|
// insert/replace, origin, cursor visibility — without clearing the
|
|
// buffer. DECSTR does not cover xterm-specific extensions, so also
|
|
// explicitly disable mouse tracking (1000/1002/1003/1006) and
|
|
// bracketed paste (2004). Finally home the cursor.
|
|
term.write(
|
|
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
|
|
// 4. Only now — after every prep byte has been applied to the
|
|
// terminal — start the new session, so its first output can't
|
|
// interleave with the reset sequence.
|
|
startNewSession,
|
|
);
|
|
});
|
|
};
|
|
|
|
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
|
status,
|
|
isLocalConnection,
|
|
isSerialConnection,
|
|
isDisconnectedDialogDismissed,
|
|
hideConnectingDialogForConnectionReuse: shouldHideConnectingDialogForConnectionReuse({
|
|
reuseConnectionFromSessionId,
|
|
host,
|
|
connectionReuseFellBack,
|
|
}),
|
|
});
|
|
|
|
const {
|
|
handleDragEnter,
|
|
handleDragLeave,
|
|
handleDragOver,
|
|
handleDrop,
|
|
isDraggingOver,
|
|
} = useTerminalDragDrop({
|
|
host,
|
|
isLocalConnection,
|
|
isNetworkDevice,
|
|
onOpenSftp,
|
|
resolveSftpInitialPath,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
sessionId,
|
|
sessionRef,
|
|
status,
|
|
t,
|
|
terminalBackend,
|
|
termRef,
|
|
});
|
|
|
|
useTerminalFilePaste({
|
|
isLocalConnection,
|
|
supportsRemoteImagePaste,
|
|
status,
|
|
termRef,
|
|
sessionRef,
|
|
terminalBackend,
|
|
resolveSftpInitialPath,
|
|
scrollOnPasteRef,
|
|
onPasteData: broadcastUserPasteData,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
containerRef,
|
|
});
|
|
|
|
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
|
|
<TerminalToolbar
|
|
status={status}
|
|
host={host}
|
|
compactToolbar={compactToolbar}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
|
|
onOpenSFTP={handleOpenSFTP}
|
|
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
|
|
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
|
|
onOpenScripts={onOpenScripts ?? (() => {})}
|
|
onOpenHistory={onOpenHistory}
|
|
onOpenTheme={onOpenTheme ?? (() => {})}
|
|
onUpdateHost={handleUpdateHostFromTerminal}
|
|
showClose={opts?.showClose}
|
|
onClose={() => onCloseSession?.(sessionId)}
|
|
isSearchOpen={isSearchOpen}
|
|
onToggleSearch={handleToggleSearch}
|
|
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
|
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
|
terminalEncoding={terminalEncoding}
|
|
onSetTerminalEncoding={handleSetTerminalEncoding}
|
|
/>
|
|
), [
|
|
compactToolbar,
|
|
executeSnippet,
|
|
handleOpenSFTP,
|
|
handleReceiveYmodem,
|
|
handleSendYmodem,
|
|
handleSetTerminalEncoding,
|
|
handleToggleSearch,
|
|
host,
|
|
inWorkspace,
|
|
isSerialConnection,
|
|
isComposeBarOpen,
|
|
isSearchOpen,
|
|
isWorkspaceComposeBarOpen,
|
|
onCloseSession,
|
|
onOpenScripts,
|
|
onOpenHistory,
|
|
onOpenTheme,
|
|
onToggleComposeBar,
|
|
handleUpdateHostFromTerminal,
|
|
sessionId,
|
|
snippetPackages,
|
|
snippets,
|
|
status,
|
|
terminalEncoding,
|
|
]);
|
|
|
|
const statusDotTone =
|
|
status === "connected"
|
|
? "bg-emerald-400"
|
|
: status === "connecting"
|
|
? "bg-amber-400"
|
|
: "bg-rose-500";
|
|
const terminalPreviewVars = useMemo(() => ({
|
|
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
|
|
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
|
|
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
|
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
|
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
|
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
|
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
|
|
|
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
|
|
|
|
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
|
|
|
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
|
};
|
|
|
|
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
|
|
Terminal.displayName = "Terminal";
|
|
|
|
export default Terminal;
|