From 9792908fe1f3dbb8e6f3da81b030d2e5c03d8fff Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Fri, 20 Jun 2025 09:21:20 +0200 Subject: [PATCH] Add terminal max width control and fix WebSocket panic (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add terminal max width option - Add terminal preferences manager for persistent settings storage - Add maxCols property to terminal component with width constraint logic - Add UI toggle button (∞/80) in session header for easy width control - Default behavior unchanged: unlimited width (takes full container) - Optional 80-column max width limit when enabled - Preferences saved to localStorage and restored on page load - Real-time updates without page refresh 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: enhance terminal width selector with common presets and custom input - Add common terminal width presets: ∞, 80, 100, 120, 132, 160 - Add custom width input field (20-500 columns) - Replace simple toggle with dropdown selector UI - Include helpful descriptions for each preset - Support keyboard shortcuts (Enter to submit, Escape to cancel) - Add click-outside-to-close functionality - Maintain all existing preferences persistence - Show current width in button label and tooltip Common widths: - 80: Classic terminal - 100: Modern standard - 120: Wide terminal - 132: Mainframe width - 160: Ultra-wide - Custom: User-defined (20-500) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: prevent WebSocket send on closed channel panic Added safeSend helper function with panic recovery to handle race conditions when multiple goroutines access WebSocket channels. Replaces unsafe channel sends with graceful error handling. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- linux/pkg/api/websocket.go | 103 +++++++------- web/src/client/components/file-browser.ts | 8 +- web/src/client/components/session-view.ts | 139 ++++++++++++++++++- web/src/client/components/terminal.ts | 55 +++++--- web/src/client/utils/terminal-preferences.ts | 102 ++++++++++++++ 5 files changed, 326 insertions(+), 81 deletions(-) create mode 100644 web/src/client/utils/terminal-preferences.ts 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(); + } +}