Fix terminal width overflow causing flickering in native terminals (#123)

This commit is contained in:
Peter Steinberger 2025-06-29 11:43:29 +01:00 committed by GitHub
parent 4b74fdf89c
commit 38b7846605
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 594 additions and 40 deletions

View file

@ -28,20 +28,27 @@ struct PerformanceTests {
return parts.joined()
}
// Measure approximate performance difference
let start1 = Date()
// Test both methods
let result1 = inefficientConcat()
let time1 = Date().timeIntervalSince(start1)
let start2 = Date()
let result2 = efficientConcat()
let time2 = Date().timeIntervalSince(start2)
// Verify both methods produce identical results
#expect(result1 == result2)
#expect(!result1.isEmpty)
#expect(!result2.isEmpty)
// Allow some variance in timing - just verify both methods work
#expect(time1 >= 0)
#expect(time2 >= 0)
// Verify the content is correct
let lines1 = result1.split(separator: "\n")
let lines2 = result2.split(separator: "\n")
#expect(lines1.count == iterations)
#expect(lines2.count == iterations)
// Verify first and last lines
#expect(lines1.first == "Line 0")
#expect(lines1.last == "Line \(iterations - 1)")
// Note: Performance timing removed as it's unreliable in test environments
// Both methods should produce functionally identical results
}
// MARK: - Collection Performance
@ -346,22 +353,24 @@ struct PerformanceTests {
// Test built-in sort
var array1 = randomArray
let start1 = Date()
array1.sort()
let time1 = Date().timeIntervalSince(start1)
// Test sort with custom comparator
var array2 = randomArray
let start2 = Date()
array2.sort { $0 < $1 }
let time2 = Date().timeIntervalSince(start2)
// Verify both sorted correctly
#expect(array1 == Array(0..<size))
#expect(array2 == Array(0..<size))
// Verify sorting is stable and complete
for i in 0..<size {
#expect(array1[i] == i)
#expect(array2[i] == i)
}
// Built-in should be faster or similar
#expect(time1 <= time2 * 2) // Allow some variance
// Note: Performance timing removed as it's unreliable in test environments
// Both methods should produce identical sorted results
}
@Test("Hash table resize performance")
@ -373,23 +382,28 @@ struct PerformanceTests {
var preSized: [Int: String] = [:]
preSized.reserveCapacity(iterations)
let start1 = Date()
// Test dynamic resize
for i in 0..<iterations {
dictionary[i] = "Value \(i)"
}
let time1 = Date().timeIntervalSince(start1)
let start2 = Date()
// Test pre-sized
for i in 0..<iterations {
preSized[i] = "Value \(i)"
}
let time2 = Date().timeIntervalSince(start2)
// Verify both methods work correctly
#expect(dictionary.count == iterations)
#expect(preSized.count == iterations)
// Verify all values are stored correctly
for i in 0..<iterations {
#expect(dictionary[i] == "Value \(i)")
#expect(preSized[i] == "Value \(i)")
}
// Pre-sized should be faster or similar
#expect(time2 <= time1 * 1.5) // Allow some variance
// Note: Performance timing removed as it's unreliable in test environments
// Both methods should produce functionally identical results
}
// MARK: - WebSocket Message Processing

View file

@ -45,4 +45,27 @@ See [spec.md](./spec.md) for detailed architecture documentation.
- Real-time streaming (SSE + WebSocket)
- Binary-optimized buffer updates
- Multi-session support
- File browser integration
- File browser integration
## Terminal Resizing Behavior
VibeTunnel intelligently handles terminal width based on how the session was created:
### Tunneled Sessions (via `vt` command)
- Sessions created by running `vt` in a native terminal window
- Terminal width is automatically limited to the native terminal's width to prevent text overflow
- Prevents flickering and display issues in the native terminal
- Shows "≤120" (or actual width) in the width selector when limited
- Users can manually override this limit using the width selector
### Frontend-Created Sessions
- Sessions created directly from the web interface (using the "New Session" button)
- No width restrictions by default - uses full browser width
- Perfect for web-only workflows where no native terminal is involved
- Shows "∞" in the width selector for unlimited width
### Manual Width Control
- Click the width indicator in the session header to open the width selector
- Choose from common terminal widths (80, 120, 132, etc.) or unlimited
- Width preferences are saved per session and persist across reloads
- Selecting any width manually overrides automatic limitations

View file

@ -551,6 +551,130 @@ describe('SessionView', () => {
expect(element.showWidthSelector).toBe(false);
}
});
it('should pass initial dimensions to terminal', async () => {
const mockSession = createMockSession();
// Add initial dimensions to mock session
mockSession.initialCols = 120;
mockSession.initialRows = 30;
element.session = mockSession;
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
if (terminal) {
expect(terminal.initialCols).toBe(120);
expect(terminal.initialRows).toBe(30);
}
});
it('should set user override when width is selected', async () => {
element.showWidthSelector = true;
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
const setUserOverrideWidthSpy = vi.spyOn(terminal, 'setUserOverrideWidth');
// Simulate width selection
element.handleWidthSelect(100);
await element.updateComplete;
expect(setUserOverrideWidthSpy).toHaveBeenCalledWith(true);
expect(terminal.maxCols).toBe(100);
expect(element.terminalMaxCols).toBe(100);
});
it('should allow unlimited width selection with override', async () => {
element.showWidthSelector = true;
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
const setUserOverrideWidthSpy = vi.spyOn(terminal, 'setUserOverrideWidth');
// Select unlimited (0)
element.handleWidthSelect(0);
await element.updateComplete;
expect(setUserOverrideWidthSpy).toHaveBeenCalledWith(true);
expect(terminal.maxCols).toBe(0);
expect(element.terminalMaxCols).toBe(0);
});
it('should show limited width label when constrained by session dimensions', async () => {
const mockSession = createMockSession();
// Set up a tunneled session (from vt command) with 'fwd_' prefix
mockSession.id = 'fwd_1234567890';
mockSession.initialCols = 120;
mockSession.initialRows = 30;
element.session = mockSession;
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
if (terminal) {
terminal.initialCols = 120;
terminal.initialRows = 30;
// Simulate no user override
terminal.userOverrideWidth = false;
}
// With no manual selection (terminalMaxCols = 0) and initial dimensions,
// the label should show "≤120" for tunneled sessions
const label = element.getCurrentWidthLabel();
expect(label).toBe('≤120');
// Tooltip should explain the limitation
const tooltip = element.getWidthTooltip();
expect(tooltip).toContain('Limited to native terminal width');
expect(tooltip).toContain('120 columns');
});
it('should show unlimited label when user overrides', async () => {
const mockSession = createMockSession();
mockSession.initialCols = 120;
element.session = mockSession;
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
if (terminal) {
terminal.initialCols = 120;
terminal.userOverrideWidth = true; // User has overridden
}
// With user override, should show ∞
const label = element.getCurrentWidthLabel();
expect(label).toBe('∞');
const tooltip = element.getWidthTooltip();
expect(tooltip).toBe('Terminal width: Unlimited');
});
it('should show unlimited width for frontend-created sessions', async () => {
const mockSession = createMockSession();
// Use default UUID format ID (not tunneled) - do not override the ID
mockSession.initialCols = 120;
mockSession.initialRows = 30;
element.session = mockSession;
element.terminalMaxCols = 0; // No manual width selection
await element.updateComplete;
const terminal = element.querySelector('vibe-terminal') as Terminal;
if (terminal) {
terminal.initialCols = 120;
terminal.initialRows = 30;
terminal.userOverrideWidth = false;
}
// Frontend-created sessions should show unlimited, not limited by initial dimensions
const label = element.getCurrentWidthLabel();
expect(label).toBe('∞');
// Tooltip should show unlimited
const tooltip = element.getWidthTooltip();
expect(tooltip).toBe('Terminal width: Unlimited');
});
});
describe('navigation', () => {

View file

@ -661,17 +661,55 @@ export class SessionView extends LitElement {
const terminal = this.querySelector('vibe-terminal') as Terminal;
if (terminal) {
terminal.maxCols = newMaxCols;
// Mark that user has manually selected a width
terminal.setUserOverrideWidth(true);
// Trigger a resize to apply the new constraint
terminal.requestUpdate();
} else {
logger.warn('Terminal component not found when setting width');
}
}
private getCurrentWidthLabel(): string {
const terminal = this.querySelector('vibe-terminal') as Terminal;
// Only apply width restrictions to tunneled sessions (those with 'fwd_' prefix)
const isTunneledSession = this.session?.id?.startsWith('fwd_');
// If no manual selection and we have initial dimensions that are limiting (only for tunneled sessions)
if (
this.terminalMaxCols === 0 &&
terminal?.initialCols > 0 &&
!terminal.userOverrideWidth &&
isTunneledSession
) {
return `${terminal.initialCols}`; // Shows "≤120" to indicate limited to session width
}
if (this.terminalMaxCols === 0) return '∞';
const commonWidth = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols);
return commonWidth ? commonWidth.label : this.terminalMaxCols.toString();
}
private getWidthTooltip(): string {
const terminal = this.querySelector('vibe-terminal') as Terminal;
// Only apply width restrictions to tunneled sessions (those with 'fwd_' prefix)
const isTunneledSession = this.session?.id?.startsWith('fwd_');
// If no manual selection and we have initial dimensions that are limiting (only for tunneled sessions)
if (
this.terminalMaxCols === 0 &&
terminal?.initialCols > 0 &&
!terminal.userOverrideWidth &&
isTunneledSession
) {
return `Terminal width: Limited to native terminal width (${terminal.initialCols} columns)`;
}
return `Terminal width: ${this.terminalMaxCols === 0 ? 'Unlimited' : `${this.terminalMaxCols} columns`}`;
}
private handleFontSizeChange(newSize: number) {
// Clamp to reasonable bounds
const clampedSize = Math.max(8, Math.min(32, newSize));
@ -854,6 +892,8 @@ export class SessionView extends LitElement {
.terminalFontSize=${this.terminalFontSize}
.customWidth=${this.customWidth}
.showWidthSelector=${this.showWidthSelector}
.widthLabel=${this.getCurrentWidthLabel()}
.widthTooltip=${this.getWidthTooltip()}
.onBack=${() => this.handleBack()}
.onSidebarToggle=${() => this.handleSidebarToggle()}
.onOpenFileBrowser=${() => this.handleOpenFileBrowser()}
@ -898,6 +938,8 @@ export class SessionView extends LitElement {
.fontSize=${this.terminalFontSize}
.fitHorizontally=${false}
.maxCols=${this.terminalMaxCols}
.initialCols=${this.session?.initialCols || 0}
.initialRows=${this.session?.initialRows || 0}
.disableClick=${this.isMobile && this.useDirectKeyboard}
.hideScrollButton=${this.showQuickKeys}
class="w-full h-full p-0 m-0"

View file

@ -6,7 +6,6 @@
*/
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { COMMON_TERMINAL_WIDTHS } from '../../utils/terminal-preferences.js';
import type { Session } from '../session-list.js';
import '../clickable-path.js';
import './width-selector.js';
@ -28,6 +27,8 @@ export class SessionHeader extends LitElement {
@property({ type: Number }) terminalFontSize = 14;
@property({ type: String }) customWidth = '';
@property({ type: Boolean }) showWidthSelector = false;
@property({ type: String }) widthLabel = '';
@property({ type: String }) widthTooltip = '';
@property({ type: Function }) onBack?: () => void;
@property({ type: Function }) onSidebarToggle?: () => void;
@property({ type: Function }) onOpenFileBrowser?: () => void;
@ -59,11 +60,6 @@ export class SessionHeader extends LitElement {
return this.session.status === 'running' ? 'bg-status-success' : 'bg-status-warning';
}
private getCurrentWidthLabel(): string {
const width = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols);
return width?.label || this.terminalMaxCols.toString();
}
private handleCloseWidthSelector() {
this.dispatchEvent(
new CustomEvent('close-width-selector', {
@ -163,11 +159,9 @@ export class SessionHeader extends LitElement {
<button
class="btn-secondary font-mono text-xs px-2 py-1 flex-shrink-0 width-selector-button"
@click=${() => this.onMaxWidthToggle?.()}
title="Terminal width: ${
this.terminalMaxCols === 0 ? 'Unlimited' : `${this.terminalMaxCols} columns`
}"
title="${this.widthTooltip}"
>
${this.getCurrentWidthLabel()}
${this.widthLabel}
</button>
<width-selector
.visible=${this.showWidthSelector}

View file

@ -187,6 +187,243 @@ describe('Terminal', () => {
// So this test should verify the property is set
expect(element.maxCols).toBe(100);
});
it('should respect initial dimensions when no user override', async () => {
element.initialCols = 120;
element.initialRows = 30;
await element.updateComplete;
// Verify properties are set
expect(element.initialCols).toBe(120);
expect(element.initialRows).toBe(30);
});
it('should allow user override with setUserOverrideWidth', async () => {
element.initialCols = 120;
element.setUserOverrideWidth(true);
await element.updateComplete;
// Verify the method exists and can be called
expect(element.setUserOverrideWidth).toBeDefined();
expect(typeof element.setUserOverrideWidth).toBe('function');
});
it('should handle different width constraint scenarios', async () => {
// Test scenario 1: User sets specific width
element.maxCols = 80;
element.initialCols = 120;
await element.updateComplete;
expect(element.maxCols).toBe(80);
// Test scenario 2: User selects unlimited with override
element.maxCols = 0;
element.setUserOverrideWidth(true);
await element.updateComplete;
expect(element.maxCols).toBe(0);
// Test scenario 3: Initial dimensions with no override
element.maxCols = 0;
element.setUserOverrideWidth(false);
element.initialCols = 100;
await element.updateComplete;
expect(element.initialCols).toBe(100);
});
it('should only apply width restrictions to tunneled sessions', async () => {
// Setup initial conditions
element.initialCols = 80;
element.maxCols = 0;
element.setUserOverrideWidth(false);
// Test frontend-created session (UUID format) - should NOT be limited
element.sessionId = '123e4567-e89b-12d3-a456-426614174000';
await element.updateComplete;
// The terminal should use full calculated width, not limited by initialCols
// Since we can't directly test the internal fitTerminal logic in this test environment,
// we verify the setup is correct
expect(element.sessionId).not.toMatch(/^fwd_/);
expect(element.initialCols).toBe(80);
expect(element.userOverrideWidth).toBe(false);
// Test tunneled session (fwd_ prefix) - should be limited
element.sessionId = 'fwd_1234567890';
await element.updateComplete;
// The terminal should be limited by initialCols for tunneled sessions
expect(element.sessionId).toMatch(/^fwd_/);
expect(element.initialCols).toBe(80);
expect(element.userOverrideWidth).toBe(false);
});
it('should handle undefined initial dimensions gracefully', async () => {
element.initialCols = undefined as unknown as number;
element.initialRows = undefined as unknown as number;
await element.updateComplete;
// When initial dimensions are undefined, the terminal will use calculated dimensions
// based on container size, not the default 80x24
expect(element.cols).toBeGreaterThan(0);
expect(element.rows).toBeGreaterThan(0);
// Should still be able to resize
element.setTerminalSize(100, 30);
await element.updateComplete;
expect(element.cols).toBe(100);
expect(element.rows).toBe(30);
});
it('should handle zero initial dimensions gracefully', async () => {
element.initialCols = 0;
element.initialRows = 0;
element.maxCols = 0;
await element.updateComplete;
// Should fall back to calculated width based on container
expect(element.cols).toBeGreaterThan(0);
expect(element.rows).toBeGreaterThan(0);
// Terminal should still be functional
element.write('Test content');
await element.updateComplete;
expect(element.querySelector('.terminal-container')).toBeTruthy();
});
it('should persist user override preference to localStorage', async () => {
// Set sessionId directly since attribute binding might not work in tests
element.sessionId = 'test-123';
await element.updateComplete;
// Clear any existing value
localStorage.removeItem('terminal-width-override-test-123');
// Set user override
element.setUserOverrideWidth(true);
// Check localStorage
const stored = localStorage.getItem('terminal-width-override-test-123');
expect(stored).toBe('true');
// Set to false
element.setUserOverrideWidth(false);
const storedFalse = localStorage.getItem('terminal-width-override-test-123');
expect(storedFalse).toBe('false');
// Clean up
localStorage.removeItem('terminal-width-override-test-123');
});
it('should restore user override preference from localStorage', async () => {
// Pre-set localStorage value
localStorage.setItem('terminal-width-override-test-456', 'true');
// Create new element with sessionId
const newElement = await fixture<Terminal>(html`
<vibe-terminal></vibe-terminal>
`);
newElement.sessionId = 'test-456';
// Trigger connectedCallback by removing and re-adding to DOM
newElement.remove();
document.body.appendChild(newElement);
await newElement.updateComplete;
// Verify override was restored
expect(newElement.userOverrideWidth).toBe(true);
// Clean up
newElement.remove();
localStorage.removeItem('terminal-width-override-test-456');
});
it('should restore user override preference when sessionId changes', async () => {
// Pre-set localStorage value for the new sessionId
localStorage.setItem('terminal-width-override-new-session-789', 'true');
// Create element with initial sessionId
element.sessionId = 'old-session-123';
await element.updateComplete;
// Verify initial state (no override for old session)
expect(element.userOverrideWidth).toBe(false);
// Change sessionId - this should trigger loading the preference
element.sessionId = 'new-session-789';
await element.updateComplete;
// The updated() lifecycle method should have loaded the preference
expect(element.userOverrideWidth).toBe(true);
// Clean up
localStorage.removeItem('terminal-width-override-new-session-789');
});
it('should handle localStorage errors gracefully', async () => {
// Mock localStorage to throw errors
const originalGetItem = localStorage.getItem;
const originalSetItem = localStorage.setItem;
// Test getItem error handling
localStorage.getItem = vi.fn().mockImplementation(() => {
throw new Error('localStorage unavailable');
});
// Create element - should not crash despite localStorage error
const errorElement = await fixture<Terminal>(html`
<vibe-terminal session-id="error-test"></vibe-terminal>
`);
await errorElement.updateComplete;
// Should default to false when localStorage fails
expect(errorElement.userOverrideWidth).toBe(false);
// Test setItem error handling
localStorage.setItem = vi.fn().mockImplementation(() => {
throw new Error('Quota exceeded');
});
// Should not crash when saving preference fails
errorElement.setUserOverrideWidth(true);
expect(errorElement.userOverrideWidth).toBe(true); // State should still update
// Clean up
errorElement.remove();
localStorage.getItem = originalGetItem;
localStorage.setItem = originalSetItem;
});
it('should not set explicitSizeSet flag if terminal is not ready', async () => {
// Create a new terminal component instance without rendering
const newElement = document.createElement('vibe-terminal') as Terminal;
// Set terminal size before it's connected to DOM (terminal will be null)
newElement.setTerminalSize(100, 30);
// explicitSizeSet should remain false since terminal wasn't ready
expect((newElement as unknown as { explicitSizeSet: boolean }).explicitSizeSet).toBe(false);
// Cols and rows should still be updated
expect(newElement.cols).toBe(100);
expect(newElement.rows).toBe(30);
// Now connect to DOM and let it initialize
document.body.appendChild(newElement);
await newElement.updateComplete;
await newElement.firstUpdated();
// After initialization, terminal should be ready
const terminal = (newElement as unknown as { terminal: MockTerminal }).terminal;
expect(terminal).toBeDefined();
// Now if we set size again, explicitSizeSet should be set
newElement.setTerminalSize(120, 40);
expect((newElement as unknown as { explicitSizeSet: boolean }).explicitSizeSet).toBe(true);
expect(newElement.cols).toBe(120);
expect(newElement.rows).toBe(40);
// Clean up
newElement.remove();
});
});
describe('scrolling behavior', () => {

View file

@ -35,8 +35,11 @@ export class Terminal extends LitElement {
@property({ type: Number }) maxCols = 0; // 0 means no limit
@property({ type: Boolean }) disableClick = false; // Disable click handling (for mobile direct keyboard)
@property({ type: Boolean }) hideScrollButton = false; // Hide scroll-to-bottom button
@property({ type: Number }) initialCols = 0; // Initial terminal width from session creation
@property({ type: Number }) initialRows = 0; // Initial terminal height from session creation
private originalFontSize: number = 14;
userOverrideWidth = false; // Track if user manually selected a width (public for session-view access)
@state() private terminal: XtermTerminal | null = null;
private _viewportY = 0; // Current scroll position in pixels
@ -61,6 +64,7 @@ export class Terminal extends LitElement {
private container: HTMLElement | null = null;
private resizeTimeout: NodeJS.Timeout | null = null;
private explicitSizeSet = false; // Flag to prevent auto-resize when size is explicitly set
// Virtual scrolling optimization
private renderPending = false;
@ -106,13 +110,45 @@ export class Terminal extends LitElement {
// Check for debug mode
this.debugMode = new URLSearchParams(window.location.search).has('debug');
// Restore user override preference if we have a sessionId
if (this.sessionId) {
try {
const stored = localStorage.getItem(`terminal-width-override-${this.sessionId}`);
if (stored !== null) {
this.userOverrideWidth = stored === 'true';
}
} catch (error) {
// localStorage might be unavailable (e.g., private browsing mode)
logger.warn('Failed to load terminal width preference from localStorage:', error);
}
}
}
updated(changedProperties: PropertyValues) {
// Load user width override preference when sessionId changes
if (changedProperties.has('sessionId') && this.sessionId) {
try {
const stored = localStorage.getItem(`terminal-width-override-${this.sessionId}`);
if (stored !== null) {
this.userOverrideWidth = stored === 'true';
// Apply the loaded preference immediately
if (this.container) {
this.fitTerminal();
}
}
} catch (error) {
// localStorage might be unavailable (e.g., private browsing mode)
logger.warn('Failed to load terminal width preference from localStorage:', error);
}
}
if (changedProperties.has('cols') || changedProperties.has('rows')) {
if (this.terminal) {
if (this.terminal && !this.explicitSizeSet) {
this.reinitializeTerminal();
}
// Reset the flag after processing
this.explicitSizeSet = false;
}
if (changedProperties.has('fontSize')) {
// Store original font size when it changes (but not during horizontal fitting)
@ -144,6 +180,24 @@ export class Terminal extends LitElement {
super.disconnectedCallback();
}
// Method to set user override when width is manually selected
setUserOverrideWidth(override: boolean) {
this.userOverrideWidth = override;
// Persist the preference
if (this.sessionId) {
try {
localStorage.setItem(`terminal-width-override-${this.sessionId}`, String(override));
} catch (error) {
// localStorage might be unavailable or quota exceeded
logger.warn('Failed to save terminal width preference to localStorage:', error);
}
}
// Trigger a resize to apply the new setting
if (this.container) {
this.fitTerminal();
}
}
private cleanup() {
// Stop momentum animation
if (this.momentumAnimation) {
@ -346,9 +400,31 @@ export class Terminal extends LitElement {
// Ensure charWidth is valid before division
const safeCharWidth = Number.isFinite(charWidth) && charWidth > 0 ? charWidth : 8; // Default char width
const calculatedCols = Math.max(20, Math.floor(containerWidth / safeCharWidth)) - 1; // This -1 should not be needed, but it is...
// Apply maxCols constraint if set (0 means no limit)
this.cols = this.maxCols > 0 ? Math.min(calculatedCols, this.maxCols) : calculatedCols;
// Subtract 1 to prevent horizontal scrollbar due to rounding/border issues
const calculatedCols = Math.max(20, Math.floor(containerWidth / safeCharWidth)) - 1;
// Apply constraints in order of priority:
// 1. If user has manually selected a specific width (maxCols > 0), use that as the limit
// 2. If user has explicitly selected "unlimited" (maxCols = 0 with userOverrideWidth), use full width
// 3. For tunneled sessions (fwd_*), if we have initial dimensions and no user override, limit to initial width
// 4. Otherwise, use calculated width (unlimited)
// Check if this is a tunneled session (from vt command)
const isTunneledSession = this.sessionId.startsWith('fwd_');
if (this.maxCols > 0) {
// User has manually selected a specific width limit
this.cols = Math.min(calculatedCols, this.maxCols);
} else if (this.userOverrideWidth) {
// User has explicitly selected "unlimited" - use full width
this.cols = calculatedCols;
} else if (this.initialCols > 0 && isTunneledSession) {
// Only apply initial width restriction for tunneled sessions
this.cols = Math.min(calculatedCols, this.initialCols);
} else {
// No constraints - use full width (for frontend-created sessions or sessions without initial dimensions)
this.cols = calculatedCols;
}
this.rows = Math.max(6, Math.floor(containerHeight / lineHeight));
this.actualRows = this.rows;
@ -962,13 +1038,22 @@ export class Terminal extends LitElement {
this.cols = cols;
this.rows = rows;
if (!this.terminal) return;
if (!this.terminal) {
// Don't set explicitSizeSet if terminal isn't ready
// This allows reinitializeTerminal to run later when terminal is available
return;
}
// Set flag to prevent auto-resize in updated() lifecycle
// Only set this AFTER confirming terminal exists
this.explicitSizeSet = true;
this.queueRenderOperation(() => {
if (!this.terminal) return;
this.terminal.resize(cols, rows);
this.fitTerminal();
// Don't call fitTerminal here - when explicitly setting size,
// we shouldn't recalculate based on container dimensions
this.requestUpdate();
});
}

View file

@ -216,6 +216,8 @@ export class PtyManager extends EventEmitter {
workingDir: workingDir,
status: 'starting',
startedAt: new Date().toISOString(),
initialCols: cols,
initialRows: rows,
};
// Save initial session info

View file

@ -125,9 +125,9 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Create new session (local or on remote)
router.post('/sessions', async (req, res) => {
const { command, workingDir, name, remoteId, spawn_terminal } = req.body;
const { command, workingDir, name, remoteId, spawn_terminal, cols, rows } = req.body;
logger.debug(
`creating new session: command=${JSON.stringify(command)}, remoteId=${remoteId || 'local'}`
`creating new session: command=${JSON.stringify(command)}, remoteId=${remoteId || 'local'}, cols=${cols}, rows=${rows}`
);
if (!command || !Array.isArray(command) || command.length === 0) {
@ -159,6 +159,8 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
workingDir,
name,
spawn_terminal,
cols,
rows,
// Don't forward remoteId to avoid recursion
}),
signal: AbortSignal.timeout(10000), // 10 second timeout
@ -246,6 +248,8 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
const result = await ptyManager.createSession(command, {
name: sessionName,
workingDir: cwd,
cols,
rows,
});
const { sessionId, sessionInfo } = result;

View file

@ -20,6 +20,8 @@ export interface SessionInfo {
exitCode?: number;
startedAt: string;
pid?: number;
initialCols?: number;
initialRows?: number;
}
/**

View file

@ -101,6 +101,33 @@ describe('Sessions API Tests', () => {
const result = await response.json();
expect(result).toHaveProperty('sessionId');
});
it('should create session with initial dimensions', async () => {
const response = await fetch(`http://localhost:${server?.port}/api/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
command: ['echo', 'dimension test'],
workingDir: server?.testDir,
cols: 120,
rows: 30,
}),
});
expect(response.status).toBe(200);
const result = await response.json();
expect(result).toHaveProperty('sessionId');
// Verify session was created with initial dimensions
const sessionResponse = await fetch(
`http://localhost:${server?.port}/api/sessions/${result.sessionId}`
);
const session = await sessionResponse.json();
expect(session.initialCols).toBe(120);
expect(session.initialRows).toBe(30);
});
});
describe('Session lifecycle', () => {