diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json
index 219e511a0..40abb5e3a 100644
--- a/plugins/ssh-terminal/locales/en.json
+++ b/plugins/ssh-terminal/locales/en.json
@@ -44,6 +44,7 @@
"reconnect": "Reconnect",
"reconnected": "Reconnected successfully",
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
+ "autoReconnecting": "Connection lost, reconnecting...",
"searchCaseSensitive": "Match Case",
"searchClose": "Close (Escape)",
"searchNext": "Next Match (Enter)",
@@ -352,6 +353,9 @@
},
"sudoPasswordAutoFill": {
"label": "Fill sudo prompts"
+ },
+ "autoReconnect": {
+ "label": "Reconnect automatically"
}
},
"user": {
@@ -479,6 +483,8 @@
"none": "None",
"passwordPromptAutoFillDesc": "Offer to fill in the saved password when the terminal asks for one. Turn off to never auto-fill any password on this host.",
"passwordPromptAutoFillLabel": "Offer Password Auto-fill",
+ "autoReconnectLabel": "Auto Reconnect",
+ "autoReconnectDesc": "Reconnect on its own when the SSH connection drops, like after a network blip or sleep. Keeps the scrollback.",
"rightClickSelectsWord": "Right Click Selects Word",
"rightClickSelectsWordLabel": "Right-click Selects Word",
"rightClickSelectsWordShortDesc": "Select the word under cursor on right-click",
diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json
index 5137c013b..1684e7ddc 100644
--- a/plugins/ssh-terminal/manifest.json
+++ b/plugins/ssh-terminal/manifest.json
@@ -702,6 +702,13 @@
"labelKey": "settings.host.sudoPasswordAutoFill.label",
"hidden": true,
"default": false
+ },
+ {
+ "key": "autoReconnect",
+ "type": "boolean",
+ "labelKey": "settings.host.autoReconnect.label",
+ "hidden": true,
+ "default": false
}
]
},
diff --git a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx
index 62c701f74..191ec6344 100644
--- a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx
+++ b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx
@@ -720,6 +720,16 @@ export function HostTerminalSettings({
onChange={(v) => setField("autoMosh", v)}
/>
+
+ setField("autoReconnect", v)}
+ />
+
(
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef(null);
const sessionOriginRef = useRef<"local" | "remote">("local");
+ // Set while an auto reconnect runs, so the old output stays on screen.
+ const keepScrollbackRef = useRef(false);
const customKeybindingsRef = useRef([]);
const resizeTimeout = useRef(null);
const wasDisconnectedBySSH = useRef(false);
@@ -1514,6 +1516,7 @@ const TerminalInner = forwardRef(
if (reconnectAttempts.current >= maxReconnectAttempts) {
setIsConnecting(false);
+ keepScrollbackRef.current = false;
shouldNotReconnectRef.current = true;
setShowDisconnectedOverlay(true);
addLog({
@@ -1526,7 +1529,11 @@ const TerminalInner = forwardRef(
isReconnectingRef.current = true;
- if (terminal && !isAttachingSessionRef.current) {
+ if (
+ terminal &&
+ !isAttachingSessionRef.current &&
+ !keepScrollbackRef.current
+ ) {
terminal.clear();
}
@@ -1564,7 +1571,7 @@ const TerminalInner = forwardRef(
}
if (terminal && hostConfig) {
- if (!isAttachingSessionRef.current) {
+ if (!isAttachingSessionRef.current && !keepScrollbackRef.current) {
terminal.clear();
}
const cols = terminal.cols;
@@ -1929,6 +1936,13 @@ const TerminalInner = forwardRef(
errorMessage.toLowerCase().includes("timeout") ||
errorMessage.toLowerCase().includes("network")
) {
+ if (keepScrollbackRef.current && !isUnmountingRef.current) {
+ // The host is still unreachable; try again after the backoff.
+ isConnectingRef.current = false;
+ wasDisconnectedBySSH.current = false;
+ attemptReconnection();
+ return;
+ }
updateConnectionError(errorMessage);
setIsConnected(false);
if (terminal) {
@@ -1948,6 +1962,7 @@ const TerminalInner = forwardRef(
errorMessage.toLowerCase().includes("key"))) ||
errorMessage.toLowerCase().includes("incorrect password")
) {
+ keepScrollbackRef.current = false;
updateConnectionError(errorMessage);
setIsConnecting(false);
shouldNotReconnectRef.current = true;
@@ -1960,6 +1975,10 @@ const TerminalInner = forwardRef(
updateConnectionError(errorMessage);
setIsConnecting(false);
} else if (msg.type === "connected") {
+ if (keepScrollbackRef.current) {
+ keepScrollbackRef.current = false;
+ reconnectAttempts.current = 0;
+ }
wasConnectedRef.current = true;
setIsConnected(true);
setIsConnecting(false);
@@ -2061,7 +2080,23 @@ const TerminalInner = forwardRef(
if (onClose) onClose();
} else if (wasConnectedRef.current) {
wasConnectedRef.current = false;
- setShowDisconnectedOverlay(true);
+ if (
+ termSettingsRef.current.autoReconnect &&
+ !isUnmountingRef.current
+ ) {
+ wasDisconnectedBySSH.current = false;
+ shouldNotReconnectRef.current = false;
+ reconnectAttempts.current = 0;
+ keepScrollbackRef.current = true;
+ terminal?.write(
+ `
+[2m${t("terminal.autoReconnecting")}[0m
+`,
+ );
+ attemptReconnection();
+ } else {
+ setShowDisconnectedOverlay(true);
+ }
} else if (!connectionErrorRef.current) {
updateConnectionError(
msg.message || t("terminal.connectionRejected"),
@@ -2386,6 +2421,7 @@ const TerminalInner = forwardRef(
if (
!wasConnectedRef.current &&
!isAttachingSessionRef.current &&
+ !keepScrollbackRef.current &&
event.wasClean &&
(event.code === 1005 || event.code === 1000)
) {
diff --git a/plugins/ssh-terminal/src/shared/terminal-settings.ts b/plugins/ssh-terminal/src/shared/terminal-settings.ts
index da2f6754c..d64fb96d7 100644
--- a/plugins/ssh-terminal/src/shared/terminal-settings.ts
+++ b/plugins/ssh-terminal/src/shared/terminal-settings.ts
@@ -81,6 +81,7 @@ export interface TerminalBehavior {
localEcho: HostLocalEcho;
passwordPromptAutoFill: boolean;
sudoPasswordAutoFill: boolean;
+ autoReconnect: boolean;
}
/** Everything the host editor's Terminal tab edits. */
@@ -121,6 +122,7 @@ export const BEHAVIOR_KEYS = [
"localEcho",
"passwordPromptAutoFill",
"sudoPasswordAutoFill",
+ "autoReconnect",
] as const satisfies readonly (keyof TerminalBehavior)[];
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
@@ -169,6 +171,7 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
localEcho: "auto",
passwordPromptAutoFill: true,
sudoPasswordAutoFill: false,
+ autoReconnect: false,
};
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
diff --git a/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts
new file mode 100644
index 000000000..c738ed229
--- /dev/null
+++ b/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts
@@ -0,0 +1,64 @@
+// @vitest-environment node
+import fs from "node:fs";
+import vm from "node:vm";
+import { describe, expect, it, vi } from "vitest";
+
+const source = fs.readFileSync(
+ new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url),
+ "utf8",
+);
+const attempt = source.slice(
+ source.indexOf(" function attemptReconnection()"),
+ source.indexOf(" // A persisted session that timed out"),
+);
+
+function run(keepScrollback: boolean, attempts = 0) {
+ const timers: Array<() => void> = [];
+ const context = {
+ terminal: { cols: 100, rows: 30, clear: vi.fn() },
+ hostConfig: { id: 1 },
+ isUnmountingRef: { current: false },
+ shouldNotReconnectRef: { current: false },
+ isReconnectingRef: { current: false },
+ isConnectingRef: { current: false },
+ isAttachingSessionRef: { current: false },
+ wasDisconnectedBySSH: { current: false },
+ reconnectTimeoutRef: { current: null as unknown },
+ reconnectAttempts: { current: attempts },
+ keepScrollbackRef: { current: keepScrollback },
+ maxReconnectAttempts: 8,
+ setIsConnecting: vi.fn(),
+ setShowDisconnectedOverlay: vi.fn(),
+ addLog: vi.fn(),
+ t: (key: string) => key,
+ connectToHost: vi.fn(),
+ setTimeout: (fn: () => void) => {
+ timers.push(fn);
+ return 1;
+ },
+ Math,
+ };
+ vm.runInNewContext(`${attempt}\nattemptReconnection();`, context);
+ timers.forEach((fn) => fn());
+ return context;
+}
+
+describe("terminal auto reconnect", () => {
+ it("keeps the scrollback while reconnecting after a drop", () => {
+ const context = run(true);
+ expect(context.terminal.clear).not.toHaveBeenCalled();
+ expect(context.connectToHost).toHaveBeenCalledWith(100, 30);
+ });
+
+ it("still clears the screen on an ordinary reconnect", () => {
+ const context = run(false);
+ expect(context.terminal.clear).toHaveBeenCalled();
+ });
+
+ it("gives up after the last attempt and stops keeping scrollback", () => {
+ const context = run(true, 8);
+ expect(context.connectToHost).not.toHaveBeenCalled();
+ expect(context.setShowDisconnectedOverlay).toHaveBeenCalledWith(true);
+ expect(context.keepScrollbackRef.current).toBe(false);
+ });
+});