From 29f938dcc1f84f9b478a817c412e9ad0663ddd4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 12 Jul 2025 22:11:30 +0200 Subject: [PATCH] Terminal theme improvements (#332) Co-authored-by: Claude --- CLAUDE.md | 1 + apple/docs/swift-testing-playbook.md | 289 ++++++++++++++ mac/VibeTunnel/Core/Models/AppConstants.swift | 76 ++-- mac/VibeTunnel/Core/Services/BunServer.swift | 10 + .../Utilities/TerminalLauncher.swift | 8 +- web/CLAUDE.md | 10 +- web/src/client/app.ts | 14 +- web/src/client/components/session-card.ts | 39 ++ web/src/client/components/session-view.ts | 119 +++--- .../components/session-view/width-selector.ts | 362 ++++++++++++------ web/src/client/components/sidebar-header.ts | 2 +- web/src/client/components/terminal.ts | 102 ++++- web/src/client/components/unified-settings.ts | 8 +- .../client/components/vibe-terminal-buffer.ts | 24 +- web/src/client/styles.css | 18 +- web/src/client/utils/monaco-loader.ts | 7 +- web/src/client/utils/terminal-preferences.ts | 10 +- web/src/client/utils/terminal-themes.ts | 6 + web/src/client/utils/theme-utils.ts | 77 ++++ web/src/client/utils/title-manager.ts | 106 +++++ web/src/client/utils/title-updater.ts | 108 ------ web/src/server/server.ts | 34 +- web/src/server/utils/activity-detector.ts | 2 +- web/src/test/e2e/logs-api.e2e.test.ts | 18 +- web/src/test/unit/terminal-themes.test.ts | 51 +++ web/src/test/utils/activity-detector.test.ts | 4 + 26 files changed, 1124 insertions(+), 381 deletions(-) create mode 100644 web/src/client/utils/theme-utils.ts create mode 100644 web/src/client/utils/title-manager.ts delete mode 100644 web/src/client/utils/title-updater.ts create mode 100644 web/src/test/unit/terminal-themes.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index eb37c0f3..deafc462 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ When the user says "release" or asks to create a release, ALWAYS read and follow - PRs sometimes contain multiple different features and that's okay - Always check current branch with `git branch` before making changes - If unsure about branching, ASK THE USER FIRST +- **"Adopt" means REVIEW, not merge!** When asked to "adopt" a PR, switch to its branch and review the changes. NEVER merge without explicit permission. ### Terminal Title Management with VT diff --git a/apple/docs/swift-testing-playbook.md b/apple/docs/swift-testing-playbook.md index 9ab6ecd9..040849d7 100644 --- a/apple/docs/swift-testing-playbook.md +++ b/apple/docs/swift-testing-playbook.md @@ -441,6 +441,295 @@ Continue using XCTest for the following, as they are not currently supported by --- +## **13. Swift 6.2 Testing Enhancements (2025 Edition)** + +Swift 6.2 introduces powerful new testing capabilities that take Swift Testing to the next level. These features provide enhanced debugging context, process lifecycle testing, and more sophisticated test control. + +### 13.1 Exit Tests: Testing Process Lifecycle and Crashes + +Exit tests allow you to verify that code properly handles process termination scenarios, crash recovery, and subprocess management. This is crucial for applications that spawn external processes or need robust crash recovery. + +| Feature | Use Case | Benefits | +|---|---|---| +| **`#expect(processExitsWith: .success)`** | Test that processes terminate cleanly | Verifies graceful shutdown and cleanup | +| **`#expect(processExitsWith: .failure)`** | Test that invalid operations fail appropriately | Ensures proper error handling and exit codes | +| **Process Recovery Testing** | Test auto-restart and crash recovery logic | Validates resilience mechanisms | + +#### Practical Example: Server Process Lifecycle + +```swift +@Test("Server handles unexpected termination gracefully", .tags(.exitTests)) +func serverCrashRecovery() async throws { + await #expect(processExitsWith: .success) { + let serverManager = ServerManager.shared + + // Start server process + await serverManager.start() + + // Simulate unexpected termination + if let server = serverManager.bunServer { + server.terminate() + } + + // Wait for auto-restart logic + try await Task.sleep(for: .milliseconds(2_000)) + + // Verify recovery behavior + // Test should verify that the system handles the crash gracefully + + // Clean shutdown + await serverManager.stop() + } +} +``` + +#### CLI Tool Process Validation + +```swift +@Test("Shell command executes successfully", .tags(.exitTests)) +func shellCommandExecution() async throws { + await #expect(processExitsWith: .success) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "ls /tmp | head -5"] + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ProcessError.commandFailed(process.terminationStatus) + } + } +} +``` + +### 13.2 Attachments: Rich Debugging Context + +Attachments revolutionize test debugging by capturing system state, configuration details, and diagnostic information when tests fail. This provides invaluable context that makes debugging faster and more effective. + +| Attachment Type | Best For | Example Use Cases | +|---|---|---| +| **System Information** | Environment debugging | OS version, memory, processor info | +| **Configuration State** | Settings and setup issues | Server ports, network config, feature flags | +| **Process Details** | Service lifecycle issues | PIDs, running state, error messages | +| **Performance Metrics** | Performance regression analysis | Timing data, memory usage, throughput | + +#### Enhanced Test with Diagnostic Attachments + +```swift +@Test("Network configuration with full diagnostics", .tags(.attachmentTests)) +func networkConfigurationDiagnostics() throws { + // Attach system environment for context + Attachment.record("System Info", """ + OS: \(ProcessInfo.processInfo.operatingSystemVersionString) + Environment: \(ProcessInfo.processInfo.environment["CI"] != nil ? "CI" : "Local") + Timestamp: \(Date().ISO8601Format()) + """) + + // Attach network state + let localIP = NetworkUtility.getLocalIPAddress() + let allIPs = NetworkUtility.getAllIPAddresses() + + Attachment.record("Network Configuration", """ + Local IP: \(localIP ?? "none") + All IPs: \(allIPs.joined(separator: ", ")) + Interface Count: \(allIPs.count) + """) + + // Test logic with rich failure context + #expect(localIP != nil || allIPs.isEmpty) +} +``` + +#### Performance Testing with Detailed Metrics + +```swift +@Test("API performance with statistical analysis", .tags(.performance, .attachmentTests)) +func apiPerformanceAnalysis() async throws { + var timings: [TimeInterval] = [] + let iterations = 100 + + // Capture test configuration + Attachment.record("Performance Test Setup", """ + Iterations: \(iterations) + Test Environment: \(ProcessInfo.processInfo.environment["CI"] != nil ? "CI" : "Local") + API Endpoint: /api/sessions + """) + + // Collect timing data + for _ in 0.. ConditionResult { + let bunPath = "/usr/local/bin/bun" + let altBunPath = "/opt/homebrew/bin/bun" + + let hasBinary = FileManager.default.fileExists(atPath: bunPath) || + FileManager.default.fileExists(atPath: altBunPath) + + return hasBinary ? .continue : .skip(reason: "Server binary not available") + } +} + +/// Checks if network interfaces are available for testing +struct NetworkAvailableCondition: ConditionTrait { + func evaluate(for test: Test) -> ConditionResult { + let hasNetwork = !NetworkUtility.getAllIPAddresses().isEmpty + return hasNetwork ? .continue : .skip(reason: "No network interfaces available") + } +} + +/// Checks if we're in a valid Git repository for repository-specific tests +struct ValidGitRepositoryCondition: ConditionTrait { + func evaluate(for test: Test) -> ConditionResult { + let gitDir = FileManager.default.fileExists(atPath: ".git") + return gitDir ? .continue : .skip(reason: "Not in a Git repository") + } +} + +/// Checks if we're running in CI environment +struct NotInCICondition: ConditionTrait { + func evaluate(for test: Test) -> ConditionResult { + let isCI = ProcessInfo.processInfo.environment["CI"] != nil || + ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] != nil + return isCI ? .skip(reason: "Disabled in CI environment") : .continue + } +} +``` + +#### Smart Test Execution + +```swift +// Replace broad .disabled() with intelligent conditions +@Suite("Server Manager Tests", .tags(.serverManager)) +struct ServerManagerTests { + + @Test("Server lifecycle with full binary", ServerBinaryAvailableCondition(), NotInCICondition()) + func serverLifecycleWithBinary() async throws { + // This test only runs when: + // 1. The server binary is actually available + // 2. We're not in a CI environment + // This prevents false failures and provides clear skip reasons + } + + @Test("Network configuration", NetworkAvailableCondition()) + func networkConfiguration() throws { + // Only runs when network interfaces are available + // Automatically skipped in containerized or network-less environments + } +} +``` + +### 13.4 Enhanced Tag System for Better Organization + +Expand your tag system to include the new Swift 6.2 testing capabilities: + +```swift +// Enhanced TestTags.swift +extension Tag { + // Existing tags + @Tag static var critical: Self + @Tag static var networking: Self + @Tag static var concurrency: Self + + // Swift 6.2 Enhanced Tags + @Tag static var exitTests: Self // Tests using processExitsWith + @Tag static var attachmentTests: Self // Tests with rich diagnostic attachments + @Tag static var requiresServerBinary: Self // Tests needing actual server binary + @Tag static var requiresNetwork: Self // Tests needing network interfaces + @Tag static var processSpawn: Self // Tests that spawn external processes + @Tag static var gitRepository: Self // Tests that need to run in a Git repository +} +``` + +#### Strategic Test Filtering + +```bash +# Run only tests with attachments for debugging +swift test --filter .attachmentTests + +# Skip exit tests in CI environments +swift test --skip .exitTests + +# Run performance tests with attachments for detailed analysis +swift test --filter .performance --filter .attachmentTests + +# Run all tests that require external dependencies +swift test --filter .requiresServerBinary --filter .requiresNetwork + +# Run Git repository tests only when in a Git repo +swift test --filter .gitRepository +``` + +### 13.5 Best Practices for Swift 6.2 Features + +#### Exit Tests Guidelines +- **Use Sparingly**: Exit tests are powerful but should be used for critical process lifecycle scenarios +- **Clean State**: Always ensure proper cleanup in exit test blocks +- **Avoid Elevated Permissions**: Don't test operations requiring sudo or admin rights +- **Mock When Possible**: Use mock processes for testing logic without actual system processes + +#### Attachment Strategy +- **Contextual Information**: Attach system state, configuration, and environment details +- **Progressive Detail**: Start with summary info, add detailed data as needed +- **Performance Focus**: Use attachments extensively in performance tests for trend analysis +- **Failure Context**: Attach diagnostic info that helps understand why tests failed + +#### Condition Trait Design +- **Specific Conditions**: Create focused conditions for specific capabilities +- **Clear Skip Reasons**: Always provide descriptive reasons for skipped tests +- **Environment Awareness**: Consider CI, local development, and different platforms +- **Combine Thoughtfully**: Use multiple conditions when tests have multiple requirements + +### Action Items for Swift 6.2 Adoption + +- [ ] **Audit Disabled Tests**: Replace broad `.disabled()` with specific condition traits +- [ ] **Add Exit Tests**: Identify critical process lifecycle scenarios and add exit tests +- [ ] **Enhance Debugging**: Add attachments to complex tests and all performance tests +- [ ] **Update Tag Strategy**: Add Swift 6.2 tags and update filtering strategies +- [ ] **Create Condition Traits**: Build reusable conditions for common system requirements +- [ ] **Document Skip Reasons**: Ensure all conditional tests have clear skip explanations +- [ ] **CI Integration**: Update CI scripts to handle new test filtering and skip reasons + +--- + ## **Appendix: Evergreen Testing Principles (The F.I.R.S.T. Principles)** These foundational principles are framework-agnostic, and Swift Testing is designed to make adhering to them easier than ever. diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index 9176965c..474bae0f 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -16,25 +16,25 @@ enum AppConstants { static let preventSleepWhenRunning = "preventSleepWhenRunning" static let enableScreencapService = "enableScreencapService" static let repositoryBasePath = "repositoryBasePath" - + // Server Configuration static let serverPort = "serverPort" static let dashboardAccessMode = "dashboardAccessMode" static let cleanupOnStartup = "cleanupOnStartup" static let authenticationMode = "authenticationMode" - + // Development Settings static let debugMode = "debugMode" static let useDevServer = "useDevServer" static let devServerPath = "devServerPath" static let logLevel = "logLevel" - + // Application Preferences static let preferredGitApp = "preferredGitApp" static let preferredTerminal = "preferredTerminal" static let showInDock = "showInDock" static let updateChannel = "updateChannel" - + // New Session keys static let newSessionCommand = "NewSession.command" static let newSessionWorkingDirectory = "NewSession.workingDirectory" @@ -50,19 +50,19 @@ enum AppConstants { static let enableScreencapService = true /// Default repository base path for auto-discovery static let repositoryBasePath = "~/" - + // Server Configuration - static let serverPort = 4020 + static let serverPort = 4_020 static let dashboardAccessMode = "localhost" static let cleanupOnStartup = true static let authenticationMode = "os" - + // Development Settings static let debugMode = false static let useDevServer = false static let devServerPath = "" static let logLevel = "info" - + // Application Preferences static let showInDock = false static let updateChannel = "stable" @@ -122,7 +122,7 @@ enum AppConstants { // Key exists but contains non-string value, return empty string return "" } - + /// Helper to get integer value with proper default static func intValue(for key: String) -> Int { // If the key doesn't exist in UserDefaults, return our default @@ -139,69 +139,69 @@ enum AppConstants { } // MARK: - Configuration Helpers + extension AppConstants { - /// Development server configuration struct DevServerConfig { let useDevServer: Bool let devServerPath: String - - static func current() -> DevServerConfig { - DevServerConfig( + + static func current() -> Self { + Self( useDevServer: boolValue(for: UserDefaultsKeys.useDevServer), devServerPath: stringValue(for: UserDefaultsKeys.devServerPath) ) } } - + /// Authentication configuration struct AuthConfig { let mode: String - - static func current() -> AuthConfig { - AuthConfig( + + static func current() -> Self { + Self( mode: stringValue(for: UserDefaultsKeys.authenticationMode) ) } } - + /// Debug configuration struct DebugConfig { let debugMode: Bool let logLevel: String - - static func current() -> DebugConfig { - DebugConfig( + + static func current() -> Self { + Self( debugMode: boolValue(for: UserDefaultsKeys.debugMode), logLevel: stringValue(for: UserDefaultsKeys.logLevel) ) } } - + /// Server configuration struct ServerConfig { let port: Int let dashboardAccessMode: String let cleanupOnStartup: Bool - - static func current() -> ServerConfig { - ServerConfig( + + static func current() -> Self { + Self( port: intValue(for: UserDefaultsKeys.serverPort), dashboardAccessMode: stringValue(for: UserDefaultsKeys.dashboardAccessMode), cleanupOnStartup: boolValue(for: UserDefaultsKeys.cleanupOnStartup) ) } } - + /// Application preferences struct AppPreferences { let preferredGitApp: String? let preferredTerminal: String? let showInDock: Bool let updateChannel: String - - static func current() -> AppPreferences { - AppPreferences( + + static func current() -> Self { + Self( preferredGitApp: UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredGitApp), preferredTerminal: UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredTerminal), showInDock: boolValue(for: UserDefaultsKeys.showInDock), @@ -209,42 +209,42 @@ extension AppConstants { ) } } - + // MARK: - Convenience Methods - + /// Check if the app is in development mode (debug or dev server enabled) static func isInDevelopmentMode() -> Bool { let debug = DebugConfig.current() let devServer = DevServerConfig.current() return debug.debugMode || devServer.useDevServer } - + /// Get development status for UI display static func getDevelopmentStatus() -> (debugMode: Bool, useDevServer: Bool) { let debug = DebugConfig.current() let devServer = DevServerConfig.current() return (debug.debugMode, devServer.useDevServer) } - + /// Preference helpers static func getPreferredGitApp() -> String? { UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredGitApp) } - + static func setPreferredGitApp(_ app: String?) { - if let app = app { + if let app { UserDefaults.standard.set(app, forKey: UserDefaultsKeys.preferredGitApp) } else { UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.preferredGitApp) } } - + static func getPreferredTerminal() -> String? { UserDefaults.standard.string(forKey: UserDefaultsKeys.preferredTerminal) } - + static func setPreferredTerminal(_ terminal: String?) { - if let terminal = terminal { + if let terminal { UserDefaults.standard.set(terminal, forKey: UserDefaultsKeys.preferredTerminal) } else { UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.preferredTerminal) diff --git a/mac/VibeTunnel/Core/Services/BunServer.swift b/mac/VibeTunnel/Core/Services/BunServer.swift index fbb7c18f..4ab94d9a 100644 --- a/mac/VibeTunnel/Core/Services/BunServer.swift +++ b/mac/VibeTunnel/Core/Services/BunServer.swift @@ -242,6 +242,12 @@ final class BunServer { } } + // Set NODE_ENV to development in debug builds to disable caching + #if DEBUG + environment["NODE_ENV"] = "development" + logger.info("Running in DEBUG configuration - setting NODE_ENV=development to disable caching") + #endif + // Add Node.js memory settings as command line arguments instead of NODE_OPTIONS // NODE_OPTIONS can interfere with SEA binaries @@ -432,6 +438,10 @@ final class BunServer { // Add Node.js memory settings environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128" + // Always set NODE_ENV to development for dev server to ensure caching is disabled + environment["NODE_ENV"] = "development" + logger.info("Dev server mode - setting NODE_ENV=development to disable caching") + // Add pnpm to PATH so that scripts can use it // pnpmDir is already defined above if let existingPath = environment["PATH"] { diff --git a/mac/VibeTunnel/Utilities/TerminalLauncher.swift b/mac/VibeTunnel/Utilities/TerminalLauncher.swift index 56ec7cad..1ec3ca0a 100644 --- a/mac/VibeTunnel/Utilities/TerminalLauncher.swift +++ b/mac/VibeTunnel/Utilities/TerminalLauncher.swift @@ -467,12 +467,16 @@ final class TerminalLauncher { if let windowIDValue = UInt32(components[0]) { windowID = CGWindowID(windowIDValue) tabReference = "tab id \(components[1]) of window id \(components[0])" - logger.info("Terminal.app window ID: \(windowID ?? 0), tab reference: \(tabReference ?? "")") + logger + .info("Terminal.app window ID: \(windowID ?? 0), tab reference: \(tabReference ?? "")") } else { logger.warning("Failed to parse window ID from components[0]: '\(components[0])'") } } else { - logger.warning("Unexpected AppleScript result format for Terminal.app. Expected 'windowID|tabID', got: '\(result)'. Components: \(components)") + logger + .warning( + "Unexpected AppleScript result format for Terminal.app. Expected 'windowID|tabID', got: '\(result)'. Components: \(components)" + ) } } else if config.terminal == .iTerm2 { // iTerm2 returns window ID diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 6ac95ee0..396719a8 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -72,4 +72,12 @@ Do NOT use three separate commands (add, commit, push) as this is slow. - Delete unused functions and code paths immediately ## Best Practices -- ALWAYS use `Z_INDEX` constants in `src/client/utils/constants.ts` instead of setting z-index properties using primitives / magic numbers \ No newline at end of file +- ALWAYS use `Z_INDEX` constants in `src/client/utils/constants.ts` instead of setting z-index properties using primitives / magic numbers + +## CRITICAL: Package Installation Policy +**NEVER install packages without explicit user approval!** +- Do NOT run `pnpm add`, `npm install`, or any package installation commands +- Do NOT modify `package.json` or `pnpm-lock.yaml` unless explicitly requested +- Always ask for permission before suggesting new dependencies +- Understand and work with the existing codebase architecture first +- This project has custom implementations - don't assume we need standard packages \ No newline at end of file diff --git a/web/src/client/app.ts b/web/src/client/app.ts index 7ff4ee4a..1e3cf95a 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -19,7 +19,7 @@ import { createLogger } from './utils/logger.js'; import { isIOS } from './utils/mobile-utils.js'; import { type MediaQueryState, responsiveObserver } from './utils/responsive-utils.js'; import { triggerTerminalResize } from './utils/terminal-utils.js'; -import { initTitleUpdater } from './utils/title-updater.js'; +import { titleManager } from './utils/title-manager.js'; // Import version import { VERSION } from './version.js'; @@ -95,7 +95,7 @@ export class VibeTunnelApp extends LitElement { this.setupResponsiveObserver(); this.setupPreferences(); // Initialize title updater - initTitleUpdater(); + titleManager.initAutoUpdates(); // Initialize authentication and routing together this.initializeApp(); } @@ -373,7 +373,7 @@ export class VibeTunnelApp extends LitElement { // Update page title if we're in list view if (this.currentView === 'list') { const sessionCount = this.sessions.length; - document.title = `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`; + titleManager.setListTitle(sessionCount); } // Handle session loading state tracking @@ -723,7 +723,7 @@ export class VibeTunnelApp extends LitElement { if (session) { const sessionName = session.name || session.command.join(' '); console.log('[App] Setting title:', sessionName); - document.title = `${sessionName} - VibeTunnel`; + titleManager.setSessionTitle(sessionName); } else { console.log('[App] No session found:', sessionId); } @@ -745,7 +745,7 @@ export class VibeTunnelApp extends LitElement { this.selectedSessionId = sessionId || null; // Update document title - document.title = 'VibeTunnel - File Browser'; + titleManager.setFileBrowserTitle(); // Navigate to file browser view this.currentView = 'file-browser'; @@ -758,7 +758,7 @@ export class VibeTunnelApp extends LitElement { // Update document title with session count const sessionCount = this.sessions.length; - document.title = `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`; + titleManager.setListTitle(sessionCount); // Disable View Transitions when navigating from session detail view // to prevent animations when sidebar is involved @@ -1199,7 +1199,7 @@ export class VibeTunnelApp extends LitElement { return 'w-full min-h-screen flex flex-col'; } - const baseClasses = 'bg-secondary border-r border-base flex flex-col'; + const baseClasses = 'bg-secondary flex flex-col'; const isMobile = this.mediaState.isMobile; // Only apply transition class when animations are ready (not during initial load) const transitionClass = this.sidebarAnimationReady && !isMobile ? 'sidebar-transition' : ''; diff --git a/web/src/client/components/session-card.ts b/web/src/client/components/session-card.ts index 25230419..2c0c262d 100644 --- a/web/src/client/components/session-card.ts +++ b/web/src/client/components/session-card.ts @@ -17,6 +17,8 @@ import type { AuthClient } from '../services/auth-client.js'; import { isAIAssistantSession, sendAIPrompt } from '../utils/ai-sessions.js'; import { createLogger } from '../utils/logger.js'; import { copyToClipboard } from '../utils/path-utils.js'; +import { TerminalPreferencesManager } from '../utils/terminal-preferences.js'; +import type { TerminalThemeId } from '../utils/terminal-themes.js'; const logger = createLogger('session-card'); import './vibe-terminal-buffer.js'; @@ -63,12 +65,33 @@ export class SessionCard extends LitElement { @state() private isActive = false; @state() private isHovered = false; @state() private isSendingPrompt = false; + @state() private terminalTheme: TerminalThemeId = 'auto'; private killingInterval: number | null = null; private activityTimeout: number | null = null; + private storageListener: ((e: StorageEvent) => void) | null = null; + private themeChangeListener: ((e: CustomEvent) => void) | null = null; + private preferencesManager = TerminalPreferencesManager.getInstance(); connectedCallback() { super.connectedCallback(); + + // Load initial theme from TerminalPreferencesManager + this.loadThemeFromStorage(); + + // Listen for storage changes to update theme reactively (cross-tab) + this.storageListener = (e: StorageEvent) => { + if (e.key === 'vibetunnel_terminal_preferences') { + this.loadThemeFromStorage(); + } + }; + window.addEventListener('storage', this.storageListener); + + // Listen for custom theme change events (same-tab) + this.themeChangeListener = (e: CustomEvent) => { + this.terminalTheme = e.detail as TerminalThemeId; + }; + window.addEventListener('terminal-theme-changed', this.themeChangeListener as EventListener); } disconnectedCallback() { @@ -79,6 +102,17 @@ export class SessionCard extends LitElement { if (this.activityTimeout) { clearTimeout(this.activityTimeout); } + if (this.storageListener) { + window.removeEventListener('storage', this.storageListener); + this.storageListener = null; + } + if (this.themeChangeListener) { + window.removeEventListener( + 'terminal-theme-changed', + this.themeChangeListener as EventListener + ); + this.themeChangeListener = null; + } } private handleCardClick() { @@ -340,6 +374,10 @@ export class SessionCard extends LitElement { this.isHovered = false; } + private loadThemeFromStorage() { + this.terminalTheme = this.preferencesManager.getTheme(); + } + render() { // Debug logging to understand what's in the session if (!this.session.name) { @@ -475,6 +513,7 @@ export class SessionCard extends LitElement { : html` ) { super.updated(changedProperties); - // If session changed, clean up old stream connection + // If session changed, clean up old stream connection and reset terminal state if (changedProperties.has('session')) { const oldSession = changedProperties.get('session') as Session | null; - if (oldSession && oldSession.id !== this.session?.id) { + const sessionChanged = oldSession?.id !== this.session?.id; + + if (sessionChanged && oldSession) { logger.log('Session changed, cleaning up old stream connection'); if (this.connectionManager) { this.connectionManager.cleanupStreamConnection(); } + // Clean up terminal lifecycle manager for fresh start + if (this.terminalLifecycleManager) { + this.terminalLifecycleManager.cleanup(); + } } + // Update managers with new session if (this.inputManager) { this.inputManager.setSession(this.session); @@ -485,59 +497,64 @@ export class SessionView extends LitElement { if (this.lifecycleEventManager) { this.lifecycleEventManager.setSession(this.session); } + + // Initialize terminal when session first becomes available + if (this.session && this.connected && !oldSession) { + logger.log('Session data now available, initializing terminal'); + this.ensureTerminalInitialized(); + } } - // Stop loading and create terminal when session becomes available + // Stop loading and ensure terminal is initialized when session becomes available if ( changedProperties.has('session') && this.session && this.loadingAnimationManager.isLoading() ) { this.loadingAnimationManager.stopLoading(); - this.terminalLifecycleManager.setupTerminal(); + this.ensureTerminalInitialized(); } - // Initialize terminal after first render when terminal element exists - if (!this.terminalLifecycleManager.getTerminal() && this.session && this.connected) { - const terminalElement = this.querySelector('vibe-terminal') as Terminal; - if (terminalElement) { - this.terminalLifecycleManager.initializeTerminal(); - } + // Ensure terminal is initialized when connected state changes + if (changedProperties.has('connected') && this.connected && this.session) { + this.ensureTerminalInitialized(); + } + } + + /** + * Ensures terminal is properly initialized with current session data. + * This method is idempotent and can be called multiple times safely. + */ + private ensureTerminalInitialized() { + if (!this.session || !this.connected) { + logger.log('Cannot initialize terminal: missing session or not connected'); + return; } - // Create hidden input if direct keyboard is enabled on mobile - if ( - this.isMobile && - this.useDirectKeyboard && - !this.directKeyboardManager.getShowQuickKeys() && - this.session && - this.connected - ) { - // Clear any existing timeout - if (this.createHiddenInputTimeout) { - clearTimeout(this.createHiddenInputTimeout); - } - - // Delay creation to ensure terminal is rendered and DOM is stable - const TERMINAL_RENDER_DELAY_MS = 100; - this.createHiddenInputTimeout = setTimeout(() => { - try { - // Re-validate conditions in case component state changed during the delay - if ( - this.isMobile && - this.useDirectKeyboard && - !this.directKeyboardManager.getShowQuickKeys() && - this.connected // Ensure component is still connected to DOM - ) { - this.directKeyboardManager.ensureHiddenInputVisible(); - } - } catch (error) { - logger.warn('Failed to create hidden input during setTimeout:', error); - } - // Clear the timeout reference after execution - this.createHiddenInputTimeout = null; - }, TERMINAL_RENDER_DELAY_MS); + // Check if terminal is already initialized + if (this.terminalLifecycleManager.getTerminal()) { + logger.log('Terminal already initialized'); + return; } + + // Check if terminal element exists in DOM + const terminalElement = this.querySelector('vibe-terminal') as Terminal; + if (!terminalElement) { + logger.log('Terminal element not found in DOM, deferring initialization'); + // Retry after next render cycle + requestAnimationFrame(() => { + this.ensureTerminalInitialized(); + }); + return; + } + + logger.log('Initializing terminal with session:', this.session.id); + + // Setup terminal with session data + this.terminalLifecycleManager.setupTerminal(); + + // Initialize terminal after setup + this.terminalLifecycleManager.initializeTerminal(); } async handleKeyboardInput(e: KeyboardEvent) { @@ -874,6 +891,8 @@ export class SessionView extends LitElement { } private handleThemeChange(newTheme: TerminalThemeId) { + logger.debug('Changing terminal theme to:', newTheme); + this.terminalTheme = newTheme; this.preferencesManager.setTheme(newTheme); this.terminalLifecycleManager.setTerminalTheme(newTheme); @@ -963,6 +982,10 @@ export class SessionView extends LitElement { // Update the local session object with the server-assigned name this.session = { ...this.session, name: actualName }; + // Update the page title with the new session name + const sessionName = actualName || this.session.command.join(' '); + titleManager.setSessionTitle(sessionName); + // Dispatch event to notify parent components with the actual name this.dispatchEvent( new CustomEvent('session-renamed', { @@ -1491,7 +1514,7 @@ export class SessionView extends LitElement { > - + > ${ diff --git a/web/src/client/components/session-view/width-selector.ts b/web/src/client/components/session-view/width-selector.ts index deb1580f..d87dce17 100644 --- a/web/src/client/components/session-view/width-selector.ts +++ b/web/src/client/components/session-view/width-selector.ts @@ -1,27 +1,67 @@ /** - * Width Selector Component + * Terminal Settings Component * - * Dropdown menu for selecting terminal width constraints. - * Includes common presets and custom width input with font size controls. + * Modal for configuring terminal width, font size, and theme. + * Features a grid-based layout with conditional custom width input. */ 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 { createLogger } from '../../utils/logger.js'; +import { + COMMON_TERMINAL_WIDTHS, + TerminalPreferencesManager, +} from '../../utils/terminal-preferences.js'; import { TERMINAL_THEMES, type TerminalThemeId } from '../../utils/terminal-themes.js'; +import { getTextColorEncoded } from '../../utils/theme-utils.js'; -@customElement('width-selector') -export class WidthSelector extends LitElement { +const logger = createLogger('terminal-settings-modal'); + +@customElement('terminal-settings-modal') +export class TerminalSettingsModal extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } + connectedCallback() { + super.connectedCallback(); + + // Clean up old conflicting localStorage key if it exists + if (localStorage.getItem('terminal-theme')) { + const oldTheme = localStorage.getItem('terminal-theme') as TerminalThemeId; + // Migrate to TerminalPreferencesManager if it's a valid theme + if ( + oldTheme && + ['auto', 'light', 'dark', 'vscode-dark', 'dracula', 'nord'].includes(oldTheme) + ) { + this.preferencesManager.setTheme(oldTheme); + } + localStorage.removeItem('terminal-theme'); + } + + // Load theme from TerminalPreferencesManager + this.terminalTheme = this.preferencesManager.getTheme(); + } + + private preferencesManager = TerminalPreferencesManager.getInstance(); + @property({ type: Boolean }) visible = false; @property({ type: Number }) terminalMaxCols = 0; @property({ type: Number }) terminalFontSize = 14; - @property({ type: String }) terminalTheme: TerminalThemeId = 'auto'; + + private _terminalTheme: TerminalThemeId = 'auto'; + @property({ type: String }) + get terminalTheme(): TerminalThemeId { + return this._terminalTheme; + } + set terminalTheme(value: TerminalThemeId) { + logger.debug('Terminal theme set to:', value); + this._terminalTheme = value; + this.requestUpdate(); + } @property({ type: String }) customWidth = ''; + @property({ type: Boolean }) showCustomInput = false; @property({ type: Function }) onWidthSelect?: (width: number) => void; @property({ type: Function }) onFontSizeChange?: (size: number) => void; @property({ type: Function }) onThemeChange?: (theme: TerminalThemeId) => void; @@ -39,158 +79,228 @@ export class WidthSelector extends LitElement { if (!Number.isNaN(width) && width >= 20 && width <= 500) { this.onWidthSelect?.(width); this.customWidth = ''; + this.showCustomInput = false; } } + private handleClose() { + this.showCustomInput = false; + this.customWidth = ''; + this.onClose?.(); + } + private handleCustomWidthKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { this.handleCustomWidthSubmit(); } else if (e.key === 'Escape') { - this.customWidth = ''; - this.onClose?.(); + this.handleClose(); + } + } + + private getArrowColor(): string { + // Return URL-encoded text color from CSS custom properties + return getTextColorEncoded(); + } + + updated(changedProperties: Map) { + super.updated(changedProperties); + + // Force update the theme select value when terminalTheme property changes OR when visible changes + if (changedProperties.has('terminalTheme') || changedProperties.has('visible')) { + // Use requestAnimationFrame to ensure DOM is fully updated + requestAnimationFrame(() => { + const themeSelect = this.querySelector('#theme-select') as HTMLSelectElement; + if (themeSelect && this.terminalTheme) { + logger.debug('Updating theme select value to:', this.terminalTheme); + themeSelect.value = this.terminalTheme; + } + }); } } render() { if (!this.visible) return null; + // Debug localStorage when dialog opens + logger.debug('Dialog opening, terminal theme:', this.terminalTheme); + + // Check if we're showing a custom value that doesn't match presets + const isCustomValue = + this.terminalMaxCols > 0 && + !COMMON_TERMINAL_WIDTHS.find((w) => w.value === this.terminalMaxCols); + return html`
this.onClose?.()} + role="dialog" + aria-modal="true" + aria-labelledby="terminal-settings-title" + @click=${() => this.handleClose()} >
- +
-
-
-
Terminal Width
- +
+
+

Terminal Settings

- ${COMMON_TERMINAL_WIDTHS.map( - (width) => html` - +
+
` - )} -
-
Custom (20-500)
-
- +
+ +
+ + + ${this.terminalFontSize}px + + +
+
+
+ + +
+ +
-
-
Font Size
-
- - - ${this.terminalFontSize}px - - - -
-
-
-
Theme
- -
`; diff --git a/web/src/client/components/sidebar-header.ts b/web/src/client/components/sidebar-header.ts index b43ba311..3c7785b1 100644 --- a/web/src/client/components/sidebar-header.ts +++ b/web/src/client/components/sidebar-header.ts @@ -16,7 +16,7 @@ export class SidebarHeader extends HeaderBase { return html`