Fix mobile terminal resize loop (#305)

This commit is contained in:
Peter Steinberger 2025-07-11 08:23:47 +02:00 committed by GitHub
parent b16035b9f8
commit 0c617aed8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 674 additions and 366 deletions

365
docs/architecture-mario.md Normal file
View file

@ -0,0 +1,365 @@
# VibeTunnel Architecture Analysis - Mario's Technical Deep Dive
This document contains comprehensive technical insights from Mario's debugging session about VibeTunnel's architecture, critical performance issues, and detailed solutions.
## Executive Summary
Mario identified two critical issues causing performance problems in VibeTunnel:
1. **850MB Session Bug**: External terminal sessions (via `fwd.ts`) bypass the clear sequence truncation in `stream-watcher.ts`, sending entire gigabyte files instead of the last 2MB
2. **Resize Loop**: Claude terminal app issues full clear sequence (`\x1b[2J`) and re-renders entire scroll buffer on every resize event, creating exponential data growth
Note: A third issue with Node-PTY's shared pipe architecture causing Electron crashes has already been resolved with a custom PTY implementation.
## System Architecture
### Core Components
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────▶│ Web Server │────▶│ PTY Process │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
│ ▼ ▼
│ ┌──────────┐ ┌──────────┐
└─────────────▶│ Terminal │ │ Ascinema │
│ Manager │ │ Files │
└──────────┘ └──────────┘
```
### Detailed Sequence Flow
```mermaid
sequenceDiagram
participant UI as Browser/Electron
participant WS as WebSocket
participant Server as Node Server
participant PTY as PTYManager
participant FWD as fwd.ts
participant Proc as User Process
UI->>WS: keystrokes
WS->>Server: /api/sessions/:id/input
Server->>PTY: IPC Socket message
PTY->>Proc: write to stdin
Proc-->>PTY: stdout (ANSI sequences)
PTY-->>Server: write to ascinema file
alt External Terminal
FWD-->>TTY: mirror stdout to terminal
end
Server-->>UI: SSE stream (truncated)
```
### Key Files and Their Roles
| File | Purpose | Critical Functions |
|------|---------|-------------------|
| `server.ts` | Main web server | HTTP endpoints, WebSocket handling |
| `pty-manager.ts` | PTY lifecycle management | `createSession()`, `setupPtyHandlers()` |
| `stream-watcher.ts` | Monitors ascinema files | `sendExistingContent()` - implements clear truncation |
| `fwd.ts` | External terminal forwarding | Process spawning, **BYPASSES TRUNCATION** |
| `terminal-manager.ts` | Binary buffer rendering | Converts ANSI to binary cells format |
### Data Flow Paths
#### Input Path (Keystroke → Terminal)
1. Browser captures key press
2. WebSocket sends to `/api/sessions/:id/input`
3. Server writes to IPC socket
4. PTY Manager writes to process stdin
5. Process executes command
#### Output Path (Terminal → Browser)
1. Process writes to stdout
2. PTY Manager captures via `onData` handler
3. Writes to ascinema file (with write queue for backpressure)
4. Stream watcher monitors file changes
5. For existing content: **scans for last clear sequence**
6. Client receives via:
- SSE: `/api/sessions/:id/stream` (text/ascinema format)
- WebSocket: `/buffers` (binary cell format)
### Binary Cell Buffer Format
The terminal manager pre-renders terminal output into a binary format for efficient transmission:
```
For each cell at (row, column):
- Character (UTF-8 encoded)
- Foreground color (RGB values)
- Background color (RGB values)
- Attributes (bold, italic, underline, etc.)
```
Benefits:
- Server-side ANSI parsing eliminates client CPU usage
- Efficient binary transmission reduces bandwidth
- Only last 10,000 lines kept in memory
- Client simply renders pre-computed cells
## Critical Bugs Analysis
### 1. The 850MB Session Loading Bug
**Symptom**: Sessions with large output (850MB+) cause infinite loading and browser unresponsiveness.
**Root Cause**: External terminal sessions via `fwd.ts` bypass the clear sequence truncation logic.
**Technical Details**:
```javascript
// In stream-watcher.ts - WORKING CORRECTLY
sendExistingContent() {
// Scans backwards for last clear sequence
const lastClear = content.lastIndexOf('\x1b[2J');
// Sends only content after clear
return content.slice(lastClear); // 2MB instead of 850MB
}
```
**Evidence from Testing**:
- Test file: 980MB containing 2,400 clear sequences
- Server-created sessions: Correctly send only last 2MB
- External terminal sessions: Send entire 980MB file
- Processing time: 2-3 seconds to scan 1GB file
- Client receives instant replay for 2MB truncated content
**The Issue**: External terminal path doesn't trigger `sendExistingContent()`, sending gigabyte files to clients.
### 2. Resize Event Performance Catastrophe
**Problem**: Each resize event causes Claude to re-render the entire terminal history.
**Claude's Behavior**:
```
1. Resize event received
2. Claude issues clear sequence: \x1b[2J
3. Re-renders ENTIRE scroll buffer from line 1
4. Rendering causes viewport changes
5. Viewport changes trigger resize event
6. GOTO step 1 (infinite loop)
```
**Technical Evidence**:
- In 850MB session: each resize → full buffer re-render
- Claude renders from "Welcome to Claude" message every time
- Mobile UI particularly problematic (frequent resize events)
- Header button position shifts during rendering indicate viewport instability
- Session with 39 resize events can generate 850MB+ files
**Contributing Factors**:
- React Ink (TUI framework) unnecessarily re-renders entire components
- Session-detail-view has buggy resize observer
- Mobile Safari behaves differently than desktop at same viewport size
- Touch events vs mouse events complicate scrolling behavior
### 3. Node-PTY Architecture Flaw (ALREADY FIXED)
This issue has been resolved by implementing a custom PTY solution without the shared pipe architecture.
## Ascinema Format Details
VibeTunnel uses the ascinema format for recording terminal sessions:
```javascript
// Format: [timestamp, event_type, data]
[1.234, "o", "Hello World\n"] // Output event
[1.235, "i", "k"] // Input event (keypress)
[1.236, "r", "80x24"] // Resize event
```
Clear sequence detection:
```javascript
const CLEAR_SEQUENCE = '\x1b[2J'; // ANSI clear screen
const CLEAR_WITH_HOME = '\x1b[H\x1b[2J'; // Home + clear
function findLastClearSequence(buffer) {
// Search from end for efficiency
let lastClear = buffer.lastIndexOf(CLEAR_SEQUENCE);
return lastClear === -1 ? 0 : lastClear;
}
```
## Proposed Solutions
### Priority 1: Fix External Terminal Clear Truncation (IMMEDIATE)
**Problem**: External terminal sessions don't use `sendExistingContent()` truncation.
**Investigation Needed**:
1. Trace how `fwd.ts` connects to client streams
2. Determine why it bypasses stream-watcher's truncation
3. Ensure external terminals use same code path as server sessions
4. Test with 980MB file to verify fix
**Expected Impact**: Immediate fix for users experiencing infinite loading with large sessions.
### Priority 2: Fix Resize Handling (INVESTIGATION)
**Debugging Approach**:
1. Instrument session-detail-view with resize observer logging
2. Identify what causes viewport expansion
3. Implement resize event debouncing
4. Fix mobile-specific issues:
- Keyboard state affects scrolling
- Touch vs mouse event handling
- Scrollbar visibility problems
**Code to Add**:
```javascript
// Add to session-detail-view
let resizeCount = 0;
new ResizeObserver((entries) => {
console.log(`Resize ${++resizeCount}:`, entries[0].contentRect);
// Debounce resize events
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.handleResize();
}, 100);
}).observe(this.terminalElement);
```
## Implementation Details
### Write Queue Implementation
The PTY manager implements backpressure handling:
```javascript
class WriteQueue {
constructor(writer) {
this.queue = [];
this.writing = false;
this.writer = writer;
}
async write(data) {
this.queue.push(data);
if (!this.writing) {
await this.flush();
}
}
async flush() {
this.writing = true;
while (this.queue.length > 0) {
const chunk = this.queue.shift();
await this.writer.write(chunk);
}
this.writing = false;
}
}
```
### Platform-Specific Considerations
**macOS**:
- Screen Recording permission required for terminal access
- Terminal.app specific behaviors and quirks
**Mobile Safari**:
- Different behavior than desktop Safari at same viewport
- Touch events complicate scrolling
- Keyboard state affects scroll behavior
- Missing/hidden scrollbars
- Viewport meta tag issues
**Windows (Future)**:
- ConPTY vs WinPTY support
- Different ANSI sequence handling
- Path normalization requirements
## Performance Metrics
| Metric | Current | After Fix |
|--------|---------|-----------|
| 980MB session initial load | Infinite/Crash | 2-3 seconds |
| Data sent to client | 980MB | 2MB |
| Memory per terminal | 50-100MB | Target: 10MB |
| Clear sequence scan time | N/A | ~2 seconds for 1GB |
| Resize event storms | Exponential growth | Debounced |
## Testing and Debugging
### Test Large Session Handling
```bash
# Create large session file
cd web
npm run dev
# In another terminal, create session
SESSION_ID=$(curl -X POST localhost:3000/api/sessions | jq -r .id)
# Stop server, inject large file
cp /path/to/850mb-test-file ~/.vibetunnel/sessions/$SESSION_ID/stdout
# Restart and verify truncation works
npm run dev
```
### Debug Resize Events
```javascript
// Add to any component to detect resize loops
window.addEventListener('resize', () => {
console.count('resize');
console.trace('Resize triggered from:');
});
```
### Monitor Network Traffic
- Check `/api/sessions/:id/stream` response size
- Verify only sends data after last clear
- Monitor WebSocket `/buffers` for binary updates
## Architectural Insights
### Why Current Architecture Works (When Not Bugged)
1. **Simplicity**: "Es ist die todeleinfachste Variante" - It's the simplest possible approach
2. **Efficiency**: 2MB instead of 980MB transmission after clear sequence truncation
3. **Server-side rendering**: Binary cell format eliminates client ANSI parsing
### Community Contribution Challenges
- High development velocity makes contribution difficult
- "Velocity kills" - rapid changes discourage contributors
- LitElement/Web Components unfamiliar to most developers
- Large file sizes cause AI tools to refuse processing
### Future Architecture Considerations
**Go Migration Benefits**:
- Automatic test dependency tracking
- Only runs tests that changed
- Pre-allocated buffers minimize GC
- Better suited for AI-assisted development
**Rust Benefits**:
- 2MB static binary
- 10MB RAM usage
- Direct C interop for PTY code
- No garbage collection overhead
## Action Plan Summary
1. **Immediate (End of Week)**: Fix external terminal truncation bug
- Debug why `fwd.ts` bypasses `sendExistingContent()`
- Deploy fix for immediate user relief
2. **Short Term**: Comprehensive resize fix
- Debug session-detail-view triggers
- Implement proper debouncing
- Fix mobile-specific issues
3. **Long Term**: Consider architecture migration
- Evaluate Rust forward binary
- Consider Go for web server
- Maintain backwards compatibility
## Key Technical Quotes
- "Wir schicken 2MB statt 980MB" - We send 2MB instead of 980MB
- "Die haben einen Shared Pipe, wo alle reinschreiben" - They have a shared pipe where everyone writes
- "Es gibt keinen Grund, warum ich von da weg alles neu rendern muss" - There's no reason to re-render everything from the beginning
- "Das ist known good" - Referring to battle-tested implementations
This architecture analysis provides the technical foundation for fixing VibeTunnel's critical performance issues while maintaining its elegant simplicity.

View file

@ -79,148 +79,12 @@ process.on('SIGTERM', () => {
process.exit(1);
});
function applyMinimalPatches() {
console.log('Applying minimal SEA patches to node-pty...');
// Create sea-loader.js
const seaLoaderPath = path.join(__dirname, 'node_modules/node-pty/lib/sea-loader.js');
if (!fs.existsSync(seaLoaderPath)) {
const seaLoaderContent = `"use strict";
/* VIBETUNNEL_SEA_LOADER */
Object.defineProperty(exports, "__esModule", { value: true });
var path = require("path");
var fs = require("fs");
// Custom loader for SEA that uses process.dlopen
var pty;
// Helper function to load native module using dlopen
function loadNativeModule(modulePath) {
const module = { exports: {} };
process.dlopen(module, modulePath);
return module.exports;
}
// Determine the path to pty.node
function getPtyPath() {
const execDir = path.dirname(process.execPath);
// Look for pty.node next to the executable first
const ptyPath = path.join(execDir, 'pty.node');
if (fs.existsSync(ptyPath)) {
// Add path validation for security
const resolvedPath = path.resolve(ptyPath);
const resolvedExecDir = path.resolve(execDir);
if (!resolvedPath.startsWith(resolvedExecDir)) {
throw new Error('Invalid pty.node path detected');
}
return ptyPath;
}
// If not found, throw error with helpful message
throw new Error('Could not find pty.node next to executable at: ' + ptyPath);
}
try {
const ptyPath = getPtyPath();
// Set spawn-helper path for macOS only
// Linux uses forkpty() directly and doesn't need spawn-helper
if (process.platform === 'darwin') {
const execDir = path.dirname(process.execPath);
const spawnHelperPath = path.join(execDir, 'spawn-helper');
if (fs.existsSync(spawnHelperPath)) {
process.env.NODE_PTY_SPAWN_HELPER_PATH = spawnHelperPath;
}
}
pty = loadNativeModule(ptyPath);
} catch (error) {
console.error('Failed to load pty.node:', error);
throw error;
}
exports.default = pty;
`;
fs.writeFileSync(seaLoaderPath, seaLoaderContent);
}
// Patch index.js
const indexPath = path.join(__dirname, 'node_modules/node-pty/lib/index.js');
if (fs.existsSync(indexPath)) {
let content = fs.readFileSync(indexPath, 'utf8');
if (!content.includes('VIBETUNNEL_SEA')) {
content = content.replace(
"exports.native = (process.platform !== 'win32' ? require('../build/Release/pty.node') : null);",
"exports.native = (process.platform !== 'win32' ? (process.env.VIBETUNNEL_SEA ? require('./sea-loader').default : require('../build/Release/pty.node')) : null);"
);
fs.writeFileSync(indexPath, content);
}
}
// Patch unixTerminal.js
const unixPath = path.join(__dirname, 'node_modules/node-pty/lib/unixTerminal.js');
if (fs.existsSync(unixPath)) {
let content = fs.readFileSync(unixPath, 'utf8');
if (!content.includes('VIBETUNNEL_SEA')) {
// Find and replace the pty loading section
const startMarker = 'var pty;\nvar helperPath;';
const endMarker = 'var DEFAULT_FILE = \'sh\';';
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const newSection = `var pty;
var helperPath;
// For SEA, check environment variables
if (process.env.VIBETUNNEL_SEA) {
pty = require('./sea-loader').default;
// In SEA context, look for spawn-helper on macOS only (Linux doesn't use it)
if (process.platform === 'darwin') {
const execDir = path.dirname(process.execPath);
const spawnHelperPath = path.join(execDir, 'spawn-helper');
if (require('fs').existsSync(spawnHelperPath)) {
helperPath = spawnHelperPath;
} else if (process.env.NODE_PTY_SPAWN_HELPER_PATH) {
helperPath = process.env.NODE_PTY_SPAWN_HELPER_PATH;
}
}
// On Linux, helperPath remains undefined which is fine
} else {
// Original loading logic
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
}
catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
}
catch (innerError) {
console.error('innerError', innerError);
// Re-throw the exception from the Release require if the Debug require fails as well
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
`;
content = content.substring(0, startIdx) + newSection + content.substring(endIdx);
fs.writeFileSync(unixPath, content);
}
}
}
console.log('SEA patches applied successfully');
}
// No patching needed - SEA support is built into our vendored node-pty
async function main() {
try {
// Apply minimal patches to node-pty
applyMinimalPatches();
// No patching needed - SEA support is built into our vendored node-pty
console.log('Using vendored node-pty with built-in SEA support...');
// Ensure native modules are built (in case postinstall didn't run)
const nativePtyDir = 'node_modules/node-pty/build/Release';

View file

@ -1,5 +1,4 @@
node_modules/
build/
lib/
*.log
.DS_Store

View file

@ -11,7 +11,7 @@
'xcode_settings': {
'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
'CLANG_CXX_LIBRARY': 'libc++',
'MACOSX_DEPLOYMENT_TARGET': '10.7',
'MACOSX_DEPLOYMENT_TARGET': '14.0',
},
'msvs_settings': {
'VCCLCompilerTool': { 'ExceptionHandling': 1 },
@ -43,7 +43,7 @@
'conditions': [
['OS=="mac"', {
'xcode_settings': {
'MACOSX_DEPLOYMENT_TARGET': '10.12'
'MACOSX_DEPLOYMENT_TARGET': '14.0'
}
}]
]

View file

@ -40,24 +40,48 @@ const terminal_1 = require("./terminal");
const utils_1 = require("./utils");
let pty;
let helperPath;
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
// Check if running in SEA (Single Executable Application) context
if (process.env.VIBETUNNEL_SEA) {
// In SEA mode, load native module using process.dlopen
const fs = require('fs');
const execDir = path.dirname(process.execPath);
const ptyPath = path.join(execDir, 'pty.node');
if (fs.existsSync(ptyPath)) {
const module = { exports: {} };
process.dlopen(module, ptyPath);
pty = module.exports;
}
else {
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
}
// Set spawn-helper path for macOS only (Linux doesn't use it)
if (process.platform === 'darwin') {
helperPath = path.join(execDir, 'spawn-helper');
if (!fs.existsSync(helperPath)) {
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
}
}
}
catch (outerError) {
else {
// Standard Node.js loading
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
}
catch (innerError) {
console.error('innerError', innerError);
// Re-throw the exception from the Release require if the Debug require fails as well
throw outerError;
catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
}
catch (innerError) {
console.error('innerError', innerError);
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
const DEFAULT_FILE = 'sh';
const DEFAULT_NAME = 'xterm';
const DESTROY_SOCKET_TIMEOUT_MS = 200;

View file

@ -13,23 +13,48 @@ import { assign } from './utils';
let pty: IUnixNative;
let helperPath: string;
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
} catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
} catch (innerError) {
console.error('innerError', innerError);
// Re-throw the exception from the Release require if the Debug require fails as well
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
// Check if running in SEA (Single Executable Application) context
if (process.env.VIBETUNNEL_SEA) {
// In SEA mode, load native module using process.dlopen
const fs = require('fs');
const execDir = path.dirname(process.execPath);
const ptyPath = path.join(execDir, 'pty.node');
if (fs.existsSync(ptyPath)) {
const module = { exports: {} };
process.dlopen(module, ptyPath);
pty = module.exports as IUnixNative;
} else {
throw new Error(`Could not find pty.node next to executable at: ${ptyPath}`);
}
// Set spawn-helper path for macOS only (Linux doesn't use it)
if (process.platform === 'darwin') {
helperPath = path.join(execDir, 'spawn-helper');
if (!fs.existsSync(helperPath)) {
console.warn(`spawn-helper not found at ${helperPath}, PTY operations may fail`);
}
}
} else {
// Standard Node.js loading
try {
pty = require('../build/Release/pty.node');
helperPath = '../build/Release/spawn-helper';
} catch (outerError) {
try {
pty = require('../build/Debug/pty.node');
helperPath = '../build/Debug/spawn-helper';
} catch (innerError) {
console.error('innerError', innerError);
throw outerError;
}
}
helperPath = path.resolve(__dirname, helperPath);
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
const DEFAULT_FILE = 'sh';
const DEFAULT_NAME = 'xterm';

View file

@ -75,7 +75,7 @@
"mime-types": "^3.0.1",
"monaco-editor": "^0.52.2",
"multer": "^2.0.1",
"node-pty": "file:./vendored-pty",
"node-pty": "file:./node-pty",
"postject": "^1.0.0-alpha.6",
"signal-exit": "^4.1.0",
"web-push": "^3.6.7",

View file

@ -72,8 +72,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1
node-pty:
specifier: file:./vendored-pty
version: '@vibetunnel/vendored-pty@file:vendored-pty'
specifier: file:./node-pty
version: file:node-pty
postject:
specifier: ^1.0.0-alpha.6
version: 1.0.0-alpha.6
@ -970,9 +970,6 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@vibetunnel/vendored-pty@file:vendored-pty':
resolution: {directory: vendored-pty, type: directory}
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
peerDependencies:
@ -2311,6 +2308,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@file:node-pty:
resolution: {directory: node-pty, type: directory}
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@ -3934,10 +3934,6 @@ snapshots:
'@types/node': 24.0.4
optional: true
'@vibetunnel/vendored-pty@file:vendored-pty':
dependencies:
node-addon-api: 7.1.1
'@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
dependencies:
'@ampproject/remapping': 2.3.0
@ -5398,6 +5394,10 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-pty@file:node-pty:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.19: {}
normalize-path@3.0.0: {}

View file

@ -79,7 +79,11 @@
<!-- Mobile viewport height fix -->
<script>
// Handle dynamic viewport height for mobile browsers
// Check if mobile device
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
// Handle viewport height - set only once on mobile to prevent resize loops
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
@ -88,11 +92,14 @@
// Set initial height
setViewportHeight();
// Update on resize and orientation change
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', () => {
setTimeout(setViewportHeight, 100);
});
// On mobile, we only set viewport height once to prevent resize loops
// On desktop, we still allow dynamic resizing
if (!isMobile) {
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', () => {
setTimeout(setViewportHeight, 100);
});
}
// Force full-screen behavior
window.addEventListener('load', () => {

View file

@ -859,14 +859,14 @@ describe('SessionView', () => {
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Check that terminal container height was calculated correctly
// Quick keys height (150) + keyboard height (300) + buffer (10) = 460px reduction
// On mobile with keyboard and quick keys, height should be calculated dynamically
// Height reduction = keyboardHeight (300) + quickKeysHeight (150) + buffer (10) = 460px
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
// Should have called fitTerminal
// fitTerminal should be called even on mobile now (height changes allowed)
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
// Should have called scrollToBottom due to height reduction
// scrollToBottom should be called when height is reduced
expect(terminalElement.scrollToBottom).toHaveBeenCalled();
vi.useRealTimers();
@ -887,8 +887,8 @@ describe('SessionView', () => {
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// On desktop, quick keys should not affect terminal height
expect(element.terminalContainerHeight).toBe('100%');
// On desktop, quick keys should affect terminal height
expect(element.terminalContainerHeight).toBe('calc(100% - 150px)');
vi.useRealTimers();
});
@ -905,6 +905,8 @@ describe('SessionView', () => {
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// On mobile with keyboard only, height should be calculated dynamically
// Height reduction = keyboardHeight (300) + buffer (10) = 310px
expect(element.terminalContainerHeight).toBe('calc(100% - 310px)');
// Now hide the keyboard
@ -914,7 +916,7 @@ describe('SessionView', () => {
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Height should be reset to 100%
// On mobile with keyboard hidden, height should be back to 100%
expect(element.terminalContainerHeight).toBe('100%');
vi.useRealTimers();
@ -958,10 +960,11 @@ describe('SessionView', () => {
vi.advanceTimersByTime(110);
await vi.runAllTimersAsync();
// Should use the latest values: keyboard 300 + quick keys 150 + buffer 10 = 460px
// On mobile with quick keys and keyboard, height should be calculated
// Height reduction = keyboardHeight (300) + quickKeysHeight (150) + buffer (10) = 460px
expect(element.terminalContainerHeight).toBe('calc(100% - 460px)');
// Should have called fitTerminal only once due to debounce
// fitTerminal should be called on mobile for height changes
expect(fitTerminalSpy).toHaveBeenCalledTimes(1);
vi.useRealTimers();

View file

@ -1117,7 +1117,7 @@ export class SessionView extends LitElement {
// Calculate height reduction for keyboard and quick keys
let heightReduction = 0;
if (this.showQuickKeys && this.isMobile) {
if (this.showQuickKeys) {
// Quick keys height (approximately 140px based on CSS)
// Add 10px buffer to ensure content is visible above quick keys
const quickKeysHeight = 150;

View file

@ -149,13 +149,21 @@ export class TerminalLifecycleManager {
async handleTerminalResize(event: Event) {
const customEvent = event as CustomEvent;
// Update terminal dimensions for display
const { cols, rows } = customEvent.detail;
const { cols, rows, isMobile, isHeightOnlyChange, source } = customEvent.detail;
// Notify the session view to update its state
if (this.stateCallbacks) {
this.stateCallbacks.updateTerminalDimensions(cols, rows);
}
// On mobile, skip sending height-only changes to the server (keyboard events)
if (isMobile && isHeightOnlyChange) {
logger.debug(
`skipping mobile height-only resize to server: ${cols}x${rows} (source: ${source})`
);
return;
}
// Debounce resize requests to prevent jumpiness
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);

View file

@ -21,9 +21,8 @@ interface TestTerminal extends Terminal {
measureCharacterWidth(): number;
fitTerminal(): void;
userOverrideWidth: boolean;
resizeCoordinator: {
forceUpdateDimensions(cols: number, rows: number): void;
};
lastCols: number;
lastRows: number;
}
describe('Terminal', () => {
@ -607,8 +606,9 @@ describe('Terminal', () => {
element.cols = currentCols;
element.rows = currentRows;
// Initialize ResizeCoordinator with current dimensions
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
// Initialize last dimensions to match current dimensions
(element as TestTerminal).lastCols = currentCols;
(element as TestTerminal).lastRows = currentRows;
// Mock character width measurement
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
@ -668,7 +668,7 @@ describe('Terminal', () => {
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'terminal-resize',
detail: { cols, rows },
detail: expect.objectContaining({ cols, rows }),
})
);
});
@ -696,8 +696,9 @@ describe('Terminal', () => {
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
// Initialize ResizeCoordinator with current dimensions
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(currentCols, currentRows);
// Initialize last dimensions to match current dimensions
(element as TestTerminal).lastCols = currentCols;
(element as TestTerminal).lastRows = currentRows;
// Call fitTerminal multiple times
(element as TestTerminal).fitTerminal();
@ -860,8 +861,9 @@ describe('Terminal', () => {
vi.spyOn(element as TestTerminal, 'measureCharacterWidth').mockReturnValue(8);
// Initialize ResizeCoordinator with current dimensions
(element as TestTerminal).resizeCoordinator.forceUpdateDimensions(100, 30);
// Initialize last dimensions to match current dimensions
(element as TestTerminal).lastCols = 100;
(element as TestTerminal).lastRows = 30;
// Clear previous calls
mockTerminal?.resize.mockClear();

View file

@ -15,7 +15,6 @@ import { html, LitElement, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js';
import { createLogger } from '../utils/logger.js';
import { ResizeCoordinator } from '../utils/resize-coordinator.js';
import { UrlHighlighter } from '../utils/url-highlighter';
const logger = createLogger('terminal');
@ -73,7 +72,12 @@ export class Terminal extends LitElement {
private momentumVelocityX = 0;
private momentumAnimation: number | null = null;
private resizeObserver: ResizeObserver | null = null;
private resizeCoordinator = new ResizeCoordinator();
private mobileWidthResizeComplete = false;
private pendingResize: number | null = null;
private lastCols = 0;
private lastRows = 0;
private isMobile = false;
private mobileInitialResizeTimeout: NodeJS.Timeout | null = null;
// Operation queue for batching buffer modifications
private operationQueue: (() => void | Promise<void>)[] = [];
@ -136,7 +140,7 @@ export class Terminal extends LitElement {
this.userOverrideWidth = stored === 'true';
// Apply the loaded preference immediately
if (this.container) {
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
}
} catch (error) {
@ -159,7 +163,7 @@ export class Terminal extends LitElement {
}
// Recalculate terminal dimensions when font size changes
if (this.terminal && this.container) {
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
}
if (changedProperties.has('fitHorizontally')) {
@ -167,12 +171,12 @@ export class Terminal extends LitElement {
// Restore original font size when turning off horizontal fitting
this.fontSize = this.originalFontSize;
}
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
// If maxCols changed, trigger a resize
if (changedProperties.has('maxCols')) {
if (this.terminal && this.container) {
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
}
}
@ -196,7 +200,7 @@ export class Terminal extends LitElement {
}
// Trigger a resize to apply the new setting
if (this.container) {
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
}
@ -212,8 +216,14 @@ export class Terminal extends LitElement {
this.resizeObserver = null;
}
if (this.resizeCoordinator) {
this.resizeCoordinator.destroy();
if (this.pendingResize) {
cancelAnimationFrame(this.pendingResize);
this.pendingResize = null;
}
if (this.mobileInitialResizeTimeout) {
clearTimeout(this.mobileInitialResizeTimeout);
this.mobileInitialResizeTimeout = null;
}
if (this.terminal) {
@ -226,14 +236,81 @@ export class Terminal extends LitElement {
// Store the initial font size as original
this.originalFontSize = this.fontSize;
// Set up resize coordinator callback
this.resizeCoordinator.setResizeCallback((source: string) => {
this.fitTerminal(source);
});
// Initialize terminal immediately
this.initializeTerminal();
}
// Consistent mobile detection across the app
private detectMobile(): boolean {
// Use the same logic as index.html for consistency
return (
/iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints !== undefined && navigator.maxTouchPoints > 1)
);
}
private requestResize(source: string) {
// Update mobile state using consistent detection
this.isMobile = this.detectMobile();
logger.debug(`[Terminal] Resize requested from ${source} (mobile: ${this.isMobile})`);
// Cancel any pending resize
if (this.pendingResize) {
cancelAnimationFrame(this.pendingResize);
}
// Schedule resize for next animation frame
this.pendingResize = requestAnimationFrame(() => {
this.fitTerminal(source);
this.pendingResize = null;
});
}
private shouldResize(cols: number, rows: number): boolean {
// On mobile, prevent WIDTH changes after initial setup, but allow HEIGHT changes
if (this.isMobile && this.mobileWidthResizeComplete) {
// Check if only height changed (allow keyboard resizes)
const widthChanged = this.lastCols !== cols;
const heightChanged = this.lastRows !== rows;
if (widthChanged) {
logger.debug(`[Terminal] Preventing WIDTH resize on mobile (width already set)`);
return false;
}
if (heightChanged) {
logger.debug(
`[Terminal] Allowing HEIGHT resize on mobile: ${this.lastRows}${rows} rows`
);
this.lastRows = rows;
return true;
}
return false;
}
// Check if dimensions actually changed
const changed = this.lastCols !== cols || this.lastRows !== rows;
if (changed) {
logger.debug(
`[Terminal] Dimensions changed: ${this.lastCols}x${this.lastRows}${cols}x${rows}`
);
this.lastCols = cols;
this.lastRows = rows;
// Mark mobile WIDTH resize as complete after first resize
if (this.isMobile && !this.mobileWidthResizeComplete) {
this.mobileWidthResizeComplete = true;
logger.debug(`[Terminal] Mobile WIDTH resize complete - blocking future width changes`);
}
}
return changed;
}
private async initializeTerminal() {
try {
this.requestUpdate();
@ -275,7 +352,7 @@ export class Terminal extends LitElement {
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
this.terminal.resize(safeCols, safeRows);
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
}
}
@ -362,7 +439,18 @@ export class Terminal extends LitElement {
}
private fitTerminal(source?: string) {
if (!this.terminal || !this.container) return;
if (!this.terminal || !this.container) {
logger.warn('[Terminal] Cannot fit terminal: terminal or container not initialized');
return;
}
logger.debug(`[Terminal] fitTerminal called from source: ${source || 'unknown'}`);
// Use the class property instead of rechecking
if (this.isMobile) {
logger.debug(
`[Terminal] Mobile detected in fitTerminal - source: ${source}, userAgent: ${navigator.userAgent}`
);
}
const _oldActualRows = this.actualRows;
const oldLineHeight = this.fontSize * 1.2;
@ -400,15 +488,29 @@ export class Terminal extends LitElement {
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
// Save old dimensions before shouldResize updates them
const oldCols = this.lastCols;
const oldRows = this.lastRows;
// Use resize coordinator to check if we should actually resize
if (this.resizeCoordinator.shouldResize(safeCols, safeRows)) {
if (this.shouldResize(safeCols, safeRows)) {
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
// Include mobile flag and whether this is height-only change
const isWidthChange = safeCols !== oldCols;
const isHeightOnlyChange = !isWidthChange && safeRows !== oldRows;
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: safeCols, rows: safeRows },
detail: {
cols: safeCols,
rows: safeRows,
isMobile: this.isMobile,
isHeightOnlyChange,
source: source || 'unknown',
},
bubbles: true,
})
);
@ -459,15 +561,29 @@ export class Terminal extends LitElement {
const safeCols = Number.isFinite(this.cols) ? Math.floor(this.cols) : 80;
const safeRows = Number.isFinite(this.rows) ? Math.floor(this.rows) : 24;
// Save old dimensions before shouldResize updates them
const oldCols = this.lastCols;
const oldRows = this.lastRows;
// Use resize coordinator to check if we should actually resize
if (this.resizeCoordinator.shouldResize(safeCols, safeRows)) {
if (this.shouldResize(safeCols, safeRows)) {
logger.debug(`Resizing terminal (${source || 'unknown'}): ${safeCols}x${safeRows}`);
this.terminal.resize(safeCols, safeRows);
// Dispatch resize event for backend synchronization
// Include mobile flag and whether this is height-only change
const isWidthChange = safeCols !== oldCols;
const isHeightOnlyChange = !isWidthChange && safeRows !== oldRows;
this.dispatchEvent(
new CustomEvent('terminal-resize', {
detail: { cols: safeCols, rows: safeRows },
detail: {
cols: safeCols,
rows: safeRows,
isMobile: this.isMobile,
isHeightOnlyChange,
source: source || 'unknown',
},
bubbles: true,
})
);
@ -502,27 +618,41 @@ export class Terminal extends LitElement {
private setupResize() {
if (!this.container) return;
this.resizeObserver = new ResizeObserver(() => {
this.resizeCoordinator.requestResize('ResizeObserver');
});
this.resizeObserver.observe(this.container);
// Set the class property using consistent detection
this.isMobile = this.detectMobile();
logger.debug(
`[Terminal] Setting up resize - isMobile: ${this.isMobile}, userAgent: ${navigator.userAgent}`
);
window.addEventListener('resize', () => {
this.resizeCoordinator.requestResize('window-resize');
});
// On mobile, delay initial resize to let everything settle
if (this.resizeCoordinator.getIsMobile()) {
setTimeout(() => {
this.resizeCoordinator.requestResize('initial-mobile');
// Mark initial resize complete after a short delay
setTimeout(() => {
this.resizeCoordinator.markInitialResizeComplete();
}, 100);
}, 100);
if (this.isMobile) {
// On mobile: Do initial resize to set width, then allow HEIGHT changes only (for keyboard)
logger.debug('[Terminal] Mobile detected - scheduling initial resize in 200ms');
this.mobileInitialResizeTimeout = setTimeout(() => {
logger.debug('[Terminal] Mobile: Executing initial resize');
this.fitTerminal('initial-mobile-only');
// That's it - no observers, no event listeners, nothing
logger.debug(
'[Terminal] Mobile: Initial width set, future WIDTH resizes blocked (height allowed for keyboard)'
);
this.mobileInitialResizeTimeout = null; // Clear reference after execution
}, 200);
} else {
// Desktop: Normal resize handling with observers
logger.debug('[Terminal] Desktop detected - setting up resize observers');
this.resizeObserver = new ResizeObserver(() => {
logger.debug('[Terminal] ResizeObserver triggered');
this.requestResize('ResizeObserver');
});
this.resizeObserver.observe(this.container);
window.addEventListener('resize', () => {
logger.debug('[Terminal] Window resize event triggered');
this.requestResize('window-resize');
});
// Desktop: immediate initial resize
this.resizeCoordinator.requestResize('initial-desktop');
logger.debug('[Terminal] Desktop: Requesting initial resize');
this.requestResize('initial-desktop');
}
}
@ -1110,7 +1240,7 @@ export class Terminal extends LitElement {
this.queueRenderOperation(() => {
if (!this.terminal) return;
this.resizeCoordinator.requestResize('property-change');
this.requestResize('property-change');
const buffer = this.terminal.buffer.active;
const lineHeight = this.fontSize * 1.2;
@ -1324,7 +1454,7 @@ export class Terminal extends LitElement {
}
// Recalculate fit
this.resizeCoordinator.requestResize('fit-mode-change');
this.requestResize('fit-mode-change');
// Restore scroll position - prioritize staying at bottom if we were there
if (wasAtBottom) {

View file

@ -1,119 +0,0 @@
/**
* Resize Coordinator
*
* Centralizes and coordinates all terminal resize requests to prevent multiple
* resize events from causing terminal reflows. Essential for mobile stability.
*/
export interface ResizeDimensions {
cols: number;
rows: number;
}
export class ResizeCoordinator {
private pendingResize: number | null = null;
private lastDimensions: ResizeDimensions | null = null;
private resizeCallback: ((source: string) => void) | null = null;
private initialResizeComplete = false;
private resizeSources = new Set<string>();
/**
* Set the callback function to be called when resize should happen
*/
setResizeCallback(callback: (source: string) => void) {
this.resizeCallback = callback;
}
/**
* Request a resize from a specific source
* All resize requests are coalesced into a single animation frame
*/
requestResize(source: string) {
this.resizeSources.add(source);
// Cancel any pending resize
if (this.pendingResize) {
cancelAnimationFrame(this.pendingResize);
}
// Schedule resize for next animation frame
this.pendingResize = requestAnimationFrame(() => {
const sources = Array.from(this.resizeSources).join(', ');
this.resizeSources.clear();
if (this.resizeCallback) {
this.resizeCallback(sources);
}
this.pendingResize = null;
});
}
/**
* Check if dimensions have actually changed
*/
shouldResize(cols: number, rows: number): boolean {
const isMobile = this.getIsMobile();
// On mobile, after initial resize, never resize again
// This prevents rotation and keyboard events from causing reflows
if (isMobile && this.initialResizeComplete) {
return false;
}
if (!this.lastDimensions) {
this.lastDimensions = { cols, rows };
return true;
}
const changed = this.lastDimensions.cols !== cols || this.lastDimensions.rows !== rows;
if (changed) {
this.lastDimensions = { cols, rows };
}
return changed;
}
/**
* Mark initial resize as complete
* After this, mobile will only resize on width changes
*/
markInitialResizeComplete() {
this.initialResizeComplete = true;
}
/**
* Get last known dimensions
*/
getLastDimensions(): ResizeDimensions | null {
return this.lastDimensions;
}
/**
* Force update dimensions (for explicit user actions)
*/
forceUpdateDimensions(cols: number, rows: number) {
this.lastDimensions = { cols, rows };
}
/**
* Check if running on mobile (dynamically checks on each call)
*/
getIsMobile(): boolean {
// Check viewport width and touch capability dynamically
// This handles orientation changes and window resizing
return window.innerWidth < 768 && 'ontouchstart' in window;
}
/**
* Cleanup
*/
destroy() {
if (this.pendingResize) {
cancelAnimationFrame(this.pendingResize);
}
this.resizeCallback = null;
this.resizeSources.clear();
}
}