Ghost Shell

JavaScript NOASSERTION

Stars
0
Forks
0
Downloads
N/A
Open Issues
0
Files main

Repository Files

Loading file structure...
src/provider/terminal-provider.jsx
import React from "react";
import { Channel } from "@tauri-apps/api/core";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { useSecurity } from "@/provider/security-provider";
import { yieldToUi } from "@/lib/async";
import { invoke } from "@/lib/tauri";
import {
  appendSessionLog,
  createSessionRecord,
  finalizeSessionRecord,
  getSessionLog,
  updateSessionRecord,
} from "@/lib/session-history";

import { TerminalContext } from "../context/terminal-context";

const STORAGE_KEY = "ghost-shell-terminal-sessions";

function getXtermTheme() {
  const isDark = document.documentElement.classList.contains("dark");
  return {
    background: isDark ? "#141414" : "#ffffff",
    foreground: isDark ? "#e5e5e5" : "#171717",
    cursor: "#22c55e",
    selectionBackground: isDark ? "#264f3a" : "#bbf7d0",
    selectionInactiveBackground: isDark ? "#264f3a88" : "#bbf7d088",
  };
}

function writeStatusLine(term, stage, message) {
  term.writeln(`\r\x1b[90m[${stage}] ${message}\x1b[0m`);
}

function captureScrollback(term) {
  const buffer = term.buffer.active;
  const lines = [];
  for (let i = 0; i < buffer.length; i++) {
    lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
  }
  return lines.join("\n");
}

function createWriteFlusher(sessionId, runtimesRef, onUserInput) {
  let pending = "";
  let scheduled = false;

  const flush = () => {
    scheduled = false;
    if (!pending) return;
    const chunk = pending;
    pending = "";
    onUserInput?.(chunk);

    const runtime = runtimesRef.current?.get(sessionId);
    if (runtime) {
      if (!runtime.typedBuffer) {
        runtime.typedBuffer = "";
      }
      for (let i = 0; i < chunk.length; i++) {
        const char = chunk[i];
        if (char === "\r" || char === "\n") {
          const cmd = runtime.typedBuffer.trim().toLowerCase();
          if (cmd === "exit" || cmd === "logout") {
            runtime.userExited = true;
          }
          runtime.typedBuffer = "";
        } else if (char === "\x7f" || char === "\x08") {
          runtime.typedBuffer = runtime.typedBuffer.slice(0, -1);
        } else if (char === "\x04") {
          runtime.userExited = true;
        } else if (char.match(/^[a-zA-Z0-9_-]$/)) {
          runtime.typedBuffer += char;
        }
      }
    }

    invoke("ssh_write", {
      sessionId,
      data: new TextEncoder().encode(chunk),
    }).catch(() => {});
  };

  return (data) => {
    pending += data;
    if (!scheduled) {
      scheduled = true;
      queueMicrotask(flush);
    }
  };
}

function createTerminalInstance(sessionId, runtimesRef, onUserInput) {
  const term = new Terminal({
    scrollback: 10000,
    convertEol: true,
    cursorBlink: true,
    fontSize: 13,
    fontFamily: "Geist Mono, ui-monospace, Menlo, monospace",
    theme: getXtermTheme(),
    allowProposedApi: true,
  });
  const fit = new FitAddon();
  term.loadAddon(fit);

  const search = new SearchAddon();
  term.loadAddon(search);
  term._searchAddon = search;

  term.onData(createWriteFlusher(sessionId, runtimesRef, onUserInput));

  let resizeTimer;
  term.onResize(({ cols, rows }) => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      invoke("ssh_resize", { sessionId, cols, rows }).catch(() => {});
    }, 100);
  });

  return { term, fit };
}

function loadPersistedState() {
  try {
    const raw = sessionStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

function savePersistedState(
  sessions,
  activeId,
  runtimesRef,
  { captureBuffers = false } = {},
) {
  let buffers = {};
  if (captureBuffers) {
    for (const session of sessions) {
      const runtime = runtimesRef.current.get(session.id);
      if (runtime?.term) {
        buffers[session.id] = captureScrollback(runtime.term);
      }
    }
  } else {
    try {
      const raw = sessionStorage.getItem(STORAGE_KEY);
      if (raw) {
        buffers = JSON.parse(raw).buffers ?? {};
      }
    } catch {
      buffers = {};
    }
  }

  sessionStorage.setItem(
    STORAGE_KEY,
    JSON.stringify({
      sessions: sessions.map((s) => ({
        id: s.id,
        title: s.title,
        status: s.status,
        stage: s.stage,
        stageMessage: s.stageMessage,
        wasConnected: s.status === "connected",
        host: {
          id: s.host.id,
          name: s.host.name,
          address: s.host.address,
          port: s.host.port,
          username: s.host.username,
          key_id: s.host.key_id ?? null,
        },
      })),
      activeId,
      buffers,
    }),
  );
}

function clearPersistedState() {
  sessionStorage.removeItem(STORAGE_KEY);
}

export function TerminalProvider({ children }) {
  const { unlocked } = useSecurity();
  const [sessions, setSessions] = React.useState([]);
  const [activeId, setActiveId] = React.useState(null);
  const [authPrompt, setAuthPrompt] = React.useState(null);
  const runtimesRef = React.useRef(new Map());
  const connectQueueRef = React.useRef(new Set());
  const restoredRef = React.useRef(false);
  const persistTimerRef = React.useRef(null);
  const logFlushTimersRef = React.useRef(new Map());
  const hadSessionsRef = React.useRef(false);
  const sessionsRef = React.useRef(sessions);
  const activeIdRef = React.useRef(activeId);

  React.useEffect(() => {
    sessionsRef.current = sessions;
    activeIdRef.current = activeId;
    if (sessions.length > 0) hadSessionsRef.current = true;
  }, [sessions, activeId]);

  const flushRuntimeLog = React.useCallback((sessionId) => {
    const runtime = runtimesRef.current.get(sessionId);
    if (!runtime?.pendingLog) return;
    appendSessionLog(sessionId, runtime.pendingLog);
    runtime.pendingLog = "";
    clearTimeout(logFlushTimersRef.current.get(sessionId));
    logFlushTimersRef.current.delete(sessionId);
  }, []);

  const appendRuntimeLog = React.useCallback(
    (sessionId, chunk) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime || !chunk) return;
      runtime.pendingLog = (runtime.pendingLog ?? "") + chunk;
      if (logFlushTimersRef.current.has(sessionId)) return;
      logFlushTimersRef.current.set(
        sessionId,
        setTimeout(() => flushRuntimeLog(sessionId), 2000),
      );
    },
    [flushRuntimeLog],
  );

  const persistNow = React.useCallback((captureBuffers = false) => {
    const currentSessions = sessionsRef.current;
    if (currentSessions.length === 0) return;
    for (const session of currentSessions) {
      flushRuntimeLog(session.id);
    }
    savePersistedState(currentSessions, activeIdRef.current, runtimesRef, {
      captureBuffers,
    });
  }, [flushRuntimeLog]);

  const schedulePersist = React.useCallback(
    (captureBuffers = false) => {
      clearTimeout(persistTimerRef.current);
      persistTimerRef.current = setTimeout(
        () => persistNow(captureBuffers),
        captureBuffers ? 0 : 3000,
      );
    },
    [persistNow],
  );

  const closeSession = React.useCallback(
    (id) => {
      flushRuntimeLog(id);
      
      const runtime = runtimesRef.current.get(id);
      if (runtime) {
        runtime.term.dispose();
        runtimesRef.current.delete(id);
      }
      connectQueueRef.current.delete(id);
      finalizeSessionRecord(id, "closed");
      invoke("ssh_disconnect", { sessionId: id }).catch(() => {});

      const filteredSessions = sessionsRef.current.filter((s) => s.id !== id);
      const nextActiveId = activeIdRef.current === id 
        ? (filteredSessions.length ? filteredSessions[filteredSessions.length - 1].id : null)
        : activeIdRef.current;

      // Persist the filtered sessions list immediately!
      if (filteredSessions.length === 0) {
        clearPersistedState();
      } else {
        savePersistedState(filteredSessions, nextActiveId, runtimesRef, {
          captureBuffers: true,
        });
      }

      setSessions(filteredSessions);
      setActiveId(nextActiveId);
      setAuthPrompt((prev) => (prev?.sessionId === id ? null : prev));
    },
    [flushRuntimeLog],
  );

  const updateSession = React.useCallback((id, patch) => {
    setSessions((prev) =>
      prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
    );
    if (patch.status) {
      updateSessionRecord(id, { status: patch.status });
    }
  }, []);

  const refreshTerminal = React.useCallback((sessionId) => {
    const runtime = runtimesRef.current.get(sessionId);
    if (!runtime?.term || !runtime?.fit) return;

    const mountEl = runtime.mountEl;
    if (!mountEl || !mountEl.isConnected) return;

    try {
      runtime.fit.fit();
      runtime.term.refresh(0, Math.max(runtime.term.rows - 1, 0));
    } catch {
      /* ignore fit errors during layout */
    }
  }, []);

  const connectSession = React.useCallback(
    async (sessionId, secrets = {}) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime) return;

      updateSession(sessionId, {
        status: "connecting",
        stage: "init",
        stageMessage: "Starting connection...",
        authType: null,
      });

      if (secrets && Object.keys(secrets).length > 0) {
        runtime.credentials = { ...runtime.credentials, ...secrets };
      }

      runtime.userExited = false;

      if (runtime.channel) {
        runtime.channel.onmessage = null;
      }

      const channel = new Channel();
      runtime.channel = channel;

      channel.onmessage = (event) => {
        switch (event.type) {
          case "status": {
            const line = `\r[${event.stage}] ${event.message}\r\n`;
            updateSession(sessionId, {
              stage: event.stage,
              stageMessage: event.message,
            });
            writeStatusLine(runtime.term, event.stage, event.message);
            appendRuntimeLog(sessionId, line);
            break;
          }
          case "connected":
            runtime.reconnectCount = 0;
            updateSession(sessionId, {
              status: "connected",
              stage: "connected",
              stageMessage: "Connected",
              authType: null,
            });
            appendRuntimeLog(sessionId, "\r\n── Connected ──\r\n");
            if (runtime.pendingCredentialSave) {
              const { type, value, hostId, keyId } = runtime.pendingCredentialSave;
              delete runtime.pendingCredentialSave;
              if (type === "password") {
                invoke("save_host_password", { hostId, password: value }).catch(
                  () => {},
                );
                runtime.host = { ...runtime.host, password: value };
              } else if (keyId) {
                invoke("save_key_passphrase", {
                  keyId,
                  passphrase: value,
                }).catch(() => {});
              }
            }
            break;
          case "data": {
            const bytes = new Uint8Array(event.bytes);
            runtime.term.write(bytes);
            appendRuntimeLog(
              sessionId,
              new TextDecoder().decode(bytes, { stream: true }),
            );
            break;
          }
          case "closed": {
            handleDisconnect(sessionId, event.message || "Connection closed");
            break;
          }
          case "error": {
            handleDisconnect(sessionId, event.message || "Connection error");
            break;
          }
          case "needPassword":
            updateSession(sessionId, {
              status: "auth-required",
              authType: "password",
            });
            setAuthPrompt({ sessionId, type: "password" });
            break;
          case "needPassphrase":
            updateSession(sessionId, {
              status: "auth-required",
              authType: "passphrase",
            });
            setAuthPrompt({ sessionId, type: "passphrase" });
            break;
          default:
            break;
        }
      };

      await yieldToUi();

      try {
        refreshTerminal(sessionId);
        const connectionSecrets = {
          ...runtime.credentials,
          ...secrets
        };
        await invoke("ssh_connect", {
          sessionId,
          hostId: runtime.host.id,
          cols: runtime.term.cols,
          rows: runtime.term.rows,
          password: connectionSecrets.password ?? null,
          passphrase: connectionSecrets.passphrase ?? null,
          onEvent: channel,
        });
      } catch (err) {
        const message = String(err);
        if (!message.includes("Couldn't find callback id")) {
          updateSession(sessionId, {
            status: "error",
            stageMessage: message,
          });
          runtime.term.writeln(`\r\n\x1b[31mError: ${message}\x1b[0m`);
          appendRuntimeLog(sessionId, `\r\nError: ${message}\r\n`);
        }
      }
    },
    [updateSession, refreshTerminal, appendRuntimeLog, flushRuntimeLog, closeSession],
  );

  const attemptReconnect = React.useCallback(
    async (sessionId) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime) return;

      const exists = sessionsRef.current.some((s) => s.id === sessionId);
      if (!exists) return;

      const session = sessionsRef.current.find((s) => s.id === sessionId);
      if (session && (session.status === "connecting" || session.status === "connected")) {
        return;
      }

      if (!runtime.reconnectCount) {
        runtime.reconnectCount = 0;
      }
      runtime.reconnectCount++;

      if (runtime.reconnectCount > 5) {
        updateSession(sessionId, {
          status: "error",
          stageMessage: "Reconnection failed after 5 attempts.",
        });
        runtime.term.writeln(`\r\n\x1b[31m[Reconnection failed after 5 attempts. Focus tab to try again.]\x1b[0m\r\n`);
        runtime.reconnectCount = 0;
        return;
      }

      const delay = Math.min(1000 * Math.pow(2, runtime.reconnectCount - 1), 10000);
      
      setTimeout(async () => {
        const stillExists = sessionsRef.current.some((s) => s.id === sessionId);
        if (!stillExists) return;

        const currentSession = sessionsRef.current.find((s) => s.id === sessionId);
        if (currentSession && (currentSession.status === "connecting" || currentSession.status === "connected")) {
          return;
        }

        runtime.term.writeln(`\r\x1b[90m── Reconnecting (Attempt ${runtime.reconnectCount}/5) ──\x1b[0m`);
        try {
          await connectSession(sessionId);
        } catch {
          // connectSession catches internally
        }
      }, delay);
    },
    [connectSession, updateSession],
  );

  const handleDisconnect = React.useCallback(
    (sessionId, reason) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime) return;

      if (
        runtime.userExited ||
        (reason &&
          (reason.includes("Process exited") ||
            reason.includes("exit") ||
            reason.includes("Closed by application") ||
            reason.includes("Disconnected by application")))
      ) {
        closeSession(sessionId);
        return;
      }

      const session = sessionsRef.current.find((s) => s.id === sessionId);
      if (session && session.status === "disconnected") {
        return;
      }

      updateSession(sessionId, {
        status: "disconnected",
        stageMessage: `Disconnected: ${reason}`,
      });

      runtime.term.writeln(`\r\n\x1b[33m[Connection lost: ${reason}]\x1b[0m`);
      runtime.term.writeln(`\x1b[36m[Attempting to reconnect...]\x1b[0m\r\n`);

      attemptReconnect(sessionId);
    },
    [closeSession, updateSession, attemptReconnect],
  );

  const checkConnectionAlive = React.useCallback(
    async (sessionId) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime) return;

      const session = sessionsRef.current.find((s) => s.id === sessionId);
      if (!session) return;

      if (session.status === "disconnected" || session.status === "error") {
        runtime.reconnectCount = 0;
        runtime.term.writeln(`\r\n\x1b[36m[Focus detected: reconnecting...]\x1b[0m\r\n`);
        attemptReconnect(sessionId);
        return;
      }

      if (session.status !== "connected") return;

      try {
        const isAlive = await invoke("ssh_is_alive", { sessionId });
        if (!isAlive) {
          handleDisconnect(sessionId, "Connection timeout / closed");
        }
      } catch {
        handleDisconnect(sessionId, "Connection check failed");
      }
    },
    [handleDisconnect, attemptReconnect],
  );

  const attachTerminal = React.useCallback(
    (sessionId, element) => {
      const runtime = runtimesRef.current.get(sessionId);
      if (!runtime || !element) return;

      const detached =
        runtime.opened &&
        runtime.mountEl &&
        (!runtime.term.element?.isConnected ||
          runtime.term.element.parentElement !== element);

      if (detached) {
        runtime.savedScrollback = captureScrollback(runtime.term);
        runtime.term.dispose();
        const { term, fit } = createTerminalInstance(sessionId, runtimesRef, (data) =>
          appendRuntimeLog(sessionId, data),
        );
        runtime.term = term;
        runtime.fit = fit;
        runtime.opened = false;
      }

      runtime.mountEl = element;

      if (!runtime.opened) {
        runtime.term.open(element);
        runtime.opened = true;
        const scrollback =
          runtime.savedScrollback || getSessionLog(sessionId)?.log || "";
        if (scrollback) {
          runtime.term.write(scrollback);
          runtime.savedScrollback = null;
        }
        if (connectQueueRef.current.has(sessionId)) {
          connectQueueRef.current.delete(sessionId);
          connectSession(sessionId);
        }
      }

      refreshTerminal(sessionId);
      if (sessionId === activeId) {
        runtime.term.focus();
      }
    },
    [connectSession, activeId, refreshTerminal, appendRuntimeLog],
  );

  const registerRuntime = React.useCallback(
    (id, host, meta, scrollback = "", append = true, createHistory = true) => {
      const { term, fit } = createTerminalInstance(id, runtimesRef, (data) =>
        appendRuntimeLog(id, data),
      );

      term.attachCustomKeyEventHandler((e) => {
        const isMac = navigator.userAgent.toLowerCase().includes("mac");
        const isMod = isMac ? e.metaKey : e.ctrlKey;
        const isW = e.key === "w" || e.key === "W" || e.keyCode === 87;
        const isC = e.key === "c" || e.key === "C" || e.keyCode === 67;
        const isV = e.key === "v" || e.key === "V" || e.keyCode === 86;
        const isF = e.key === "f" || e.key === "F" || e.keyCode === 70;

        if (e.type === "keydown") {
          // --- macOS Shell Key Bindings (Cmd + Backspace, Cmd + Arrows, Option + Arrows) ---
          if (isMac) {
            // Cmd + Backspace: remove complete word (Ctrl+W)
            if (e.metaKey && (e.key === "Backspace" || e.keyCode === 8)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x17"), // Send Ctrl+W
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }

            // Option + Backspace: remove complete word (Ctrl+W)
            if (e.altKey && (e.key === "Backspace" || e.keyCode === 8)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x17"), // Send Ctrl+W
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }

            // Cmd + Left Arrow: move cursor to beginning of line (Ctrl+A)
            if (e.metaKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x01"), // Send Ctrl+A
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }

            // Cmd + Right Arrow: move cursor to end of line (Ctrl+E)
            if (e.metaKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x05"), // Send Ctrl+E
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }

            // Option + Left Arrow: move cursor one word backward (ESC + b)
            if (e.altKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x1bb"), // Send ESC + b
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }

            // Option + Right Arrow: move cursor one word forward (ESC + f)
            if (e.altKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x1bf"), // Send ESC + f
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }
          } else {
            // Windows/Linux equivalents
            // Ctrl + Backspace: remove complete word (Ctrl+W)
            if (e.ctrlKey && (e.key === "Backspace" || e.keyCode === 8)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x17"), // Send Ctrl+W
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }
            // Ctrl + Left Arrow: move cursor one word backward (ESC + b)
            if (e.ctrlKey && (e.key === "ArrowLeft" || e.keyCode === 37)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x1bb"), // Send ESC + b
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }
            // Ctrl + Right Arrow: move cursor one word forward (ESC + f)
            if (e.ctrlKey && (e.key === "ArrowRight" || e.keyCode === 39)) {
              invoke("ssh_write", {
                sessionId: id,
                data: new TextEncoder().encode("\x1bf"), // Send ESC + f
              }).catch(() => {});
              e.preventDefault();
              e.stopPropagation();
              return false;
            }
          }

          // Ctrl+W or Cmd+W (Close Tab)
          if ((e.ctrlKey || e.metaKey) && isW) {
            closeSession(id);
            e.preventDefault();
            e.stopPropagation();
            return false;
          }

          // Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux if selection exists)
          if (isMod && isC) {
            if (isMac || term.hasSelection()) {
              if (term.hasSelection()) {
                navigator.clipboard.writeText(term.getSelection());
              }
              e.preventDefault();
              e.stopPropagation();
              return false;
            }
          }

          // Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux)
          if (isMod && isV) {
            navigator.clipboard.readText().then((text) => {
              if (text) {
                invoke("ssh_write", {
                  sessionId: id,
                  data: new TextEncoder().encode(text),
                }).catch(() => {});
              }
            });
            e.preventDefault();
            e.stopPropagation();
            return false;
          }

          // Find: Cmd+F (Mac) or Ctrl+F (Windows/Linux)
          if (isMod && isF) {
            window.dispatchEvent(
              new CustomEvent("toggle-terminal-search", {
                detail: { sessionId: id },
              }),
            );
            e.preventDefault();
            e.stopPropagation();
            return false;
          }
        }
        return true;
      });

      runtimesRef.current.set(id, {
        term,
        fit,
        host,
        channel: null,
        opened: false,
        mountEl: null,
        savedScrollback: scrollback,
        pendingLog: "",
      });

      if (createHistory) {
        createSessionRecord({ id, host });
      }

      const session = {
        id,
        host,
        title: meta.title ?? host.name,
        status: meta.status ?? "disconnected",
        stage: meta.stage ?? "restored",
        stageMessage:
          meta.stageMessage ?? "Session restored — reconnect to continue",
        authType: null,
      };

      if (append) {
        setSessions((prev) => [...prev, session]);
      }
      return session;
    },
    [appendRuntimeLog, closeSession],
  );

  const openSession = React.useCallback(
    (host) => {
      const id = `session-${host.id}-${Date.now()}`;
      registerRuntime(id, host, {
        title: host.name,
        status: "connecting",
        stage: "init",
        stageMessage: "Waiting for terminal...",
      });
      setActiveId(id);
      connectQueueRef.current.add(id);
      return id;
    },
    [registerRuntime],
  );

  const setActive = React.useCallback(
    (id) => {
      setActiveId(id);
      if (!id) return;

      requestAnimationFrame(() => {
        refreshTerminal(id);
        requestAnimationFrame(() => {
          refreshTerminal(id);
          runtimesRef.current.get(id)?.term.focus();
          checkConnectionAlive(id);
        });
      });
    },
    [refreshTerminal, checkConnectionAlive],
  );


  const reconnect = React.useCallback(
    (id) => {
      const runtime = runtimesRef.current.get(id);
      if (!runtime) return;
      const line = "\r── Reconnecting ──\r\n";
      runtime.term.writeln("\r\x1b[90m── Reconnecting ──\x1b[0m");
      appendRuntimeLog(id, line);
      setAuthPrompt((prev) => (prev?.sessionId === id ? null : prev));
      connectSession(id);
    },
    [connectSession, appendRuntimeLog],
  );

  const disposeRuntimes = React.useCallback(() => {
    clearTimeout(persistTimerRef.current);
    const currentSessions = sessionsRef.current;
    const currentActiveId = activeIdRef.current;
    if (currentSessions.length > 0) {
      for (const session of currentSessions) {
        flushRuntimeLog(session.id);
      }
      savePersistedState(currentSessions, currentActiveId, runtimesRef, {
        captureBuffers: true,
      });
    }
    for (const id of [...runtimesRef.current.keys()]) {
      invoke("ssh_disconnect", { sessionId: id }).catch(() => {});
      runtimesRef.current.get(id)?.term.dispose();
    }
    runtimesRef.current.clear();
    connectQueueRef.current.clear();
    setSessions((prev) => (prev.length === 0 ? prev : []));
    setActiveId((prev) => (prev === null ? prev : null));
    setAuthPrompt((prev) => (prev === null ? prev : null));
    restoredRef.current = false;
  }, [flushRuntimeLog]);

  const closeAll = React.useCallback(() => {
    for (const session of sessionsRef.current) {
      flushRuntimeLog(session.id);
      finalizeSessionRecord(session.id, "closed");
    }
    disposeRuntimes();
    clearPersistedState();
  }, [disposeRuntimes, flushRuntimeLog]);

  const failSession = React.useCallback(
    (sessionId, message) => {
      invoke("ssh_disconnect", { sessionId }).catch(() => {});
      const runtime = runtimesRef.current.get(sessionId);
      updateSession(sessionId, {
        status: "error",
        stageMessage: message,
        authType: null,
      });
      if (runtime?.term) {
        runtime.term.writeln(`\r\n\x1b[31m${message}\x1b[0m`);
      }
      appendRuntimeLog(sessionId, `\r\n${message}\r\n`);
      flushRuntimeLog(sessionId);
    },
    [updateSession, appendRuntimeLog, flushRuntimeLog],
  );

  const cancelAuth = React.useCallback(() => {
    if (!authPrompt) return;
    const { sessionId } = authPrompt;
    setAuthPrompt(null);
    failSession(sessionId, "Connection canceled by user");
  }, [authPrompt, failSession]);

  const submitAuth = React.useCallback(
    async (value, savePassphrase = false) => {
      if (!authPrompt) return;
      const { sessionId, type } = authPrompt;

      if (!value?.trim()) {
        failSession(sessionId, "Connection error: credentials are required");
        setAuthPrompt(null);
        return;
      }

      setAuthPrompt(null);

      if (savePassphrase) {
        const runtime = runtimesRef.current.get(sessionId);
        if (runtime) {
          runtime.pendingCredentialSave = {
            type,
            value,
            hostId: runtime.host.id,
            keyId: runtime.host.key_id ?? null,
          };
        }
      }

      await connectSession(
        sessionId,
        type === "password" ? { password: value } : { passphrase: value },
      );
    },
    [authPrompt, connectSession, failSession],
  );

  React.useEffect(() => {
    if (!unlocked) {
      disposeRuntimes();
      return;
    }

    if (restoredRef.current || runtimesRef.current.size > 0) return;

    const saved = loadPersistedState();
    if (!saved?.sessions?.length) return;

    const restoredSessions = saved.sessions.map((s) => {
      const scrollback =
        saved.buffers?.[s.id] ?? getSessionLog(s.id)?.log ?? "";
      registerRuntime(
        s.id,
        s.host,
        {
          title: s.title,
          status: "disconnected",
          stage: "restored",
          stageMessage: "Restoring session...",
        },
        scrollback,
        false,
        false,
      );
      if (s.wasConnected) {
        connectQueueRef.current.add(s.id);
      }
      return {
        id: s.id,
        host: s.host,
        title: s.title,
        status: s.wasConnected ? "connecting" : "disconnected",
        stage: "restored",
        stageMessage: s.wasConnected
          ? "Reconnecting..."
          : "Session restored — click Reconnect to continue",
        authType: null,
      };
    });

    setSessions(restoredSessions);
    restoredRef.current = true;
    if (saved.activeId) {
      setActiveId(saved.activeId);
    }
  }, [unlocked, disposeRuntimes, registerRuntime]);

  React.useEffect(() => {
    if (!unlocked) return;
    if (sessions.length === 0) {
      if (hadSessionsRef.current) {
        clearPersistedState();
        hadSessionsRef.current = false;
      }
      return;
    }
    schedulePersist(false);
    return () => clearTimeout(persistTimerRef.current);
  }, [sessions, activeId, unlocked, schedulePersist]);

  React.useEffect(() => {
    if (!unlocked) return;
    const persist = () => persistNow(true);
    const disconnect = () => {
      for (const id of runtimesRef.current.keys()) {
        invoke("ssh_disconnect", { sessionId: id }).catch(() => {});
      }
    };
    window.addEventListener("beforeunload", persist);
    window.addEventListener("beforeunload", disconnect);
    return () => {
      window.removeEventListener("beforeunload", persist);
      window.removeEventListener("beforeunload", disconnect);
    };
  }, [unlocked, persistNow]);

  React.useEffect(() => {
    const observer = new MutationObserver(() => {
      for (const runtime of runtimesRef.current.values()) {
        runtime.term.options.theme = getXtermTheme();
      }
    });
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });
    return () => observer.disconnect();
  }, []);

  React.useEffect(() => {
    const handleWindowFocus = () => {
      const activeId = activeIdRef.current;
      if (activeId) {
        checkConnectionAlive(activeId);
      }
    };
    window.addEventListener("focus", handleWindowFocus);
    return () => {
      window.removeEventListener("focus", handleWindowFocus);
    };
  }, [checkConnectionAlive]);

  const findInTerminal = React.useCallback((sessionId, query, direction = "next") => {
    const runtime = runtimesRef.current.get(sessionId);
    if (!runtime?.term?._searchAddon) return;

    const isDark = document.documentElement.classList.contains("dark");
    const options = {
      incremental: direction === "next",
      decorations: {
        matchBackground: isDark ? "#a16207" : "#fef08a",
        matchBorder: isDark ? "#eab308" : "#ca8a04",
        activeMatchBackground: isDark ? "#15803d" : "#bbf7d0",
        activeMatchBorder: isDark ? "#22c55e" : "#16a34a",
      }
    };

    if (direction === "next") {
      runtime.term._searchAddon.findNext(query, options);
    } else {
      runtime.term._searchAddon.findPrevious(query, options);
    }
  }, []);

  const focusTerminal = React.useCallback((sessionId) => {
    runtimesRef.current.get(sessionId)?.term.focus();
  }, []);

  const value = React.useMemo(
    () => ({
      sessions,
      activeId,
      openSession,
      setActive,
      closeSession,
      reconnect,
      closeAll,
      attachTerminal,
      refreshTerminal,
      authPrompt,
      submitAuth,
      cancelAuth,
      findInTerminal,
      focusTerminal,
      checkConnectionAlive,
    }),
    [
      sessions,
      activeId,
      openSession,
      setActive,
      closeSession,
      reconnect,
      closeAll,
      attachTerminal,
      refreshTerminal,
      authPrompt,
      submitAuth,
      cancelAuth,
      findInTerminal,
      focusTerminal,
      checkConnectionAlive,
    ],
  );

  return (
    <TerminalContext.Provider value={value}>
      {children}
    </TerminalContext.Provider>
  );
}