diff --git a/REIZE_FIXES_SUMMARY.md b/REIZE_FIXES_SUMMARY.md deleted file mode 100644 index 026a3cb..0000000 --- a/REIZE_FIXES_SUMMARY.md +++ /dev/null @@ -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. diff --git a/bun.lock b/bun.lock index 4573060..057f302 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } diff --git a/package.json b/package.json index f3e1c49..979baba 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pyproject.toml b/pyproject.toml index d5dfb8c..914337c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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] diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 207733e..3af3a27 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -846,7 +846,6 @@ class LocalServer: {page_title} - - - -
-

Resize Test Controls

- - - - - -
Ready
-
- -
-
- - - - - - \ No newline at end of file diff --git a/tests/test_local_server.py b/tests/test_local_server.py index f51ba7f..9d06a7b 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -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.""" diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 6e1657e..0c8d804 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -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