Replace xterm.js with ghostty-web
Migrate from xterm.js to ghostty-web (Ghostty WASM terminal emulator). Benefits: - WASM-compiled parser from Ghostty (same code as native app) - Better Unicode/complex script handling - Simpler initialization (no viewport.scrollBarWidth issues) - ~400KB WASM bundle Changes: - Update package.json: remove @xterm/* deps, add ghostty-web - Rewrite terminal.ts using ghostty-web API - Use built-in FitAddon with observeResize() - Remove WebGL/Canvas/Unicode11/WebLinks/Clipboard addons - Remove xterm.css (ghostty uses canvas renderer) - Add ghostty-vt.wasm to static assets - Update HTML template and tests BREAKING: Major version bump to 0.4.0
This commit is contained in:
@@ -846,7 +846,6 @@ class LocalServer:
|
||||
<html>
|
||||
<head>
|
||||
<title>{page_title}</title>
|
||||
<link rel=\"stylesheet\" href=\"/static/css/xterm.css\">
|
||||
<link rel=\"stylesheet\" href=\"/static/monospace.css\">
|
||||
<style>
|
||||
html, body {{ width: 100%; height: 100%; }}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,18 +1,12 @@
|
||||
/**
|
||||
* xterm.js 6.0 terminal client for textual-webterm.
|
||||
* ghostty-web 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";
|
||||
import { Terminal, FitAddon, type ITerminalOptions, type ITheme } from "ghostty-web";
|
||||
|
||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||
const DEFAULT_FONT_FAMILY =
|
||||
@@ -45,285 +39,109 @@ function parseConfig(element: HTMLElement): TerminalConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Get WASM path based on script location */
|
||||
function getWasmPath(): string {
|
||||
// Try to find the script element and derive path from it
|
||||
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
||||
if (scripts.length > 0) {
|
||||
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
||||
const basePath = scriptSrc.substring(0, scriptSrc.lastIndexOf('/') + 1);
|
||||
return basePath + 'ghostty-vt.wasm';
|
||||
}
|
||||
// Fallback to common static paths
|
||||
return '/static/js/ghostty-vt.wasm';
|
||||
}
|
||||
|
||||
/**
|
||||
* WebTerminal - wraps xterm.js with WebSocket communication.
|
||||
* WebTerminal - wraps ghostty-web with WebSocket communication.
|
||||
*/
|
||||
class WebTerminal {
|
||||
private terminal: Terminal;
|
||||
private socket: WebSocket | null = null;
|
||||
private fitAddon: FitAddon;
|
||||
private socket: WebSocket | null = null;
|
||||
private element: HTMLElement;
|
||||
private wsUrl: string;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private resizeRaf = 0;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
private resizeState: {
|
||||
isResizing: boolean;
|
||||
lastValidSize: {cols: number, rows: number} | null;
|
||||
pendingResize: {cols: number, rows: number} | null;
|
||||
resizeAttempts: number;
|
||||
} = {
|
||||
isResizing: false,
|
||||
lastValidSize: null,
|
||||
pendingResize: null,
|
||||
resizeAttempts: 0
|
||||
};
|
||||
private messageQueue: [string, unknown][] | null = null;
|
||||
private minResizeInterval = 50; // ms
|
||||
private lastResizeTime = 0;
|
||||
private messageQueue: [string, unknown][] = [];
|
||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||
|
||||
constructor(container: HTMLElement, wsUrl: string, config: TerminalConfig = {}) {
|
||||
private constructor(
|
||||
container: HTMLElement,
|
||||
wsUrl: string,
|
||||
terminal: Terminal,
|
||||
fitAddon: FitAddon
|
||||
) {
|
||||
this.element = container;
|
||||
this.wsUrl = wsUrl;
|
||||
this.terminal = terminal;
|
||||
this.fitAddon = fitAddon;
|
||||
}
|
||||
|
||||
/** Create and initialize a WebTerminal instance */
|
||||
static async create(
|
||||
container: HTMLElement,
|
||||
wsUrl: string,
|
||||
config: TerminalConfig
|
||||
): Promise<WebTerminal> {
|
||||
// Determine WASM path - try to find it relative to the script location
|
||||
const wasmPath = getWasmPath();
|
||||
|
||||
// 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,
|
||||
wasmPath,
|
||||
};
|
||||
|
||||
this.terminal = new Terminal(options);
|
||||
const terminal = new Terminal(options);
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
// Initialize addons
|
||||
this.fitAddon = new FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
// Open terminal (this loads WASM and initializes everything)
|
||||
await terminal.open(container);
|
||||
|
||||
// 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());
|
||||
}
|
||||
const instance = new WebTerminal(container, wsUrl, terminal, fitAddon);
|
||||
instance.initialize();
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Unicode support for wide characters
|
||||
const unicode11 = new Unicode11Addon();
|
||||
this.terminal.loadAddon(unicode11);
|
||||
this.terminal.unicode.activeVersion = "11";
|
||||
/** Initialize event handlers and connect */
|
||||
private initialize(): void {
|
||||
// Fit to container
|
||||
this.fitAddon.fit();
|
||||
this.fitAddon.observeResize();
|
||||
|
||||
// Clickable URLs
|
||||
this.terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Clipboard integration
|
||||
this.terminal.loadAddon(new ClipboardAddon());
|
||||
|
||||
// Open terminal in container
|
||||
this.terminal.open(container);
|
||||
// Handle window resize (some browsers don't trigger ResizeObserver on window resize)
|
||||
window.addEventListener("resize", () => {
|
||||
this.fitAddon.fit();
|
||||
});
|
||||
|
||||
// Handle terminal input
|
||||
this.terminal.onData((data) => {
|
||||
this.send(["stdin", data]);
|
||||
});
|
||||
|
||||
// Handle resize with validation
|
||||
this.terminal.onResize(({ cols, rows }) => {
|
||||
if (this.isValidSize(cols, rows)) {
|
||||
this.resizeState.lastValidSize = { cols, rows };
|
||||
this.send(["resize", { width: cols, height: rows }]);
|
||||
} else {
|
||||
console.warn(`Invalid resize dimensions: ${cols}x${rows}`);
|
||||
if (this.resizeState.lastValidSize) {
|
||||
// Restore valid size
|
||||
this.terminal.resize(
|
||||
this.resizeState.lastValidSize.cols,
|
||||
this.resizeState.lastValidSize.rows
|
||||
);
|
||||
}
|
||||
// Handle resize
|
||||
this.terminal.onResize((size) => {
|
||||
if (this.isValidSize(size.cols, size.rows)) {
|
||||
this.lastValidSize = { cols: size.cols, rows: size.rows };
|
||||
this.send(["resize", { width: size.cols, height: size.rows }]);
|
||||
}
|
||||
});
|
||||
|
||||
this.ensureInitialFit();
|
||||
|
||||
// Fit to container and handle resize changes
|
||||
this.scheduleFit();
|
||||
|
||||
// Enhanced window resize handling with throttling
|
||||
const throttledWindowResize = this.createThrottledHandler(() => this.scheduleFit(), 100);
|
||||
window.addEventListener("resize", throttledWindowResize);
|
||||
|
||||
// Enhanced resize observer that also watches parent elements
|
||||
if (window.ResizeObserver) {
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce multiple entries from the same resize event
|
||||
this.scheduleFit();
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(container);
|
||||
|
||||
// Also observe parent elements up to body to catch layout changes
|
||||
let parent = container.parentElement;
|
||||
while (parent && parent !== document.body && parent !== document.documentElement) {
|
||||
this.resizeObserver.observe(parent);
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect WebSocket
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private ensureInitialFit(): void {
|
||||
if (!("fonts" in document)) {
|
||||
return;
|
||||
}
|
||||
document.fonts.ready
|
||||
.then(() => this.scheduleFit())
|
||||
.catch(() => {
|
||||
// Ignore font readiness errors; resize observer will handle future resizes.
|
||||
});
|
||||
}
|
||||
|
||||
/** Fit terminal to container size */
|
||||
/** Fit terminal to container size with state management */
|
||||
fit(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle rapid resize attempts
|
||||
if (now - this.lastResizeTime < this.minResizeInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.resizeState.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if terminal is ready before attempting fit
|
||||
if (!this.isTerminalReady()) {
|
||||
console.debug("Terminal not ready for fit operation, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.resizeState.isResizing = true;
|
||||
this.resizeState.resizeAttempts++;
|
||||
this.lastResizeTime = now;
|
||||
|
||||
// Wrap FitAddon operation in additional safety check
|
||||
if (this.fitAddon && typeof this.fitAddon.fit === 'function') {
|
||||
this.fitAddon.fit();
|
||||
this.resizeState.resizeAttempts = 0; // Reset on success
|
||||
} else {
|
||||
throw new Error("FitAddon not properly initialized");
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn("Fit failed:", e);
|
||||
this.handleResizeFailure();
|
||||
} finally {
|
||||
this.resizeState.isResizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle resize failures with fallback logic */
|
||||
private handleResizeFailure(): void {
|
||||
if (this.resizeState.resizeAttempts > 3) {
|
||||
if (this.resizeState.lastValidSize) {
|
||||
// Restore last known good size
|
||||
console.warn("Restoring last valid terminal size:", this.resizeState.lastValidSize);
|
||||
this.terminal.resize(
|
||||
this.resizeState.lastValidSize.cols,
|
||||
this.resizeState.lastValidSize.rows
|
||||
);
|
||||
} else {
|
||||
// Use reasonable fallback
|
||||
const fallback = { cols: 80, rows: 24 };
|
||||
console.warn("Using fallback terminal dimensions:", fallback);
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
}
|
||||
this.resizeState.resizeAttempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if terminal is ready for resize operations */
|
||||
private isTerminalReady(): boolean {
|
||||
try {
|
||||
// Check if terminal exists
|
||||
if (!this.terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if terminal core is initialized
|
||||
if (!this.terminal._core) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const core = this.terminal._core;
|
||||
|
||||
// Check if viewport is available (FitAddon requirement)
|
||||
if (!core.viewport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if viewport has scrollBarWidth (this is what was failing)
|
||||
if (core.viewport.scrollBarWidth === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if render service exists
|
||||
if (!core._renderService) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if render service has dimensions
|
||||
const renderService = core._renderService;
|
||||
if (!renderService.dimensions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if cell dimensions are valid
|
||||
const dims = renderService.dimensions;
|
||||
if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional safety check for FitAddon internal state
|
||||
if (this.fitAddon) {
|
||||
try {
|
||||
// Try to access FitAddon's terminal reference
|
||||
const fitTerminal = (this.fitAddon as any)._terminal;
|
||||
if (!fitTerminal || fitTerminal !== this.terminal) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
// FitAddon might not have the expected structure
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("Terminal readiness check failed:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate terminal dimensions */
|
||||
private isValidSize(cols: number, rows: number): boolean {
|
||||
return cols >= 10 && cols <= 500 && rows >= 5 && rows <= 200;
|
||||
}
|
||||
|
||||
/** Schedule fit operation with enhanced debouncing */
|
||||
private scheduleFit(): void {
|
||||
if (this.resizeRaf) {
|
||||
window.cancelAnimationFrame(this.resizeRaf);
|
||||
}
|
||||
|
||||
this.resizeRaf = window.requestAnimationFrame(() => {
|
||||
this.resizeRaf = 0;
|
||||
this.fit();
|
||||
});
|
||||
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 200;
|
||||
}
|
||||
|
||||
/** Connect to WebSocket server */
|
||||
@@ -340,107 +158,16 @@ class WebTerminal {
|
||||
this.element.classList.add("-connected");
|
||||
this.element.classList.remove("-disconnected");
|
||||
|
||||
// Process any queued messages immediately
|
||||
// Process any queued messages
|
||||
this.processMessageQueue();
|
||||
|
||||
// Send initial size.
|
||||
// Important: the PTY hard-wraps output based on its initial cols/rows.
|
||||
// If we send a resize before fonts/layout settle, the initial cols can be
|
||||
// too small and the shell will wrap permanently.
|
||||
|
||||
const init = () => {
|
||||
const fallback = { cols: 132, rows: 45 };
|
||||
const maxAttempts = 120;
|
||||
|
||||
const attemptFitAndResize = (attempt: number) => {
|
||||
// Check terminal readiness before calling proposeDimensions to avoid
|
||||
// "viewport.scrollBarWidth" errors when terminal is not fully initialized
|
||||
if (!this.isTerminalReady()) {
|
||||
if (attempt < maxAttempts) {
|
||||
window.requestAnimationFrame(() => attemptFitAndResize(attempt + 1));
|
||||
return;
|
||||
}
|
||||
// Terminal not ready after max attempts - use fallback directly
|
||||
// Don't call proposeDimensions() as it will throw
|
||||
console.warn("Terminal not ready after max attempts, using fallback dimensions");
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = (() => {
|
||||
try {
|
||||
return this.fitAddon.proposeDimensions();
|
||||
} catch (e) {
|
||||
console.warn("proposeDimensions failed:", e);
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
if (!dims) {
|
||||
if (attempt < maxAttempts) {
|
||||
window.requestAnimationFrame(() => attemptFitAndResize(attempt + 1));
|
||||
return;
|
||||
}
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate dimensions and terminal readiness before applying
|
||||
// Use a defensive approach with multiple fallback strategies
|
||||
try {
|
||||
if (dims && this.isValidSize(dims.cols, dims.rows) && this.isTerminalReady()) {
|
||||
this.terminal.resize(dims.cols, dims.rows);
|
||||
this.resizeState.lastValidSize = dims;
|
||||
this.send(["resize", { width: dims.cols, height: dims.rows }]);
|
||||
} else {
|
||||
const reason = !dims ? "proposeDimensions failed" :
|
||||
!this.isValidSize(dims.cols, dims.rows) ? `invalid dimensions: ${dims.cols}x${dims.rows}` :
|
||||
"terminal not ready";
|
||||
console.warn(`Initial fit ${reason}, using fallback`);
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Initial fit failed with exception: ${e.message}, using fallback`);
|
||||
// If anything goes wrong, use the fallback dimensions
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(() => attemptFitAndResize(0));
|
||||
};
|
||||
|
||||
if ("fonts" in document) {
|
||||
document.fonts.ready.then(init).catch(init);
|
||||
} else {
|
||||
init();
|
||||
// Send initial size
|
||||
const cols = this.terminal.cols;
|
||||
const rows = this.terminal.rows;
|
||||
if (this.isValidSize(cols, rows)) {
|
||||
this.lastValidSize = { cols, rows };
|
||||
this.send(["resize", { width: cols, height: rows }]);
|
||||
}
|
||||
|
||||
// Add a timeout-based fallback in case the initial fit never succeeds
|
||||
const fallbackTimeout = setTimeout(() => {
|
||||
if (!this.resizeState.lastValidSize) {
|
||||
console.warn("Initial fit timed out, applying fallback dimensions");
|
||||
const fallback = { cols: 80, rows: 24 };
|
||||
try {
|
||||
this.terminal.resize(fallback.cols, fallback.rows);
|
||||
this.resizeState.lastValidSize = fallback;
|
||||
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
|
||||
} catch (e) {
|
||||
console.error("Fallback resize failed:", e);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Clean up timeout when WebSocket connects
|
||||
this.socket?.addEventListener('open', () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
});
|
||||
|
||||
// Focus terminal
|
||||
this.terminal.focus();
|
||||
@@ -493,39 +220,24 @@ class WebTerminal {
|
||||
|
||||
/** Send message to server with queueing support */
|
||||
private send(message: [string, unknown]): void {
|
||||
// Initialize message queue if needed
|
||||
if (!this.messageQueue) {
|
||||
this.messageQueue = [];
|
||||
}
|
||||
|
||||
// Queue the message
|
||||
this.messageQueue.push(message);
|
||||
|
||||
// Process queue if connected
|
||||
this.processMessageQueue();
|
||||
}
|
||||
|
||||
|
||||
/** Process queued messages when WebSocket is ready */
|
||||
private processMessageQueue(): void {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN || !this.messageQueue) {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process all queued messages
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
try {
|
||||
if (message) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
|
||||
// Special handling for resize messages
|
||||
if (message[0] === "resize") {
|
||||
this.resizeState.pendingResize = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send message:", e, message);
|
||||
// Put failed message back at front of queue
|
||||
if (message) {
|
||||
this.messageQueue.unshift(message);
|
||||
}
|
||||
@@ -550,40 +262,10 @@ class WebTerminal {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Create throttled event handler */
|
||||
private createThrottledHandler(func: Function, wait: number): () => void {
|
||||
let lastCall = 0;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
return function(this: any, ...args: any[]) {
|
||||
const now = Date.now();
|
||||
|
||||
// Leading edge - execute immediately if not called recently
|
||||
if (now - lastCall >= wait) {
|
||||
lastCall = now;
|
||||
func.apply(this, args);
|
||||
} else if (!timeoutId) {
|
||||
// Trailing edge - schedule execution after delay
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timeoutId = null;
|
||||
lastCall = Date.now();
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
|
||||
/** Clean up resources */
|
||||
dispose(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this.resizeRaf) {
|
||||
window.cancelAnimationFrame(this.resizeRaf);
|
||||
this.resizeRaf = 0;
|
||||
}
|
||||
this.socket?.close();
|
||||
this.fitAddon.dispose();
|
||||
this.terminal.dispose();
|
||||
}
|
||||
}
|
||||
@@ -592,23 +274,29 @@ class WebTerminal {
|
||||
const instances: Map<HTMLElement, WebTerminal> = new Map();
|
||||
|
||||
/** Initialize all terminal containers on page load */
|
||||
function initTerminals(): void {
|
||||
document.querySelectorAll<HTMLElement>(".textual-terminal").forEach((el) => {
|
||||
async function initTerminals(): Promise<void> {
|
||||
const containers = document.querySelectorAll<HTMLElement>(".textual-terminal");
|
||||
|
||||
for (const el of containers) {
|
||||
const wsUrl = el.dataset.sessionWebsocketUrl;
|
||||
if (!wsUrl) {
|
||||
console.error("Missing data-session-websocket-url on terminal container");
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = parseConfig(el);
|
||||
const terminal = new WebTerminal(el, wsUrl, config);
|
||||
instances.set(el, terminal);
|
||||
});
|
||||
try {
|
||||
const terminal = await WebTerminal.create(el, wsUrl, config);
|
||||
instances.set(el, terminal);
|
||||
} catch (e) {
|
||||
console.error("Failed to create terminal:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTerminals);
|
||||
document.addEventListener("DOMContentLoaded", () => initTerminals());
|
||||
} else {
|
||||
initTerminals();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user