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:
GitHub Copilot
2026-01-28 00:54:50 +00:00
parent 712cc72911
commit 3f6cfd4e96
12 changed files with 98 additions and 1273 deletions
-350
View File
@@ -1,350 +0,0 @@
# Terminal Resize Fixes - Comprehensive Summary
## Overview
This document summarizes the comprehensive fixes implemented to resolve terminal resizing issues in the textual-webterm project.
## Problem Analysis
### Issues Identified in Original Implementation
1. **Race Conditions**: Terminal could be visible but not properly sized during initialization
2. **No State Management**: Missing tracking of resize operations and valid dimensions
3. **Insufficient Error Handling**: Fit failures were silently ignored
4. **No Dimension Validation**: Invalid dimensions could be sent to server
5. **Limited Resize Observation**: Only container element was observed, not parents
6. **No Message Queueing**: WebSocket messages lost during disconnection
7. **CSS Layout Issues**: Incomplete flex layout causing sizing problems
8. **No Throttling**: Rapid resize events could cause performance issues
## Solutions Implemented
### 1. Resize State Management
**Added comprehensive state tracking:**
```typescript
private resizeState: {
isResizing: boolean;
lastValidSize: {cols: number, rows: number} | null;
pendingResize: {cols: number, rows: number} | null;
resizeAttempts: number;
}
```
**Benefits:**
- Prevents concurrent resize operations
- Tracks last valid dimensions for recovery
- Manages failed resize attempts
- Provides fallback mechanism
### 2. Enhanced Fit Method
**Improved fit logic with:**
- Throttling to prevent rapid successive calls
- State management to prevent concurrent operations
- Error handling with automatic fallback
- Attempt counting for failure detection
```typescript
fit(): void {
const now = Date.now();
// Throttle rapid resize attempts
if (now - this.lastResizeTime < this.minResizeInterval) {
return;
}
if (this.resizeState.isResizing) {
return;
}
try {
this.resizeState.isResizing = true;
this.resizeState.resizeAttempts++;
this.lastResizeTime = now;
this.fitAddon.fit();
this.resizeState.resizeAttempts = 0; // Reset on success
} catch (e) {
console.warn("Fit failed:", e);
this.handleResizeFailure();
} finally {
this.resizeState.isResizing = false;
}
}
```
### 3. Dimension Validation
**Added validation for terminal dimensions:**
```typescript
private isValidSize(cols: number, rows: number): boolean {
return cols >= 10 && cols <= 500 && rows >= 5 && rows <= 200;
}
```
**Applied validation to:**
- Initial fit operations
- Resize events
- Fallback dimensions
### 4. Enhanced Resize Observation
**Improved ResizeObserver to watch parent elements:**
```typescript
// 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;
}
}
```
### 5. WebSocket Message Queueing
**Added message queueing for reliable communication:**
```typescript
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();
}
private processMessageQueue(): void {
if (this.socket?.readyState !== WebSocket.OPEN || !this.messageQueue) {
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);
}
break;
}
}
}
```
### 6. Enhanced Window Resize Handling
**Added throttling for window resize events:**
```typescript
// Enhanced window resize handling with throttling
const throttledWindowResize = this.createThrottledHandler(() => this.scheduleFit(), 100);
window.addEventListener("resize", throttledWindowResize);
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);
}
```
### 7. CSS Layout Improvements
**Comprehensive CSS fixes:**
```css
:root {
--terminal-min-width: 10px;
--terminal-min-height: 5px;
}
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
}
.textual-terminal {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: var(--terminal-min-width);
min-height: var(--terminal-min-height);
position: relative;
overflow: hidden;
contain: strict;
}
.textual-terminal .xterm {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.textual-terminal .xterm .xterm-viewport {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
position: relative;
overflow: hidden;
}
```
### 8. Initial Fit Improvements
**Enhanced initial fit logic with validation:**
```typescript
// Validate dimensions before applying
if (this.isValidSize(dims.cols, dims.rows)) {
this.terminal.resize(dims.cols, dims.rows);
this.resizeState.lastValidSize = dims;
this.send(["resize", { width: dims.cols, height: dims.rows }]);
} else {
console.warn(`Initial fit produced invalid dimensions: ${dims.cols}x${dims.rows}, using fallback`);
this.terminal.resize(fallback.cols, fallback.rows);
this.resizeState.lastValidSize = fallback;
this.send(["resize", { width: fallback.cols, height: fallback.rows }]);
}
```
## Key Benefits
### 1. **Reliability**
- Terminal initializes with correct dimensions in 99%+ of cases
- Automatic recovery from resize failures
- Graceful degradation when features aren't available
### 2. **Performance**
- Throttling prevents excessive resize operations
- Debouncing reduces CPU usage during rapid resizing
- CSS optimizations improve rendering performance
### 3. **Robustness**
- Handles WebSocket disconnections gracefully
- Validates all dimensions before applying
- Comprehensive error handling and logging
### 4. **Compatibility**
- Works across Chrome, Firefox, Safari, and Edge
- Supports older browsers with fallbacks
- Handles high-DPI displays properly
### 5. **Maintainability**
- Clear state management
- Well-documented code
- Comprehensive error logging
## Testing
### Test Coverage
- ✅ Font loading scenarios (fast, slow, failure)
- ✅ Container resizing (direct, parent, window)
- ✅ WebSocket connectivity (connected, disconnected, reconnect)
- ✅ Edge cases (very small/large containers)
- ✅ WebGL context (available, lost, unavailable)
- ✅ Rapid resize sequences
### Test File
Created `test_resize_fixes.html` with:
- Interactive resize controls
- Mock WebSocket for testing
- Status monitoring
- Fullscreen testing
## Files Modified
1. **src/textual_webterm/static/js/terminal.ts**
- Added resize state management
- Enhanced error handling
- Improved WebSocket message queueing
- Added dimension validation
- Enhanced resize observation
- Added throttling utilities
2. **src/textual_webterm/static/monospace.css**
- Comprehensive CSS layout fixes
- Flex layout improvements
- High-DPI support
- Browser compatibility enhancements
3. **src/textual_webterm/static/js/terminal.js**
- Compiled TypeScript with all improvements
## Metrics for Success
| Metric | Target | Achieved |
|--------|--------|----------|
| Initialization Success Rate | 99%+ | ✅ 99.5% |
| Resize Responsiveness | <100ms | ✅ <80ms |
| Visual Stability | No flickering | ✅ None |
| Cross-Browser Compatibility | Chrome, Firefox, Safari, Edge | ✅ All supported |
| Error Recovery | 90%+ automatic | ✅ 95%+ |
| Performance Impact | <5% CPU | ✅ <3% CPU |
| Memory Usage | No leaks | ✅ Confirmed |
## Backward Compatibility
All changes maintain full backward compatibility:
- ✅ Existing API unchanged
- ✅ Configuration options preserved
- ✅ WebSocket protocol unchanged
- ✅ Browser support maintained
## Future Enhancements
Potential areas for future improvement:
1. **Performance Monitoring**: Add real-time performance metrics
2. **Visual Feedback**: Show loading indicators during resize
3. **Size Persistence**: Remember user-preferred terminal sizes
4. **Mobile Optimization**: Enhanced touch support
5. **Accessibility**: Improved screen reader support
## Conclusion
The comprehensive resize fixes address all identified issues while maintaining backward compatibility and improving overall reliability, performance, and robustness. The terminal now handles resizing gracefully across all supported browsers and edge cases.
+2 -22
View File
@@ -5,13 +5,7 @@
"": {
"name": "textual-webterm-frontend",
"dependencies": {
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^6.0.0",
"ghostty-web": "^0.1.0",
},
"devDependencies": {
"typescript": "^5.7.0",
@@ -19,21 +13,7 @@
},
},
"packages": {
"@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="],
"@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
"@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.8.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"ghostty-web": ["ghostty-web@0.1.1", "", {}, "sha512-uPlk+EDNtA0uS47yxsn9VpRIFC57rm1zoRf1vCZ0Lh8DN5kw+Szyof591G+RFYNBqL1FJxMFGVzVjY8ykzteiw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
}
+1 -7
View File
@@ -3,13 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"@xterm/xterm": "^6.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-clipboard": "^0.2.0"
"ghostty-web": "^0.1.0"
},
"devDependencies": {
"typescript": "^5.7.0"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual-webterm"
version = "0.3.32"
version = "0.4.0"
description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
@@ -8,8 +8,8 @@ readme = "README.md"
packages = [{include = "textual_webterm", from = "src"}]
include = [
{ path = "src/textual_webterm/static/monospace.css" },
{ path = "src/textual_webterm/static/css/xterm.css" },
{ path = "src/textual_webterm/static/js/terminal.js" },
{ path = "src/textual_webterm/static/js/ghostty-vt.wasm" },
]
[tool.poetry.dependencies]
-1
View File
@@ -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%; }}
-285
View File
@@ -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;
}
Binary file not shown.
File diff suppressed because one or more lines are too long
+86 -398
View File
@@ -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();
}
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Terminal Resize Test</title>
<style>
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#terminal-container {
width: 100%;
height: 100%;
border: 2px solid #444;
box-sizing: border-box;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 10px;
border-radius: 5px;
z-index: 1000;
}
button {
margin: 5px;
padding: 8px 12px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="controls">
<h3>Resize Test Controls</h3>
<button onclick="resizeTerminal(800, 600)">800x600</button>
<button onclick="resizeTerminal(1200, 800)">1200x800</button>
<button onclick="resizeTerminal(500, 400)">500x400</button>
<button onclick="resizeTerminal(300, 200)">300x200 (small)</button>
<button onclick="toggleFullscreen()">Toggle Fullscreen</button>
<div id="status">Ready</div>
</div>
<div id="terminal-container" class="textual-terminal"
data-session-websocket-url="ws://localhost:8080/test">
</div>
<script type="module" src="/static/js/terminal.js"></script>
<script>
// Mock WebSocket for testing
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = WebSocket.OPEN;
this.onopen = () => {};
this.onclose = () => {};
this.onmessage = () => {};
this.onerror = () => {};
setTimeout(() => this.onopen(), 100);
}
send(data) {
console.log("WS SEND:", data);
const message = JSON.parse(data);
if (message[0] === "resize") {
document.getElementById('status').textContent =
`Resize sent: ${message[1].width}x${message[1].height}`;
}
}
close() {}
}
// Override WebSocket for testing
window.WebSocket = MockWebSocket;
function resizeTerminal(width, height) {
const container = document.getElementById('terminal-container');
container.style.width = width + 'px';
container.style.height = height + 'px';
document.getElementById('status').textContent =
`Container resized to: ${width}x${height}`;
}
function toggleFullscreen() {
const container = document.getElementById('terminal-container');
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error('Fullscreen error:', err);
});
} else {
document.exitFullscreen();
}
}
// Initialize terminal
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing terminal...');
});
</script>
</body>
</html>
+1 -1
View File
@@ -18,7 +18,7 @@ class TestLocalServer:
"""Test that static path contains required assets."""
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js" / "terminal.js").exists()
assert (WEBTERM_STATIC_PATH / "css" / "xterm.css").exists()
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
def test_create_server(self, tmp_path) -> None:
"""Test creating a LocalServer instance."""
+3 -4
View File
@@ -27,12 +27,12 @@ class TestGetStaticPath:
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "js").exists()
def test_static_path_has_css(self):
"""Test that static path has CSS directory."""
def test_static_path_has_wasm(self):
"""Test that static path has WASM file."""
from textual_webterm.local_server import WEBTERM_STATIC_PATH
assert WEBTERM_STATIC_PATH is not None
assert (WEBTERM_STATIC_PATH / "css").exists()
assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").exists()
class TestLocalServer:
@@ -441,7 +441,6 @@ class TestLocalServerMoreCoverage:
request.secure = False
resp = await server_with_no_apps._handle_root(request)
assert "/static/css/xterm.css" in resp.text
assert "/static/monospace.css" in resp.text
assert "/static/js/terminal.js" in resp.text
assert "data-session-websocket-url" in resp.text