diff --git a/linux/pkg/api/websocket.go b/linux/pkg/api/websocket.go index ada9567c..b7820d0d 100644 --- a/linux/pkg/api/websocket.go +++ b/linux/pkg/api/websocket.go @@ -20,7 +20,7 @@ import ( const ( // Magic byte for binary messages BufferMagicByte = 0xbf - + // WebSocket timeouts writeWait = 10 * time.Second pongWait = 60 * time.Second @@ -48,6 +48,22 @@ func NewBufferWebSocketHandler(manager *session.Manager) *BufferWebSocketHandler } } +// safeSend safely sends data to a channel, returning false if the channel is closed +func safeSend(send chan []byte, data []byte, done chan struct{}) bool { + defer func() { + if r := recover(); r != nil { + // Channel was closed, ignore the panic + } + }() + + select { + case send <- data: + return true + case <-done: + return false + } +} + func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { @@ -59,9 +75,9 @@ func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques // Set up connection parameters conn.SetReadLimit(maxMessageSize) conn.SetReadDeadline(time.Now().Add(pongWait)) - conn.SetPongHandler(func(string) error { + conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(pongWait)) - return nil + return nil }) // Start ping ticker @@ -113,9 +129,7 @@ func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message case "ping": // Send pong response pong, _ := json.Marshal(map[string]string{"type": "pong"}) - select { - case send <- pong: - case <-done: + if !safeSend(send, pong, done) { return } @@ -124,7 +138,7 @@ func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message if !ok { return } - + // Start streaming session data go h.streamSession(sessionID, send, done) @@ -142,10 +156,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt "type": "error", "message": fmt.Sprintf("Session not found: %v", err), }) - select { - case send <- errorMsg: - case <-done: - } + safeSend(send, errorMsg, done) return } @@ -163,10 +174,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt "type": "error", "message": "Session stream not available", }) - select { - case send <- errorMsg: - case <-done: - } + safeSend(send, errorMsg, done) return } time.Sleep(100 * time.Millisecond) @@ -180,10 +188,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt "type": "error", "message": "Failed to create file watcher", }) - select { - case send <- errorMsg: - case <-done: - } + safeSend(send, errorMsg, done) return } defer watcher.Close() @@ -196,10 +201,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt "type": "error", "message": fmt.Sprintf("Failed to watch session stream: %v", err), }) - select { - case send <- errorMsg: - case <-done: - } + safeSend(send, errorMsg, done) return } @@ -214,7 +216,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt select { case <-done: return - + case event, ok := <-watcher.Events: if !ok { return @@ -235,10 +237,7 @@ func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byt if !sess.IsAlive() { // Send exit event exitMsg := h.createBinaryMessage(sessionID, []byte(`{"type":"exit","code":0}`)) - select { - case send <- exitMsg: - case <-done: - } + safeSend(send, exitMsg, done) return } } @@ -307,14 +306,12 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str *headerSent = true // Send header as binary message headerData, _ := json.Marshal(map[string]interface{}{ - "type": "header", - "width": header.Width, + "type": "header", + "width": header.Width, "height": header.Height, }) msg := h.createBinaryMessage(sessionID, headerData) - select { - case send <- msg: - case <-done: + if !safeSend(send, msg, done) { return } continue @@ -331,29 +328,25 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str if ok1 && ok2 && ok3 && eventType == "o" { // Create terminal output message outputData, _ := json.Marshal(map[string]interface{}{ - "type": "output", + "type": "output", "timestamp": timestamp, - "data": data, + "data": data, }) - + msg := h.createBinaryMessage(sessionID, outputData) - select { - case send <- msg: - case <-done: + if !safeSend(send, msg, done) { return } } else if ok1 && ok2 && ok3 && eventType == "r" { // Create resize message resizeData, _ := json.Marshal(map[string]interface{}{ - "type": "resize", - "timestamp": timestamp, + "type": "resize", + "timestamp": timestamp, "dimensions": data, }) - + msg := h.createBinaryMessage(sessionID, resizeData) - select { - case send <- msg: - case <-done: + if !safeSend(send, msg, done) { return } } @@ -364,34 +357,34 @@ func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath str func (h *BufferWebSocketHandler) createBinaryMessage(sessionID string, data []byte) []byte { // Binary message format: // [magic byte (1)] [session ID length (4, little endian)] [session ID] [data] - + sessionIDBytes := []byte(sessionID) totalLen := 1 + 4 + len(sessionIDBytes) + len(data) - + msg := make([]byte, totalLen) offset := 0 - + // Magic byte msg[offset] = BufferMagicByte offset++ - + // Session ID length (little endian) binary.LittleEndian.PutUint32(msg[offset:], uint32(len(sessionIDBytes))) offset += 4 - + // Session ID copy(msg[offset:], sessionIDBytes) offset += len(sessionIDBytes) - + // Data copy(msg[offset:], data) - + return msg } func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte, ticker *time.Ticker, done chan struct{}) { defer close(send) - + for { select { case message, ok := <-send: @@ -419,9 +412,9 @@ func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte, if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } - + case <-done: return } } -} \ No newline at end of file +} diff --git a/web/src/client/components/file-browser.ts b/web/src/client/components/file-browser.ts index 3e4e798a..842ef25f 100644 --- a/web/src/client/components/file-browser.ts +++ b/web/src/client/components/file-browser.ts @@ -238,8 +238,12 @@ export class FileBrowser extends LitElement { : ''}
- - + +
diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 2a8fdf4f..421f9e20 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -4,6 +4,10 @@ import type { Session } from './session-list.js'; import './terminal.js'; import type { Terminal } from './terminal.js'; import { CastConverter } from '../utils/cast-converter.js'; +import { + TerminalPreferencesManager, + COMMON_TERMINAL_WIDTHS, +} from '../utils/terminal-preferences.js'; @customElement('session-view') export class SessionView extends LitElement { @@ -28,6 +32,11 @@ export class SessionView extends LitElement { @state() private terminalRows = 0; @state() private showCtrlAlpha = false; @state() private terminalFitHorizontally = false; + @state() private terminalMaxCols = 0; + @state() private showWidthSelector = false; + @state() private customWidth = ''; + + private preferencesManager = TerminalPreferencesManager.getInstance(); @state() private reconnectCount = 0; @state() private ctrlSequence: string[] = []; @@ -115,14 +124,33 @@ export class SessionView extends LitElement { } }; + 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(); + // 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(); @@ -149,6 +177,9 @@ export class SessionView extends LitElement { super.disconnectedCallback(); this.connected = false; + // Remove click outside handler + document.removeEventListener('click', this.handleClickOutside); + // Remove click handler this.removeEventListener('click', () => this.focus()); @@ -218,6 +249,7 @@ export class SessionView extends LitElement { this.terminal.rows = 24; this.terminal.fontSize = 14; 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( @@ -749,6 +781,52 @@ export class SessionView extends LitElement { } } + 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 = parseInt(this.customWidth, 10); + if (!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 async sendInputText(text: string) { if (!this.session) return; @@ -885,7 +963,65 @@ export class SessionView extends LitElement { -
+
+ + ${this.showWidthSelector + ? html` +
+
+
Terminal Width
+ ${COMMON_TERMINAL_WIDTHS.map( + (width) => html` + + ` + )} +
+
Custom (20-500)
+
+ + +
+
+
+
+ ` + : ''}
@@ -931,6 +1067,7 @@ export class SessionView extends LitElement { .rows=${24} .fontSize=${14} .fitHorizontally=${false} + .maxCols=${this.terminalMaxCols} class="w-full h-full" >
diff --git a/web/src/client/components/terminal.ts b/web/src/client/components/terminal.ts index 078327f5..83ef3ee0 100644 --- a/web/src/client/components/terminal.ts +++ b/web/src/client/components/terminal.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from 'lit'; +import { LitElement, html, PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/headless'; import { UrlHighlighter } from '../utils/url-highlighter.js'; @@ -15,6 +15,7 @@ export class Terminal extends LitElement { @property({ type: Number }) rows = 24; @property({ type: Number }) fontSize = 14; @property({ type: Boolean }) fitHorizontally = false; + @property({ type: Number }) maxCols = 0; // 0 means no limit private originalFontSize: number = 14; @@ -87,6 +88,33 @@ export class Terminal extends LitElement { this.debugMode = new URLSearchParams(window.location.search).has('debug'); } + updated(changedProperties: PropertyValues) { + if (changedProperties.has('cols') || changedProperties.has('rows')) { + if (this.terminal) { + this.reinitializeTerminal(); + } + } + if (changedProperties.has('fontSize')) { + // Store original font size when it changes (but not during horizontal fitting) + if (!this.fitHorizontally) { + this.originalFontSize = this.fontSize; + } + } + if (changedProperties.has('fitHorizontally')) { + if (!this.fitHorizontally) { + // Restore original font size when turning off horizontal fitting + this.fontSize = this.originalFontSize; + } + this.fitTerminal(); + } + // If maxCols changed, trigger a resize + if (changedProperties.has('maxCols')) { + if (this.terminal && this.container) { + this.fitTerminal(); + } + } + } + disconnectedCallback() { this.cleanup(); super.disconnectedCallback(); @@ -110,27 +138,6 @@ export class Terminal extends LitElement { } } - updated(changedProperties: Map) { - if (changedProperties.has('cols') || changedProperties.has('rows')) { - if (this.terminal) { - this.reinitializeTerminal(); - } - } - if (changedProperties.has('fontSize')) { - // Store original font size when it changes (but not during horizontal fitting) - if (!this.fitHorizontally) { - this.originalFontSize = this.fontSize; - } - } - if (changedProperties.has('fitHorizontally')) { - if (!this.fitHorizontally) { - // Restore original font size when turning off horizontal fitting - this.fontSize = this.originalFontSize; - } - this.fitTerminal(); - } - } - firstUpdated() { // Store the initial font size as original this.originalFontSize = this.fontSize; @@ -303,7 +310,9 @@ export class Terminal extends LitElement { const lineHeight = this.fontSize * 1.2; const charWidth = this.measureCharacterWidth(); - this.cols = Math.max(20, Math.floor(containerWidth / charWidth)) - 1; // This -1 should not be needed, but it is... + const calculatedCols = Math.max(20, Math.floor(containerWidth / charWidth)) - 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; this.rows = Math.max(6, Math.floor(containerHeight / lineHeight)); this.actualRows = this.rows; diff --git a/web/src/client/utils/terminal-preferences.ts b/web/src/client/utils/terminal-preferences.ts new file mode 100644 index 00000000..584b757d --- /dev/null +++ b/web/src/client/utils/terminal-preferences.ts @@ -0,0 +1,102 @@ +/** + * Terminal preferences management + * Handles saving and loading terminal-related user preferences + */ + +export interface TerminalPreferences { + maxCols: number; // 0 means no limit, positive numbers set max width + fontSize: number; + fitHorizontally: boolean; +} + +// Common terminal widths +export const COMMON_TERMINAL_WIDTHS = [ + { value: 0, label: '∞', description: 'Unlimited (full width)' }, + { value: 80, label: '80', description: 'Classic terminal' }, + { value: 100, label: '100', description: 'Modern standard' }, + { value: 120, label: '120', description: 'Wide terminal' }, + { value: 132, label: '132', description: 'Mainframe width' }, + { value: 160, label: '160', description: 'Ultra-wide' }, +] as const; + +const DEFAULT_PREFERENCES: TerminalPreferences = { + maxCols: 0, // No limit by default - take as much as possible + fontSize: 14, + fitHorizontally: false, +}; + +const STORAGE_KEY_TERMINAL_PREFS = 'vibetunnel_terminal_preferences'; + +export class TerminalPreferencesManager { + private static instance: TerminalPreferencesManager; + private preferences: TerminalPreferences; + + private constructor() { + this.preferences = this.loadPreferences(); + } + + static getInstance(): TerminalPreferencesManager { + if (!TerminalPreferencesManager.instance) { + TerminalPreferencesManager.instance = new TerminalPreferencesManager(); + } + return TerminalPreferencesManager.instance; + } + + private loadPreferences(): TerminalPreferences { + try { + const saved = localStorage.getItem(STORAGE_KEY_TERMINAL_PREFS); + if (saved) { + const parsed = JSON.parse(saved); + // Merge with defaults to handle new properties + return { ...DEFAULT_PREFERENCES, ...parsed }; + } + } catch (error) { + console.warn('Failed to load terminal preferences:', error); + } + return { ...DEFAULT_PREFERENCES }; + } + + private savePreferences() { + try { + localStorage.setItem(STORAGE_KEY_TERMINAL_PREFS, JSON.stringify(this.preferences)); + } catch (error) { + console.warn('Failed to save terminal preferences:', error); + } + } + + getMaxCols(): number { + return this.preferences.maxCols; + } + + setMaxCols(maxCols: number) { + this.preferences.maxCols = Math.max(0, maxCols); // Ensure non-negative + this.savePreferences(); + } + + getFontSize(): number { + return this.preferences.fontSize; + } + + setFontSize(fontSize: number) { + this.preferences.fontSize = Math.max(8, Math.min(32, fontSize)); // Reasonable bounds + this.savePreferences(); + } + + getFitHorizontally(): boolean { + return this.preferences.fitHorizontally; + } + + setFitHorizontally(fitHorizontally: boolean) { + this.preferences.fitHorizontally = fitHorizontally; + this.savePreferences(); + } + + getPreferences(): TerminalPreferences { + return { ...this.preferences }; + } + + resetToDefaults() { + this.preferences = { ...DEFAULT_PREFERENCES }; + this.savePreferences(); + } +}