From d019559f2b86fcc9a0027623f9affcd87682a93e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 12 Jul 2025 11:52:17 +0200 Subject: [PATCH] feat(web): add terminal theme preference --- web/src/client/components/session-view.ts | 19 +++ .../terminal-lifecycle-manager.ts | 7 + .../components/session-view/width-selector.ts | 20 ++- web/src/client/components/terminal.ts | 73 ++------- web/src/client/utils/terminal-preferences.ts | 12 ++ web/src/client/utils/terminal-themes.ts | 149 ++++++++++++++++++ 6 files changed, 222 insertions(+), 58 deletions(-) create mode 100644 web/src/client/utils/terminal-themes.ts diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 586e8243..c545cc9d 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -32,6 +32,7 @@ import { COMMON_TERMINAL_WIDTHS, TerminalPreferencesManager, } from '../utils/terminal-preferences.js'; +import type { TerminalThemeId } from '../utils/terminal-themes.js'; import { ConnectionManager } from './session-view/connection-manager.js'; import { type DirectKeyboardCallbacks, @@ -87,6 +88,7 @@ export class SessionView extends LitElement { @state() private showImagePicker = false; @state() private isDragOver = false; @state() private terminalFontSize = 14; + @state() private terminalTheme: TerminalThemeId = 'auto'; @state() private terminalContainerHeight = '100%'; @state() private isLandscape = false; @@ -369,8 +371,10 @@ export class SessionView extends LitElement { // Load terminal preferences this.terminalMaxCols = this.preferencesManager.getMaxCols(); this.terminalFontSize = this.preferencesManager.getFontSize(); + this.terminalTheme = this.preferencesManager.getTheme(); this.terminalLifecycleManager.setTerminalFontSize(this.terminalFontSize); this.terminalLifecycleManager.setTerminalMaxCols(this.terminalMaxCols); + this.terminalLifecycleManager.setTerminalTheme(this.terminalTheme); // Initialize lifecycle event manager this.lifecycleEventManager = new LifecycleEventManager(); @@ -869,6 +873,18 @@ export class SessionView extends LitElement { } } + private handleThemeChange(newTheme: TerminalThemeId) { + this.terminalTheme = newTheme; + this.preferencesManager.setTheme(newTheme); + this.terminalLifecycleManager.setTerminalTheme(newTheme); + + const terminal = this.querySelector('vibe-terminal') as Terminal; + if (terminal) { + terminal.theme = newTheme; + terminal.requestUpdate(); + } + } + private handleOpenFileBrowser() { this.showFileBrowser = true; } @@ -1285,6 +1301,7 @@ export class SessionView extends LitElement { .fontSize=${this.terminalFontSize} .fitHorizontally=${false} .maxCols=${this.terminalMaxCols} + .theme=${this.terminalTheme} .initialCols=${this.session?.initialCols || 0} .initialRows=${this.session?.initialRows || 0} .disableClick=${this.isMobile && this.useDirectKeyboard} @@ -1478,10 +1495,12 @@ export class SessionView extends LitElement { .visible=${this.showWidthSelector} .terminalMaxCols=${this.terminalMaxCols} .terminalFontSize=${this.terminalFontSize} + .terminalTheme=${this.terminalTheme} .customWidth=${this.customWidth} .isMobile=${this.isMobile} .onWidthSelect=${(width: number) => this.handleWidthSelect(width)} .onFontSizeChange=${(size: number) => this.handleFontSizeChange(size)} + .onThemeChange=${(theme: TerminalThemeId) => this.handleThemeChange(theme)} .onClose=${() => { this.showWidthSelector = false; this.customWidth = ''; diff --git a/web/src/client/components/session-view/terminal-lifecycle-manager.ts b/web/src/client/components/session-view/terminal-lifecycle-manager.ts index 2d264576..b9f6f008 100644 --- a/web/src/client/components/session-view/terminal-lifecycle-manager.ts +++ b/web/src/client/components/session-view/terminal-lifecycle-manager.ts @@ -7,6 +7,7 @@ import { authClient } from '../../services/auth-client.js'; import { createLogger } from '../../utils/logger.js'; +import type { TerminalThemeId } from '../../utils/terminal-themes.js'; import type { Session } from '../session-list.js'; import type { Terminal } from '../terminal.js'; import type { ConnectionManager } from './connection-manager.js'; @@ -32,6 +33,7 @@ export class TerminalLifecycleManager { private connected = false; private terminalFontSize = 14; private terminalMaxCols = 0; + private terminalTheme: TerminalThemeId = 'auto'; private resizeTimeout: number | null = null; private lastResizeWidth = 0; private lastResizeHeight = 0; @@ -67,6 +69,10 @@ export class TerminalLifecycleManager { this.terminalMaxCols = maxCols; } + setTerminalTheme(theme: TerminalThemeId) { + this.terminalTheme = theme; + } + getTerminal(): Terminal | null { return this.terminal; } @@ -114,6 +120,7 @@ export class TerminalLifecycleManager { 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 + this.terminal.theme = this.terminalTheme; if (this.eventHandlers) { // Listen for session exit events diff --git a/web/src/client/components/session-view/width-selector.ts b/web/src/client/components/session-view/width-selector.ts index 646c08c6..deb1580f 100644 --- a/web/src/client/components/session-view/width-selector.ts +++ b/web/src/client/components/session-view/width-selector.ts @@ -8,6 +8,7 @@ import { html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Z_INDEX } from '../../utils/constants.js'; import { COMMON_TERMINAL_WIDTHS } from '../../utils/terminal-preferences.js'; +import { TERMINAL_THEMES, type TerminalThemeId } from '../../utils/terminal-themes.js'; @customElement('width-selector') export class WidthSelector extends LitElement { @@ -19,9 +20,11 @@ export class WidthSelector extends LitElement { @property({ type: Boolean }) visible = false; @property({ type: Number }) terminalMaxCols = 0; @property({ type: Number }) terminalFontSize = 14; + @property({ type: String }) terminalTheme: TerminalThemeId = 'auto'; @property({ type: String }) customWidth = ''; @property({ type: Function }) onWidthSelect?: (width: number) => void; @property({ type: Function }) onFontSizeChange?: (size: number) => void; + @property({ type: Function }) onThemeChange?: (theme: TerminalThemeId) => void; @property({ type: Function }) onClose?: () => void; @property({ type: Boolean }) isMobile = false; @@ -128,8 +131,8 @@ export class WidthSelector extends LitElement {
-
Font Size
-
+
Font Size
+
`; diff --git a/web/src/client/components/terminal.ts b/web/src/client/components/terminal.ts index 5b06c036..59487078 100644 --- a/web/src/client/components/terminal.ts +++ b/web/src/client/components/terminal.ts @@ -16,6 +16,8 @@ import { customElement, property, state } from 'lit/decorators.js'; import { processKeyboardShortcuts } from '../utils/keyboard-shortcut-highlighter.js'; import { createLogger } from '../utils/logger.js'; import { detectMobile } from '../utils/mobile-utils.js'; +import { TerminalPreferencesManager } from '../utils/terminal-preferences.js'; +import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js'; import { UrlHighlighter } from '../utils/url-highlighter'; const logger = createLogger('terminal'); @@ -34,6 +36,7 @@ export class Terminal extends LitElement { @property({ type: Number }) fontSize = 14; @property({ type: Boolean }) fitHorizontally = false; @property({ type: Number }) maxCols = 0; // 0 means no limit + @property({ type: String }) theme: TerminalThemeId = 'auto'; @property({ type: Boolean }) disableClick = false; // Disable click handling (for mobile direct keyboard) @property({ type: Boolean }) hideScrollButton = false; // Hide scroll-to-bottom button @property({ type: Number }) initialCols = 0; // Initial terminal width from session creation @@ -138,6 +141,8 @@ export class Terminal extends LitElement { private themeObserver?: MutationObserver; connectedCallback() { + const prefs = TerminalPreferencesManager.getInstance(); + this.theme = prefs.getTheme(); super.connectedCallback(); // Check for debug mode @@ -217,6 +222,12 @@ export class Terminal extends LitElement { this.requestResize('property-change'); } } + + if (changedProperties.has('theme')) { + if (this.terminal) { + this.terminal.options.theme = this.getTerminalTheme(); + } + } } disconnectedCallback() { @@ -353,64 +364,14 @@ export class Terminal extends LitElement { } private getTerminalTheme() { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + let themeId = this.theme; - if (isDark) { - // Dark theme (original colors) - return { - background: '#1e1e1e', - foreground: '#d4d4d4', - cursor: 'rgb(var(--color-primary))', - cursorAccent: '#1e1e1e', - // Standard 16 colors (0-15) - using proper xterm colors - black: '#000000', - red: '#cd0000', - green: '#00cd00', - yellow: '#cdcd00', - blue: '#0000ee', - magenta: '#cd00cd', - cyan: '#00cdcd', - white: '#e5e5e5', - brightBlack: '#7f7f7f', - brightRed: '#ff0000', - brightGreen: '#00ff00', - brightYellow: '#ffff00', - brightBlue: '#5c5cff', - brightMagenta: '#ff00ff', - brightCyan: '#00ffff', - brightWhite: '#ffffff', - }; - } else { - // Light theme - optimized for readability with softer contrast - return { - background: '#f8f9fa', // Slightly off-white for less eye strain - foreground: '#1f2328', // Dark gray for better readability than pure black - cursor: 'rgb(var(--color-primary))', - cursorAccent: '#f8f9fa', - // Standard 16 colors optimized for light backgrounds - // Based on GitHub light theme for proven readability - black: '#24292f', - red: '#cf222e', - green: '#1a7f37', - yellow: '#9a6700', - blue: '#0969da', - magenta: '#8250df', - cyan: '#1b7c83', - white: '#6e7781', - brightBlack: '#57606a', - brightRed: '#da3633', - brightGreen: '#2da44e', - brightYellow: '#bf8700', - brightBlue: '#218bff', - brightMagenta: '#a475f9', - brightCyan: '#3192aa', - brightWhite: '#8c959f', - // Selection colors for better visibility - selectionBackground: '#0969da', - selectionForeground: '#ffffff', - selectionInactiveBackground: '#e1e4e8', - }; + if (themeId === 'auto') { + themeId = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; } + + const preset = TERMINAL_THEMES.find((t) => t.id === themeId) || TERMINAL_THEMES[0]; + return { ...preset.colors }; } private async initializeTerminal() { diff --git a/web/src/client/utils/terminal-preferences.ts b/web/src/client/utils/terminal-preferences.ts index 5222b64f..46ffdd94 100644 --- a/web/src/client/utils/terminal-preferences.ts +++ b/web/src/client/utils/terminal-preferences.ts @@ -5,6 +5,7 @@ import { createLogger } from './logger.js'; import { detectMobile } from './mobile-utils.js'; +import type { TerminalThemeId } from './terminal-themes.js'; const logger = createLogger('terminal-preferences'); @@ -12,6 +13,7 @@ export interface TerminalPreferences { maxCols: number; // 0 means no limit, positive numbers set max width fontSize: number; fitHorizontally: boolean; + theme: TerminalThemeId; } // Common terminal widths @@ -28,6 +30,7 @@ const DEFAULT_PREFERENCES: TerminalPreferences = { maxCols: 0, // No limit by default - take as much as possible fontSize: detectMobile() ? 12 : 14, // 12px on mobile, 14px on desktop fitHorizontally: false, + theme: 'auto', }; const STORAGE_KEY_TERMINAL_PREFS = 'vibetunnel_terminal_preferences'; @@ -96,6 +99,15 @@ export class TerminalPreferencesManager { this.savePreferences(); } + getTheme(): TerminalThemeId { + return this.preferences.theme; + } + + setTheme(theme: TerminalThemeId) { + this.preferences.theme = theme; + this.savePreferences(); + } + getPreferences(): TerminalPreferences { return { ...this.preferences }; } diff --git a/web/src/client/utils/terminal-themes.ts b/web/src/client/utils/terminal-themes.ts new file mode 100644 index 00000000..350d820a --- /dev/null +++ b/web/src/client/utils/terminal-themes.ts @@ -0,0 +1,149 @@ +export type TerminalThemeId = 'auto' | 'light' | 'dark' | 'vscode-dark' | 'dracula' | 'nord'; + +export interface TerminalTheme { + id: TerminalThemeId; + name: string; + description: string; + colors: Record; +} + +export const TERMINAL_THEMES: TerminalTheme[] = [ + { + id: 'dark', + name: 'Dark', + description: 'VibeTunnel default dark', + colors: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: 'rgb(var(--color-primary))', + cursorAccent: '#1e1e1e', + black: '#000000', + red: '#cd0000', + green: '#00cd00', + yellow: '#cdcd00', + blue: '#0000ee', + magenta: '#cd00cd', + cyan: '#00cdcd', + white: '#e5e5e5', + brightBlack: '#7f7f7f', + brightRed: '#ff0000', + brightGreen: '#00ff00', + brightYellow: '#ffff00', + brightBlue: '#5c5cff', + brightMagenta: '#ff00ff', + brightCyan: '#00ffff', + brightWhite: '#ffffff', + }, + }, + { + id: 'light', + name: 'Light', + description: 'Soft light theme', + colors: { + background: '#f8f9fa', + foreground: '#1f2328', + cursor: 'rgb(var(--color-primary))', + cursorAccent: '#f8f9fa', + black: '#24292f', + red: '#cf222e', + green: '#1a7f37', + yellow: '#9a6700', + blue: '#0969da', + magenta: '#8250df', + cyan: '#1b7c83', + white: '#6e7781', + brightBlack: '#57606a', + brightRed: '#da3633', + brightGreen: '#2da44e', + brightYellow: '#bf8700', + brightBlue: '#218bff', + brightMagenta: '#a475f9', + brightCyan: '#3192aa', + brightWhite: '#8c959f', + selectionBackground: '#0969da', + selectionForeground: '#ffffff', + selectionInactiveBackground: '#e1e4e8', + }, + }, + { + id: 'vscode-dark', + name: 'VS Code Dark', + description: 'Popular theme from Visual Studio Code', + colors: { + background: '#1E1E1E', + foreground: '#D4D4D4', + cursor: '#AEAFAD', + cursorAccent: '#1E1E1E', + black: '#000000', + red: '#CD3131', + green: '#0DBC79', + yellow: '#E5E510', + blue: '#2472C8', + magenta: '#BC3FBC', + cyan: '#11A8CD', + white: '#E5E5E5', + brightBlack: '#666666', + brightRed: '#F14C4C', + brightGreen: '#23D18B', + brightYellow: '#F5F543', + brightBlue: '#3B8EEA', + brightMagenta: '#D670D6', + brightCyan: '#29B8DB', + brightWhite: '#FFFFFF', + }, + }, + { + id: 'dracula', + name: 'Dracula', + description: 'Classic dark theme', + colors: { + background: '#282A36', + foreground: '#F8F8F2', + cursor: '#F8F8F2', + cursorAccent: '#282A36', + black: '#21222C', + red: '#FF5555', + green: '#50FA7B', + yellow: '#F1FA8C', + blue: '#BD93F9', + magenta: '#FF79C6', + cyan: '#8BE9FD', + white: '#F8F8F2', + brightBlack: '#6272A4', + brightRed: '#FF6E6E', + brightGreen: '#69FF94', + brightYellow: '#FFFFA5', + brightBlue: '#D6ACFF', + brightMagenta: '#FF92DF', + brightCyan: '#A4FFFF', + brightWhite: '#FFFFFF', + }, + }, + { + id: 'nord', + name: 'Nord', + description: 'Arctic north-bluish palette', + colors: { + background: '#2E3440', + foreground: '#D8DEE9', + cursor: '#D8DEE9', + cursorAccent: '#2E3440', + black: '#3B4252', + red: '#BF616A', + green: '#A3BE8C', + yellow: '#EBCB8B', + blue: '#81A1C1', + magenta: '#B48EAD', + cyan: '#88C0D0', + white: '#E5E9F0', + brightBlack: '#4C566A', + brightRed: '#BF616A', + brightGreen: '#A3BE8C', + brightYellow: '#EBCB8B', + brightBlue: '#81A1C1', + brightMagenta: '#B48EAD', + brightCyan: '#8FBCBB', + brightWhite: '#ECEFF4', + }, + }, +];