Add terminal max width control and fix WebSocket panic (#29)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Helmut Januschka 2025-06-20 09:21:20 +02:00 committed by GitHub
parent c6b000a421
commit 9792908fe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 326 additions and 81 deletions

View file

@ -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
}
}
}
}

View file

@ -238,8 +238,12 @@ export class FileBrowser extends LitElement {
: ''}
<div class="p-4 border-t border-dark-border flex gap-4 flex-shrink-0">
<button class="btn-ghost font-mono flex-1 py-3" @click=${this.handleCancel}>Cancel</button>
<button class="btn-primary font-mono flex-1 py-3" @click=${this.handleSelect}>Select</button>
<button class="btn-ghost font-mono flex-1 py-3" @click=${this.handleCancel}>
Cancel
</button>
<button class="btn-primary font-mono flex-1 py-3" @click=${this.handleSelect}>
Select
</button>
</div>
</div>
</div>

View file

@ -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 {
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2">
<div class="flex items-center gap-2 text-xs flex-shrink-0 ml-2 relative">
<button
class="btn-secondary font-mono text-xs px-2 py-1 flex-shrink-0 width-selector-button"
@click=${this.handleMaxWidthToggle}
title="Terminal width: ${this.terminalMaxCols === 0
? 'Unlimited'
: this.terminalMaxCols + ' columns'}"
>
${this.getCurrentWidthLabel()}
</button>
${this.showWidthSelector
? html`
<div
class="width-selector-container absolute top-8 right-0 bg-dark-bg-secondary border border-dark-border rounded-md shadow-lg z-50 min-w-48"
>
<div class="p-2">
<div class="text-xs text-dark-text-muted mb-2 px-2">Terminal Width</div>
${COMMON_TERMINAL_WIDTHS.map(
(width) => html`
<button
class="w-full text-left px-2 py-1 text-xs hover:bg-dark-border rounded-sm flex justify-between items-center
${this.terminalMaxCols === width.value
? 'bg-dark-border text-accent-green'
: 'text-dark-text'}"
@click=${() => this.handleWidthSelect(width.value)}
>
<span class="font-mono">${width.label}</span>
<span class="text-dark-text-muted text-xs">${width.description}</span>
</button>
`
)}
<div class="border-t border-dark-border mt-2 pt-2">
<div class="text-xs text-dark-text-muted mb-1 px-2">Custom (20-500)</div>
<div class="flex gap-1">
<input
type="number"
min="20"
max="500"
placeholder="80"
.value=${this.customWidth}
@input=${this.handleCustomWidthInput}
@keydown=${this.handleCustomWidthKeydown}
class="flex-1 bg-dark-bg border border-dark-border rounded px-2 py-1 text-xs font-mono text-dark-text"
/>
<button
class="btn-secondary text-xs px-2 py-1"
@click=${this.handleCustomWidthSubmit}
?disabled=${!this.customWidth ||
parseInt(this.customWidth) < 20 ||
parseInt(this.customWidth) > 500}
>
Set
</button>
</div>
</div>
</div>
</div>
`
: ''}
<div class="flex flex-col items-end gap-0">
<span class="${this.getStatusColor()} text-xs flex items-center gap-1">
<div class="w-2 h-2 rounded-full ${this.getStatusDotColor()}"></div>
@ -931,6 +1067,7 @@ export class SessionView extends LitElement {
.rows=${24}
.fontSize=${14}
.fitHorizontally=${false}
.maxCols=${this.terminalMaxCols}
class="w-full h-full"
></vibe-terminal>
</div>

View file

@ -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<string, unknown>) {
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;

View file

@ -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();
}
}