41d24beade
- Track application bracketed-paste mode from terminal output. - Capture browser and mobile clipboard pastes only while that mode is enabled, normalizing line endings and sending a single BPM-framed input event. - Remove the synchronous output decoding and console diagnostics that could stall terminal rendering under output load. - Keep the terminal's zero-scrollback configuration and resolve existing no-case-declarations lint blockers in terminal keyboard shortcuts. Quality gates: npm run typecheck, npm run lint
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
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 changeFontSizeRefs = useRef<
|
|
Record<string, ((delta: number) => void) | null>
|
|
>({});
|
|
const sendDataRefs = useRef<Record<string, ((data: string) => void) | null>>(
|
|
{},
|
|
);
|
|
const focusInputRefs = useRef<Record<string, (() => void) | 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 [instanceInfo, setInstanceInfo] = useState<{
|
|
display_name: string;
|
|
workspace_name?: string | null;
|
|
tool_type_name: string;
|
|
} | null>(null);
|
|
|
|
const {
|
|
sessions,
|
|
activeSessionId,
|
|
setActiveSessionId,
|
|
createSession,
|
|
closeSession,
|
|
renameSession,
|
|
resetSession,
|
|
loading,
|
|
error,
|
|
} = useTerminalSessions(instanceId ?? "");
|
|
|
|
// Fetch instance details for tab title
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
const load = async () => {
|
|
try {
|
|
const { getUserSessions } = await import("../api/sessions");
|
|
const allSessions = await getUserSessions();
|
|
const match = allSessions.find((s) => s.id === instanceId);
|
|
if (match) {
|
|
setInstanceInfo({
|
|
display_name: match.display_name,
|
|
workspace_name: match.workspace_name,
|
|
tool_type_name: match.tool_type_name,
|
|
});
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
void load();
|
|
}, [instanceId]);
|
|
|
|
// Auto-create default session
|
|
useEffect(() => {
|
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
|
void createSession("Session 1");
|
|
}
|
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
|
|
|
// Update document title based on active terminal session
|
|
useEffect(() => {
|
|
if (!instanceId) {
|
|
document.title = "Terminal";
|
|
return;
|
|
}
|
|
const active = sessions.find((s) => s.id === activeSessionId);
|
|
const baseName = instanceInfo
|
|
? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}`
|
|
: `Instance ${instanceId.slice(0, 8)}`;
|
|
if (sessions.length <= 1) {
|
|
document.title = baseName;
|
|
} else {
|
|
const sessionName = active?.name ?? "Session";
|
|
document.title = `${baseName} ${sessionName}`;
|
|
}
|
|
return () => {
|
|
document.title = "Headquarter";
|
|
};
|
|
}, [instanceId, activeSessionId, sessions, instanceInfo]);
|
|
|
|
// 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];
|
|
delete sendDataRefs.current[id];
|
|
delete focusInputRefs.current[id];
|
|
delete changeFontSizeRefs.current[id];
|
|
}
|
|
}
|
|
setTerminalStatuses((prev) => {
|
|
const next: Record<string, TerminalStatus> = {};
|
|
for (const id of currentIds) {
|
|
if (prev[id]) {
|
|
next[id] = prev[id];
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, [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;
|
|
|
|
const activeSessionIndex = activeSessionId
|
|
? sessions.findIndex((session) => session.id === activeSessionId)
|
|
: -1;
|
|
|
|
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 (activeSessionIndex > 0) {
|
|
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
|
}
|
|
break;
|
|
case "arrowright":
|
|
e.preventDefault();
|
|
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
|
setActiveSessionId(sessions[activeSessionIndex + 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
|
|
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(
|
|
(
|
|
sessionId: string | undefined,
|
|
sendData: (data: string) => void,
|
|
status: TerminalStatus,
|
|
focusInput: () => void,
|
|
changeFontSize: (delta: number) => void,
|
|
) => {
|
|
const key = sessionId ?? "default";
|
|
setTerminalStatuses((prev) => ({
|
|
...prev,
|
|
[key]: status,
|
|
}));
|
|
sendDataRefs.current[key] = sendData;
|
|
focusInputRefs.current[key] = focusInput;
|
|
changeFontSizeRefs.current[key] = changeFontSize;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFontSizeChange = useCallback(
|
|
(delta: number) => {
|
|
const key = activeSessionId ?? "default";
|
|
changeFontSizeRefs.current[key]?.(delta);
|
|
},
|
|
[activeSessionId],
|
|
);
|
|
|
|
const handleSendKey = useCallback(
|
|
(data: string) => {
|
|
const key = activeSessionId ?? "default";
|
|
sendDataRefs.current[key]?.(data);
|
|
},
|
|
[activeSessionId],
|
|
);
|
|
|
|
const handleReset = useCallback(() => {
|
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
|
terminalRefs.current[activeSessionId].current?.reset();
|
|
}
|
|
}, [activeSessionId]);
|
|
|
|
return {
|
|
instanceId,
|
|
navigate,
|
|
isMobile,
|
|
isFullscreen,
|
|
setIsFullscreen,
|
|
terminalRefs,
|
|
headerAutoHide,
|
|
terminalStatuses,
|
|
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),
|
|
};
|
|
};
|