/** * Session View Component * * Full-screen terminal view for an active session. Handles terminal I/O, * streaming updates via SSE, file browser integration, and mobile overlays. * * @fires navigate-to-list - When navigating back to session list * @fires error - When an error occurs (detail: string) * @fires warning - When a warning occurs (detail: string) * * @listens session-exit - From SSE stream when session exits * @listens terminal-ready - From terminal component when ready * @listens file-selected - From file browser when file is selected * @listens browser-cancel - From file browser when cancelled */ import { html, LitElement, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { Session } from './session-list.js'; import './terminal.js'; import './file-browser.js'; import './clickable-path.js'; import { AuthClient } from '../services/auth-client.js'; import { CastConverter } from '../utils/cast-converter.js'; import { createLogger } from '../utils/logger.js'; import { COMMON_TERMINAL_WIDTHS, TerminalPreferencesManager, } from '../utils/terminal-preferences.js'; import type { Terminal } from './terminal.js'; const logger = createLogger('session-view'); @customElement('session-view') export class SessionView extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } @property({ type: Object }) session: Session | null = null; @property({ type: Boolean }) showBackButton = true; @property({ type: Boolean }) showSidebarToggle = false; @property({ type: Boolean }) sidebarCollapsed = false; @state() private connected = false; @state() private terminal: Terminal | null = null; @state() private streamConnection: { eventSource: EventSource; disconnect: () => void; errorHandler?: EventListener; } | null = null; @state() private showMobileInput = false; @state() private mobileInputText = ''; @state() private isMobile = false; @state() private touchStartX = 0; @state() private touchStartY = 0; @state() private loading = false; @state() private loadingFrame = 0; @state() private terminalCols = 0; @state() private terminalRows = 0; @state() private showCtrlAlpha = false; @state() private terminalFitHorizontally = false; @state() private terminalMaxCols = 0; @state() private showWidthSelector = false; @state() private customWidth = ''; @state() private showFileBrowser = false; @state() private terminalFontSize = 14; private preferencesManager = TerminalPreferencesManager.getInstance(); private authClient = new AuthClient(); @state() private reconnectCount = 0; @state() private ctrlSequence: string[] = []; private loadingInterval: number | null = null; private keyboardListenerAdded = false; private touchListenersAdded = false; private resizeTimeout: number | null = null; private lastResizeWidth = 0; private lastResizeHeight = 0; private instanceId = `session-view-${Math.random().toString(36).substr(2, 9)}`; private keyboardHandler = (e: KeyboardEvent) => { // Check if we're typing in an input field const target = e.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' ) { // Allow normal input in form fields return; } // Handle Cmd+O / Ctrl+O to open file browser if ((e.metaKey || e.ctrlKey) && e.key === 'o') { e.preventDefault(); this.showFileBrowser = true; return; } if (!this.session) return; // Allow important browser shortcuts to pass through const isMacOS = navigator.platform.toLowerCase().includes('mac'); // Allow F12 and Ctrl+Shift+I (DevTools) if ( e.key === 'F12' || (!isMacOS && e.ctrlKey && e.shiftKey && e.key === 'I') || (isMacOS && e.metaKey && e.altKey && e.key === 'I') ) { return; } // Allow Ctrl+A (select all), Ctrl+F (find), Ctrl+R (refresh), Ctrl+C/V (copy/paste), etc. if ( !isMacOS && e.ctrlKey && !e.shiftKey && ['a', 'f', 'r', 'l', 't', 'w', 'n', 'c', 'v'].includes(e.key.toLowerCase()) ) { return; } // Allow Cmd+A, Cmd+F, Cmd+R, Cmd+C/V (copy/paste), etc. on macOS if ( isMacOS && e.metaKey && !e.shiftKey && !e.altKey && ['a', 'f', 'r', 'l', 't', 'w', 'n', 'c', 'v'].includes(e.key.toLowerCase()) ) { return; } // Allow Alt+Tab, Cmd+Tab (window switching) if ((e.altKey || e.metaKey) && e.key === 'Tab') { return; } // Only prevent default for keys we're actually going to handle e.preventDefault(); e.stopPropagation(); this.handleKeyboardInput(e); }; private touchStartHandler = (e: TouchEvent) => { if (!this.isMobile) return; const touch = e.touches[0]; this.touchStartX = touch.clientX; this.touchStartY = touch.clientY; }; private touchEndHandler = (e: TouchEvent) => { if (!this.isMobile) return; const touch = e.changedTouches[0]; const touchEndX = touch.clientX; const touchEndY = touch.clientY; const deltaX = touchEndX - this.touchStartX; const deltaY = touchEndY - this.touchStartY; // Check for horizontal swipe from left edge (back gesture) const isSwipeRight = deltaX > 100; const isVerticallyStable = Math.abs(deltaY) < 100; const startedFromLeftEdge = this.touchStartX < 50; if (isSwipeRight && isVerticallyStable && startedFromLeftEdge) { // Trigger back navigation this.handleBack(); } }; private handleClickOutside = (e: Event) => { if (this.showWidthSelector) { const target = e.target as HTMLElement; const widthSelector = this.querySelector('.width-selector-container'); const widthButton = this.querySelector('.width-selector-button'); if (!widthSelector?.contains(target) && !widthButton?.contains(target)) { this.showWidthSelector = false; this.customWidth = ''; } } }; connectedCallback() { super.connectedCallback(); this.connected = true; // Load terminal preferences this.terminalMaxCols = this.preferencesManager.getMaxCols(); this.terminalFontSize = this.preferencesManager.getFontSize(); // Make session-view focusable this.tabIndex = 0; this.addEventListener('click', () => this.focus()); // Add click outside handler for width selector document.addEventListener('click', this.handleClickOutside); // Show loading animation if no session yet if (!this.session) { this.startLoading(); } // Detect mobile device - only show onscreen keyboard on actual mobile devices this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); // Only add listeners if not already added if (!this.isMobile && !this.keyboardListenerAdded) { document.addEventListener('keydown', this.keyboardHandler); this.keyboardListenerAdded = true; } else if (this.isMobile && !this.touchListenersAdded) { // Add touch event listeners for mobile swipe gestures document.addEventListener('touchstart', this.touchStartHandler, { passive: true }); document.addEventListener('touchend', this.touchEndHandler, { passive: true }); this.touchListenersAdded = true; } } disconnectedCallback() { super.disconnectedCallback(); this.connected = false; logger.log('SessionView disconnectedCallback called', { sessionId: this.session?.id, sessionStatus: this.session?.status, }); // Reset terminal size for external terminals when leaving session view if (this.session && this.session.status !== 'exited') { logger.log('Calling resetTerminalSize for session', this.session.id); this.resetTerminalSize(); } // Remove click outside handler document.removeEventListener('click', this.handleClickOutside); // Remove click handler this.removeEventListener('click', () => this.focus()); // Remove global keyboard event listener if (!this.isMobile && this.keyboardListenerAdded) { document.removeEventListener('keydown', this.keyboardHandler); this.keyboardListenerAdded = false; } else if (this.isMobile && this.touchListenersAdded) { // Remove touch event listeners document.removeEventListener('touchstart', this.touchStartHandler); document.removeEventListener('touchend', this.touchEndHandler); this.touchListenersAdded = false; } // Stop loading animation this.stopLoading(); // Cleanup stream connection if it exists this.cleanupStreamConnection(); // Terminal cleanup is handled by the component itself this.terminal = null; } firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); if (this.session) { this.stopLoading(); this.setupTerminal(); } } private cleanupStreamConnection(): void { if (this.streamConnection) { logger.log('Cleaning up stream connection'); this.streamConnection.disconnect(); this.streamConnection = null; } } updated(changedProperties: Map) { super.updated(changedProperties); // If session changed, clean up old stream connection if (changedProperties.has('session')) { const oldSession = changedProperties.get('session') as Session | null; if (oldSession && oldSession.id !== this.session?.id) { logger.log('Session changed, cleaning up old stream connection'); this.cleanupStreamConnection(); } } // Stop loading and create terminal when session becomes available if (changedProperties.has('session') && this.session && this.loading) { this.stopLoading(); this.setupTerminal(); } // Initialize terminal after first render when terminal element exists if (!this.terminal && this.session && !this.loading && this.connected) { const terminalElement = this.querySelector('vibe-terminal') as Terminal; if (terminalElement) { this.initializeTerminal(); } } } private setupTerminal() { // Terminal element will be created in render() // We'll initialize it in updated() after first render } private async initializeTerminal() { const terminalElement = this.querySelector('vibe-terminal') as Terminal; if (!terminalElement || !this.session) { logger.warn(`Cannot initialize terminal - missing element or session`); return; } this.terminal = terminalElement; // Configure terminal for interactive session this.terminal.cols = 80; this.terminal.rows = 24; this.terminal.fontSize = this.terminalFontSize; // Apply saved font size preference this.terminal.fitHorizontally = false; // Allow natural terminal sizing this.terminal.maxCols = this.terminalMaxCols; // Apply saved max width preference // Listen for session exit events this.terminal.addEventListener( 'session-exit', this.handleSessionExit.bind(this) as EventListener ); // Listen for terminal resize events to capture dimensions this.terminal.addEventListener( 'terminal-resize', this.handleTerminalResize.bind(this) as unknown as EventListener ); // Listen for paste events from terminal this.terminal.addEventListener( 'terminal-paste', this.handleTerminalPaste.bind(this) as EventListener ); // Connect to stream directly without artificial delays // Use setTimeout to ensure we're still connected after all synchronous updates setTimeout(() => { if (this.connected) { this.connectToStream(); } else { logger.warn(`Component disconnected before stream connection`); } }, 0); } private connectToStream() { if (!this.terminal || !this.session) { logger.warn(`Cannot connect to stream - missing terminal or session`); return; } // Don't connect if we're already disconnected if (!this.connected) { logger.warn(`Component already disconnected, not connecting to stream`); return; } logger.log(`Connecting to stream for session ${this.session.id}`); // Clean up existing connection this.cleanupStreamConnection(); // Get auth client from the main app const authClient = new AuthClient(); const user = authClient.getCurrentUser(); // Build stream URL with auth token as query parameter (EventSource doesn't support headers) let streamUrl = `/api/sessions/${this.session.id}/stream`; if (user?.token) { streamUrl += `?token=${encodeURIComponent(user.token)}`; } // Use CastConverter to connect terminal to stream with reconnection tracking const connection = CastConverter.connectToStream(this.terminal, streamUrl); // Wrap the connection to track reconnections const originalEventSource = connection.eventSource; let lastErrorTime = 0; const reconnectThreshold = 3; // Max reconnects before giving up const reconnectWindow = 5000; // 5 second window const handleError = () => { const now = Date.now(); // Reset counter if enough time has passed since last error if (now - lastErrorTime > reconnectWindow) { this.reconnectCount = 0; } this.reconnectCount++; lastErrorTime = now; logger.log(`stream error #${this.reconnectCount} for session ${this.session?.id}`); // If we've had too many reconnects, mark session as exited if (this.reconnectCount >= reconnectThreshold) { logger.warn(`session ${this.session?.id} marked as exited due to excessive reconnections`); if (this.session && this.session.status !== 'exited') { this.session = { ...this.session, status: 'exited' }; this.requestUpdate(); // Disconnect the stream and load final snapshot this.cleanupStreamConnection(); // Load final snapshot requestAnimationFrame(() => { this.loadSessionSnapshot(); }); } } }; // Override the error handler originalEventSource.addEventListener('error', handleError); // Store the connection with error handler reference this.streamConnection = { ...connection, errorHandler: handleError as EventListener, }; } private async handleKeyboardInput(e: KeyboardEvent) { if (!this.session) return; // Handle Escape key specially for exited sessions if (e.key === 'Escape' && this.session.status === 'exited') { this.handleBack(); return; } // Don't send input to exited sessions if (this.session.status === 'exited') { logger.log('ignoring keyboard input - session has exited'); return; } // Allow standard browser copy/paste shortcuts const isMacOS = navigator.platform.toLowerCase().includes('mac'); const isStandardPaste = (isMacOS && e.metaKey && e.key === 'v' && !e.ctrlKey && !e.shiftKey) || (!isMacOS && e.ctrlKey && e.key === 'v' && !e.shiftKey); const isStandardCopy = (isMacOS && e.metaKey && e.key === 'c' && !e.ctrlKey && !e.shiftKey) || (!isMacOS && e.ctrlKey && e.key === 'c' && !e.shiftKey); if (isStandardPaste || isStandardCopy) { // Allow standard browser copy/paste to work return; } let inputText = ''; // Handle special keys switch (e.key) { case 'Enter': if (e.ctrlKey) { // Ctrl+Enter - send to tty-fwd for proper handling inputText = 'ctrl_enter'; } else if (e.shiftKey) { // Shift+Enter - send to tty-fwd for proper handling inputText = 'shift_enter'; } else { // Regular Enter inputText = 'enter'; } break; case 'Escape': inputText = 'escape'; break; case 'ArrowUp': inputText = 'arrow_up'; break; case 'ArrowDown': inputText = 'arrow_down'; break; case 'ArrowLeft': inputText = 'arrow_left'; break; case 'ArrowRight': inputText = 'arrow_right'; break; case 'Tab': inputText = '\t'; break; case 'Backspace': inputText = '\b'; break; case 'Delete': inputText = '\x7f'; break; case ' ': inputText = ' '; break; default: // Handle regular printable characters if (e.key.length === 1) { inputText = e.key; } else { // Ignore other special keys return; } break; } // Handle Ctrl combinations (but not if we already handled Ctrl+Enter above) if (e.ctrlKey && e.key.length === 1 && e.key !== 'Enter') { const charCode = e.key.toLowerCase().charCodeAt(0); if (charCode >= 97 && charCode <= 122) { // a-z inputText = String.fromCharCode(charCode - 96); // Ctrl+A = \x01, etc. } } // Send the input to the session try { // Determine if we should send as key or text const body = [ 'enter', 'escape', 'arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'ctrl_enter', 'shift_enter', ].includes(inputText) ? { key: inputText } : { text: inputText }; const response = await fetch(`/api/sessions/${this.session.id}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, body: JSON.stringify(body), }); if (!response.ok) { if (response.status === 400) { logger.log('session no longer accepting input (likely exited)'); // Update session status to exited if we get 400 error if (this.session && (this.session.status as string) !== 'exited') { this.session = { ...this.session, status: 'exited' }; this.requestUpdate(); } } else { logger.error('failed to send input to session', { status: response.status }); } } } catch (error) { logger.error('error sending input', error); } } private handleBack() { // Dispatch a custom event that the app can handle with view transitions this.dispatchEvent( new CustomEvent('navigate-to-list', { bubbles: true, composed: true, }) ); } private handleSidebarToggle() { // Dispatch event to toggle sidebar this.dispatchEvent( new CustomEvent('toggle-sidebar', { bubbles: true, composed: true, }) ); } private handleSessionExit(e: Event) { const customEvent = e as CustomEvent; logger.log('session exit event received', customEvent.detail); if (this.session && customEvent.detail.sessionId === this.session.id) { // Update session status to exited this.session = { ...this.session, status: 'exited' }; this.requestUpdate(); // Switch to snapshot mode - disconnect stream and load final snapshot this.cleanupStreamConnection(); } } private async loadSessionSnapshot() { if (!this.terminal || !this.session) return; try { const url = `/api/sessions/${this.session.id}/snapshot`; const response = await fetch(url); if (!response.ok) throw new Error(`Failed to fetch snapshot: ${response.status}`); const castContent = await response.text(); // Clear terminal and load snapshot this.terminal.clear(); await CastConverter.dumpToTerminal(this.terminal, castContent); // Scroll to bottom after loading this.terminal.queueCallback(() => { if (this.terminal) { this.terminal.scrollToBottom(); } }); } catch (error) { logger.error('failed to load session snapshot', error); } } private async handleTerminalResize(event: Event) { const customEvent = event as CustomEvent; // Update terminal dimensions for display const { cols, rows } = customEvent.detail; this.terminalCols = cols; this.terminalRows = rows; this.requestUpdate(); // Debounce resize requests to prevent jumpiness if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } this.resizeTimeout = window.setTimeout(async () => { // Only send resize request if dimensions actually changed if (cols === this.lastResizeWidth && rows === this.lastResizeHeight) { logger.debug(`skipping redundant resize request: ${cols}x${rows}`); return; } // Send resize request to backend if session is active if (this.session && this.session.status !== 'exited') { try { logger.debug( `sending resize request: ${cols}x${rows} (was ${this.lastResizeWidth}x${this.lastResizeHeight})` ); const response = await fetch(`/api/sessions/${this.session.id}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, body: JSON.stringify({ cols: cols, rows: rows }), }); if (response.ok) { // Cache the successfully sent dimensions this.lastResizeWidth = cols; this.lastResizeHeight = rows; } else { logger.warn(`failed to resize session: ${response.status}`); } } catch (error) { logger.warn('failed to send resize request', error); } } }, 250) as unknown as number; // 250ms debounce delay } private handleTerminalPaste(e: Event) { const customEvent = e as CustomEvent; const text = customEvent.detail?.text; if (text && this.session) { this.sendInputText(text); } } // Mobile input methods private handleMobileInputToggle() { this.showMobileInput = !this.showMobileInput; if (this.showMobileInput) { // Focus the textarea after ensuring it's rendered and visible setTimeout(() => { const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; if (textarea) { // Ensure textarea is visible and focusable textarea.style.visibility = 'visible'; textarea.removeAttribute('readonly'); textarea.focus(); // Trigger click to ensure keyboard shows textarea.click(); this.adjustTextareaForKeyboard(); } }, 100); } else { // Clean up viewport listener when closing overlay const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; if (textarea) { const textareaWithCleanup = textarea as HTMLTextAreaElement & { _viewportCleanup?: () => void; }; if (textareaWithCleanup._viewportCleanup) { textareaWithCleanup._viewportCleanup(); } } // Refresh terminal scroll position after closing mobile input this.refreshTerminalAfterMobileInput(); } } private adjustTextareaForKeyboard() { // Adjust the layout when virtual keyboard appears const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; const controls = this.querySelector('#mobile-controls') as HTMLElement; if (!textarea || !controls) return; const adjustLayout = () => { const viewportHeight = window.visualViewport?.height || window.innerHeight; const windowHeight = window.innerHeight; const keyboardHeight = windowHeight - viewportHeight; // If keyboard is visible (viewport height is significantly smaller) if (keyboardHeight > 100) { // Move controls above the keyboard controls.style.transform = `translateY(-${keyboardHeight}px)`; controls.style.transition = 'transform 0.3s ease'; // Calculate available space to match closed keyboard layout const header = this.querySelector( '.flex.items-center.justify-between.p-4.border-b' ) as HTMLElement; const headerHeight = header?.offsetHeight || 60; const controlsHeight = controls?.offsetHeight || 120; // Calculate exact space to maintain same gap as when keyboard is closed const availableHeight = viewportHeight - headerHeight - controlsHeight; const inputArea = textarea.parentElement as HTMLElement; if (inputArea && availableHeight > 0) { // Set the input area to exactly fill the space, maintaining natural flex behavior inputArea.style.height = `${availableHeight}px`; inputArea.style.maxHeight = `${availableHeight}px`; inputArea.style.overflow = 'hidden'; inputArea.style.display = 'flex'; inputArea.style.flexDirection = 'column'; inputArea.style.paddingBottom = '0px'; // Remove any extra padding // Let textarea use flex-1 behavior but constrain the container textarea.style.height = 'auto'; // Let it grow naturally textarea.style.maxHeight = 'none'; // Remove height constraints textarea.style.marginBottom = '8px'; // Keep consistent margin textarea.style.flex = '1'; // Fill available space } } else { // Reset position when keyboard is hidden controls.style.transform = 'translateY(0px)'; controls.style.transition = 'transform 0.3s ease'; // Reset textarea height and constraints to original flex behavior const inputArea = textarea.parentElement as HTMLElement; if (inputArea) { inputArea.style.height = ''; inputArea.style.maxHeight = ''; inputArea.style.overflow = ''; inputArea.style.display = ''; inputArea.style.flexDirection = ''; inputArea.style.paddingBottom = ''; textarea.style.height = ''; textarea.style.maxHeight = ''; textarea.style.flex = ''; } } }; // Listen for viewport changes (keyboard show/hide) if (window.visualViewport) { window.visualViewport.addEventListener('resize', adjustLayout); // Clean up listener when overlay is closed const cleanup = () => { if (window.visualViewport) { window.visualViewport.removeEventListener('resize', adjustLayout); } }; // Store cleanup function for later use (textarea as HTMLTextAreaElement & { _viewportCleanup?: () => void })._viewportCleanup = cleanup; } // Initial adjustment requestAnimationFrame(adjustLayout); } private handleMobileInputChange(e: Event) { const textarea = e.target as HTMLTextAreaElement; this.mobileInputText = textarea.value; } private async handleMobileInputSendOnly() { // Get the current value from the textarea directly const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; const textToSend = textarea?.value?.trim() || this.mobileInputText.trim(); if (!textToSend) return; try { // Send text without enter key await this.sendInputText(textToSend); // Clear both the reactive property and textarea this.mobileInputText = ''; if (textarea) { textarea.value = ''; } // Trigger re-render to update button state this.requestUpdate(); // Hide the input overlay after sending this.showMobileInput = false; // Refresh terminal scroll position after closing mobile input this.refreshTerminalAfterMobileInput(); } catch (error) { logger.error('error sending mobile input', error); // Don't hide the overlay if there was an error } } private async handleMobileInputSend() { // Get the current value from the textarea directly const textarea = this.querySelector('#mobile-input-textarea') as HTMLTextAreaElement; const textToSend = textarea?.value?.trim() || this.mobileInputText.trim(); if (!textToSend) return; try { // Add enter key at the end to execute the command await this.sendInputText(textToSend); await this.sendInputText('enter'); // Clear both the reactive property and textarea this.mobileInputText = ''; if (textarea) { textarea.value = ''; } // Trigger re-render to update button state this.requestUpdate(); // Hide the input overlay after sending this.showMobileInput = false; // Refresh terminal scroll position after closing mobile input this.refreshTerminalAfterMobileInput(); } catch (error) { logger.error('error sending mobile input', error); // Don't hide the overlay if there was an error } } private async handleSpecialKey(key: string) { await this.sendInputText(key); } private handleCtrlAlphaToggle() { this.showCtrlAlpha = !this.showCtrlAlpha; } private async handleCtrlKey(letter: string) { // Add to sequence instead of immediately sending this.ctrlSequence = [...this.ctrlSequence, letter]; this.requestUpdate(); } private async handleSendCtrlSequence() { // Send each ctrl key in sequence for (const letter of this.ctrlSequence) { const controlCode = String.fromCharCode(letter.charCodeAt(0) - 64); await this.sendInputText(controlCode); } // Clear sequence and close overlay this.ctrlSequence = []; this.showCtrlAlpha = false; this.requestUpdate(); } private handleClearCtrlSequence() { this.ctrlSequence = []; this.requestUpdate(); } private handleCtrlAlphaBackdrop(e: Event) { if (e.target === e.currentTarget) { this.showCtrlAlpha = false; this.ctrlSequence = []; this.requestUpdate(); } } private handleTerminalFitToggle() { this.terminalFitHorizontally = !this.terminalFitHorizontally; // Find the terminal component and call its handleFitToggle method const terminal = this.querySelector('vibe-terminal') as HTMLElement & { handleFitToggle?: () => void; }; if (terminal?.handleFitToggle) { // Use the terminal's own toggle method which handles scroll position correctly terminal.handleFitToggle(); } } private handleMaxWidthToggle() { this.showWidthSelector = !this.showWidthSelector; } private handleWidthSelect(newMaxCols: number) { this.terminalMaxCols = newMaxCols; this.preferencesManager.setMaxCols(newMaxCols); this.showWidthSelector = false; // Update the terminal component const terminal = this.querySelector('vibe-terminal') as Terminal; if (terminal) { terminal.maxCols = newMaxCols; // Trigger a resize to apply the new constraint terminal.requestUpdate(); } } private handleCustomWidthInput(e: Event) { const input = e.target as HTMLInputElement; this.customWidth = input.value; } private handleCustomWidthSubmit() { const width = Number.parseInt(this.customWidth, 10); if (!Number.isNaN(width) && width >= 20 && width <= 500) { this.handleWidthSelect(width); this.customWidth = ''; } } private handleCustomWidthKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { this.handleCustomWidthSubmit(); } else if (e.key === 'Escape') { this.customWidth = ''; this.showWidthSelector = false; } } private getCurrentWidthLabel(): string { if (this.terminalMaxCols === 0) return '∞'; const commonWidth = COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols); return commonWidth ? commonWidth.label : this.terminalMaxCols.toString(); } private handleFontSizeChange(newSize: number) { // Clamp to reasonable bounds const clampedSize = Math.max(8, Math.min(32, newSize)); this.terminalFontSize = clampedSize; this.preferencesManager.setFontSize(clampedSize); // Update the terminal component const terminal = this.querySelector('vibe-terminal') as Terminal; if (terminal) { terminal.fontSize = clampedSize; terminal.requestUpdate(); } } private handleOpenFileBrowser() { this.showFileBrowser = true; } private handleCloseFileBrowser() { this.showFileBrowser = false; } private async handleInsertPath(event: CustomEvent) { const { path, type } = event.detail; if (!path || !this.session) return; // Escape the path for shell use (wrap in quotes if it contains spaces) const escapedPath = path.includes(' ') ? `"${path}"` : path; // Send the path to the terminal await this.sendInputText(escapedPath); logger.log(`inserted ${type} path into terminal: ${escapedPath}`); } private async sendInputText(text: string) { if (!this.session) return; try { // Determine if we should send as key or text const body = [ 'enter', 'escape', 'arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'ctrl_enter', 'shift_enter', ].includes(text) ? { key: text } : { text }; const response = await fetch(`/api/sessions/${this.session.id}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, body: JSON.stringify(body), }); if (!response.ok) { logger.error('failed to send input to session', { status: response.status }); } } catch (error) { logger.error('error sending input', error); } } private async resetTerminalSize() { if (!this.session) { logger.warn('resetTerminalSize called but no session available'); return; } logger.log('Sending reset-size request for session', this.session.id); try { const response = await fetch(`/api/sessions/${this.session.id}/reset-size`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, }); if (!response.ok) { logger.error('failed to reset terminal size', { status: response.status, sessionId: this.session.id, }); } else { logger.log('terminal size reset successfully for session', this.session.id); } } catch (error) { logger.error('error resetting terminal size', { error, sessionId: this.session.id, }); } } private refreshTerminalAfterMobileInput() { // After closing mobile input, the viewport changes and the terminal // needs to recalculate its scroll position to avoid getting stuck if (!this.terminal) return; // Give the viewport time to settle after keyboard disappears setTimeout(() => { if (this.terminal) { // Force the terminal to recalculate its viewport dimensions and scroll boundaries // This fixes the issue where maxScrollPixels becomes incorrect after keyboard changes const terminalElement = this.terminal as unknown as { fitTerminal?: () => void }; if (typeof terminalElement.fitTerminal === 'function') { terminalElement.fitTerminal(); } // Then scroll to bottom to fix the position this.terminal.scrollToBottom(); } }, 300); // Wait for viewport to settle } private startLoading() { this.loading = true; this.loadingFrame = 0; this.loadingInterval = window.setInterval(() => { this.loadingFrame = (this.loadingFrame + 1) % 4; this.requestUpdate(); }, 200) as unknown as number; // Update every 200ms for smooth animation } private stopLoading() { this.loading = false; if (this.loadingInterval) { clearInterval(this.loadingInterval); this.loadingInterval = null; } } private getLoadingText(): string { const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; return frames[this.loadingFrame % frames.length]; } private getStatusText(): string { if (!this.session) return ''; if ('active' in this.session && this.session.active === false) { return 'waiting'; } return this.session.status; } private getStatusColor(): string { if (!this.session) return 'text-dark-text-muted'; if ('active' in this.session && this.session.active === false) { return 'text-dark-text-muted'; } return this.session.status === 'running' ? 'text-status-success' : 'text-status-warning'; } private getStatusDotColor(): string { if (!this.session) return 'bg-dark-text-muted'; if ('active' in this.session && this.session.active === false) { return 'bg-dark-text-muted'; } return this.session.status === 'running' ? 'bg-status-success' : 'bg-status-warning'; } render() { if (!this.session) { return html`
${this.getLoadingText()}
Waiting for session...
`; } return html`
${this.showSidebarToggle && this.sidebarCollapsed ? html` ` : ''} ${this.showBackButton ? html` ` : ''}
${ this.session.name || (Array.isArray(this.session.command) ? this.session.command.join(' ') : this.session.command) }
${ this.showWidthSelector ? html`
Terminal Width
${COMMON_TERMINAL_WIDTHS.map( (width) => html` ` )}
Custom (20-500)
e.stopPropagation()} class="flex-1 bg-dark-bg border border-dark-border rounded px-2 py-1 text-xs font-mono text-dark-text" />
Font Size
${this.terminalFontSize}px
` : '' }
${this.getStatusText().toUpperCase()}
${ this.terminalCols > 0 && this.terminalRows > 0 ? html` ${this.terminalCols}×${this.terminalRows} ` : '' }
${ this.loading ? html`
${this.getLoadingText()}
Connecting to session...
` : '' }
${ this.session?.status === 'exited' ? html`
SESSION EXITED
` : '' } ${ this.isMobile && !this.showMobileInput ? html`
` : '' } ${ this.isMobile && this.showMobileInput ? html`
{ if (e.target === e.currentTarget) { this.showMobileInput = false; } }} @touchstart=${this.touchStartHandler} @touchend=${this.touchEndHandler} >
e.stopPropagation()} >
` : '' } ${ this.isMobile && this.showCtrlAlpha ? html`
e.stopPropagation()} >
Ctrl + Key
Build sequences like ctrl+c ctrl+c
${ this.ctrlSequence.length > 0 ? html`
Current sequence:
${this.ctrlSequence.map((letter) => `Ctrl+${letter}`).join(' ')}
` : '' }
${[ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ].map( (letter) => html` ` )}
Common: C=interrupt, X=exit, O=save, W=search
${ this.ctrlSequence.length > 0 ? html` ` : '' }
` : '' }
`; } }