refactor terminal page into renderer, catalog, and session hooks
release / build (push) Successful in 4m16s
release / build (push) Successful in 4m16s
also bump beta cuz why not
This commit is contained in:
7 files changed
+1070
-819
No files matched your search
+7
-7
@@ -15,13 +15,13 @@
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xterm/addon-clipboard": "0.3.0-beta.290",
|
||||
"@xterm/addon-fit": "0.12.0-beta.290",
|
||||
"@xterm/addon-image": "0.10.0-beta.290",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.290",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.290",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.290",
|
||||
"@xterm/xterm": "6.1.0-beta.290",
|
||||
"@xterm/addon-clipboard": "0.3.0-beta.301",
|
||||
"@xterm/addon-fit": "0.12.0-beta.301",
|
||||
"@xterm/addon-image": "0.10.0-beta.301",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.301",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.301",
|
||||
"@xterm/xterm": "6.1.0-beta.301",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
|
||||
Generated
+47
-47
@@ -18,26 +18,26 @@ importers:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2([email protected](@types/[email protected])([email protected]))
|
||||
'@xterm/addon-clipboard':
|
||||
specifier: 0.3.0-beta.290
|
||||
version: 0.3.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.3.0-beta.301
|
||||
version: 0.3.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.290
|
||||
version: 0.12.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.12.0-beta.301
|
||||
version: 0.12.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/addon-image':
|
||||
specifier: 0.10.0-beta.290
|
||||
version: 0.10.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.10.0-beta.301
|
||||
version: 0.10.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/addon-unicode-graphemes':
|
||||
specifier: 0.5.0-beta.290
|
||||
version: 0.5.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.5.0-beta.301
|
||||
version: 0.5.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.290
|
||||
version: 0.10.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.10.0-beta.301
|
||||
version: 0.10.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.290
|
||||
version: 0.13.0-beta.290(@xterm/[email protected].290)
|
||||
specifier: 0.13.0-beta.301
|
||||
version: 0.13.0-beta.301(@xterm/[email protected].301)
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.290
|
||||
version: 6.1.0-beta.290
|
||||
specifier: 6.1.0-beta.301
|
||||
version: 6.1.0-beta.301
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -836,38 +836,38 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-ky44+eNKIxL9clMgrWCBRSRe1Y6Oj+ayNavySfFioScxpuyicu/GYaF7n97eqW3FW0eClElCtsK+wJ6AGqnJmw==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-mJ77s82VhARA8XhuWcUnWYKAZkEDzSnCzgBrT9qjHJlHWr+G5duDZcOBnxnVVNtp21LDKaONr3c7IxuLii1XNg==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-MukRyJLAFQrW/++aM209jBZ8G9J/Ioe4SGS6c3ba87v+zYuX/I66JrDB7sVfykQ9t7DlT+c1MWl1jTlX2kyx1w==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-gzQfH3tACsdf52n0lY3QMwLY7HcII1ce+IvzphWs9UucQIZ3rBKjEkYz4sxBhhEP1Eb6dNn1uerrfoJECmcyGg==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-PJZL4GlQ1ReIgbffFMaOAFxc3wOit21/G2UEM82RpH3hNHjn6MH8ilz3ToDeBjYuOqU7ienBaeIuMbNktX4/HA==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-c0hF4HJrbZUlb1cVXNldl6YRf+Udoe3nc80djNnxSzjHgmryT09DhL4U4IDcCqSkzgwMENnN65mexlYKyNXNBw==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-EBZIZYS6RItWTGO/KpLqV6y3IKGYyugbP07lK0x6G7185FVA61L2t0/XVtVEQNpczsbPo8zAxBhpoV72TP8CYw==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-tSmvf6hX1tT4RAgqbC8moZc/2oLR3qkaAL0xHaSMPd5bch2wkSvjfTxDfb3cfXJLyKCkWR+uwH54JYNOnCTW5g==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-SS5BzfbvfFIikE4xZFAtHu6n0Kl5R1ZCPx41RL2WULLSE+HFo/WmYee53tMxNK/QUjpcQWepeVB7HebCPhHnig==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-p0OL4dPrmETt+6sFUniSjiV2g5nV35ckipVZQv1uuZ8GKtHYvlDTJj4Fr+cHzrsNmDhceaaiIW6fMPynJtCm1w==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-BpY297K+FGLsTQemnA6uOg6hk85DwVuUvWCqE9rIAcDxOOC7fP9GKAZTIfaEos/EZk2Ei23vD6w0P5Br6+RU7Q==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.290
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected].290':
|
||||
resolution: {integrity: sha512-lbzQN1cx0h9uLUWQM7l6HxjP0qPMoqhc1ahlwmqgbSALpTeOUAQfMhMhHr0TSGRIcv7S4grvhhzQCb6teFrZ7Q==}
|
||||
'@xterm/[email protected].301':
|
||||
resolution: {integrity: sha512-y1pH42+Mx+h5E2xDgyilYXBKYkn8/6+anToiTmIt8gVww/2gHFqheK0vW+m9LADQsZ5AFs+SQvcK+tOyxWfRJA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
@@ -2862,31 +2862,31 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290(@xterm/[email protected].290)':
|
||||
'@xterm/[email protected].301(@xterm/[email protected].301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.290
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected].290': {}
|
||||
'@xterm/[email protected].301': {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
+60
-765
@@ -1,23 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
type Agent,
|
||||
type TerminalSession,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
listTerminals,
|
||||
} from "@/api";
|
||||
import type { Agent } from "@/api";
|
||||
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,24 +11,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
TerminalAccessoryBar,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalAccessoryBar } from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
mergeTerminalSession,
|
||||
orderTerminalSessions,
|
||||
reconcileTerminalSessions,
|
||||
restoreCandidates,
|
||||
visibleTerminalText,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
import { useTerminalSession } from "@/pages/terminal/useTerminalSession";
|
||||
import { useTerminalSessions } from "@/pages/terminal/useTerminalSessions";
|
||||
import { useXterm } from "@/pages/terminal/useXterm";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -51,11 +23,6 @@ type Props = {
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
type TerminalTouch = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
@@ -63,31 +30,7 @@ type TerminalTouch = {
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||
const thing = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
// Session storage survives route unmounts but remains scoped to this browser
|
||||
// tab. The ID coordinates attachment ownership; API authentication remains
|
||||
// the security boundary.
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? thing();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
@@ -96,34 +39,24 @@ export function TerminalPage({
|
||||
}: Props) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const modifiersRef = useRef<TerminalModifiers>(NO_TERMINAL_MODIFIERS);
|
||||
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||
const refreshSessionsRef = useRef<() => void>(() => {});
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
|
||||
const [terminalFontSize, setTerminalFontSize] = useState(
|
||||
DEFAULT_TERMINAL_FONT_SIZE,
|
||||
);
|
||||
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
const catalog = useTerminalSessions();
|
||||
const { sessions } = catalog;
|
||||
const terminal = useXterm({
|
||||
pageRef,
|
||||
hostRef,
|
||||
});
|
||||
const terminalSession = useTerminalSession({
|
||||
terminal: terminal.terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const clock = window.setInterval(
|
||||
@@ -133,9 +66,6 @@ export function TerminalPage({
|
||||
return () => window.clearInterval(clock);
|
||||
}, []);
|
||||
|
||||
activeSessionRef.current = session;
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
);
|
||||
@@ -151,89 +81,25 @@ export function TerminalPage({
|
||||
const canRequestStart = Boolean(
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
connection !== "connecting",
|
||||
terminalSession.connection !== "connecting" &&
|
||||
terminal.terminal.ready,
|
||||
);
|
||||
const terminalInputReady = connection === "ready";
|
||||
const terminalInputReady = terminalSession.connection === "ready";
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const activeModifiers = modifiersRef.current;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, activeModifiers)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (consumeModifiers && (activeModifiers.ctrl || activeModifiers.meta)) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback((modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const copyTerminalText = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
function start() {
|
||||
if (agentAtSessionLimit) {
|
||||
toast.error("Terminal session limit reached", {
|
||||
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
void terminalSession.start();
|
||||
}
|
||||
|
||||
const pasteTerminalText = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeTerminalData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [writeTerminalData]);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||
},
|
||||
[writeTerminalData],
|
||||
);
|
||||
|
||||
const toggleTerminalKeyboard = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
function toggleTerminalKeyboard() {
|
||||
const terminalInstance = terminal.terminal;
|
||||
const blurTerminalInput = () => {
|
||||
terminal.textarea?.blur();
|
||||
terminalInstance.blurInput();
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
@@ -248,583 +114,10 @@ export function TerminalPage({
|
||||
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||
if (shouldHide) {
|
||||
blurTerminalInput();
|
||||
// Base UI may restore the previously focused element as its pointer
|
||||
// interaction completes. Dismiss the soft keyboard after that cycle too.
|
||||
// Base UI may restore the previously focused element after pointer handling.
|
||||
window.requestAnimationFrame(blurTerminalInput);
|
||||
} else {
|
||||
terminal.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendResize = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
const socket = socketRef.current;
|
||||
if (!terminal || socket?.readyState !== WebSocket.OPEN) return;
|
||||
if (
|
||||
lastTerminalSizeRef.current.rows === terminal.rows &&
|
||||
lastTerminalSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastTerminalSizeRef.current = {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
};
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const changeTerminalFontSize = useCallback(
|
||||
(delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setTerminalFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
sendResize();
|
||||
});
|
||||
},
|
||||
[sendResize],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
setSessions((current) =>
|
||||
mergeTerminalSession(current, {
|
||||
...nextSession,
|
||||
operator_attached: true,
|
||||
}),
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
fitRef.current?.fit();
|
||||
sendResize();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
refreshSessionsRef.current();
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||
sendResize();
|
||||
terminalRef.current?.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminalRef.current?.writeln("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminalRef.current?.writeln("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === nextSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setConnection((current) => {
|
||||
if (current === "exited") return current;
|
||||
return "disconnected";
|
||||
});
|
||||
// The agent may have reaped or reported sessions while we were away.
|
||||
refreshSessionsRef.current();
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, sendResize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadTerminalFontFamily().then((fontFamily) => {
|
||||
if (!cancelled) setTerminalFontFamily(fontFamily);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !terminalFontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily: terminalFontFamily,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
// xterm uses this width for its internal scrollbar as well as the
|
||||
// overview ruler. Reserve a larger touch target on coarse pointers.
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true, // this for unicode11 addon. FOR SOME reason xtermjs doesnt tell me this in the readme.
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.loadAddon(
|
||||
new ImageAddon({
|
||||
// // Keep image terminals useful on phones without accepting the addon's
|
||||
// // much larger default decode and retained-canvas memory ceilings.
|
||||
// pixelLimit: 4 * 1024 * 1024,
|
||||
// sixelSizeLimit: 8 * 1024 * 1024,
|
||||
// iipSizeLimit: 8 * 1024 * 1024,
|
||||
// storageLimit: 32,
|
||||
}),
|
||||
);
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fit);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
|
||||
// Chromium reserves Ctrl+Shift+C for DevTools. Other clipboard shortcuts
|
||||
// fall through to xterm's native copy/paste event handlers.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeSessionRef.current?.terminal_id;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fit.fit();
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fit;
|
||||
|
||||
let cancelled = false;
|
||||
let refreshInFlight: Promise<TerminalSession[]> | null = null;
|
||||
let refreshRequested = false;
|
||||
const refreshSessions = (): Promise<TerminalSession[]> => {
|
||||
if (refreshInFlight) {
|
||||
refreshRequested = true;
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
let listed: TerminalSession[] = [];
|
||||
do {
|
||||
refreshRequested = false;
|
||||
listed = await listTerminals();
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
} while (refreshRequested && !cancelled);
|
||||
return listed;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
if (refreshRequested && !cancelled) {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await refreshSessions();
|
||||
if (cancelled) return;
|
||||
setSessions(orderTerminalSessions(listed));
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
listed,
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const input = terminal.onData((data) => writeTerminalData(data, true));
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fit.fit();
|
||||
sendResize();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
let eventsSocket: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let healthyTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(
|
||||
websocketUrl("/api/v1/control/terminals/events/ws"),
|
||||
);
|
||||
eventsSocket = socket;
|
||||
socket.onmessage = (event) => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data) as { type?: string };
|
||||
if (message.type === "sessions_changed") {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed event frames; the fallback refresh remains active.
|
||||
}
|
||||
};
|
||||
socket.onopen = () => {
|
||||
window.clearTimeout(healthyTimer);
|
||||
healthyTimer = window.setTimeout(() => {
|
||||
if (eventsSocket === socket && socket.readyState === WebSocket.OPEN) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
window.clearTimeout(healthyTimer);
|
||||
const ceiling = Math.min(500 * 2 ** reconnectAttempt, 12_000);
|
||||
const delay = Math.min(
|
||||
Math.round(ceiling * (0.8 + Math.random() * 0.4)),
|
||||
15_000,
|
||||
);
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 5);
|
||||
reconnectTimer = window.setTimeout(connectEvents, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
};
|
||||
connectEvents();
|
||||
void restoreTerminalSession();
|
||||
refreshSessionsRef.current = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}, 15_000);
|
||||
const refreshOnFocus = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
input.dispose();
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
window.clearInterval(refreshTimer);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearTimeout(healthyTimer);
|
||||
eventsSocket?.close();
|
||||
eventsSocket = null;
|
||||
refreshSessionsRef.current = () => {};
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
detachTransport();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [
|
||||
connect,
|
||||
detachTransport,
|
||||
sendResize,
|
||||
terminalFontFamily,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
async function start() {
|
||||
if (!selectedAgentId || !terminalRef.current) return;
|
||||
if (agentAtSessionLimit) {
|
||||
toast.error("Terminal session limit reached", {
|
||||
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession) {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === previousSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
setConnection("connecting");
|
||||
terminalRef.current.reset();
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
const created = await createTerminal(
|
||||
selectedAgentId,
|
||||
terminalRef.current.rows,
|
||||
terminalRef.current.cols,
|
||||
);
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function activateSession(nextSession: TerminalSession) {
|
||||
if (nextSession.terminal_id === session?.terminal_id) return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession) {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === previousSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("connecting");
|
||||
terminalRef.current?.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void listTerminals()
|
||||
.then((listed) =>
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
if (!session) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminalRef.current?.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(session.terminal_id, operatorId);
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSession(closingSession: TerminalSession) {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = sessions.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void listTerminals()
|
||||
.then((listed) =>
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== closingId),
|
||||
);
|
||||
setSessionTitles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[closingId];
|
||||
return next;
|
||||
});
|
||||
if (window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("idle");
|
||||
// xterm.clear() deliberately preserves the active cursor line. Closing
|
||||
// a session should discard its complete screen and terminal modes.
|
||||
terminalRef.current?.reset();
|
||||
if (fallback) void activateSession(fallback);
|
||||
terminalInstance.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,13 +141,13 @@ export function TerminalPage({
|
||||
)}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
disabled={connection === "connecting"}
|
||||
disabled={terminalSession.connection === "connecting"}
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!selectedAgent && connection === "idle" ? (
|
||||
{!selectedAgent && terminalSession.connection === "idle" ? (
|
||||
<p className="terminal-notice">
|
||||
No connected terminal-capable agent is selected.
|
||||
</p>
|
||||
@@ -869,33 +162,33 @@ export function TerminalPage({
|
||||
<TerminalSessionTabs
|
||||
agents={agents}
|
||||
sessions={sessions}
|
||||
activeTerminalId={session?.terminal_id}
|
||||
connection={connection}
|
||||
sessionTitles={sessionTitles}
|
||||
activeTerminalId={terminalSession.session?.terminal_id}
|
||||
connection={terminalSession.connection}
|
||||
sessionTitles={terminal.sessionTitles}
|
||||
nowUnix={nowUnix}
|
||||
canRequestStart={canRequestStart}
|
||||
agentAtSessionLimit={agentAtSessionLimit}
|
||||
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||
onActivate={(item) => void activateSession(item)}
|
||||
onClose={(item) => void closeSession(item)}
|
||||
onStart={() => void start()}
|
||||
onActivate={(item) => void terminalSession.activate(item)}
|
||||
onClose={(item) => void terminalSession.close(item)}
|
||||
onStart={start}
|
||||
/>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="terminal-status"
|
||||
data-state={connection}
|
||||
data-state={terminalSession.connection}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{connection}
|
||||
{terminalSession.connection}
|
||||
</Badge>
|
||||
{connection === "disconnected" ? (
|
||||
{terminalSession.connection === "disconnected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={reconnect}
|
||||
onClick={() => void terminalSession.reconnect()}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Reconnect
|
||||
@@ -928,9 +221,9 @@ export function TerminalPage({
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={connection === "idle"}
|
||||
disabled={terminalSession.connection === "idle"}
|
||||
aria-label="Clear terminal"
|
||||
onClick={() => terminalRef.current?.clear()}
|
||||
onClick={terminal.terminal.clear}
|
||||
>
|
||||
<Eraser className="size-4" aria-hidden />
|
||||
</Button>
|
||||
@@ -942,20 +235,22 @@ export function TerminalPage({
|
||||
{accessoriesOpen ? (
|
||||
<TerminalAccessoryBar
|
||||
inputReady={terminalInputReady}
|
||||
modifiers={modifiers}
|
||||
fontSize={terminalFontSize}
|
||||
modifiers={terminalSession.modifiers}
|
||||
fontSize={terminal.fontSize}
|
||||
onRememberInputFocus={() => {
|
||||
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||
hostRef.current?.contains(document.activeElement),
|
||||
);
|
||||
}}
|
||||
onToggleModifier={toggleModifier}
|
||||
onWriteKey={writeAccessoryKey}
|
||||
onScrollPages={(pages) => terminalRef.current?.scrollPages(pages)}
|
||||
onCopy={() => void copyTerminalText()}
|
||||
onPaste={() => void pasteTerminalText()}
|
||||
onToggleModifier={terminalSession.toggleModifier}
|
||||
onWriteKey={terminalSession.writeAccessoryKey}
|
||||
onScrollPages={terminal.terminal.scrollPages}
|
||||
onCopy={() => void terminal.terminal.copy()}
|
||||
onPaste={() =>
|
||||
void terminal.terminal.paste(terminalSession.writeTerminalData)
|
||||
}
|
||||
onToggleKeyboard={toggleTerminalKeyboard}
|
||||
onChangeFontSize={changeTerminalFontSize}
|
||||
onChangeFontSize={terminal.terminal.changeFontSize}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
@@ -963,7 +258,7 @@ export function TerminalPage({
|
||||
ref={hostRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") {
|
||||
terminalRef.current?.focus();
|
||||
terminal.terminal.focus();
|
||||
return;
|
||||
}
|
||||
terminalTouchRef.current = {
|
||||
@@ -992,7 +287,7 @@ export function TerminalPage({
|
||||
!touch.moved &&
|
||||
event.pointerType === "touch"
|
||||
) {
|
||||
terminalRef.current?.focus();
|
||||
terminal.terminal.focus();
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
type TerminalSession,
|
||||
} from "@/api";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
orderTerminalSessions,
|
||||
restoreCandidates,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import type { TerminalCatalog } from "@/pages/terminal/useTerminalSessions";
|
||||
import type { TerminalPort } from "@/pages/terminal/useXterm";
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
|
||||
// Fallback for browsers that do not implement crypto.randomUUID().
|
||||
const randomId = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? randomId();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
type Options = {
|
||||
terminal: TerminalPort;
|
||||
catalog: TerminalCatalog;
|
||||
selectedAgentId: string;
|
||||
};
|
||||
|
||||
export function useTerminalSession({
|
||||
terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
}: Options) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const catalogRef = useRef(catalog);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
catalogRef.current = catalog;
|
||||
activeSessionRef.current = session;
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, modifiersRef.current)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (
|
||||
consumeModifiers &&
|
||||
(modifiersRef.current.ctrl || modifiersRef.current.meta)
|
||||
) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const modifiersRef = useRef(modifiers);
|
||||
modifiersRef.current = modifiers;
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
terminal.setActiveTerminalId(nextSession.terminal_id);
|
||||
catalogRef.current.merge({ ...nextSession, operator_attached: true });
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminal.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
window.requestAnimationFrame(() => {
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message), true);
|
||||
terminal.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminal.writeLine("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminal.writeLine("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
catalogRef.current.markDetached(nextSession.terminal_id);
|
||||
setConnection((current) =>
|
||||
current === "exited" ? current : "disconnected",
|
||||
);
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
async (nextSession: TerminalSession) => {
|
||||
if (nextSession.terminal_id === activeSessionRef.current?.terminal_id)
|
||||
return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[connect, detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const dimensions = terminal.dimensions();
|
||||
if (!selectedAgentIdRef.current || !dimensions) return;
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
try {
|
||||
terminal.fit();
|
||||
const fittedDimensions = terminal.dimensions() ?? dimensions;
|
||||
const created = await createTerminal(
|
||||
selectedAgentIdRef.current,
|
||||
fittedDimensions.rows,
|
||||
fittedDimensions.cols,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}, [connect, detachTransport, terminal]);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
const currentSession = activeSessionRef.current;
|
||||
if (!currentSession) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminal.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(
|
||||
currentSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [connect, detachTransport, operatorId, terminal]);
|
||||
|
||||
const close = useCallback(
|
||||
async (closingSession: TerminalSession) => {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = catalogRef.current
|
||||
.getSessions()
|
||||
.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
catalogRef.current.remove(closingId);
|
||||
terminal.forgetTitle(closingId);
|
||||
if (
|
||||
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||
) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
// reset discards the complete screen and terminal modes; clear() does not.
|
||||
terminal.reset();
|
||||
if (fallback) void activate(fallback);
|
||||
}
|
||||
},
|
||||
[activate, detachTransport, terminal],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal, writeTerminalData],
|
||||
);
|
||||
|
||||
// Transport input/resize subscriptions and restoration begin only once xterm is usable.
|
||||
useEffect(() => {
|
||||
if (!terminal.ready) return;
|
||||
mountedRef.current = true;
|
||||
const input = terminal.onInput((data) => writeTerminalData(data, true));
|
||||
const resize = terminal.onResize(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
}
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await catalogRef.current.refresh();
|
||||
if (cancelled) return;
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
orderTerminalSessions(listed),
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) return;
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void restoreTerminalSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
input.dispose();
|
||||
resize.dispose();
|
||||
detachTransport();
|
||||
};
|
||||
}, [
|
||||
terminal.ready,
|
||||
terminal.onInput,
|
||||
terminal.onResize,
|
||||
terminal.reset,
|
||||
terminal.sendResize,
|
||||
terminal,
|
||||
connect,
|
||||
detachTransport,
|
||||
operatorId,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
return {
|
||||
session,
|
||||
connection,
|
||||
modifiers,
|
||||
start,
|
||||
activate,
|
||||
reconnect,
|
||||
close,
|
||||
writeTerminalData,
|
||||
writeAccessoryKey,
|
||||
toggleModifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { listTerminals, type TerminalSession } from "@/api";
|
||||
import {
|
||||
mergeTerminalSession,
|
||||
reconcileTerminalSessions,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function useTerminalSessions() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const sessionsRef = useRef(sessions);
|
||||
const refreshRef = useRef<() => Promise<TerminalSession[]>>(async () => []);
|
||||
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const refresh = useCallback(() => refreshRef.current(), []);
|
||||
const merge = useCallback((session: TerminalSession) => {
|
||||
setSessions((current) => mergeTerminalSession(current, session));
|
||||
}, []);
|
||||
const markDetached = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === terminalId
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const remove = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== terminalId),
|
||||
);
|
||||
}, []);
|
||||
const reconcile = useCallback((listed: TerminalSession[]) => {
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed));
|
||||
}, []);
|
||||
const getSessions = useCallback(() => sessionsRef.current, []);
|
||||
|
||||
// The catalog stream and fallback timer live for the page mount, independent of active sessions.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshInFlight: Promise<TerminalSession[]> | null = null;
|
||||
let refreshRequested = false;
|
||||
let eventsSocket: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let healthyTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const refreshSessions = (): Promise<TerminalSession[]> => {
|
||||
if (refreshInFlight) {
|
||||
// Coalesce bursts and fetch again if another invalidation arrives in flight.
|
||||
refreshRequested = true;
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
let listed: TerminalSession[] = [];
|
||||
do {
|
||||
refreshRequested = false;
|
||||
listed = await listTerminals();
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
} while (refreshRequested && !cancelled);
|
||||
return listed;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
if (refreshRequested && !cancelled) {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
refreshRef.current = refreshSessions;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(
|
||||
websocketUrl("/api/v1/control/terminals/events/ws"),
|
||||
);
|
||||
eventsSocket = socket;
|
||||
socket.onmessage = (event) => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data) as { type?: string };
|
||||
if (message.type === "sessions_changed") {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Invalidations are hints; the periodic recovery fetch remains active.
|
||||
}
|
||||
};
|
||||
socket.onopen = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
window.clearTimeout(healthyTimer);
|
||||
healthyTimer = window.setTimeout(() => {
|
||||
if (eventsSocket === socket && socket.readyState === WebSocket.OPEN) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
window.clearTimeout(healthyTimer);
|
||||
const ceiling = Math.min(500 * 2 ** reconnectAttempt, 12_000);
|
||||
const delay = Math.min(
|
||||
Math.round(ceiling * (0.8 + Math.random() * 0.4)),
|
||||
15_000,
|
||||
);
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 5);
|
||||
reconnectTimer = window.setTimeout(connectEvents, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
void refreshSessions().catch(() => undefined);
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}, 15_000);
|
||||
const refreshOnFocus = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(refreshTimer);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearTimeout(healthyTimer);
|
||||
eventsSocket?.close();
|
||||
eventsSocket = null;
|
||||
refreshRef.current = async () => [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
refresh,
|
||||
merge,
|
||||
markDetached,
|
||||
remove,
|
||||
reconcile,
|
||||
getSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalCatalog = ReturnType<typeof useTerminalSessions>;
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { visibleTerminalText } from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
|
||||
// Stable behavioral surface shared with session transport and page controls.
|
||||
export type TerminalPort = {
|
||||
clear: () => void;
|
||||
copy: () => Promise<void>;
|
||||
dimensions: () => { rows: number; cols: number } | null;
|
||||
forgetTitle: (terminalId: string) => void;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
blurInput: () => void;
|
||||
onInput: (handler: (data: string) => void) => { dispose: () => void };
|
||||
onResize: (handler: () => void) => { dispose: () => void };
|
||||
paste: (write: (data: string) => void) => Promise<void>;
|
||||
reset: () => void;
|
||||
scrollPages: (pages: number) => void;
|
||||
sendResize: (send: (message: string) => void, force?: boolean) => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
setActiveTerminalId: (terminalId: string | undefined) => void;
|
||||
write: (data: Uint8Array) => void;
|
||||
writeLine: (line: string) => void;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
pageRef: RefObject<HTMLElement | null>;
|
||||
hostRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useXterm({ pageRef, hostRef }: Options) {
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const activeTerminalIdRef = useRef<string | undefined>(undefined);
|
||||
const lastSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const resizeListenersRef = useRef(new Set<() => void>());
|
||||
const [fontFamily, setFontFamily] = useState<string>();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const setActiveTerminalId = useCallback((terminalId: string | undefined) => {
|
||||
activeTerminalIdRef.current = terminalId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadTerminalFontFamily().then((loadedFontFamily) => {
|
||||
if (!cancelled) setFontFamily(loadedFontFamily);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, [pageRef]);
|
||||
|
||||
const sendResize = useCallback(
|
||||
(send: (message: string) => void, force = false) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
if (
|
||||
!force &&
|
||||
lastSizeRef.current.rows === terminal.rows &&
|
||||
lastSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols };
|
||||
send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => fitRef.current?.fit(), []);
|
||||
const focus = useCallback(() => terminalRef.current?.focus(), []);
|
||||
const blurInput = useCallback(
|
||||
() => terminalRef.current?.textarea?.blur(),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => terminalRef.current?.reset(), []);
|
||||
const clear = useCallback(() => terminalRef.current?.clear(), []);
|
||||
const write = useCallback(
|
||||
(data: Uint8Array) => terminalRef.current?.write(data),
|
||||
[],
|
||||
);
|
||||
const writeLine = useCallback(
|
||||
(line: string) => terminalRef.current?.writeln(line),
|
||||
[],
|
||||
);
|
||||
const dimensions = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
}, []);
|
||||
const forgetTitle = useCallback((terminalId: string) => {
|
||||
setSessionTitles((current) => {
|
||||
if (!(terminalId in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const onInput = useCallback(
|
||||
(handler: (data: string) => void) =>
|
||||
terminalRef.current?.onData(handler) ?? { dispose: () => {} },
|
||||
[],
|
||||
);
|
||||
const onResize = useCallback((handler: () => void) => {
|
||||
resizeListenersRef.current.add(handler);
|
||||
return { dispose: () => resizeListenersRef.current.delete(handler) };
|
||||
}, []);
|
||||
const scrollPages = useCallback(
|
||||
(pages: number) => terminalRef.current?.scrollPages(pages),
|
||||
[],
|
||||
);
|
||||
const copy = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const paste = useCallback(async (writeData: (data: string) => void) => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const changeFontSize = useCallback((delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wait for the font before creating xterm; this effect owns the instance and its observers.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !fontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.loadAddon(new ImageAddon({}));
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeTerminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fitAddon.fit();
|
||||
// Consumers subscribe only after this transition; the port methods stay stable.
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
setReady(true);
|
||||
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
|
||||
return () => {
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
resizeListenersRef.current.clear();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, [fontFamily, hostRef]);
|
||||
|
||||
const terminal = useMemo<TerminalPort>(
|
||||
() => ({
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
}),
|
||||
[
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
],
|
||||
);
|
||||
|
||||
return { terminal, fontSize, sessionTitles };
|
||||
}
|
||||
Reference in new issue
Block a user