mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Terminal theme improvements (#332)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d019559f2b
commit
29f938dcc1
26 changed files with 1124 additions and 381 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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..<iterations {
|
||||
let start = CFAbsoluteTimeGetCurrent()
|
||||
_ = await apiCall()
|
||||
let end = CFAbsoluteTimeGetCurrent()
|
||||
timings.append(end - start)
|
||||
}
|
||||
|
||||
// Calculate comprehensive statistics
|
||||
let average = timings.reduce(0, +) / Double(timings.count)
|
||||
let stdDev = calculateStandardDeviation(timings)
|
||||
let p95 = timings.sorted()[Int(0.95 * Double(timings.count))]
|
||||
|
||||
// Attach detailed performance metrics
|
||||
Attachment.record("Performance Results", """
|
||||
Average: \(String(format: "%.2f", average * 1000))ms
|
||||
Standard Deviation: \(String(format: "%.2f", stdDev * 1000))ms
|
||||
95th Percentile: \(String(format: "%.2f", p95 * 1000))ms
|
||||
Min: \(String(format: "%.2f", (timings.min() ?? 0) * 1000))ms
|
||||
Max: \(String(format: "%.2f", (timings.max() ?? 0) * 1000))ms
|
||||
""")
|
||||
|
||||
// Attach raw timing data for analysis
|
||||
let timingData = timings.enumerated().map { i, timing in
|
||||
"Sample \(i + 1): \(String(format: "%.4f", timing * 1000))ms"
|
||||
}.joined(separator: "\n")
|
||||
Attachment.record("Raw Timing Data", timingData)
|
||||
|
||||
#expect(average < 0.05, "Average response time exceeded 50ms")
|
||||
}
|
||||
```
|
||||
|
||||
### 13.3 Enhanced Conditional Testing with ConditionTrait.evaluate()
|
||||
|
||||
Swift 6.2 enhances conditional testing with the ability to check trait conditions outside of test context, enabling smarter test execution based on system capabilities.
|
||||
|
||||
#### Custom Condition Traits
|
||||
|
||||
```swift
|
||||
/// Checks if the required server binary is available
|
||||
struct ServerBinaryAvailableCondition: ConditionTrait {
|
||||
func evaluate(for test: Test) -> 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 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
|
||||
|
|
@ -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' : '';
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<vibe-terminal-buffer
|
||||
.sessionId=${this.session.id}
|
||||
.theme=${this.terminalTheme}
|
||||
class="w-full h-full"
|
||||
style="pointer-events: none;"
|
||||
@content-changed=${this.handleContentChanged}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type { FilePicker } from './file-picker.js';
|
|||
import './clickable-path.js';
|
||||
import './terminal-quick-keys.js';
|
||||
import './session-view/mobile-input-overlay.js';
|
||||
import { titleManager } from '../utils/title-manager.js';
|
||||
import './session-view/ctrl-alpha-overlay.js';
|
||||
import './session-view/width-selector.js';
|
||||
import './session-view/session-header.js';
|
||||
|
|
@ -372,6 +373,7 @@ export class SessionView extends LitElement {
|
|||
this.terminalMaxCols = this.preferencesManager.getMaxCols();
|
||||
this.terminalFontSize = this.preferencesManager.getFontSize();
|
||||
this.terminalTheme = this.preferencesManager.getTheme();
|
||||
logger.debug('Loaded terminal theme:', this.terminalTheme);
|
||||
this.terminalLifecycleManager.setTerminalFontSize(this.terminalFontSize);
|
||||
this.terminalLifecycleManager.setTerminalMaxCols(this.terminalMaxCols);
|
||||
this.terminalLifecycleManager.setTerminalTheme(this.terminalTheme);
|
||||
|
|
@ -457,24 +459,34 @@ export class SessionView extends LitElement {
|
|||
|
||||
firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.session && this.connected) {
|
||||
// Terminal setup is handled by state machine when reaching active state
|
||||
this.terminalLifecycleManager.setupTerminal();
|
||||
}
|
||||
|
||||
// Load terminal preferences BEFORE terminal setup to ensure proper initialization
|
||||
this.terminalTheme = this.preferencesManager.getTheme();
|
||||
logger.debug('Loaded terminal theme from preferences:', this.terminalTheme);
|
||||
|
||||
// Don't setup terminal here - wait for session data to be available
|
||||
// Terminal setup will be triggered in updated() when session becomes available
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>) {
|
||||
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 {
|
|||
></file-picker>
|
||||
|
||||
<!-- Width Selector Modal (moved here for proper positioning) -->
|
||||
<width-selector
|
||||
<terminal-settings-modal
|
||||
.visible=${this.showWidthSelector}
|
||||
.terminalMaxCols=${this.terminalMaxCols}
|
||||
.terminalFontSize=${this.terminalFontSize}
|
||||
|
|
@ -1505,7 +1528,7 @@ export class SessionView extends LitElement {
|
|||
this.showWidthSelector = false;
|
||||
this.customWidth = '';
|
||||
}}
|
||||
></width-selector>
|
||||
></terminal-settings-modal>
|
||||
|
||||
<!-- Drag & Drop Overlay -->
|
||||
${
|
||||
|
|
|
|||
|
|
@ -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<string | number | symbol, unknown>) {
|
||||
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`
|
||||
<!-- Backdrop to close on outside click -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
@click=${() => this.onClose?.()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terminal-settings-title"
|
||||
@click=${() => this.handleClose()}
|
||||
></div>
|
||||
|
||||
<!-- Width selector modal -->
|
||||
<!-- Terminal settings modal -->
|
||||
<div
|
||||
class="width-selector-container fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-surface border border-border rounded-lg shadow-elevated min-w-[280px] max-w-[90vw] animate-fade-in"
|
||||
class="width-selector-container fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-surface border border-border rounded-lg shadow-elevated w-[400px] max-w-[90vw] animate-fade-in"
|
||||
style="z-index: ${Z_INDEX.WIDTH_SELECTOR_DROPDOWN};"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-sm font-semibold text-text-bright">Terminal Width</div>
|
||||
<!-- Close button for mobile -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="terminal-settings-title" class="text-lg font-semibold text-text-bright">Terminal Settings</h2>
|
||||
<button
|
||||
class="sm:hidden p-1.5 rounded-md text-text-muted hover:text-text hover:bg-surface transition-all duration-200"
|
||||
@click=${() => this.onClose?.()}
|
||||
aria-label="Close width selector"
|
||||
class="text-muted hover:text-primary transition-colors p-1"
|
||||
@click=${() => this.handleClose()}
|
||||
title="Close"
|
||||
aria-label="Close terminal settings"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${COMMON_TERMINAL_WIDTHS.map(
|
||||
(width) => html`
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 text-sm rounded-md flex justify-between items-center transition-all duration-200
|
||||
${
|
||||
this.terminalMaxCols === width.value
|
||||
? 'bg-primary bg-opacity-20 text-primary font-semibold border border-primary'
|
||||
: 'text-text hover:bg-surface hover:text-text-bright border border-transparent'
|
||||
}"
|
||||
@click=${() => this.onWidthSelect?.(width.value)}
|
||||
|
||||
<!-- Settings grid -->
|
||||
<div class="space-y-4">
|
||||
<!-- Width setting -->
|
||||
<div class="grid grid-cols-[120px_1fr] gap-4 items-center">
|
||||
<label class="text-sm font-medium text-text-bright text-right">Width</label>
|
||||
<select
|
||||
class="w-full bg-bg-secondary border border-border rounded-md pl-4 pr-10 py-3 text-sm font-mono text-text focus:border-primary focus:shadow-glow-sm cursor-pointer appearance-none"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 20 20%22 fill=%22${this.getArrowColor()}%22%3e%3cpath fill-rule=%22evenodd%22 d=%22M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z%22 clip-rule=%22evenodd%22/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-repeat: no-repeat; background-size: 1.25em 1.25em;"
|
||||
.value=${isCustomValue || this.showCustomInput ? 'custom' : String(this.terminalMaxCols)}
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
if (value === 'custom') {
|
||||
this.showCustomInput = true;
|
||||
this.customWidth = isCustomValue ? String(this.terminalMaxCols) : '';
|
||||
} else {
|
||||
this.showCustomInput = false;
|
||||
this.customWidth = '';
|
||||
this.onWidthSelect?.(Number.parseInt(value));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-mono font-medium">${width.label}</span>
|
||||
<span class="text-text-muted text-xs ml-4">${width.description}</span>
|
||||
</button>
|
||||
<option value="0">Fit to Window</option>
|
||||
${COMMON_TERMINAL_WIDTHS.slice(1).map(
|
||||
(width) => html`
|
||||
<option value=${width.value}>
|
||||
${width.description} (${width.value})
|
||||
</option>
|
||||
`
|
||||
)}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom width input (conditional) -->
|
||||
${
|
||||
this.showCustomInput
|
||||
? html`
|
||||
<div class="grid grid-cols-[120px_1fr] gap-4 items-center">
|
||||
<div></div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="20"
|
||||
max="500"
|
||||
placeholder="Enter width (20-500)"
|
||||
.value=${this.customWidth}
|
||||
@input=${this.handleCustomWidthInput}
|
||||
@keydown=${this.handleCustomWidthKeydown}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="flex-1 bg-bg-secondary border border-border rounded-md px-4 py-3 text-sm font-mono text-text placeholder:text-text-dim focus:border-primary focus:shadow-glow-sm transition-all"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-3 rounded-md text-sm font-medium transition-all duration-200
|
||||
${
|
||||
!this.customWidth ||
|
||||
Number.parseInt(this.customWidth) < 20 ||
|
||||
Number.parseInt(this.customWidth) > 500
|
||||
? 'bg-bg-secondary border border-border text-text-muted cursor-not-allowed'
|
||||
: 'bg-primary text-text-bright hover:bg-primary-hover active:scale-95'
|
||||
}"
|
||||
@click=${this.handleCustomWidthSubmit}
|
||||
?disabled=${
|
||||
!this.customWidth ||
|
||||
Number.parseInt(this.customWidth) < 20 ||
|
||||
Number.parseInt(this.customWidth) > 500
|
||||
}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div class="border-t border-border mt-3 pt-3">
|
||||
<div class="text-sm font-semibold text-text-bright mb-2">Custom (20-500)</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="20"
|
||||
max="500"
|
||||
placeholder="80"
|
||||
.value=${this.customWidth}
|
||||
@input=${this.handleCustomWidthInput}
|
||||
@keydown=${this.handleCustomWidthKeydown}
|
||||
: ''
|
||||
}
|
||||
|
||||
<!-- Font size setting -->
|
||||
<div class="grid grid-cols-[120px_1fr] gap-4 items-center">
|
||||
<label class="text-sm font-medium text-text-bright text-right">Font Size</label>
|
||||
<div class="flex items-center gap-3 bg-bg-secondary border border-border rounded-md px-4 py-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-md border transition-all duration-200 flex items-center justify-center
|
||||
${
|
||||
this.terminalFontSize <= 8
|
||||
? 'border-border bg-bg-tertiary text-text-muted cursor-not-allowed'
|
||||
: 'border-border bg-bg-elevated text-text hover:border-primary hover:text-primary active:scale-95'
|
||||
}"
|
||||
@click=${() => this.onFontSizeChange?.(this.terminalFontSize - 1)}
|
||||
?disabled=${this.terminalFontSize <= 8}
|
||||
title="Decrease font size"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-mono text-base font-medium text-text-bright min-w-[60px] text-center">
|
||||
${this.terminalFontSize}px
|
||||
</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-md border transition-all duration-200 flex items-center justify-center
|
||||
${
|
||||
this.terminalFontSize >= 32
|
||||
? 'border-border bg-bg-tertiary text-text-muted cursor-not-allowed'
|
||||
: 'border-border bg-bg-elevated text-text hover:border-primary hover:text-primary active:scale-95'
|
||||
}"
|
||||
@click=${() => this.onFontSizeChange?.(this.terminalFontSize + 1)}
|
||||
?disabled=${this.terminalFontSize >= 32}
|
||||
title="Increase font size"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme setting -->
|
||||
<div class="grid grid-cols-[120px_1fr] gap-4 items-center">
|
||||
<label class="text-sm font-medium text-text-bright text-right">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
class="w-full bg-bg-secondary border border-border rounded-md pl-4 pr-10 py-3 text-sm font-mono text-text focus:border-primary focus:shadow-glow-sm cursor-pointer appearance-none"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 20 20%22 fill=%22${this.getArrowColor()}%22%3e%3cpath fill-rule=%22evenodd%22 d=%22M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z%22 clip-rule=%22evenodd%22/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-repeat: no-repeat; background-size: 1.25em 1.25em;"
|
||||
@change=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
const value = (e.target as HTMLSelectElement).value as TerminalThemeId;
|
||||
logger.debug('Theme changed to:', value);
|
||||
// Save theme using TerminalPreferencesManager
|
||||
this.preferencesManager.setTheme(value);
|
||||
// Dispatch custom event to notify other components
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('terminal-theme-changed', { detail: value })
|
||||
);
|
||||
this.onThemeChange?.(value);
|
||||
}}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="flex-1 bg-bg-secondary border border-border rounded-md px-3 py-2 text-sm font-mono text-text placeholder:text-text-dim focus:border-primary focus:shadow-glow-sm transition-all"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 rounded-md text-sm font-medium transition-all duration-200
|
||||
${
|
||||
!this.customWidth ||
|
||||
Number.parseInt(this.customWidth) < 20 ||
|
||||
Number.parseInt(this.customWidth) > 500
|
||||
? 'bg-bg-secondary border border-border text-text-muted cursor-not-allowed'
|
||||
: 'bg-primary text-text-bright hover:bg-primary-hover active:scale-95'
|
||||
}"
|
||||
@click=${this.handleCustomWidthSubmit}
|
||||
?disabled=${
|
||||
!this.customWidth ||
|
||||
Number.parseInt(this.customWidth) < 20 ||
|
||||
Number.parseInt(this.customWidth) > 500
|
||||
}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
${TERMINAL_THEMES.map((t) => html`<option value=${t.id}>${t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border mt-3 pt-3">
|
||||
<div class="text-sm font-semibold text-text-bright mb-3">Font Size</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="w-10 h-10 rounded-md border transition-all duration-200 flex items-center justify-center
|
||||
${
|
||||
this.terminalFontSize <= 8
|
||||
? 'border-border bg-bg-secondary text-text-muted cursor-not-allowed'
|
||||
: 'border-border bg-bg-elevated text-text hover:border-primary hover:text-primary active:scale-95'
|
||||
}"
|
||||
@click=${() => this.onFontSizeChange?.(this.terminalFontSize - 1)}
|
||||
?disabled=${this.terminalFontSize <= 8}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-mono text-lg font-medium text-text-bright min-w-[60px] text-center">
|
||||
${this.terminalFontSize}px
|
||||
</span>
|
||||
<button
|
||||
class="w-10 h-10 rounded-md border transition-all duration-200 flex items-center justify-center
|
||||
${
|
||||
this.terminalFontSize >= 32
|
||||
? 'border-border bg-bg-secondary text-text-muted cursor-not-allowed'
|
||||
: 'border-border bg-bg-elevated text-text hover:border-primary hover:text-primary active:scale-95'
|
||||
}"
|
||||
@click=${() => this.onFontSizeChange?.(this.terminalFontSize + 1)}
|
||||
?disabled=${this.terminalFontSize >= 32}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="ml-auto px-3 py-2 rounded-md text-sm transition-all duration-200
|
||||
${
|
||||
this.terminalFontSize === 14
|
||||
? 'text-text-muted cursor-not-allowed'
|
||||
: 'text-text-muted hover:text-text hover:bg-surface'
|
||||
}"
|
||||
@click=${() => this.onFontSizeChange?.(14)}
|
||||
?disabled=${this.terminalFontSize === 14}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border mt-3 pt-3">
|
||||
<div class="text-sm font-semibold text-text-bright mb-3">Theme</div>
|
||||
<select
|
||||
class="w-full bg-bg-secondary border border-border rounded-md p-2 text-sm font-mono text-text focus:border-primary focus:shadow-glow-sm"
|
||||
.value=${this.terminalTheme}
|
||||
@change=${(e: Event) => this.onThemeChange?.((e.target as HTMLSelectElement).value as TerminalThemeId)}
|
||||
>
|
||||
${TERMINAL_THEMES.map(
|
||||
(t) =>
|
||||
html`<option value=${t.id} ?selected=${this.terminalTheme === t.id}>${t.name}</option>`
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export class SidebarHeader extends HeaderBase {
|
|||
|
||||
return html`
|
||||
<div
|
||||
class="app-header sidebar-header bg-gradient-to-r from-bg-secondary to-bg-tertiary border-b border-border px-4 py-2 shadow-sm"
|
||||
class="app-header sidebar-header bg-gradient-to-r from-bg-secondary to-bg-tertiary px-4 py-2"
|
||||
style="padding-top: max(0.625rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<!-- Compact layout for sidebar -->
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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 { getCurrentTheme } from '../utils/theme-utils.js';
|
||||
import { UrlHighlighter } from '../utils/url-highlighter';
|
||||
|
||||
const logger = createLogger('terminal');
|
||||
|
|
@ -103,7 +104,10 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
|
||||
private requestRenderBuffer() {
|
||||
this.queueRenderOperation(() => {});
|
||||
logger.debug('Requesting render buffer update');
|
||||
this.queueRenderOperation(() => {
|
||||
logger.debug('Executing render operation');
|
||||
});
|
||||
}
|
||||
|
||||
private async processOperationQueue(): Promise<void> {
|
||||
|
|
@ -148,10 +152,15 @@ export class Terminal extends LitElement {
|
|||
// Check for debug mode
|
||||
this.debugMode = new URLSearchParams(window.location.search).has('debug');
|
||||
|
||||
// Watch for theme changes
|
||||
// Watch for theme changes (only when using auto theme)
|
||||
this.themeObserver = new MutationObserver(() => {
|
||||
if (this.terminal) {
|
||||
if (this.terminal && this.theme === 'auto') {
|
||||
logger.debug('Auto theme detected system change, updating terminal');
|
||||
this.terminal.options.theme = this.getTerminalTheme();
|
||||
this.updateTerminalColorProperties(this.getTerminalTheme());
|
||||
this.requestRenderBuffer();
|
||||
} else if (this.theme !== 'auto') {
|
||||
logger.debug('Ignoring system theme change - explicit theme selected:', this.theme);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -224,8 +233,25 @@ export class Terminal extends LitElement {
|
|||
}
|
||||
|
||||
if (changedProperties.has('theme')) {
|
||||
if (this.terminal) {
|
||||
this.terminal.options.theme = this.getTerminalTheme();
|
||||
logger.debug('Terminal theme changed to:', this.theme);
|
||||
if (this.terminal?.options) {
|
||||
const resolvedTheme = this.getTerminalTheme();
|
||||
logger.debug('Applying terminal theme:', this.theme);
|
||||
this.terminal.options.theme = resolvedTheme;
|
||||
|
||||
// Update CSS custom properties for terminal colors
|
||||
this.updateTerminalColorProperties(resolvedTheme);
|
||||
|
||||
// Force complete HTML regeneration to pick up new colors
|
||||
if (this.container) {
|
||||
// Clear the container first
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
// Force immediate buffer re-render with new colors
|
||||
this.requestRenderBuffer();
|
||||
} else {
|
||||
logger.warn('No terminal instance found for theme update');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -367,13 +393,62 @@ export class Terminal extends LitElement {
|
|||
let themeId = this.theme;
|
||||
|
||||
if (themeId === 'auto') {
|
||||
themeId = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
themeId = getCurrentTheme();
|
||||
}
|
||||
|
||||
const preset = TERMINAL_THEMES.find((t) => t.id === themeId) || TERMINAL_THEMES[0];
|
||||
return { ...preset.colors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates CSS custom properties for terminal colors based on theme
|
||||
* This allows the already-rendered HTML to immediately pick up new colors
|
||||
*/
|
||||
private updateTerminalColorProperties(themeColors: Record<string, string>) {
|
||||
logger.debug('Updating terminal CSS color properties');
|
||||
|
||||
// Standard 16 colors mapping from XTerm.js theme to CSS custom properties
|
||||
const colorMapping = {
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
brightBlack: 8,
|
||||
brightRed: 9,
|
||||
brightGreen: 10,
|
||||
brightYellow: 11,
|
||||
brightBlue: 12,
|
||||
brightMagenta: 13,
|
||||
brightCyan: 14,
|
||||
brightWhite: 15,
|
||||
};
|
||||
|
||||
// Update the CSS custom properties
|
||||
Object.entries(colorMapping).forEach(([colorName, colorIndex]) => {
|
||||
if (themeColors[colorName]) {
|
||||
const cssProperty = `--terminal-color-${colorIndex}`;
|
||||
document.documentElement.style.setProperty(cssProperty, themeColors[colorName]);
|
||||
logger.debug(`Set CSS property ${cssProperty}:`, themeColors[colorName]);
|
||||
}
|
||||
});
|
||||
|
||||
// Update main terminal foreground and background colors
|
||||
if (themeColors.foreground) {
|
||||
document.documentElement.style.setProperty('--terminal-foreground', themeColors.foreground);
|
||||
logger.debug('Set terminal foreground color:', themeColors.foreground);
|
||||
}
|
||||
if (themeColors.background) {
|
||||
document.documentElement.style.setProperty('--terminal-background', themeColors.background);
|
||||
logger.debug('Set terminal background color:', themeColors.background);
|
||||
}
|
||||
|
||||
logger.debug('CSS terminal color properties updated');
|
||||
}
|
||||
|
||||
private async initializeTerminal() {
|
||||
try {
|
||||
this.requestUpdate();
|
||||
|
|
@ -1132,9 +1207,9 @@ export class Terminal extends LitElement {
|
|||
const tempFg = style.match(/color: ([^;]+);/)?.[1];
|
||||
const tempBg = style.match(/background-color: ([^;]+);/)?.[1];
|
||||
|
||||
// Default terminal colors
|
||||
const defaultFg = '#e4e4e4';
|
||||
const defaultBg = '#0a0a0a';
|
||||
// Use theme colors as defaults
|
||||
const defaultFg = 'var(--terminal-foreground, #e4e4e4)';
|
||||
const defaultBg = 'var(--terminal-background, #0a0a0a)';
|
||||
|
||||
// Determine actual foreground and background
|
||||
const actualFg = tempFg || defaultFg;
|
||||
|
|
@ -1551,6 +1626,13 @@ export class Terminal extends LitElement {
|
|||
};
|
||||
|
||||
render() {
|
||||
const terminalTheme = this.getTerminalTheme();
|
||||
const containerStyle = `
|
||||
view-transition-name: session-${this.sessionId};
|
||||
background-color: ${terminalTheme.background || 'var(--terminal-background, #0a0a0a)'};
|
||||
color: ${terminalTheme.foreground || 'var(--terminal-foreground, #e4e4e4)'};
|
||||
`;
|
||||
|
||||
return html`
|
||||
<style>
|
||||
/* Dynamic terminal sizing */
|
||||
|
|
@ -1571,7 +1653,7 @@ export class Terminal extends LitElement {
|
|||
class="terminal-container w-full h-full overflow-hidden p-0 m-0"
|
||||
tabindex="0"
|
||||
contenteditable="false"
|
||||
style="view-transition-name: session-${this.sessionId}"
|
||||
style="${containerStyle}"
|
||||
@paste=${this.handlePaste}
|
||||
@click=${this.handleClick}
|
||||
data-testid="terminal-container"
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export class UnifiedSettings extends LitElement {
|
|||
`
|
||||
: html`
|
||||
<!-- Main toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base"
|
||||
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base">
|
||||
<div class="flex-1">
|
||||
<label class="text-primary font-medium">Enable Notifications</label>
|
||||
<p class="text-muted text-xs mt-1">
|
||||
|
|
@ -380,7 +380,7 @@ export class UnifiedSettings extends LitElement {
|
|||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Types</h4>
|
||||
<div class="space-y-2 bg-base rounded-lg p-3"
|
||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||
${this.renderNotificationToggle('sessionExit', 'Session Exit', 'When a session terminates')}
|
||||
${this.renderNotificationToggle('sessionStart', 'Session Start', 'When a new session starts')}
|
||||
${this.renderNotificationToggle('sessionError', 'Session Errors', 'When errors occur in sessions')}
|
||||
|
|
@ -391,7 +391,7 @@ export class UnifiedSettings extends LitElement {
|
|||
<!-- Sound and vibration -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted mb-3">Notification Behavior</h4>
|
||||
<div class="space-y-2 bg-base rounded-lg p-3"
|
||||
<div class="space-y-2 bg-base rounded-lg p-3">
|
||||
${this.renderNotificationToggle('soundEnabled', 'Sound', 'Play sound with notifications')}
|
||||
${this.renderNotificationToggle('vibrationEnabled', 'Vibration', 'Vibrate device with notifications')}
|
||||
</div>
|
||||
|
|
@ -457,7 +457,7 @@ export class UnifiedSettings extends LitElement {
|
|||
${
|
||||
this.mediaState.isMobile
|
||||
? html`
|
||||
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base"
|
||||
<div class="flex items-center justify-between p-4 bg-tertiary rounded-lg border border-base">
|
||||
<div class="flex-1">
|
||||
<label class="text-primary font-medium">
|
||||
Use Direct Keyboard
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|||
import { cellsToText } from '../../shared/terminal-text-formatter.js';
|
||||
import { bufferSubscriptionService } from '../services/buffer-subscription-service.js';
|
||||
import { type BufferCell, TerminalRenderer } from '../utils/terminal-renderer.js';
|
||||
import { TERMINAL_THEMES, type TerminalThemeId } from '../utils/terminal-themes.js';
|
||||
import { getCurrentTheme } from '../utils/theme-utils.js';
|
||||
|
||||
interface BufferSnapshot {
|
||||
cols: number;
|
||||
|
|
@ -30,6 +32,7 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
}
|
||||
|
||||
@property({ type: String }) sessionId = '';
|
||||
@property({ type: String }) theme: TerminalThemeId = 'auto';
|
||||
|
||||
@state() private buffer: BufferSnapshot | null = null;
|
||||
@state() private error: string | null = null;
|
||||
|
|
@ -182,8 +185,20 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
// Subscription happens in firstUpdated or when sessionId changes
|
||||
}
|
||||
|
||||
private getTerminalTheme() {
|
||||
let themeId = this.theme;
|
||||
|
||||
if (themeId === 'auto') {
|
||||
themeId = getCurrentTheme();
|
||||
}
|
||||
|
||||
const preset = TERMINAL_THEMES.find((t) => t.id === themeId) || TERMINAL_THEMES[0];
|
||||
return { ...preset.colors };
|
||||
}
|
||||
|
||||
render() {
|
||||
const lineHeight = this.displayedFontSize * 1.2;
|
||||
const terminalTheme = this.getTerminalTheme();
|
||||
|
||||
return html`
|
||||
<style>
|
||||
|
|
@ -199,8 +214,13 @@ export class VibeTerminalBuffer extends LitElement {
|
|||
}
|
||||
</style>
|
||||
<div
|
||||
class="relative w-full h-full overflow-hidden bg-bg"
|
||||
style="view-transition-name: terminal-${this.sessionId}; min-height: 200px;"
|
||||
class="relative w-full h-full overflow-hidden"
|
||||
style="
|
||||
view-transition-name: terminal-${this.sessionId};
|
||||
min-height: 200px;
|
||||
background-color: ${terminalTheme.background || 'var(--terminal-background, #0a0a0a)'};
|
||||
color: ${terminalTheme.foreground || 'var(--terminal-foreground, #e4e4e4)'};
|
||||
"
|
||||
>
|
||||
${
|
||||
this.error
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@
|
|||
--color-bg-elevated: 38 38 38; /* #262626 */
|
||||
--color-surface: 26 26 26; /* #1a1a1a */
|
||||
--color-surface-hover: 42 42 42; /* #2a2a2a */
|
||||
--color-border: 42 42 42; /* #2a2a2a */
|
||||
--color-border-light: 58 58 58; /* #3a3a3a */
|
||||
--color-border-focus: 74 74 74; /* #4a4a4a */
|
||||
--color-border: 26 26 26; /* #1a1a1a - Much more subtle */
|
||||
--color-border-light: 31 31 31; /* #1f1f1f - More subtle */
|
||||
--color-border-focus: 58 58 58; /* #3a3a3a - Keep some contrast for focus */
|
||||
|
||||
/* Text colors - Dark Mode */
|
||||
--color-text: 228 228 228; /* #e4e4e4 */
|
||||
|
|
@ -97,9 +97,9 @@
|
|||
--color-bg-elevated: 38 38 38;
|
||||
--color-surface: 26 26 26;
|
||||
--color-surface-hover: 42 42 42;
|
||||
--color-border: 42 42 42;
|
||||
--color-border-light: 58 58 58;
|
||||
--color-border-focus: 74 74 74;
|
||||
--color-border: 26 26 26;
|
||||
--color-border-light: 31 31 31;
|
||||
--color-border-focus: 58 58 58;
|
||||
--color-text: 228 228 228;
|
||||
--color-text-bright: 255 255 255;
|
||||
--color-text-muted: 163 163 163;
|
||||
|
|
@ -998,8 +998,7 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
|
|||
/* Light theme terminal container styling */
|
||||
[data-theme="light"] .terminal-container {
|
||||
/* Terminal colors are now handled by xterm.js theme */
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
/* Removed border and border-radius to prevent double-line effect with card borders */
|
||||
}
|
||||
|
||||
/* Terminal line styling */
|
||||
|
|
@ -1081,8 +1080,7 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
|
|||
}
|
||||
|
||||
[data-theme="light"] .session-card .terminal-container {
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
/* Removed border and box-shadow to prevent double-line effect with card borders */
|
||||
}
|
||||
|
||||
/* Ensure terminal container has proper size */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* This module ensures Monaco is properly loaded and configured before use.
|
||||
*/
|
||||
import { createLogger } from './logger.js';
|
||||
import { isDarkMode } from './theme-utils.js';
|
||||
|
||||
const logger = createLogger('monaco-loader');
|
||||
|
||||
|
|
@ -108,13 +109,11 @@ export async function initializeMonaco(): Promise<void> {
|
|||
});
|
||||
|
||||
// Set initial theme based on current theme
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs');
|
||||
monaco.editor.setTheme(isDarkMode() ? 'vs-dark' : 'vs');
|
||||
|
||||
// Watch for theme changes
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
monaco.editor.setTheme(currentTheme ? 'vs-dark' : 'vs');
|
||||
monaco.editor.setTheme(isDarkMode() ? 'vs-dark' : 'vs');
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
|
|
|
|||
|
|
@ -56,17 +56,22 @@ export class TerminalPreferencesManager {
|
|||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
// Merge with defaults to handle new properties
|
||||
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
const merged = { ...DEFAULT_PREFERENCES, ...parsed };
|
||||
logger.debug('Loaded terminal preferences:', merged);
|
||||
return merged;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load terminal preferences', { error });
|
||||
}
|
||||
logger.debug('Using default terminal preferences');
|
||||
return { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
|
||||
private savePreferences() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_TERMINAL_PREFS, JSON.stringify(this.preferences));
|
||||
const toSave = JSON.stringify(this.preferences);
|
||||
localStorage.setItem(STORAGE_KEY_TERMINAL_PREFS, toSave);
|
||||
logger.debug('Saved terminal preferences to localStorage');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to save terminal preferences', { error });
|
||||
}
|
||||
|
|
@ -104,6 +109,7 @@ export class TerminalPreferencesManager {
|
|||
}
|
||||
|
||||
setTheme(theme: TerminalThemeId) {
|
||||
logger.debug('Setting terminal theme:', theme);
|
||||
this.preferences.theme = theme;
|
||||
this.savePreferences();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ export interface TerminalTheme {
|
|||
}
|
||||
|
||||
export const TERMINAL_THEMES: TerminalTheme[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
description: 'Follow system theme',
|
||||
colors: {}, // Actual colors determined at runtime
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: 'Dark',
|
||||
|
|
|
|||
77
web/src/client/utils/theme-utils.ts
Normal file
77
web/src/client/utils/theme-utils.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Theme detection utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the current effective theme (light or dark)
|
||||
* Handles both explicit theme setting and system preference
|
||||
*/
|
||||
export function getCurrentTheme(): 'light' | 'dark' {
|
||||
const explicitTheme = document.documentElement.getAttribute('data-theme');
|
||||
|
||||
if (explicitTheme === 'dark') return 'dark';
|
||||
if (explicitTheme === 'light') return 'light';
|
||||
|
||||
// No explicit theme set, use system preference
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current effective theme is dark mode
|
||||
*/
|
||||
export function isDarkMode(): boolean {
|
||||
return getCurrentTheme() === 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a color value based on current theme
|
||||
* @param lightColor Color to use in light mode
|
||||
* @param darkColor Color to use in dark mode
|
||||
*/
|
||||
export function getThemeColor(lightColor: string, darkColor: string): string {
|
||||
return isDarkMode() ? darkColor : lightColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets URL-encoded color for SVG based on current theme
|
||||
* @param lightColor Color to use in light mode (will be URL-encoded)
|
||||
* @param darkColor Color to use in dark mode (will be URL-encoded)
|
||||
*/
|
||||
export function getThemeColorEncoded(lightColor: string, darkColor: string): string {
|
||||
const color = getThemeColor(lightColor, darkColor);
|
||||
return encodeURIComponent(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a CSS custom property value as RGB color
|
||||
* @param property CSS custom property name (without --)
|
||||
*/
|
||||
function getCSSColorAsHex(property: string): string {
|
||||
const rgb = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(`--color-${property}`)
|
||||
.trim();
|
||||
|
||||
if (!rgb) return '#000000'; // fallback
|
||||
|
||||
// Convert "r g b" format to hex
|
||||
const [r, g, b] = rgb.split(' ').map((n) => Number.parseInt(n, 10));
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets URL-encoded text color for SVG arrows based on current theme
|
||||
* Uses CSS custom properties for consistent theming
|
||||
*/
|
||||
export function getTextColorEncoded(): string {
|
||||
const color = getCSSColorAsHex('text');
|
||||
return encodeURIComponent(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a media query listener for system theme changes
|
||||
*/
|
||||
export function createSystemThemeListener(callback: (isDark: boolean) => void): MediaQueryList {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => callback(e.matches));
|
||||
return mediaQuery;
|
||||
}
|
||||
106
web/src/client/utils/title-manager.ts
Normal file
106
web/src/client/utils/title-manager.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Centralized title management for VibeTunnel
|
||||
*/
|
||||
|
||||
export class TitleManager {
|
||||
private static instance: TitleManager;
|
||||
private cleanupFunctions: Array<() => void> = [];
|
||||
private currentSessionId: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TitleManager {
|
||||
if (!TitleManager.instance) {
|
||||
TitleManager.instance = new TitleManager();
|
||||
}
|
||||
return TitleManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set title for session view
|
||||
*/
|
||||
setSessionTitle(sessionName: string): void {
|
||||
document.title = `VibeTunnel - ${sessionName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set title for list view with session count
|
||||
*/
|
||||
setListTitle(sessionCount: number): void {
|
||||
document.title =
|
||||
sessionCount > 0
|
||||
? `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`
|
||||
: 'VibeTunnel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set title for file browser
|
||||
*/
|
||||
setFileBrowserTitle(): void {
|
||||
document.title = 'VibeTunnel - File Browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize automatic title updates for list view
|
||||
*/
|
||||
initAutoUpdates(): void {
|
||||
this.cleanup();
|
||||
|
||||
// Monitor URL changes for list view updates
|
||||
const updateFromUrl = () => {
|
||||
const url = new URL(window.location.href);
|
||||
const sessionId = url.searchParams.get('session');
|
||||
|
||||
if (sessionId !== this.currentSessionId) {
|
||||
this.currentSessionId = sessionId;
|
||||
|
||||
// Only auto-update title for list view (no session ID)
|
||||
if (!sessionId) {
|
||||
setTimeout(() => {
|
||||
const sessionCount = document.querySelectorAll('session-card').length;
|
||||
this.setListTitle(sessionCount);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
updateFromUrl();
|
||||
|
||||
// Monitor DOM changes for session count updates
|
||||
let mutationTimeout: NodeJS.Timeout | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mutationTimeout) clearTimeout(mutationTimeout);
|
||||
mutationTimeout = setTimeout(updateFromUrl, 100);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// Listen for URL changes
|
||||
const popstateHandler = () => updateFromUrl();
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
// Store cleanup functions
|
||||
this.cleanupFunctions = [
|
||||
() => observer.disconnect(),
|
||||
() => window.removeEventListener('popstate', popstateHandler),
|
||||
() => {
|
||||
if (mutationTimeout) clearTimeout(mutationTimeout);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listeners
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.cleanupFunctions.forEach((fn) => fn());
|
||||
this.cleanupFunctions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const titleManager = TitleManager.getInstance();
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/**
|
||||
* Simple utility to update page title based on URL
|
||||
*/
|
||||
|
||||
let currentSessionId: string | null = null;
|
||||
let cleanupFunctions: Array<() => void> = [];
|
||||
|
||||
function updateTitleFromUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
const sessionId = url.searchParams.get('session');
|
||||
|
||||
if (sessionId && sessionId !== currentSessionId) {
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// Find session name from the page content
|
||||
setTimeout(() => {
|
||||
// Look for session name in multiple places
|
||||
const sessionElements = document.querySelectorAll(
|
||||
'session-card, .sidebar, [data-session-id], .session-name, h1, h2'
|
||||
);
|
||||
let sessionName: string | null = null;
|
||||
|
||||
for (const element of sessionElements) {
|
||||
const text = element.textContent?.trim() || '';
|
||||
|
||||
// Look for any text that could be a session name
|
||||
// First try to find data attributes
|
||||
if (element.hasAttribute('data-session-name')) {
|
||||
sessionName = element.getAttribute('data-session-name');
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to extract from element content - be more flexible
|
||||
// Look for patterns like "Session X", "test-session-X", or any non-path text
|
||||
if (text && !text.includes('/') && text.length > 0 && text.length < 100) {
|
||||
// Skip if it looks like a path or too generic
|
||||
if (!text.startsWith('~') && !text.startsWith('/')) {
|
||||
sessionName = text.split('\n')[0]; // Take first line if multi-line
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionName) {
|
||||
document.title = `${sessionName} - VibeTunnel`;
|
||||
} else {
|
||||
// Fallback to generic session title
|
||||
document.title = `Session - VibeTunnel`;
|
||||
}
|
||||
}, 500);
|
||||
} else if (!sessionId && currentSessionId) {
|
||||
// Back to list view
|
||||
currentSessionId = null;
|
||||
// Wait a bit for DOM to update before counting
|
||||
setTimeout(() => {
|
||||
const sessionCount = document.querySelectorAll('session-card').length;
|
||||
document.title =
|
||||
sessionCount > 0
|
||||
? `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`
|
||||
: 'VibeTunnel';
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
export function initTitleUpdater() {
|
||||
// Clean up any existing listeners first
|
||||
cleanup();
|
||||
|
||||
// Check on load
|
||||
updateTitleFromUrl();
|
||||
|
||||
// Monitor URL changes with debouncing
|
||||
let mutationTimeout: NodeJS.Timeout | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mutationTimeout) clearTimeout(mutationTimeout);
|
||||
mutationTimeout = setTimeout(updateTitleFromUrl, 100);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
// Also listen for popstate
|
||||
const popstateHandler = () => updateTitleFromUrl();
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
// Check periodically as fallback
|
||||
const intervalId = setInterval(updateTitleFromUrl, 2000); // Less frequent
|
||||
|
||||
// Store cleanup functions
|
||||
cleanupFunctions = [
|
||||
() => observer.disconnect(),
|
||||
() => window.removeEventListener('popstate', popstateHandler),
|
||||
() => clearInterval(intervalId),
|
||||
() => {
|
||||
if (mutationTimeout) clearTimeout(mutationTimeout);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Cleanup function to prevent memory leaks
|
||||
export function cleanup() {
|
||||
cleanupFunctions.forEach((fn) => fn());
|
||||
cleanupFunctions = [];
|
||||
}
|
||||
|
|
@ -550,25 +550,37 @@ export async function createApp(): Promise<AppInstance> {
|
|||
|
||||
// Serve static files with .html extension handling and caching headers
|
||||
const publicPath = path.join(process.cwd(), 'public');
|
||||
const isDevelopment = !process.env.BUILD_DATE || process.env.NODE_ENV === 'development';
|
||||
|
||||
app.use(
|
||||
express.static(publicPath, {
|
||||
extensions: ['html'], // This allows /logs to resolve to /logs.html
|
||||
maxAge: '1d', // Cache static assets for 1 day
|
||||
etag: true, // Enable ETag generation
|
||||
lastModified: true, // Enable Last-Modified header
|
||||
maxAge: isDevelopment ? 0 : '1d', // No cache in dev, 1 day in production
|
||||
etag: !isDevelopment, // Disable ETag in development
|
||||
lastModified: !isDevelopment, // Disable Last-Modified in development
|
||||
setHeaders: (res, filePath) => {
|
||||
// Set longer cache for immutable assets
|
||||
if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
// Shorter cache for HTML files
|
||||
else if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour
|
||||
if (isDevelopment) {
|
||||
// Disable all caching in development
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
} else {
|
||||
// Production caching rules
|
||||
// Set longer cache for immutable assets
|
||||
if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
// Shorter cache for HTML files
|
||||
else if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
logger.debug(`Serving static files from: ${publicPath} with caching headers`);
|
||||
logger.debug(
|
||||
`Serving static files from: ${publicPath} ${isDevelopment ? 'with caching disabled (dev mode)' : 'with caching headers'}`
|
||||
);
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export interface AppDetector {
|
|||
// Format 4: ✳ Measuring… (120s · ⚒ 671 tokens · esc to interrupt) - with hammer symbol
|
||||
// Note: We match ANY non-whitespace character as the indicator since Claude uses many symbols
|
||||
const CLAUDE_STATUS_REGEX =
|
||||
/(\S)\s+(\w+)…\s*\((\d+)s(?:\s*·\s*(\S?)\s*([\d.]+)\s*k?\s*tokens\s*·\s*[^)]+to\s+interrupt)?\)/gi;
|
||||
/(\S)\s+([\w\s]+?)…\s*\((\d+)s(?:\s*·\s*(\S?)\s*([\d.]+)\s*k?\s*tokens\s*·\s*[^)]+to\s+interrupt)?\)/gi;
|
||||
|
||||
/**
|
||||
* Parse Claude-specific status from output
|
||||
|
|
|
|||
|
|
@ -116,13 +116,19 @@ describe.sequential('Logs API Tests', () => {
|
|||
}),
|
||||
});
|
||||
|
||||
// Give it a moment to write (longer in CI environments)
|
||||
await sleep(500);
|
||||
// Wait and retry for the log file to be written and flushed
|
||||
let info: any;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
const retryDelay = 200;
|
||||
|
||||
const response = await fetch(`http://localhost:${server?.port}/api/logs/info`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const info = await response.json();
|
||||
do {
|
||||
await sleep(retryDelay);
|
||||
const response = await fetch(`http://localhost:${server?.port}/api/logs/info`);
|
||||
expect(response.status).toBe(200);
|
||||
info = await response.json();
|
||||
attempts++;
|
||||
} while (!info.exists && attempts < maxAttempts);
|
||||
|
||||
expect(info).toHaveProperty('exists');
|
||||
expect(info).toHaveProperty('size');
|
||||
|
|
|
|||
51
web/src/test/unit/terminal-themes.test.ts
Normal file
51
web/src/test/unit/terminal-themes.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { TERMINAL_THEMES } from '../../client/utils/terminal-themes.js';
|
||||
|
||||
describe('terminal-themes', () => {
|
||||
it('should include all expected theme IDs', () => {
|
||||
const themeIds = TERMINAL_THEMES.map((t) => t.id);
|
||||
expect(themeIds).toContain('auto');
|
||||
expect(themeIds).toContain('dark');
|
||||
expect(themeIds).toContain('light');
|
||||
});
|
||||
|
||||
it('should have valid theme structure', () => {
|
||||
TERMINAL_THEMES.forEach((theme) => {
|
||||
expect(theme).toHaveProperty('id');
|
||||
expect(theme).toHaveProperty('name');
|
||||
expect(theme).toHaveProperty('description');
|
||||
expect(theme).toHaveProperty('colors');
|
||||
expect(typeof theme.id).toBe('string');
|
||||
expect(typeof theme.name).toBe('string');
|
||||
expect(typeof theme.description).toBe('string');
|
||||
expect(typeof theme.colors).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique theme IDs', () => {
|
||||
const themeIds = TERMINAL_THEMES.map((t) => t.id);
|
||||
const uniqueIds = [...new Set(themeIds)];
|
||||
expect(themeIds).toHaveLength(uniqueIds.length);
|
||||
});
|
||||
|
||||
it('should have non-empty names and descriptions', () => {
|
||||
TERMINAL_THEMES.forEach((theme) => {
|
||||
expect(theme.name.trim()).not.toBe('');
|
||||
expect(theme.description.trim()).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid color format for themes with colors', () => {
|
||||
TERMINAL_THEMES.forEach((theme) => {
|
||||
if (theme.id !== 'auto') {
|
||||
// auto theme has empty colors object
|
||||
Object.values(theme.colors).forEach((color) => {
|
||||
if (typeof color === 'string' && color.startsWith('#')) {
|
||||
// Basic hex color validation
|
||||
expect(color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -73,6 +73,10 @@ describe('Activity Detector', () => {
|
|||
input: '◐ Processing… (42s · ↑ 1.2k tokens · esc to interrupt)\n',
|
||||
expected: '◐ Processing (42s, ↑1.2k)',
|
||||
},
|
||||
{
|
||||
input: '✻ Compacting conversation… (303s · ↑ 16.3k tokens · esc to interrupt)\n',
|
||||
expected: '✻ Compacting conversation (303s, ↑16.3k)',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { input, expected } of statuses) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue