diff --git a/ios/VibeTunnelTests/PerformanceTests.swift b/ios/VibeTunnelTests/PerformanceTests.swift index 5bc749ec..6dfd746a 100644 --- a/ios/VibeTunnelTests/PerformanceTests.swift +++ b/ios/VibeTunnelTests/PerformanceTests.swift @@ -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.. { 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', () => { diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 409a39b0..aa61358e 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -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" diff --git a/web/src/client/components/session-view/session-header.ts b/web/src/client/components/session-view/session-header.ts index ca2b97d6..596ff698 100644 --- a/web/src/client/components/session-view/session-header.ts +++ b/web/src/client/components/session-view/session-header.ts @@ -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 { { // 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(html` + + `); + 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(html` + + `); + 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', () => { diff --git a/web/src/client/components/terminal.ts b/web/src/client/components/terminal.ts index 042308f3..77c8fc81 100644 --- a/web/src/client/components/terminal.ts +++ b/web/src/client/components/terminal.ts @@ -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(); }); } diff --git a/web/src/server/pty/pty-manager.ts b/web/src/server/pty/pty-manager.ts index 3fe4bde8..0b8730f6 100644 --- a/web/src/server/pty/pty-manager.ts +++ b/web/src/server/pty/pty-manager.ts @@ -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 diff --git a/web/src/server/routes/sessions.ts b/web/src/server/routes/sessions.ts index 5d015643..a1419950 100644 --- a/web/src/server/routes/sessions.ts +++ b/web/src/server/routes/sessions.ts @@ -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; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 88719720..361b6b60 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -20,6 +20,8 @@ export interface SessionInfo { exitCode?: number; startedAt: string; pid?: number; + initialCols?: number; + initialRows?: number; } /** diff --git a/web/src/test/e2e/sessions-api.e2e.test.ts b/web/src/test/e2e/sessions-api.e2e.test.ts index fec8b502..497933b5 100644 --- a/web/src/test/e2e/sessions-api.e2e.test.ts +++ b/web/src/test/e2e/sessions-api.e2e.test.ts @@ -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', () => {