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