refactor terminal page into renderer, catalog, and session hooks
release / build (push) Successful in 4m16s

also bump beta cuz why not
This commit is contained in:
lda committed 2026-10-01 14:26:50 +07:00 Verified
1 parent 37656c88ca
commit 5d77b9aeb2
7 files changed
+1070 -819

No files matched your search

@@ -74,6 +74,8 @@ The operator UI lists live sessions as tabs and stores only the active terminal
The protected `/api/v1/control/terminals/events/ws` stream sends `sessions_changed` invalidations when the volatile session catalog or attachment ownership changes. These events contain no terminal bytes or attachment credentials: browsers refetch the ordinary session list, coalescing bursts and repeating a fetch if another event arrives during it. Subscribing and reconnecting also invalidate the list. The 15-second refresh remains a recovery fallback; the stream is a freshness hint, never an authorization or locking mechanism.
The React page composes three custom hooks rather than owning every resource itself. `useXterm` owns the single renderer, its addons, fitting, fonts, and display operations. `useTerminalSessions` owns the catalog subscription, coalesced refreshes, and fallback timer. `useTerminalSession` owns active attachment, input, restoration, and session operations. The attachment hook waits for renderer readiness; stable renderer operations prevent ordinary state updates from rebuilding the terminal or reconnecting its transport. Each hook cleans up its own resources on unmount, which detaches the browser without deleting the agent-owned PTY.
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session. The connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
+7 -7
View File
@@ -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",
+47 -47
View File
@@ -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
View File
@@ -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={() => {
+434
View File
@@ -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>;
+363
View File
@@ -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 };
}