refactor: extract TerminalPage components

- Extract use-terminal-page hook for terminal state and effects
- Extract MobileTerminalView and DesktopTerminalView components
- Slim TerminalPage from 571 to 112 lines

Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
Developer
2026-06-05 19:17:04 +00:00
parent d7d5baa41a
commit e7f219f7c3
4 changed files with 753 additions and 564 deletions
@@ -0,0 +1,198 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
isFullscreen: boolean;
status: string;
error: string | null;
loading: boolean;
showResetConfirm: boolean;
onFullscreenClick: (e: React.MouseEvent<HTMLElement>) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onNavigateBack: () => void;
onToggleFullscreen: () => void;
onFontSizeChange: (delta: number) => void;
onShowResetConfirm: () => void;
onHideResetConfirm: () => void;
onReset: () => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
export const DesktopTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
isFullscreen,
status,
error,
loading,
showResetConfirm,
onFullscreenClick,
onSelect,
onClose,
onCreate,
onRename,
onNavigateBack,
onToggleFullscreen,
onFontSizeChange,
onShowResetConfirm,
onHideResetConfirm,
onReset,
onTerminalReady,
}) => {
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
onClick={onFullscreenClick}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button className="secondary-button" onClick={onNavigateBack} type="button">
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={onToggleFullscreen}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={onShowResetConfirm}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={onToggleFullscreen}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={onHideResetConfirm}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
onHideResetConfirm();
onReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
@@ -0,0 +1,161 @@
import React from "react";
import { TerminalComponent, type TerminalRef } from "./terminal";
import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs";
import { Icon } from "../../icon";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import type { ModifierKey } from "../../../hooks/use-special-keys";
import type { TerminalSession } from "../../../api/terminal";
interface Props {
instanceId: string;
sessions: TerminalSession[];
sessionInfos: TerminalSessionInfo[];
activeSessionId: string;
terminalRefs: React.MutableRefObject<Record<string, React.RefObject<TerminalRef>>>;
status: string;
error: string | null;
loading: boolean;
isKeyboardOpen: boolean;
keyboardHeight: number;
isVisible: boolean;
activeModifier: ModifierKey | null;
showSpecialKeysPanel: boolean;
onToggleHeader: () => void;
onNavigateBack: () => void;
onFontSizeChange: (delta: number) => void;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onTerminalReady: (
sendData: (data: string) => void,
status: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
onSendKey: (data: string) => void;
onModifierChange: (mod: ModifierKey | null) => void;
onShowSpecialKeys: () => void;
onHideSpecialKeys: () => void;
onKeepFocus: () => void;
}
export const MobileTerminalView: React.FC<Props> = ({
instanceId,
sessions,
sessionInfos,
activeSessionId,
terminalRefs,
status,
error,
loading,
isKeyboardOpen,
keyboardHeight,
isVisible,
activeModifier,
showSpecialKeysPanel,
onToggleHeader,
onNavigateBack,
onFontSizeChange,
onSelect,
onClose,
onCreate,
onRename,
onTerminalReady,
onSendKey,
onModifierChange,
onShowSpecialKeys,
onHideSpecialKeys,
onKeepFocus,
}) => {
const activeSession = sessions.find((s) => s.id === activeSessionId);
return (
<section className="terminal-page mobile">
<div className={`mobile-terminal-overlay ${isVisible ? "visible" : "hidden"}`} onClick={(e) => e.stopPropagation()}>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Back">
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">{activeSession?.name || "Terminal"}</span>
<span className={`mobile-terminal-status status-dot ${status}`} aria-label={`Connection status: ${status}`} />
</div>
<div className="mobile-terminal-toolbar-right">
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(-1)} type="button" aria-label="Decrease font size">
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={() => onFontSizeChange(1)} type="button" aria-label="Increase font size">
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button className="mobile-terminal-toolbtn" onClick={onNavigateBack} type="button" aria-label="Exit terminal">
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId}
onSelect={onSelect}
onClose={onClose}
onCreate={onCreate}
onRename={onRename}
isMobile={true}
/>
</div>
</div>
<div
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={onToggleHeader}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => onClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
onTerminalReady={onTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
<SpecialKeysStrip
onSend={onSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={onShowSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
<SpecialKeysPanel
onSend={onSendKey}
isOpen={showSpecialKeysPanel}
onClose={onHideSpecialKeys}
onKeepFocus={onKeepFocus}
activeModifier={activeModifier}
onModifierChange={onModifierChange}
/>
</section>
);
};
+289
View File
@@ -0,0 +1,289 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { TerminalRef } from "../components/features/terminal/terminal";
import type { TerminalSessionInfo } from "../components/features/terminal/terminal-session-tabs";
import { useMobileViewport } from "./use-mobile-viewport";
import { useAutoHide } from "./use-auto-hide";
import { useVirtualKeyboard } from "./use-virtual-keyboard";
import { useTerminalSessions } from "./use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "./use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
export const useTerminalPage = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard();
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
// Auto-create default session
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
// Sync refs with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit and focus active terminal
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (activeSessionId && window.confirm("Close this terminal session?")) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) setActiveSessionId(sessions[idx + 1].id);
}
break;
case "r":
e.preventDefault();
if (activeSessionId) void resetSession(activeSessionId);
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void requestWakeLock();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) return;
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => setActiveSessionId(sessionId),
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => closeSession(sessionId),
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
return {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
sendDataRef,
focusInputRef,
changeFontSizeRef,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
setActiveSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos: SESSIONS_TO_INFO(sessions),
};
};
+105 -564
View File
@@ -1,571 +1,112 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent, type TerminalRef } from "../components/features/terminal/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/features/terminal/terminal-session-tabs";
import { Icon } from "../components/icon";
import { SpecialKeysStrip } from "../components/features/terminal/special-keys-strip";
import { SpecialKeysPanel } from "../components/features/terminal/special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "../hooks/use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus =
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting";
import React from "react";
import { useTerminalPage } from "../hooks/use-terminal-page";
import { MobileTerminalView } from "../components/features/terminal/MobileTerminalView";
import { DesktopTerminalView } from "../components/features/terminal/DesktopTerminalView";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const {
instanceId,
navigate,
isMobile,
isFullscreen,
setIsFullscreen,
terminalRefs,
headerAutoHide,
terminalStatuses,
showResetConfirm,
setShowResetConfirm,
showSpecialKeysPanel,
setShowSpecialKeysPanel,
activeModifier,
setActiveModifier,
isKeyboardOpen,
keyboardHeight,
sessions,
activeSessionId,
loading,
error,
handleFullscreenClick,
handleSelect,
handleClose,
handleCreate,
handleRename,
handleTerminalReady,
handleFontSizeChange,
handleSendKey,
handleReset,
sessionInfos,
} = useTerminalPage();
// Track terminal status and callbacks for unified fullscreen header
const [terminalStatuses, setTerminalStatuses] = useState<
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
// Auto-create default session if none exist after loading completes
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
if (isMobile) {
return (
<MobileTerminalView
instanceId={instanceId}
sessions={sessions}
sessionInfos={sessionInfos}
activeSessionId={activeSessionId ?? ""}
terminalRefs={terminalRefs}
status={status}
error={error}
loading={loading}
isKeyboardOpen={isKeyboardOpen}
keyboardHeight={keyboardHeight}
isVisible={headerAutoHide.isVisible}
activeModifier={activeModifier}
showSpecialKeysPanel={showSpecialKeysPanel}
onToggleHeader={headerAutoHide.toggle}
onNavigateBack={() => navigate("/sessions")}
onFontSizeChange={handleFontSizeChange}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
onTerminalReady={handleTerminalReady}
onSendKey={handleSendKey}
onModifierChange={setActiveModifier}
onShowSpecialKeys={() => setShowSpecialKeysPanel(true)}
onHideSpecialKeys={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => {
/* focus handled by ref */
}}
/>
);
}
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit and focus active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Double rAF ensures layout has settled after the display:block switch
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
ref.current?.fit();
ref.current?.focus();
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Keep screen awake while terminal is open
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// Wake lock may be denied; silently ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void requestWakeLock();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
// not the page.
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Click outside terminal content/header to exit fullscreen
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!isFullscreen) return;
const target = e.target as Node;
const current = e.currentTarget as HTMLElement;
const content = current.querySelector(".terminal-page-content");
const header = current.querySelector(".terminal-fullscreen-header");
if (content?.contains(target) || header?.contains(target)) {
return;
}
setIsFullscreen(false);
},
[isFullscreen],
);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
const handleTerminalReady = useCallback(
(
sendData: (data: string) => void,
status: TerminalStatus,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
const activeSession = sessions.find((s) => s.id === activeSessionId);
const status =
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
return (
<section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
>
{/* Overlay status bar — floats over terminal, never resizes it */}
<div
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate("/sessions")}
type="button"
aria-label="Back"
>
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">
{activeSession?.name || "Terminal"}
</span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div>
<div className="mobile-terminal-toolbar-right">
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate("/sessions")}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
</div>
{/* Terminal content — always fills full viewport */}
<div
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={() => headerAutoHide.toggle()}
>
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={() => setShowSpecialKeysPanel(true)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showSpecialKeysPanel}
onClose={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</section>
);
}
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
onClick={handleFullscreenClick}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
/>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={() => setIsFullscreen(false)}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
handleReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
return (
<DesktopTerminalView
instanceId={instanceId}
sessions={sessions}
sessionInfos={sessionInfos}
activeSessionId={activeSessionId ?? ""}
terminalRefs={terminalRefs}
isFullscreen={isFullscreen}
status={status}
error={error}
loading={loading}
showResetConfirm={showResetConfirm}
onFullscreenClick={handleFullscreenClick}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
onNavigateBack={() => navigate("/sessions")}
onToggleFullscreen={() => setIsFullscreen((p) => !p)}
onFontSizeChange={handleFontSizeChange}
onShowResetConfirm={() => setShowResetConfirm(true)}
onHideResetConfirm={() => setShowResetConfirm(false)}
onReset={handleReset}
onTerminalReady={handleTerminalReady}
/>
);
};