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

+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 };
}