Replace textual-serve with direct xterm.js 6.0 bundle
- Add package.json with @xterm/xterm 6.0 and all addons - Create terminal.ts client with WebSocket protocol support - Bundle with Bun (bun run build -> terminal.js) - Remove textual-serve dependency from pyproject.toml - Remove canvas monkey-patch workaround (no longer needed) - Add scrollback support (configurable via data-scrollback) - Update static file routing to serve from /static/ - Add Makefile targets: bundle, bundle-watch, bundle-clean - Update tests for new static path structure Benefits: - Full control over xterm.js configuration - Scrollback history now works (default 1000 lines) - Custom font family without workarounds - Smaller footprint (no unused Roboto Mono fonts) - Latest xterm.js 6.0 features available
This commit is contained in:
@@ -37,22 +37,6 @@ SCREENSHOT_MAX_CACHE_SECONDS = 60.0
|
||||
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def _get_static_path() -> Path | None:
|
||||
"""Get the path to static assets from textual-serve."""
|
||||
try:
|
||||
import textual_serve
|
||||
|
||||
static_path = Path(textual_serve.__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
return static_path
|
||||
except ImportError:
|
||||
log.warning("textual-serve not installed - static assets unavailable")
|
||||
return None
|
||||
|
||||
|
||||
STATIC_PATH = _get_static_path()
|
||||
|
||||
|
||||
class LocalClientConnector(SessionConnector):
|
||||
"""Local connector that handles communication between sessions and local server."""
|
||||
|
||||
@@ -258,14 +242,11 @@ class LocalServer:
|
||||
web.get("/", self._handle_root),
|
||||
]
|
||||
|
||||
if STATIC_PATH is not None and STATIC_PATH.exists():
|
||||
routes.append(web.static("/static", STATIC_PATH))
|
||||
log.info("Static assets served from: %s", STATIC_PATH)
|
||||
else:
|
||||
log.error("Static assets not found at %s - terminal UI will not work", STATIC_PATH)
|
||||
|
||||
if WEBTERM_STATIC_PATH.exists():
|
||||
routes.append(web.static("/static-webterm", WEBTERM_STATIC_PATH))
|
||||
routes.append(web.static("/static", WEBTERM_STATIC_PATH))
|
||||
log.info("Static assets served from: %s", WEBTERM_STATIC_PATH)
|
||||
else:
|
||||
log.error("Static assets not found at %s - terminal UI will not work", WEBTERM_STATIC_PATH)
|
||||
|
||||
return routes
|
||||
|
||||
@@ -838,67 +819,20 @@ class LocalServer:
|
||||
ws_url = self._get_ws_url_from_request(request, route_key)
|
||||
page_title = available_app.name if available_app else "Textual Web Terminal"
|
||||
|
||||
# Custom monospace font stack for terminals
|
||||
custom_font = (
|
||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", '
|
||||
'"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, '
|
||||
'Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace'
|
||||
)
|
||||
# Font to replace in xterm.js canvas rendering
|
||||
old_font = "'Roboto Mono', Monaco, 'Courier New', monospace"
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{page_title}</title>
|
||||
<link rel=\"stylesheet\" href=\"/static/css/xterm.css\">
|
||||
<link rel=\"stylesheet\" href=\"/static-webterm/monospace.css\">
|
||||
<script>
|
||||
// Intercept canvas font assignments to override xterm.js fontFamily.
|
||||
// xterm.js uses canvas/WebGL rendering which ignores CSS font-family.
|
||||
// We monkey-patch CanvasRenderingContext2D.font setter to replace the default font.
|
||||
(function() {{
|
||||
const OLD_FONT = "{old_font}";
|
||||
const CUSTOM_FONT = '{custom_font}';
|
||||
const fontDescriptor = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'font');
|
||||
if (fontDescriptor && fontDescriptor.set) {{
|
||||
const originalSet = fontDescriptor.set;
|
||||
Object.defineProperty(CanvasRenderingContext2D.prototype, 'font', {{
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: fontDescriptor.get,
|
||||
set: function(value) {{
|
||||
// Replace xterm.js default font with our custom stack
|
||||
if (typeof value === 'string' && value.includes(OLD_FONT)) {{
|
||||
value = value.replace(OLD_FONT, CUSTOM_FONT);
|
||||
}}
|
||||
originalSet.call(this, value);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
<script src=\"/static/js/textual.js\"></script>
|
||||
<link rel=\"stylesheet\" href=\"/static/monospace.css\">
|
||||
<style>
|
||||
body {{ background: #0c181f; margin: 0; padding: 0; }}
|
||||
.textual-terminal {{ width: 100vw; height: 100vh; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\"></div>
|
||||
<script>
|
||||
// Focus terminal on load and when switching tabs back
|
||||
(function() {{
|
||||
function focusTerminal() {{
|
||||
const textarea = document.querySelector('.xterm-helper-textarea');
|
||||
if (textarea) {{ textarea.focus(); return; }}
|
||||
const term = document.querySelector('.xterm');
|
||||
if (term) {{ term.focus(); }}
|
||||
}}
|
||||
setTimeout(focusTerminal, 100);
|
||||
setTimeout(focusTerminal, 500);
|
||||
window.addEventListener('focus', focusTerminal);
|
||||
}})();
|
||||
</script>
|
||||
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\" data-scrollback=\"1000\"></div>
|
||||
<script src=\"/static/js/terminal.js\"></script>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
font-family: monospace;
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree > div {
|
||||
transform-origin: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
background:rgba(0,0,0,0);
|
||||
|
||||
transition: opacity 100ms linear;
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* xterm.js 6.0 terminal client for textual-webterm.
|
||||
*
|
||||
* Implements the WebSocket protocol compatible with local_server.py:
|
||||
* - Client → Server: ["stdin", data], ["resize", {width, height}], ["ping", data]
|
||||
* - Server → Client: ["stdout", data], ["pong", data], or binary frames
|
||||
*/
|
||||
|
||||
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
|
||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||
const DEFAULT_FONT_FAMILY =
|
||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
||||
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
|
||||
'"DejaVu Sans Mono", "Courier New", monospace';
|
||||
|
||||
/** Configuration options passed via data attributes or window config */
|
||||
interface TerminalConfig {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
scrollback?: number;
|
||||
theme?: ITheme;
|
||||
}
|
||||
|
||||
/** Parse configuration from element data attributes */
|
||||
function parseConfig(element: HTMLElement): TerminalConfig {
|
||||
const config: TerminalConfig = {};
|
||||
|
||||
if (element.dataset.fontFamily) {
|
||||
config.fontFamily = element.dataset.fontFamily;
|
||||
}
|
||||
if (element.dataset.fontSize) {
|
||||
config.fontSize = parseInt(element.dataset.fontSize, 10);
|
||||
}
|
||||
if (element.dataset.scrollback) {
|
||||
config.scrollback = parseInt(element.dataset.scrollback, 10);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebTerminal - wraps xterm.js with WebSocket communication.
|
||||
*/
|
||||
class WebTerminal {
|
||||
private terminal: Terminal;
|
||||
private socket: WebSocket | null = null;
|
||||
private fitAddon: FitAddon;
|
||||
private element: HTMLElement;
|
||||
private wsUrl: string;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
|
||||
constructor(container: HTMLElement, wsUrl: string, config: TerminalConfig = {}) {
|
||||
this.element = container;
|
||||
this.wsUrl = wsUrl;
|
||||
|
||||
// Build terminal options
|
||||
const options: ITerminalOptions = {
|
||||
allowProposedApi: true,
|
||||
fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
|
||||
fontSize: config.fontSize ?? 16,
|
||||
scrollback: config.scrollback ?? 1000,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
theme: config.theme,
|
||||
};
|
||||
|
||||
this.terminal = new Terminal(options);
|
||||
|
||||
// Initialize addons
|
||||
this.fitAddon = new FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
|
||||
// Try WebGL first, fall back to Canvas
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
this.terminal.loadAddon(new CanvasAddon());
|
||||
});
|
||||
this.terminal.loadAddon(webglAddon);
|
||||
} catch {
|
||||
this.terminal.loadAddon(new CanvasAddon());
|
||||
}
|
||||
|
||||
// Unicode support for wide characters
|
||||
const unicode11 = new Unicode11Addon();
|
||||
this.terminal.loadAddon(unicode11);
|
||||
this.terminal.unicode.activeVersion = "11";
|
||||
|
||||
// Clickable URLs
|
||||
this.terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Clipboard integration
|
||||
this.terminal.loadAddon(new ClipboardAddon());
|
||||
|
||||
// Open terminal in container
|
||||
this.terminal.open(container);
|
||||
|
||||
// Handle terminal input
|
||||
this.terminal.onData((data) => {
|
||||
this.send(["stdin", data]);
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
this.terminal.onResize(({ cols, rows }) => {
|
||||
this.send(["resize", { width: cols, height: rows }]);
|
||||
});
|
||||
|
||||
// Fit to container and handle window resize
|
||||
this.fit();
|
||||
window.addEventListener("resize", () => this.fit());
|
||||
|
||||
// Connect WebSocket
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Fit terminal to container size */
|
||||
fit(): void {
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch {
|
||||
// Ignore fit errors during initialization
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect to WebSocket server */
|
||||
connect(): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = new WebSocket(this.wsUrl);
|
||||
this.socket.binaryType = "arraybuffer";
|
||||
|
||||
this.socket.addEventListener("open", () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.element.classList.add("-connected");
|
||||
this.element.classList.remove("-disconnected");
|
||||
|
||||
// Send initial size
|
||||
this.fit();
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
this.send(["resize", { width: dims.cols, height: dims.rows }]);
|
||||
}
|
||||
|
||||
// Focus terminal
|
||||
this.terminal.focus();
|
||||
});
|
||||
|
||||
this.socket.addEventListener("close", () => {
|
||||
this.element.classList.remove("-connected");
|
||||
this.element.classList.add("-disconnected");
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
this.socket.addEventListener("error", () => {
|
||||
// Error handling - close event will follow
|
||||
});
|
||||
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle incoming WebSocket message */
|
||||
private handleMessage(data: string | ArrayBuffer): void {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
// Binary data - write directly to terminal
|
||||
const text = new TextDecoder().decode(data);
|
||||
this.terminal.write(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON message
|
||||
try {
|
||||
const envelope = JSON.parse(data) as [string, unknown];
|
||||
const [type, payload] = envelope;
|
||||
|
||||
switch (type) {
|
||||
case "stdout":
|
||||
this.terminal.write(payload as string);
|
||||
break;
|
||||
case "pong":
|
||||
// Keep-alive response - nothing to do
|
||||
break;
|
||||
default:
|
||||
console.debug("Unknown message type:", type);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - treat as raw text
|
||||
this.terminal.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send message to server */
|
||||
private send(message: [string, unknown]): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule reconnection attempt */
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error("Max reconnection attempts reached");
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`Reconnecting (attempt ${this.reconnectAttempts})...`);
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Clean up resources */
|
||||
dispose(): void {
|
||||
this.socket?.close();
|
||||
this.terminal.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Store instances for potential external access
|
||||
const instances: Map<HTMLElement, WebTerminal> = new Map();
|
||||
|
||||
/** Initialize all terminal containers on page load */
|
||||
function initTerminals(): void {
|
||||
document.querySelectorAll<HTMLElement>(".textual-terminal").forEach((el) => {
|
||||
const wsUrl = el.dataset.sessionWebsocketUrl;
|
||||
if (!wsUrl) {
|
||||
console.error("Missing data-session-websocket-url on terminal container");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = parseConfig(el);
|
||||
const terminal = new WebTerminal(el, wsUrl, config);
|
||||
instances.set(el, terminal);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTerminals);
|
||||
} else {
|
||||
initTerminals();
|
||||
}
|
||||
|
||||
// Export for potential external use
|
||||
export { WebTerminal, initTerminals, instances };
|
||||
Reference in New Issue
Block a user